eaf-base-api 2.4.8__tar.gz → 2.5.0__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.8
3
+ Version: 2.5.0
4
4
  Summary: A base API for EchterAlsFake's Porn APIs
5
5
  Author-email: Johannes Habel <EchterAlsFake@proton.me>
6
6
  License: LGPLv3
@@ -0,0 +1,7 @@
1
+ __all__ = ["BaseCore", "Callback", "config", "errors", "setup_logger", "Cache"]
2
+
3
+
4
+ from base_api.modules import errors
5
+ from base_api.modules.config import config
6
+ from base_api.modules.progress_bars import Callback
7
+ from base_api.base import BaseCore, setup_logger, Cache
@@ -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
@@ -14,10 +15,11 @@ import threading
14
15
  from itertools import islice
15
16
  from collections import deque
16
17
  from functools import lru_cache
17
- from urllib.parse import urljoin
18
18
  from email.utils import parsedate_to_datetime
19
+ from datetime import datetime, timezone, timedelta
20
+ from urllib.parse import urljoin, urlparse, parse_qs
21
+ from concurrent.futures import ThreadPoolExecutor, wait, FIRST_COMPLETED
19
22
  from typing import Any, Dict, List, Optional, Union, Callable, Tuple, Iterable
20
- from concurrent.futures import ThreadPoolExecutor, as_completed, wait, FIRST_COMPLETED
21
23
 
22
24
  try:
23
25
  from modules.errors import *
@@ -34,6 +36,37 @@ loggers = {}
34
36
  HEIGHT_FROM_URI = re.compile(r'(?<!\d)(\d{3,4})[pP](?!\d)') # e.g., 1080p, 720P
35
37
 
36
38
 
39
+ def _normalize_quality_value(q) -> Union[str, int]:
40
+ if isinstance(q, int):
41
+ return q
42
+ s = str(q).lower().strip()
43
+ if s in {"best", "half", "worst"}:
44
+ return s
45
+ m = re.search(r'(\d{3,4})', s)
46
+ if m:
47
+ return int(m.group(1))
48
+ raise ValueError(f"Invalid quality: {q}")
49
+
50
+
51
+ def _choose_quality_from_list(available: List[str | int], target: Union[str, int]):
52
+ # available like ["240", "360", "480", "720", "1080"]
53
+ av = sorted({int(x) for x in available})
54
+ if isinstance(target, str):
55
+ if target == "best":
56
+ return av[-1]
57
+ if target == "worst":
58
+ return av[0]
59
+ if target == "half":
60
+ return av[len(av) // 2]
61
+ raise ValueError("Invalid label.")
62
+ # numeric: highest ≤ target, else closest
63
+ le = [h for h in av if h <= target]
64
+ if le:
65
+ return le[-1]
66
+ # fallback closest (ties -> higher)
67
+ return min(av, key=lambda h: (abs(h - target), -h))
68
+
69
+
37
70
  class ErrorVideo:
38
71
  """
39
72
  Why?:
@@ -278,6 +311,15 @@ class Cache:
278
311
 
279
312
  self.cache_dictionary[url] = content
280
313
 
314
+ def save_segments_to_cache(self, m3u8_url: str, segments: list):
315
+ with self.lock:
316
+ self.cache_dictionary[m3u8_url] = segments
317
+
318
+ def get_segments_from_cache(self, m3u8_url: str):
319
+ with self.lock:
320
+ segments = self.cache_dictionary.get(m3u8_url, None)
321
+ return segments
322
+
281
323
 
282
324
  class Helper:
283
325
  def __init__(
@@ -691,18 +733,12 @@ class BaseCore:
691
733
  raise KillSwitch("CRITICAL PROXY ERROR, CHECK LOGS!")
692
734
 
693
735
  def initialize_session(self):
694
- limits = httpx.Limits(
695
- max_connections=getattr(self.config, "max_connections", 128),
696
- max_keepalive_connections=getattr(self.config, "max_keepalive", 4),
697
- keepalive_expiry=10,
698
- )
699
- ctx = ssl.create_default_context()
736
+ ctx = ssl.create_default_context(cafile=certifi.where())
700
737
  if not self.config.verify_ssl:
701
738
  ctx.check_hostname = False
702
739
  ctx.verify_mode = ssl.CERT_NONE
703
740
 
704
741
  self.session = httpx.Client(
705
- limits=limits,
706
742
  proxy=self.config.proxy,
707
743
  timeout=self.config.timeout, # e.g. 5s on good connections
708
744
  http2=self.config.use_http2,
@@ -897,7 +933,14 @@ class BaseCore:
897
933
 
898
934
  # 410: permanent gone
899
935
  if status == 410:
900
- raise ResourceGone(f"Resource: {url} is Gone (410).")
936
+ self.logger.warning(f"""
937
+ The resource: {url} is gone. This can happen because of an expired token. I will try to fix this automatically, by creating
938
+ a new session and getting a fresh m3u8 URL. This fix is very experimental and I don't know if it works. """)
939
+ self.initialize_session()
940
+
941
+
942
+
943
+
901
944
 
902
945
  # 429: rate limited — respect Retry-After if present, else backoff and retry up to cap
903
946
  if status == 429:
@@ -1087,7 +1130,37 @@ class BaseCore:
1087
1130
  # Return rank numbers instead of heights if we truly can't infer—kept simple:
1088
1131
  return [i for i, _ in enumerate(by_bw, start=1)]
1089
1132
 
1133
+ def _to_epoch(self, v: str) -> int:
1134
+ n = int(v)
1135
+ return n // 1000 if n > 10 ** 12 else n # handle ms too
1136
+
1137
+ def m3u8_expiry_validto(self, url: str, safety_seconds: int = 60):
1138
+ q = {k: v[0] for k, v in parse_qs(urlparse(url).query).items() if v}
1139
+ vf = q.get("validfrom")
1140
+ vt = q.get("validto")
1141
+ if not vt:
1142
+ return None # not this scheme
1143
+ start_utc = datetime.fromtimestamp(self._to_epoch(vf), tz=timezone.utc) if vf else None
1144
+ expires_utc = datetime.fromtimestamp(self._to_epoch(vt), tz=timezone.utc)
1145
+ refresh_utc = expires_utc - timedelta(seconds=safety_seconds)
1146
+ secs_left = int((expires_utc - datetime.now(timezone.utc)).total_seconds())
1147
+ return {
1148
+ "start_utc": start_utc,
1149
+ "expires_utc": expires_utc,
1150
+ "refresh_utc": refresh_utc,
1151
+ "seconds_left": max(secs_left, 0),
1152
+ }
1153
+
1154
+ # Example
1155
+
1156
+
1090
1157
  def get_segments(self, m3u8_url_master: str, quality: Union[str, int]) -> list:
1158
+ _cache_url = f"{m3u8_url_master}{quality}"
1159
+ _segments: list | None = self.cache.get_segments_from_cache(_cache_url)
1160
+ if _segments:
1161
+ self.logger.info(f"Received: {len(_segments)} from cache!")
1162
+ return _segments
1163
+
1091
1164
  # Resolve the quality-specific playlist URL (may still be a master in some edge cases)
1092
1165
  playlist_url = self.get_m3u8_by_quality(m3u8_url=m3u8_url_master, quality=quality)
1093
1166
  self.logger.debug(f"Trying to fetch segment from m3u8 -> {playlist_url}")
@@ -1133,6 +1206,8 @@ class BaseCore:
1133
1206
  segments.append(urljoin(base_url, seg.uri))
1134
1207
 
1135
1208
  self.logger.debug(f"Fetched {len(segments)} segments from m3u8 URL (including init if present)")
1209
+ self.logger.info(f"Saving segments to cache....")
1210
+ self.cache.save_segments_to_cache(_cache_url, segments)
1136
1211
  return segments
1137
1212
 
1138
1213
  def download_segment(self, url: str, timeout: int) -> tuple[str, bytes, bool]:
@@ -1209,41 +1284,49 @@ class BaseCore:
1209
1284
  with open(tmp_path, "wb") as out_fp:
1210
1285
  # Submit all downloads
1211
1286
  with ThreadPoolExecutor(max_workers=workers) as ex:
1212
- futures = {ex.submit(self.download_segment, url, timeout): i for i, url in enumerate(segments)}
1287
+ # submit all segments
1288
+ pending = {ex.submit(self.download_segment, url, timeout): i
1289
+ for i, url in enumerate(segments)}
1213
1290
 
1214
- # Optional: per-segment retry budget (on top of fetch() retries)
1215
1291
  seg_retries = [0] * n
1216
1292
  max_seg_retries = 2
1217
1293
 
1218
- for fut in as_completed(futures):
1219
- i = futures[fut]
1220
- try:
1221
- _, data, success = fut.result()
1222
- except Exception as e:
1223
- # Defensive: unexpected crash in worker
1224
- self.logger.error(f"Worker exception for segment {i}: {e}")
1225
- success, data = False, b""
1226
-
1227
- if success and data:
1228
- parts[i] = data
1229
- else:
1230
- # Retry this segment a couple more times at the executor level
1231
- if seg_retries[i] < max_seg_retries:
1232
- seg_retries[i] += 1
1233
- # Resubmit and continue without touching progress
1234
- futures[ex.submit(self.download_segment, segments[i], timeout)] = i
1235
- continue
1236
- # Give up on this segment
1237
- failures += 1
1238
- parts[i] = b"" # placeholder (prevents NoneType issues)
1239
-
1240
- # After any completion (success or final failure), advance write cursor
1241
- while next_to_write < n and parts[next_to_write] is not None:
1242
- if parts[next_to_write]:
1243
- out_fp.write(parts[next_to_write])
1244
- next_to_write += 1
1245
- completed += 1
1246
- callback(completed, n)
1294
+ parts: List[Optional[bytes]] = [None] * n
1295
+ next_to_write = 0
1296
+ progressed = 0 # how many segments have finished (success or final failure)
1297
+
1298
+ while pending:
1299
+ done, _ = wait(pending, return_when=FIRST_COMPLETED)
1300
+
1301
+ for fut in done:
1302
+ i = pending.pop(fut)
1303
+ try:
1304
+ _, data, success = fut.result()
1305
+ except Exception as e:
1306
+ self.logger.error(f"Worker exception for segment {i}: {e}")
1307
+ success, data = False, b""
1308
+
1309
+ if success and data:
1310
+ parts[i] = data
1311
+ # count immediately: a segment finished successfully
1312
+ progressed += 1
1313
+ callback(progressed, n)
1314
+ else:
1315
+ # retry a couple times
1316
+ if seg_retries[i] < max_seg_retries:
1317
+ seg_retries[i] += 1
1318
+ pending[ex.submit(self.download_segment, segments[i], timeout)] = i
1319
+ continue
1320
+ # give up: mark placeholder and still advance progress
1321
+ parts[i] = b""
1322
+ progressed += 1
1323
+ callback(progressed, n)
1324
+
1325
+ # now try to flush any contiguous ready parts
1326
+ while next_to_write < n and parts[next_to_write] is not None:
1327
+ if parts[next_to_write]:
1328
+ out_fp.write(parts[next_to_write])
1329
+ next_to_write += 1
1247
1330
 
1248
1331
  if failures:
1249
1332
  # Clean temp file to avoid leaving corrupted output
@@ -1545,4 +1628,4 @@ You need to install `ffmpeg-progress-yield` to use the FFmpeg download mode.""")
1545
1628
  return True
1546
1629
 
1547
1630
  elif value.lower() in ("false", "0", "no"):
1548
- return False
1631
+ return False
@@ -7,7 +7,6 @@ class RuntimeConfig:
7
7
  self.max_bandwidth_mb = None # Set speed limit in megabytes per second e.g, 2.0, 3.5 etc...
8
8
  self.ffmpeg_path = "ffmpeg"
9
9
  self.proxy = None
10
- self.raise_bot_protection = False
11
10
  self.verify_ssl = True
12
11
  self.cookies = None
13
12
  self.locale = "en-US,en;q=0.9" # If you override this, it could change regexes and thus make stuff not work...
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: eaf_base_api
3
- Version: 2.4.8
3
+ Version: 2.5.0
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.8"
7
+ version = "2.5.0"
8
8
  description = "A base API for EchterAlsFake's Porn APIs"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.6"
@@ -1,5 +0,0 @@
1
- __all__ = ["BaseCore", "Callback", "config"]
2
-
3
- from base_api.base import BaseCore
4
- from base_api.modules.progress_bars import Callback
5
- from base_api.modules.config import config
File without changes
File without changes
File without changes
File without changes