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.
- {eaf_base_api-2.4.8/eaf_base_api.egg-info → eaf_base_api-2.5.0}/PKG-INFO +1 -1
- eaf_base_api-2.5.0/base_api/__init__.py +7 -0
- {eaf_base_api-2.4.8 → eaf_base_api-2.5.0}/base_api/base.py +125 -42
- {eaf_base_api-2.4.8 → eaf_base_api-2.5.0}/base_api/modules/config.py +0 -1
- {eaf_base_api-2.4.8 → eaf_base_api-2.5.0/eaf_base_api.egg-info}/PKG-INFO +1 -1
- {eaf_base_api-2.4.8 → eaf_base_api-2.5.0}/eaf_base_api.egg-info/top_level.txt +0 -1
- {eaf_base_api-2.4.8 → eaf_base_api-2.5.0}/pyproject.toml +1 -1
- eaf_base_api-2.4.8/base_api/__init__.py +0 -5
- {eaf_base_api-2.4.8 → eaf_base_api-2.5.0}/LICENSE +0 -0
- {eaf_base_api-2.4.8 → eaf_base_api-2.5.0}/MANIFEST.in +0 -0
- {eaf_base_api-2.4.8 → eaf_base_api-2.5.0}/README.md +0 -0
- {eaf_base_api-2.4.8 → eaf_base_api-2.5.0}/base_api/modules/__init__.py +0 -0
- {eaf_base_api-2.4.8 → eaf_base_api-2.5.0}/base_api/modules/errors.py +0 -0
- {eaf_base_api-2.4.8 → eaf_base_api-2.5.0}/base_api/modules/progress_bars.py +0 -0
- {eaf_base_api-2.4.8 → eaf_base_api-2.5.0}/eaf_base_api.egg-info/SOURCES.txt +0 -0
- {eaf_base_api-2.4.8 → eaf_base_api-2.5.0}/eaf_base_api.egg-info/dependency_links.txt +0 -0
- {eaf_base_api-2.4.8 → eaf_base_api-2.5.0}/eaf_base_api.egg-info/requires.txt +0 -0
- {eaf_base_api-2.4.8 → eaf_base_api-2.5.0}/setup.cfg +0 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
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...
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|