eaf-base-api 2.4.7__tar.gz → 2.4.9__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: eaf_base_api
3
- Version: 2.4.7
3
+ Version: 2.4.9
4
4
  Summary: A base API for EchterAlsFake's Porn APIs
5
5
  Author-email: Johannes Habel <EchterAlsFake@proton.me>
6
6
  License: LGPLv3
@@ -7,6 +7,7 @@ import time
7
7
  import m3u8
8
8
  import httpx
9
9
  import random
10
+ import certifi
10
11
  import logging
11
12
  import traceback
12
13
  import threading
@@ -34,6 +35,37 @@ loggers = {}
34
35
  HEIGHT_FROM_URI = re.compile(r'(?<!\d)(\d{3,4})[pP](?!\d)') # e.g., 1080p, 720P
35
36
 
36
37
 
38
+ def _normalize_quality_value(q) -> Union[str, int]:
39
+ if isinstance(q, int):
40
+ return q
41
+ s = str(q).lower().strip()
42
+ if s in {"best", "half", "worst"}:
43
+ return s
44
+ m = re.search(r'(\d{3,4})', s)
45
+ if m:
46
+ return int(m.group(1))
47
+ raise ValueError(f"Invalid quality: {q}")
48
+
49
+
50
+ def _choose_quality_from_list(available: List[str | int], target: Union[str, int]):
51
+ # available like ["240", "360", "480", "720", "1080"]
52
+ av = sorted({int(x) for x in available})
53
+ if isinstance(target, str):
54
+ if target == "best":
55
+ return av[-1]
56
+ if target == "worst":
57
+ return av[0]
58
+ if target == "half":
59
+ return av[len(av) // 2]
60
+ raise ValueError("Invalid label.")
61
+ # numeric: highest ≤ target, else closest
62
+ le = [h for h in av if h <= target]
63
+ if le:
64
+ return le[-1]
65
+ # fallback closest (ties -> higher)
66
+ return min(av, key=lambda h: (abs(h - target), -h))
67
+
68
+
37
69
  class ErrorVideo:
38
70
  """
39
71
  Why?:
@@ -369,7 +401,7 @@ class Helper:
369
401
  ):
370
402
  """
371
403
  Yields Video/ErrorVideo in deterministic (page_idx, vid_idx) order while fetching concurrently.
372
- Extremely detailed logs are emitted for: scheduling, completion, ordering, and exceptions.
404
+ Stops scraping pages once a 404 page is encountered (self.core.fetch returns an httpx.Response).
373
405
  """
374
406
  logger = self.logger
375
407
  run_id = uuid.uuid4().hex[:8] # correlate all logs for this iterator call
@@ -395,6 +427,10 @@ class Helper:
395
427
  next_page_idx = 0 # ordering cursor
396
428
  next_video_idx = 0
397
429
 
430
+ # NEW: stop paging after first 404
431
+ stop_after_404 = False
432
+ stop_at_page_idx: Optional[int] = None
433
+
398
434
  def flush_ready():
399
435
  nonlocal next_page_idx, next_video_idx
400
436
  flushed = 0
@@ -444,17 +480,15 @@ class Helper:
444
480
  # queue of discovered videos we haven't submitted yet
445
481
  pending_videos: deque[Tuple[int, int, str]] = deque()
446
482
 
447
- # helper to respect videos_concurrency strictly, otherwise we gonna be rate limited instantly lol
483
+ # helper to respect videos_concurrency strictly
448
484
  def schedule_videos():
449
485
  scheduled = 0
450
486
  while pending_videos and len(video_in_flight) < videos_concurrency:
451
487
  pidx, vid_idx, vurl = pending_videos.popleft()
452
488
  if other_return:
453
489
  vf = video_executor.submit(self._other_return, vurl)
454
-
455
490
  else:
456
491
  vf = video_executor.submit(self._make_video_safe, vurl)
457
-
458
492
  video_in_flight[vf] = (pidx, vid_idx)
459
493
  scheduled += 1
460
494
  logger.debug(
@@ -491,7 +525,6 @@ class Helper:
491
525
 
492
526
  # If nothing in flight but items queued (shouldn't happen often), try to schedule then continue
493
527
  if not waiting_on:
494
- # No futures to wait on; try to schedule (covers corner cases)
495
528
  schedule_videos()
496
529
  if not (page_in_flight or video_in_flight):
497
530
  break
@@ -504,7 +537,7 @@ class Helper:
504
537
  if fut in page_in_flight:
505
538
  pidx, url = page_in_flight.pop(fut)
506
539
  try:
507
- html = fut.result()
540
+ html_or_resp = fut.result()
508
541
  logger.info(
509
542
  "[%s] PAGE done pidx=%d url=%s (inflight_page=%d inflight_video=%d)",
510
543
  run_id, pidx, url, len(page_in_flight), len(video_in_flight)
@@ -518,20 +551,56 @@ class Helper:
518
551
  page_counts[pidx] = 0
519
552
  # now-ordered items may be ready
520
553
  yield from flush_ready()
521
- # schedule next page if available
522
- try:
523
- npidx, nurl = next(page_iter)
524
- nfut = page_executor.submit(self.core.fetch, nurl)
525
- page_in_flight[nfut] = (npidx, nurl)
526
- logger.debug(
527
- "[%s] scheduled NEXT PAGE pidx=%d url=%s after failure",
528
- run_id, npidx, nurl
529
- )
530
- except StopIteration:
531
- pass
554
+ # schedule next page if available (unless we've already stopped)
555
+ if not stop_after_404:
556
+ try:
557
+ npidx, nurl = next(page_iter)
558
+ nfut = page_executor.submit(self.core.fetch, nurl)
559
+ page_in_flight[nfut] = (npidx, nurl)
560
+ logger.debug(
561
+ "[%s] scheduled NEXT PAGE pidx=%d url=%s after failure",
562
+ run_id, npidx, nurl
563
+ )
564
+ except StopIteration:
565
+ pass
532
566
  continue
533
567
 
534
- # Extract video URLs
568
+ # Detect a 404 "page" (httpx.Response returned)
569
+ is_404 = False
570
+ try:
571
+ if isinstance(html_or_resp, httpx.Response) and html_or_resp.status_code == 404:
572
+ is_404 = True
573
+ except Exception:
574
+ # Fallback duck-typing if httpx import fails for some reason
575
+ if getattr(html_or_resp, "status_code", None) == 404 and hasattr(html_or_resp, "headers"):
576
+ is_404 = True
577
+
578
+ if is_404:
579
+ logger.warning(
580
+ "[%s] PAGE 404 pidx=%d url=%s -> stopping further page scheduling",
581
+ run_id, pidx, url
582
+ )
583
+ stop_after_404 = True
584
+ stop_at_page_idx = pidx
585
+ # 404 page has 0 videos
586
+ page_counts[pidx] = 0
587
+ # Flush anything now ready
588
+ yield from flush_ready()
589
+ # Do NOT schedule any more pages
590
+ continue
591
+
592
+ # If we've already hit a 404 and this page index is after it, ignore this page
593
+ if stop_after_404 and stop_at_page_idx is not None and pidx > stop_at_page_idx:
594
+ logger.info(
595
+ "[%s] ignoring PAGE pidx=%d url=%s (after first 404 at pidx=%d)",
596
+ run_id, pidx, url, stop_at_page_idx
597
+ )
598
+ page_counts[pidx] = 0
599
+ yield from flush_ready()
600
+ continue
601
+
602
+ # Normal path: Extract video URLs
603
+ html = html_or_resp
535
604
  try:
536
605
  video_urls = extractor(html) or []
537
606
  logger.debug(
@@ -551,26 +620,24 @@ class Helper:
551
620
  run_id, pidx, page_counts[pidx]
552
621
  )
553
622
 
554
- # NEW: queue videos instead of submitting all at once
555
623
  for vid_idx, vurl in enumerate(video_urls):
556
624
  pending_videos.append((pidx, vid_idx, vurl))
557
- # Fill available slots respecting videos_concurrency
558
625
  schedule_videos()
559
626
 
560
- # Try flushing any now-ready items
561
627
  yield from flush_ready()
562
628
 
563
- # Fetch next page
564
- try:
565
- npidx, nurl = next(page_iter)
566
- nfut = page_executor.submit(self.core.fetch, nurl)
567
- page_in_flight[nfut] = (npidx, nurl)
568
- logger.debug(
569
- "[%s] scheduled NEXT PAGE pidx=%d url=%s (inflight_page=%d)",
570
- run_id, npidx, nurl, len(page_in_flight)
571
- )
572
- except StopIteration:
573
- logger.debug("[%s] no more pages to schedule", run_id)
629
+ # Fetch next page only if we haven't seen a 404
630
+ if not stop_after_404:
631
+ try:
632
+ npidx, nurl = next(page_iter)
633
+ nfut = page_executor.submit(self.core.fetch, nurl)
634
+ page_in_flight[nfut] = (npidx, nurl)
635
+ logger.debug(
636
+ "[%s] scheduled NEXT PAGE pidx=%d url=%s (inflight_page=%d)",
637
+ run_id, npidx, nurl, len(page_in_flight)
638
+ )
639
+ except StopIteration:
640
+ logger.debug("[%s] no more pages to schedule", run_id)
574
641
 
575
642
  # VIDEO completed
576
643
  elif fut in video_in_flight:
@@ -661,7 +728,7 @@ class BaseCore:
661
728
  max_keepalive_connections=getattr(self.config, "max_keepalive", 4),
662
729
  keepalive_expiry=10,
663
730
  )
664
- ctx = ssl.create_default_context()
731
+ ctx = ssl.create_default_context(cafile=certifi.where())
665
732
  if not self.config.verify_ssl:
666
733
  ctx.check_hostname = False
667
734
  ctx.verify_mode = ssl.CERT_NONE
@@ -858,11 +925,7 @@ class BaseCore:
858
925
 
859
926
  # 404: usually not recoverable, but we try once (if attempt==0) in case of transient CDN
860
927
  if status == 404:
861
- if attempt == 0 and max_retries > 1:
862
- self.logger.info(f"Got 404 once for {url}, retrying in case of transient edge/CDN issue.")
863
- continue
864
- self.logger.error(f"Resource not found (404) for URL: {url}")
865
- return response
928
+ return response # Immediately returning response, because 404 usually means no more content when searching
866
929
 
867
930
  # 410: permanent gone
868
931
  if status == 410:
@@ -1178,41 +1241,49 @@ class BaseCore:
1178
1241
  with open(tmp_path, "wb") as out_fp:
1179
1242
  # Submit all downloads
1180
1243
  with ThreadPoolExecutor(max_workers=workers) as ex:
1181
- futures = {ex.submit(self.download_segment, url, timeout): i for i, url in enumerate(segments)}
1244
+ # submit all segments
1245
+ pending = {ex.submit(self.download_segment, url, timeout): i
1246
+ for i, url in enumerate(segments)}
1182
1247
 
1183
- # Optional: per-segment retry budget (on top of fetch() retries)
1184
1248
  seg_retries = [0] * n
1185
1249
  max_seg_retries = 2
1186
1250
 
1187
- for fut in as_completed(futures):
1188
- i = futures[fut]
1189
- try:
1190
- _, data, success = fut.result()
1191
- except Exception as e:
1192
- # Defensive: unexpected crash in worker
1193
- self.logger.error(f"Worker exception for segment {i}: {e}")
1194
- success, data = False, b""
1195
-
1196
- if success and data:
1197
- parts[i] = data
1198
- else:
1199
- # Retry this segment a couple more times at the executor level
1200
- if seg_retries[i] < max_seg_retries:
1201
- seg_retries[i] += 1
1202
- # Resubmit and continue without touching progress
1203
- futures[ex.submit(self.download_segment, segments[i], timeout)] = i
1204
- continue
1205
- # Give up on this segment
1206
- failures += 1
1207
- parts[i] = b"" # placeholder (prevents NoneType issues)
1208
-
1209
- # After any completion (success or final failure), advance write cursor
1210
- while next_to_write < n and parts[next_to_write] is not None:
1211
- if parts[next_to_write]:
1212
- out_fp.write(parts[next_to_write])
1213
- next_to_write += 1
1214
- completed += 1
1215
- callback(completed, n)
1251
+ parts: List[Optional[bytes]] = [None] * n
1252
+ next_to_write = 0
1253
+ progressed = 0 # how many segments have finished (success or final failure)
1254
+
1255
+ while pending:
1256
+ done, _ = wait(pending, return_when=FIRST_COMPLETED)
1257
+
1258
+ for fut in done:
1259
+ i = pending.pop(fut)
1260
+ try:
1261
+ _, data, success = fut.result()
1262
+ except Exception as e:
1263
+ self.logger.error(f"Worker exception for segment {i}: {e}")
1264
+ success, data = False, b""
1265
+
1266
+ if success and data:
1267
+ parts[i] = data
1268
+ # count immediately: a segment finished successfully
1269
+ progressed += 1
1270
+ callback(progressed, n)
1271
+ else:
1272
+ # retry a couple times
1273
+ if seg_retries[i] < max_seg_retries:
1274
+ seg_retries[i] += 1
1275
+ pending[ex.submit(self.download_segment, segments[i], timeout)] = i
1276
+ continue
1277
+ # give up: mark placeholder and still advance progress
1278
+ parts[i] = b""
1279
+ progressed += 1
1280
+ callback(progressed, n)
1281
+
1282
+ # now try to flush any contiguous ready parts
1283
+ while next_to_write < n and parts[next_to_write] is not None:
1284
+ if parts[next_to_write]:
1285
+ out_fp.write(parts[next_to_write])
1286
+ next_to_write += 1
1216
1287
 
1217
1288
  if failures:
1218
1289
  # Clean temp file to avoid leaving corrupted output
@@ -1279,85 +1350,90 @@ class BaseCore:
1279
1350
  raise ModuleNotFoundError(f"PyAV is required for remuxing. Install with pip install av. Not supported on Termux! {e}")
1280
1351
 
1281
1352
  input_ = av_open(input_path)
1282
- output = av_open(output_path, mode="w", format="mp4", options={"movflags": "faststart"})
1283
-
1284
- # --- VIDEO: keep your exact remux approach ---
1285
- in_video = input_.streams.video[0]
1286
- out_video = output.add_stream_from_template(template=in_video)
1287
-
1288
- # --- AUDIO: copy if MP4-compatible; else transcode to AAC ---
1289
- in_audio = next((s for s in input_.streams if s.type == "audio"), None)
1290
- out_audio = None
1291
- transcode_audio = False
1292
- resampler = None
1293
-
1294
- if in_audio:
1295
- # Common MP4-safe audio codecs to copy without transcoding.
1296
- copy_ok = {"aac", "alac", "mp3"}
1297
- codec_name = (in_audio.codec_context.name or "").lower()
1298
-
1299
- if codec_name in copy_ok:
1300
- out_audio = output.add_stream_from_template(template=in_audio)
1301
- else:
1302
- # Build an AAC encoder stream. Match input rate/layout where possible.
1303
- transcode_audio = True
1304
- sample_rate = in_audio.codec_context.sample_rate or 48000
1305
- layout = (
1306
- in_audio.codec_context.layout.name
1307
- if getattr(in_audio.codec_context, "layout", None)
1308
- else "stereo"
1309
- )
1310
- out_audio = output.add_stream("aac", rate=sample_rate)
1311
- # Setting layout/channels helps encoder pick sensible defaults.
1312
- try:
1313
- out_audio.layout = layout
1314
- except Exception:
1315
- pass
1353
+ if input_.format.name.lower() == "mpegts":
1354
+ output = av_open(output_path, mode="w", format="mp4", options={"movflags": "faststart"})
1355
+
1356
+ # --- VIDEO: keep your exact remux approach ---
1357
+ in_video = input_.streams.video[0]
1358
+ out_video = output.add_stream_from_template(template=in_video)
1359
+
1360
+ # --- AUDIO: copy if MP4-compatible; else transcode to AAC ---
1361
+ in_audio = next((s for s in input_.streams if s.type == "audio"), None)
1362
+ out_audio = None
1363
+ transcode_audio = False
1364
+ resampler = None
1365
+
1366
+ if in_audio:
1367
+ # Common MP4-safe audio codecs to copy without transcoding.
1368
+ copy_ok = {"aac", "alac", "mp3"}
1369
+ codec_name = (in_audio.codec_context.name or "").lower()
1370
+
1371
+ if codec_name in copy_ok:
1372
+ out_audio = output.add_stream_from_template(template=in_audio)
1373
+ else:
1374
+ # Build an AAC encoder stream. Match input rate/layout where possible.
1375
+ transcode_audio = True
1376
+ sample_rate = in_audio.codec_context.sample_rate or 48000
1377
+ layout = (
1378
+ in_audio.codec_context.layout.name
1379
+ if getattr(in_audio.codec_context, "layout", None)
1380
+ else "stereo"
1381
+ )
1382
+ out_audio = output.add_stream("aac", rate=sample_rate)
1383
+ # Setting layout/channels helps encoder pick sensible defaults.
1384
+ try:
1385
+ out_audio.layout = layout
1386
+ except Exception:
1387
+ pass
1388
+
1389
+ # Ensure frames handed to the AAC encoder are in a compatible sample format.
1390
+ # ('fltp' is the usual format for AAC encoders.)
1391
+ resampler = AudioResampler(format="fltp", layout=layout, rate=sample_rate)
1392
+
1393
+ # --- DEMUX both streams so audio is included ---
1394
+ demux_streams = [in_video] + ([in_audio] if in_audio else [])
1395
+ packets = list(input_.demux(demux_streams)) # keeps your progress logic
1396
+ total = len(packets)
1397
+
1398
+ for idx, packet in enumerate(packets):
1399
+ if packet.dts is None:
1400
+ if callback:
1401
+ callback(idx + 1, total)
1402
+ continue
1316
1403
 
1317
- # Ensure frames handed to the AAC encoder are in a compatible sample format.
1318
- # ('fltp' is the usual format for AAC encoders.)
1319
- resampler = AudioResampler(format="fltp", layout=layout, rate=sample_rate)
1404
+ if packet.stream == in_video:
1405
+ # Your original video remux path (no explicit rescale).
1406
+ packet.stream = out_video
1407
+ output.mux(packet)
1320
1408
 
1321
- # --- DEMUX both streams so audio is included ---
1322
- demux_streams = [in_video] + ([in_audio] if in_audio else [])
1323
- packets = list(input_.demux(demux_streams)) # keeps your progress logic
1324
- total = len(packets)
1409
+ elif in_audio and packet.stream == in_audio:
1410
+ if not transcode_audio:
1411
+ # Audio is already MP4-compatible; just remux.
1412
+ packet.stream = out_audio
1413
+ output.mux(packet)
1414
+ else:
1415
+ # Decode -> (optionally resample) -> encode AAC -> mux.
1416
+ for frame in packet.decode():
1417
+ # Resample to match encoder expectations.
1418
+ frames = resampler.resample(frame) if resampler else [frame]
1419
+ for f in frames:
1420
+ for enc_pkt in out_audio.encode(f):
1421
+ output.mux(enc_pkt)
1325
1422
 
1326
- for idx, packet in enumerate(packets):
1327
- if packet.dts is None:
1328
1423
  if callback:
1329
1424
  callback(idx + 1, total)
1330
- continue
1331
1425
 
1332
- if packet.stream == in_video:
1333
- # Your original video remux path (no explicit rescale).
1334
- packet.stream = out_video
1335
- output.mux(packet)
1426
+ # Flush audio encoder if we transcoded.
1427
+ if transcode_audio and out_audio:
1428
+ for enc_pkt in out_audio.encode(None):
1429
+ output.mux(enc_pkt)
1336
1430
 
1337
- elif in_audio and packet.stream == in_audio:
1338
- if not transcode_audio:
1339
- # Audio is already MP4-compatible; just remux.
1340
- packet.stream = out_audio
1341
- output.mux(packet)
1342
- else:
1343
- # Decode -> (optionally resample) -> encode AAC -> mux.
1344
- for frame in packet.decode():
1345
- # Resample to match encoder expectations.
1346
- frames = resampler.resample(frame) if resampler else [frame]
1347
- for f in frames:
1348
- for enc_pkt in out_audio.encode(f):
1349
- output.mux(enc_pkt)
1350
-
1351
- if callback:
1352
- callback(idx + 1, total)
1353
-
1354
- # Flush audio encoder if we transcoded.
1355
- if transcode_audio and out_audio:
1356
- for enc_pkt in out_audio.encode(None):
1357
- output.mux(enc_pkt)
1358
-
1359
- input_.close()
1360
- output.close()
1431
+ input_.close()
1432
+ output.close()
1433
+
1434
+ else:
1435
+ self.logger.info("Stream seems to be already in MP4! Skipping remux...")
1436
+ os.rename(input_path, output_path)
1361
1437
 
1362
1438
  def FFMPEG(self, video, quality: str, callback, path: str) -> bool:
1363
1439
  try:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: eaf_base_api
3
- Version: 2.4.7
3
+ Version: 2.4.9
4
4
  Summary: A base API for EchterAlsFake's Porn APIs
5
5
  Author-email: Johannes Habel <EchterAlsFake@proton.me>
6
6
  License: LGPLv3
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "eaf_base_api"
7
- version = "2.4.7"
7
+ version = "2.4.9"
8
8
  description = "A base API for EchterAlsFake's Porn APIs"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.6"
File without changes
File without changes
File without changes
File without changes