eaf-base-api 2.3.8__tar.gz → 2.4.2__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.
@@ -0,0 +1,2 @@
1
+ include README.md
2
+ include LICENSE
@@ -1,32 +1,22 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: eaf_base_api
3
- Version: 2.3.8
3
+ Version: 2.4.2
4
4
  Summary: A base API for EchterAlsFake's Porn APIs
5
- Home-page: https://github.com/EchterAlsFake/eaf_base_api
6
- Author: Johannes Habel
7
- Author-email: EchterAlsFake@proton.me
5
+ Author-email: Johannes Habel <EchterAlsFake@proton.me>
8
6
  License: LGPLv3
7
+ Project-URL: Homepage, https://github.com/EchterAlsFake/eaf_base_api
9
8
  Classifier: License :: OSI Approved :: GNU Lesser General Public License v3 (LGPLv3)
10
9
  Classifier: Programming Language :: Python
10
+ Requires-Python: >=3.6
11
11
  Description-Content-Type: text/markdown
12
12
  License-File: LICENSE
13
13
  Requires-Dist: m3u8
14
14
  Requires-Dist: httpx
15
- Provides-Extra: remux
16
- Requires-Dist: av; extra == "remux"
15
+ Provides-Extra: av
16
+ Requires-Dist: av; extra == "av"
17
17
  Provides-Extra: ffmpeg
18
18
  Requires-Dist: ffmpeg-progress-yield; extra == "ffmpeg"
19
- Dynamic: author
20
- Dynamic: author-email
21
- Dynamic: classifier
22
- Dynamic: description
23
- Dynamic: description-content-type
24
- Dynamic: home-page
25
- Dynamic: license
26
19
  Dynamic: license-file
27
- Dynamic: provides-extra
28
- Dynamic: requires-dist
29
- Dynamic: summary
30
20
 
31
21
  # EAF Base API
32
22
 
@@ -4,11 +4,12 @@ import sys
4
4
  import time
5
5
  import m3u8
6
6
  import httpx
7
+ import random
7
8
  import logging
8
9
  import traceback
9
10
  import threading
10
11
 
11
- from typing import Union
12
+ from typing import Any, Dict, List, Optional, Union
12
13
  from functools import lru_cache
13
14
  from urllib.parse import urljoin
14
15
  from concurrent.futures import ThreadPoolExecutor, as_completed
@@ -23,11 +24,215 @@ except (ModuleNotFoundError, ImportError):
23
24
  from .modules.config import config
24
25
  from .modules.progress_bars import Callback
25
26
 
27
+ user_agents = [
28
+ "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
29
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.1",
30
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.3",
31
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.0.0 Safari/537.3",
32
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0."
33
+ ]
34
+
26
35
  loggers = {}
36
+ HEIGHT_FROM_URI = re.compile(r'(?<!\d)(\d{3,4})[pP](?!\d)') # e.g., 1080p, 720P
37
+
38
+ def _height_from_variant(variant) -> Optional[int]:
39
+ """Extract height from a variant:
40
+ 1) stream_info.resolution (w, h)
41
+ 2) URI pattern like .../720p/...
42
+ """
43
+ if getattr(variant, "stream_info", None) and variant.stream_info.resolution:
44
+ _, h = variant.stream_info.resolution # (w, h)
45
+ return int(h)
46
+
47
+ if variant.uri:
48
+ m = HEIGHT_FROM_URI.search(variant.uri)
49
+ if m:
50
+ return int(m.group(1))
51
+
52
+ return None
53
+
54
+ def _is_video_playlist(variant) -> bool:
55
+ """Filter out I-frames/audio-only playlists."""
56
+ # m3u8 lib sometimes sets is_iframe if EXT-X-I-FRAME-STREAM-INF is present.
57
+ if getattr(variant, "is_iframe", False):
58
+ return False
59
+
60
+ # If codecs known and contain only audio (mp4a, ac-3, ec-3, etc.)
61
+ codecs = getattr(variant.stream_info, "codecs", None) if getattr(variant, "stream_info", None) else None
62
+ if codecs:
63
+ # very light heuristic: if no video codec substring, probably audio-only.
64
+ # video: avc1, hvc1, hev1, vp9, av01, dvh
65
+ if not any(v in codecs.lower() for v in ("avc1", "hvc1", "hev1", "av01", "vp9", "dvh")):
66
+ return False
67
+
68
+ return True
69
+
70
+ def _collect_variants(master: m3u8.M3U8) -> List[Dict[str, Any]]:
71
+ """Normalize playlist variants to a comparable list."""
72
+ items: List[Dict[str, Any]] = []
73
+ for v in master.playlists:
74
+ if not _is_video_playlist(v):
75
+ continue
76
+
77
+ h = _height_from_variant(v)
78
+ bw = getattr(v.stream_info, "bandwidth", 0) if getattr(v, "stream_info", None) else 0
79
+ fr = getattr(v.stream_info, "frame_rate", 0.0) if getattr(v, "stream_info", None) else 0.0
80
+ items.append({
81
+ "uri": v.uri,
82
+ "height": h, # may be None
83
+ "bandwidth": int(bw or 0),
84
+ "frame_rate": float(fr or 0.0),
85
+ "resolution": getattr(v.stream_info, "resolution", None) if getattr(v, "stream_info", None) else None,
86
+ "raw": v
87
+ })
88
+ return items
89
+
90
+ def _pick_by_label(variants: List[Dict[str, Any]], label: str) -> Dict[str, Any]:
91
+ """best / worst / half based on a combined rank by (height, bandwidth)."""
92
+ # rank by height first, then bandwidth as tiebreaker
93
+ def key_fn(v):
94
+ return (v["height"] or 0, v["bandwidth"])
95
+ ordered = sorted(variants, key=key_fn)
96
+
97
+ if not ordered:
98
+ raise ValueError("No video variants available in master playlist.")
99
+
100
+ if label == "worst":
101
+ return ordered[0]
102
+ elif label == "half":
103
+ return ordered[len(ordered)//2]
104
+ elif label == "best":
105
+ return ordered[-1]
106
+ else:
107
+ raise ValueError("Invalid quality label.")
108
+
109
+ def _normalize_quality(quality: Union[str, int]) -> Union[str, int]:
110
+ """Convert '1080p'->1080, '720'->720, keep labels as-is."""
111
+ if isinstance(quality, int):
112
+ return quality
113
+ q = str(quality).strip().lower()
114
+ if q in {"best", "worst", "half"}:
115
+ return q
116
+ m = re.search(r'(\d{3,4})', q)
117
+ if m:
118
+ return int(m.group(1))
119
+ raise ValueError(f"Invalid quality value: {quality!r}")
120
+
121
+ def _pick_by_height(variants: List[Dict[str, Any]], target: int) -> Dict[str, Any]:
122
+ """Choose the highest height ≤ target; else closest by absolute diff (ties -> higher)."""
123
+ with_height = [v for v in variants if v["height"] is not None]
124
+ if with_height:
125
+ # Prefer height <= target
126
+ below_eq = [v for v in with_height if v["height"] <= target]
127
+ if below_eq:
128
+ # Among same height, prefer higher bandwidth then higher fps
129
+ best = sorted(below_eq, key=lambda v: (v["height"], v["bandwidth"], v["frame_rate"]))[-1]
130
+ return best
131
+
132
+ # Fallback: closest by absolute diff; ties -> higher height
133
+ def diff_key(v):
134
+ return (abs(v["height"] - target), -v["height"], v["bandwidth"], v["frame_rate"])
135
+ return sorted(with_height, key=diff_key)[0]
136
+
137
+ # If we have no heights at all, fall back to bandwidth ranking
138
+ return sorted(variants, key=lambda v: v["bandwidth"])[-1]
139
+
140
+
27
141
 
28
142
  # Default formatter
29
143
  formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
30
144
 
145
+ _CF_BODY_MARKERS = (
146
+ "cf-browser-verification",
147
+ "cf_chl_", # legacy CF challenge tokens
148
+ "challenges.cloudflare.com", # Turnstile/challenge host
149
+ "cf-turnstile", # Turnstile widget
150
+ "ddos protection by",
151
+ "just a moment...",
152
+ "please enable javascript and cookies",
153
+ "attention required!",
154
+ )
155
+ # Other common WAF/CDN markers
156
+ _OTHER_BODY_MARKERS = (
157
+ "akamai bot manager",
158
+ "bm_sz=", "abck=", "ak_bmsc", "ak_bm", # Akamai cookies
159
+ "request unsuccessful. incapsula incident id", # Imperva/Incapsula
160
+ )
161
+
162
+ def _is_text_html(resp) -> bool:
163
+ ct = resp.headers.get("content-type", "").lower()
164
+ return "text/html" in ct or ct == "" # some sites omit CT but send HTML
165
+
166
+ def _header_has_any(hdr_val: str, *needles: str) -> bool:
167
+ s = (hdr_val or "").lower()
168
+ return any(n in s for n in needles)
169
+
170
+ def _body_has_any(body: str, markers: tuple) -> bool:
171
+ b = (body or "").lower()
172
+ return any(m in b for m in markers)
173
+
174
+ def _looks_like_challenge(resp) -> str | None:
175
+ """
176
+ Return a human-readable reason if the response looks like a bot/WAF challenge.
177
+ Return None if it looks like a normal page.
178
+ """
179
+ status = resp.status_code
180
+ server = resp.headers.get("server", "")
181
+ set_cookie = resp.headers.get("set-cookie", "")
182
+
183
+ # Collect cheap, header-based indicators first
184
+ has_cf_cookie = _header_has_any(set_cookie, "__cf_bm", "cf_clearance", "__cf_chl")
185
+ served_by_cf = _header_has_any(server, "cloudflare")
186
+ served_by_akamai = _header_has_any(server, "akamai")
187
+ served_by_incapsula = _header_has_any(server, "incapsula")
188
+
189
+ # History-based indicators (redirect to /cdn-cgi/ etc.)
190
+ history_urls = []
191
+ try:
192
+ for h in getattr(resp, "history", []) or []:
193
+ history_urls.append(str(getattr(h, "url", "")))
194
+ except Exception:
195
+ pass
196
+ redirected_via_cdn_cgi = any("/cdn-cgi/" in u for u in history_urls)
197
+
198
+ # Read a *small* slice of the body only if it’s HTML-like
199
+ body_snippet = ""
200
+ if _is_text_html(resp):
201
+ try:
202
+ # Read up to 20KB to capture challenge markup without huge memory hit
203
+ text = resp.text
204
+ body_snippet = text[:20000]
205
+ except Exception:
206
+ body_snippet = ""
207
+
208
+ has_cf_body_markers = _body_has_any(body_snippet, _CF_BODY_MARKERS)
209
+ has_other_waf_markers = _body_has_any(body_snippet, _OTHER_BODY_MARKERS)
210
+
211
+ # Heuristics:
212
+ # 1) Hard failure statuses usually mean a challenge/waf
213
+ if status in (403, 429, 503):
214
+ if served_by_cf or has_cf_cookie or redirected_via_cdn_cgi or has_cf_body_markers or has_other_waf_markers:
215
+ return (f"HTTP {status} with WAF indicators "
216
+ f"(server={server!r}, cf_cookie={has_cf_cookie}, "
217
+ f"cdn_cgi_redirect={redirected_via_cdn_cgi}, body_markers={has_cf_body_markers or has_other_waf_markers})")
218
+
219
+ # 2) Status 200 but body is a *challenge/interstitial*
220
+ # Require strong evidence in the body or cookies, not just 'Server: cloudflare'.
221
+ if status == 200:
222
+ if has_cf_cookie or redirected_via_cdn_cgi or has_cf_body_markers or has_other_waf_markers:
223
+ return (f"200 OK but challenge content detected "
224
+ f"(server={server!r}, cf_cookie={has_cf_cookie}, "
225
+ f"cdn_cgi_redirect={redirected_via_cdn_cgi}, body_markers={has_cf_body_markers or has_other_waf_markers})")
226
+
227
+ # 3) Generic: if served by a known WAF/CDN and body is *obviously* an interstitial
228
+ if served_by_cf and has_cf_body_markers:
229
+ return "Cloudflare challenge markers present in body"
230
+ if served_by_akamai and has_other_waf_markers:
231
+ return "Akamai Bot Manager markers present in body"
232
+ if served_by_incapsula and has_other_waf_markers:
233
+ return "Imperva/Incapsula challenge markers present in body"
234
+
235
+ return None
31
236
 
32
237
  def is_android():
33
238
  """Detects if the script is running on an Android device."""
@@ -50,6 +255,7 @@ LOG_COLORS = {
50
255
  }
51
256
  RESET_COLOR = "\033[0m"
52
257
 
258
+
53
259
  class ColoredFormatter(logging.Formatter):
54
260
  def format(self, record):
55
261
  log_message = super().format(record)
@@ -168,7 +374,7 @@ class BaseCore:
168
374
  """
169
375
  The base class which has all necessary functions for other API packages
170
376
  """
171
- def __init__(self, config=config, auto_init: bool = True):
377
+ def __init__(self, config=config, auto_init: bool = True, headers: dict = None):
172
378
  self.last_request_time = time.time()
173
379
  self.total_requests = 0 # Tracks how many requests have been made
174
380
  self.session = None
@@ -177,7 +383,7 @@ class BaseCore:
177
383
  self.cache = Cache(self.config)
178
384
  self.logger = setup_logger("BASE API - [BaseCore]", log_file=False, level=logging.ERROR)
179
385
  if auto_init:
180
- self.initialize_session()
386
+ self.initialize_session(headers)
181
387
 
182
388
  def enable_logging(self, log_file=None, level=logging.DEBUG, log_ip=None, log_port=None):
183
389
  """
@@ -186,11 +392,6 @@ class BaseCore:
186
392
  self.logger = setup_logger(name="BASE API - [BaseCore]", log_file=log_file, level=level, http_ip=log_ip, http_port=log_port)
187
393
  self.cache.logger = setup_logger(name="BASE API - [Cache]", log_file=log_file, level=level, http_ip=log_ip, http_port=log_port)
188
394
 
189
- def update_user_agent(self):
190
- """Updates the User-Agent"""
191
- self.config.rotate_user_agent()
192
- self.session.headers.update({"User-Agent": self.config.headers["User-Agent"]})
193
-
194
395
  def update_cookies(self):
195
396
  """Updates cookies dynamically"""
196
397
  self.session.cookies.update(self.config.cookies)
@@ -204,7 +405,7 @@ class BaseCore:
204
405
  proxy_ip = self.config.proxy
205
406
  pattern = re.compile(
206
407
  r'^(?P<scheme>http|socks5)://'
207
- r'(?:(?:\w+:\w+)@)?' # optional user:pass@
408
+ r'(?:\w+:\w+@)?' # optional user:pass@
208
409
  r'(?P<host>[a-zA-Z0-9.-]+)'
209
410
  r':(?P<port>\d{1,5})$'
210
411
  )
@@ -233,15 +434,22 @@ class BaseCore:
233
434
  raise KillSwitch("CRITICAL PROXY ERROR, CHECK LOGS!")
234
435
 
235
436
 
236
- def initialize_session(self): # Disable SSL verification only if you really need it....
437
+ def initialize_session(self, headers: dict = None, cookies: dict = None): # Disable SSL verification only if you really need it....
237
438
  self.session = httpx.Client(
238
439
  proxy=self.config.proxy,
239
- headers=self.config.headers,
240
440
  timeout=self.config.timeout,
241
441
  follow_redirects=True,
242
442
  verify=self.config.verify_ssl
243
443
  )
244
444
 
445
+ self.session.headers.update({"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36"})
446
+
447
+ if headers:
448
+ self.session.headers.update(headers)
449
+
450
+ if cookies:
451
+ self.session.cookies.update(cookies)
452
+
245
453
  def update_headers(self, headers: dict):
246
454
  self.session.headers.update(headers)
247
455
 
@@ -275,6 +483,7 @@ class BaseCore:
275
483
  support for proxies and custom timeout.
276
484
  """
277
485
  # Check cache first
486
+ last_error = None
278
487
 
279
488
  if timeout is None:
280
489
  timeout = self.config.timeout # pls don't ask thanks
@@ -289,10 +498,6 @@ class BaseCore:
289
498
  time.sleep(1.5 * attempt) # Sleeping for 1.5 seconds to minimize site overload when doing a lot of requests
290
499
 
291
500
  try:
292
- # Update user agent periodically
293
- if self.total_requests % 30 == 0: # Changing all 30 attempts
294
- self.update_user_agent()
295
-
296
501
  self.enforce_delay()
297
502
 
298
503
  if self.kill_switch and not bypass_kill_switch:
@@ -303,6 +508,23 @@ class BaseCore:
303
508
  follow_redirects=allow_redirects, data=data)
304
509
  self.total_requests += 1
305
510
 
511
+ # NEW: bot/challenge detection
512
+ reason = _looks_like_challenge(response)
513
+ if reason:
514
+ # Log a concise diagnostic with a small body preview
515
+ body_preview = ""
516
+ try:
517
+ if _is_text_html(response):
518
+ body_preview = response.text[:400]
519
+ except Exception:
520
+ pass
521
+ self.logger.error(
522
+ "Bot protection detected for %s: %s | Body[:400]=%r",
523
+ url, reason, body_preview
524
+ )
525
+ if config.raise_bot_protection:
526
+ raise BotProtectionDetected(f"{reason} at {url}")
527
+
306
528
  # Log and handle non-200 status codes
307
529
  if response.status_code != 200:
308
530
  self.logger.warning(
@@ -312,10 +534,22 @@ class BaseCore:
312
534
  self.logger.error(f"URL: {url} Resource not found (404). This may indicate the content is unavailable.")
313
535
  return response # Return None for unavailable resources
314
536
 
537
+ if response.status_code == 403:
538
+ time.sleep(2) # Somehow fixes 403 on missav idk man
539
+ self.session.headers.update({
540
+ "User-Agent": random.choice(user_agents)
541
+ })
542
+
543
+ self.logger.warning(f"Switched User agent to: {self.session.headers['User-Agent']}")
544
+
545
+
315
546
  elif response.status_code == 403 and attempt >= 2:
316
547
  self.logger.error(f"The website rejected access after {attempt} tries. Aborting!")
317
548
  return None # Return None for forbidden resources
318
549
 
550
+ elif response.status_code == 410:
551
+ raise ResourceGone(f"Resource: {url} is Gone.")
552
+
319
553
  self.logger.info(f"Retrying ({attempt}/{self.config.max_retries}) for URL: {url}")
320
554
  continue # Retry for other non-200 status codes
321
555
 
@@ -392,11 +626,14 @@ class BaseCore:
392
626
  self.logger.error(f"Proxy Error, please switch to another Proxy, it seems to be bad -->: {e}")
393
627
  raise KillSwitch("Proxy error when trying a request, Aborting!")
394
628
 
395
- except Exception:
629
+ except Exception as e:
630
+ last_error = e
396
631
  self.logger.error(f"Attempt {attempt}: Unexpected error for URL {url}: {traceback.format_exc()}")
397
632
  raise UnknownError(f"Unexpected error for URL {url}: {traceback.format_exc()}")
398
633
 
399
634
  self.logger.error(f"Failed to fetch URL {url} after {self.config.max_retries} attempts.")
635
+ if last_error:
636
+ pass
400
637
  return None # Return None if all attempts fail
401
638
 
402
639
  @classmethod
@@ -430,67 +667,88 @@ class BaseCore:
430
667
  return sanitized[:max_length]
431
668
 
432
669
  @lru_cache(maxsize=250)
433
- def get_m3u8_by_quality(self, m3u8_url: str, quality: str) -> str:
434
- """Fetches the m3u8 URL for a given quality by extracting all possible sub-m3u8 URLs from the primary
435
- m3u8 playlist"""
436
- if "#" in m3u8_url:
437
- playlist = m3u8.loads(m3u8_url)
438
- self.logger.debug("Resolved custom m3u8")
670
+ def get_m3u8_by_quality(self, m3u8_url: str, quality: Union[str, int]) -> str:
671
+ """
672
+ Return the media-playlist URL for the requested quality.
439
673
 
674
+ quality:
675
+ - 'best' | 'half' | 'worst'
676
+ - 1080 / '1080' / '1080p' (and similar)
677
+ """
678
+ # Resolve master content
679
+ if m3u8_url.lstrip().startswith("#EXTM3U"):
680
+ master = m3u8.loads(m3u8_url)
681
+ self.logger.debug("Resolved inline/custom m3u8 master content.")
682
+ base_for_join = "" # URIs should be absolute in inline cases; join will handle if relative
440
683
  else:
441
- playlist_content = self.fetch(url=m3u8_url)
442
- playlist = m3u8.loads(playlist_content)
443
- self.logger.debug(f"Resolved m3u8 playlist: {m3u8_url}")
444
-
445
- if not playlist.is_variant:
446
- raise ValueError("Provided URL is not a master playlist.")
447
-
448
- # Extract available qualities and URLs
449
- qualities = {
450
- (variant.stream_info.resolution or "unknown"): variant.uri
451
- for variant in playlist.playlists
452
- } # Fetches the available qualities
684
+ content = self.fetch(url=m3u8_url)
685
+ master = m3u8.loads(content)
686
+ base_for_join = m3u8_url
687
+ self.logger.debug(f"Resolved m3u8 master: {m3u8_url}")
453
688
 
454
- # Sort qualities by resolution (width x height)
455
- sorted_qualities = sorted(
456
- qualities.items(),
457
- key=lambda x: (x[0][0] * x[0][1]) if x[0] != "unknown" else 0
458
- ) # Sorts qualities to support different HLS naming schemes
689
+ if not master.is_variant:
690
+ raise ValueError("Provided URL/content is not a master playlist.")
459
691
 
460
- # Select based on quality preference
461
- if quality == "best":
462
- url_tmp = sorted_qualities[-1][1] # Highest resolution URL
692
+ variants = _collect_variants(master)
693
+ if not variants:
694
+ raise ValueError("No usable video variants found in master playlist.")
463
695
 
464
- elif quality == "worst":
465
- url_tmp = sorted_qualities[0][1] # Lowest resolution URL
696
+ q = _normalize_quality(quality)
697
+ if isinstance(q, str): # 'best'/'half'/'worst'
698
+ chosen = _pick_by_label(variants, q)
699
+ else: # numeric height like 1080, 720, etc.
700
+ chosen = _pick_by_height(variants, q)
466
701
 
467
- elif quality == "half":
468
- url_tmp = sorted_qualities[len(sorted_qualities) // 2][1] # Mid-quality URL
702
+ full_url = urljoin(base_for_join or m3u8_url, chosen["uri"])
703
+ return full_url
469
704
 
705
+ def list_available_qualities(self, m3u8_url: str) -> List[int]:
706
+ """
707
+ Inspect the master playlist and return sorted unique heights (e.g., [240, 360, 480, 720, 1080]).
708
+ """
709
+ if m3u8_url.lstrip().startswith("#EXTM3U"):
710
+ master = m3u8.loads(m3u8_url)
470
711
  else:
471
- raise ValueError("Invalid quality provided.")
472
-
473
- full_url = urljoin(m3u8_url, url_tmp) # Merges the primary with the new URL to get the full URL path
474
- return full_url
712
+ content = self.fetch(url=m3u8_url)
713
+ master = m3u8.loads(content)
714
+
715
+ if not master.is_variant:
716
+ return []
717
+
718
+ heights = {h for h in (_height_from_variant(v) for v in master.playlists) if h is not None}
719
+ if heights:
720
+ return sorted(heights)
721
+ # fallback: bandwidth-only (roughly infer tiers)
722
+ by_bw = sorted(
723
+ (getattr(v.stream_info, "bandwidth", 0) for v in master.playlists if _is_video_playlist(v)),
724
+ key=int
725
+ )
726
+ # Return rank numbers instead of heights if we truly can't infer—kept simple:
727
+ return [i for i, _ in enumerate(by_bw, start=1)]
475
728
 
476
- @lru_cache(maxsize=250)
477
- def get_segments(self, m3u8_url_master: str, quality: str) -> list:
478
- """Gets all video segments from a given quality and the primary m3u8 URL"""
729
+ def get_segments(self, m3u8_url_master: str, quality: Union[str, int]) -> list:
479
730
  m3u8_url = self.get_m3u8_by_quality(m3u8_url=m3u8_url_master, quality=quality)
480
- self.logger.debug(f"Trying to fetch segment from m3u8 ->: {m3u8_url}")
731
+ self.logger.debug(f"Trying to fetch segment from m3u8 -> {m3u8_url}")
481
732
  content = self.fetch(url=m3u8_url)
482
733
 
734
+ segments = []
483
735
  m3u8_processed = m3u8.loads(content)
484
736
 
485
737
  if m3u8_processed.is_variant:
486
- self.logger.warning("The M3U8 Playlist is not a media playlist. Trying to resolve to the first m3u8...")
738
+ self.logger.warning("Media playlist expected; got variant. Resolving to first sub-playlist...")
487
739
  new_m3u8 = urljoin(m3u8_url, m3u8_processed.playlists[0].uri)
488
740
  self.logger.info(f"Resolved to new URL: {new_m3u8}")
489
741
  content = self.fetch(url=new_m3u8)
490
742
  m3u8_processed = m3u8.loads(content)
491
743
 
492
- segments = [urljoin(m3u8_url, seg.uri) for seg in m3u8_processed.segments]
493
- self.logger.debug(f"Fetched {len(segments)} segments from m3u8 URL")
744
+ if getattr(m3u8_processed, "segment_map", None):
745
+ init_uri = m3u8_processed.segment_map[0].uri
746
+ init_url = urljoin(m3u8_url, init_uri)
747
+ segments.append(init_url)
748
+ self.logger.debug(f"Found init segment: {init_url}")
749
+
750
+ segments.extend(urljoin(m3u8_url, seg.uri) for seg in m3u8_processed.segments)
751
+ self.logger.debug(f"Fetched {len(segments)} segments from m3u8 URL (including init if present)")
494
752
  return segments
495
753
 
496
754
  def download_segment(self, url: str, timeout: int) -> tuple[str, bytes, bool]:
@@ -597,31 +855,92 @@ class BaseCore:
597
855
 
598
856
  def _convert_ts_to_mp4(self, input_path: str, output_path: str, callback=None):
599
857
  try:
600
- import av
601
- except (ModuleNotFoundError, ImportError):
602
- raise ModuleNotFoundError("PyAV is required for remuxing. Install with `pip install av`. Not supported on Termux!")
603
-
604
- input_ = av.open(input_path)
605
- output = av.open(output_path, "w")
858
+ from av import open as av_open
859
+ from av.audio.resampler import AudioResampler
860
+ except (ModuleNotFoundError, ImportError) as e:
861
+ raise ModuleNotFoundError(f"PyAV is required for remuxing. Install with pip install av. Not supported on Termux! {e}")
862
+
863
+ input_ = av_open(input_path)
864
+ output = av_open(output_path, mode="w", format="mp4", options={"movflags": "faststart"})
865
+
866
+ # --- VIDEO: keep your exact remux approach ---
867
+ in_video = input_.streams.video[0]
868
+ out_video = output.add_stream_from_template(template=in_video)
869
+
870
+ # --- AUDIO: copy if MP4-compatible; else transcode to AAC ---
871
+ in_audio = next((s for s in input_.streams if s.type == "audio"), None)
872
+ out_audio = None
873
+ transcode_audio = False
874
+ resampler = None
875
+
876
+ if in_audio:
877
+ # Common MP4-safe audio codecs to copy without transcoding.
878
+ copy_ok = {"aac", "alac", "mp3"}
879
+ codec_name = (in_audio.codec_context.name or "").lower()
880
+
881
+ if codec_name in copy_ok:
882
+ out_audio = output.add_stream_from_template(template=in_audio)
883
+ else:
884
+ # Build an AAC encoder stream. Match input rate/layout where possible.
885
+ transcode_audio = True
886
+ sample_rate = in_audio.codec_context.sample_rate or 48000
887
+ layout = (
888
+ in_audio.codec_context.layout.name
889
+ if getattr(in_audio.codec_context, "layout", None)
890
+ else "stereo"
891
+ )
892
+ out_audio = output.add_stream("aac", rate=sample_rate)
893
+ # Setting layout/channels helps encoder pick sensible defaults.
894
+ try:
895
+ out_audio.layout = layout
896
+ except Exception:
897
+ pass
606
898
 
607
- in_stream = input_.streams.video[0]
608
- out_stream = output.add_stream_from_template(template=in_stream)
899
+ # Ensure frames handed to the AAC encoder are in a compatible sample format.
900
+ # ('fltp' is the usual format for AAC encoders.)
901
+ resampler = AudioResampler(format="fltp", layout=layout, rate=sample_rate)
609
902
 
610
- packets = list(input_.demux(in_stream))
903
+ # --- DEMUX both streams so audio is included ---
904
+ demux_streams = [in_video] + ([in_audio] if in_audio else [])
905
+ packets = list(input_.demux(demux_streams)) # keeps your progress logic
611
906
  total = len(packets)
612
907
 
613
908
  for idx, packet in enumerate(packets):
614
909
  if packet.dts is None:
910
+ if callback:
911
+ callback(idx + 1, total)
615
912
  continue
616
- packet.stream = out_stream
617
- output.mux(packet)
913
+
914
+ if packet.stream == in_video:
915
+ # Your original video remux path (no explicit rescale).
916
+ packet.stream = out_video
917
+ output.mux(packet)
918
+
919
+ elif in_audio and packet.stream == in_audio:
920
+ if not transcode_audio:
921
+ # Audio is already MP4-compatible; just remux.
922
+ packet.stream = out_audio
923
+ output.mux(packet)
924
+ else:
925
+ # Decode -> (optionally resample) -> encode AAC -> mux.
926
+ for frame in packet.decode():
927
+ # Resample to match encoder expectations.
928
+ frames = resampler.resample(frame) if resampler else [frame]
929
+ for f in frames:
930
+ for enc_pkt in out_audio.encode(f):
931
+ output.mux(enc_pkt)
932
+
618
933
  if callback:
619
934
  callback(idx + 1, total)
620
935
 
936
+ # Flush audio encoder if we transcoded.
937
+ if transcode_audio and out_audio:
938
+ for enc_pkt in out_audio.encode(None):
939
+ output.mux(enc_pkt)
940
+
621
941
  input_.close()
622
942
  output.close()
623
943
 
624
-
625
944
  def FFMPEG(self, video, quality: str, callback, path: str) -> bool:
626
945
  try:
627
946
  from ffmpeg_progress_yield import FfmpegProgress
@@ -656,39 +975,91 @@ class BaseCore:
656
975
 
657
976
  return False
658
977
 
659
- def legacy_download(self, path: str, url: str, callback=None) -> bool:
978
+ def legacy_download(self, path: str, url: str, callback=None,
979
+ chunk_size: int = 1 << 20, # 1 MiB
980
+ max_retries: int = 5,
981
+ read_timeout: float = 120.0) -> bool:
660
982
  """
661
- Download a file using streaming, with support for progress updates.
983
+ Download a file using streaming with stall tolerance and resume.
984
+ Assumes self.session is an httpx.Client.
662
985
  """
663
986
  try:
664
- with self.session.stream("GET", url, timeout=30) as response: # Use a reasonable timeout
665
- response.raise_for_status()
666
- file_size = int(response.headers.get('content-length', 0))
987
+ # progress UI fallback
988
+ progress_bar = None
989
+ if callback is None:
990
+ progress_bar = Callback()
667
991
 
668
- if callback is None:
669
- progress_bar = Callback()
992
+ # how many bytes we already have (for resume)
993
+ downloaded_so_far = os.path.getsize(path) if os.path.exists(path) else 0
994
+ etag = None
995
+ attempt = 0
670
996
 
671
- downloaded_so_far = 0
997
+ # prefer a longer read timeout to ride out throttling pauses
998
+ timeout = httpx.Timeout(connect=10.0, read=read_timeout, write=30.0, pool=30.0)
672
999
 
673
- # Open file for writing
674
- with open(path, 'wb') as file:
675
- for chunk in response.iter_bytes(chunk_size=1024):
676
- if not chunk:
677
- break # End of stream
678
-
679
- file.write(chunk)
680
- downloaded_so_far += len(chunk)
681
-
682
- # Update progress
683
- if callback:
684
- callback(downloaded_so_far, file_size)
685
- else:
686
- progress_bar.text_progress_bar(downloaded=downloaded_so_far, total=file_size)
687
-
688
- if not callback:
689
- del progress_bar
690
-
691
- return True
1000
+ while True:
1001
+ headers = {
1002
+ }
1003
+ if downloaded_so_far:
1004
+ headers["Range"] = f"bytes={downloaded_so_far}-"
1005
+
1006
+ # stream the next segment (or whole file if starting fresh)
1007
+ with self.session.stream("GET", url, headers=headers,
1008
+ follow_redirects=True, timeout=timeout) as response:
1009
+ # If we asked for a Range and got 200, server ignored it: start over.
1010
+ if downloaded_so_far and response.status_code == 200:
1011
+ downloaded_so_far = 0 # restart from scratch
1012
+ response.raise_for_status()
1013
+
1014
+ # track ETag to detect mid-download content changes
1015
+ etag_cur = response.headers.get("ETag")
1016
+ if etag is None:
1017
+ etag = etag_cur
1018
+ elif etag_cur and etag_cur != etag:
1019
+ raise RuntimeError("Remote content changed during download")
1020
+
1021
+ total = None
1022
+ # Prefer Content-Range total when resuming; else fall back to Content-Length
1023
+ cr = response.headers.get("Content-Range") # e.g., "bytes start-end/total"
1024
+ if cr and "/" in cr:
1025
+ try:
1026
+ total = int(cr.rsplit("/", 1)[1])
1027
+ except ValueError:
1028
+ total = None
1029
+ if total is None:
1030
+ try:
1031
+ total = int(response.headers.get("Content-Length", "0")) or None
1032
+ except ValueError:
1033
+ total = None
1034
+
1035
+ mode = "ab" if downloaded_so_far else "wb"
1036
+ with open(path, mode) as file:
1037
+ try:
1038
+ for chunk in response.iter_bytes(chunk_size=chunk_size):
1039
+ if not chunk:
1040
+ continue
1041
+ file.write(chunk)
1042
+ downloaded_so_far += len(chunk)
1043
+
1044
+ # update progress
1045
+ if callback:
1046
+ callback(downloaded_so_far, total or 0)
1047
+
1048
+ else:
1049
+ progress_bar.text_progress_bar(downloaded=downloaded_so_far, total=total or 0)
1050
+
1051
+ # finished successfully
1052
+ if progress_bar:
1053
+ del progress_bar
1054
+ return True
1055
+
1056
+ except httpx.ReadTimeout:
1057
+ # stall: back off and retry from current offset
1058
+ attempt += 1
1059
+ if attempt > max_retries:
1060
+ raise
1061
+ time.sleep(min(2 ** attempt, 30))
1062
+ continue # loop retries with Range # let him coook!!!
692
1063
 
693
1064
  except httpx.StreamClosed:
694
1065
  self.logger.error(f"Stream for: {url} was closed. This should not happen...")
@@ -711,9 +1082,15 @@ class BaseCore:
711
1082
 
712
1083
  return path
713
1084
 
714
- @classmethod
715
- def truncate(cls, string_lol) -> str:
716
- return string_lol[:200]
1085
+ def truncate(self, name: str, max_bytes: int = 245) -> str: # only 245, because we need to append .mp4
1086
+ encoded = name.encode("utf-8")
1087
+ if len(encoded) > max_bytes:
1088
+ encoded = encoded[:max_bytes]
1089
+ # Ensure not to cut in middle of a UTF-8 sequence
1090
+ while encoded[-1] & 0b11000000 == 0b10000000:
1091
+ encoded = encoded[:-1]
1092
+ return encoded.decode("utf-8", errors="ignore")
1093
+ return name
717
1094
 
718
1095
  @staticmethod
719
1096
  def str_to_bool(value):
@@ -0,0 +1,15 @@
1
+ class RuntimeConfig:
2
+ def __init__(self):
3
+ self.max_cache_items = 200
4
+ self.max_retries = 4
5
+ self.request_delay = 0
6
+ self.timeout = 20
7
+ self.max_bandwidth_mb = None # Set speed limit in megabytes per second e.g, 2.0, 3.5 etc...
8
+ self.ffmpeg_path = "ffmpeg"
9
+ self.proxy = None
10
+ self.raise_bot_protection = False
11
+ self.verify_ssl = True
12
+ self.cookies = None
13
+
14
+ # Singleton instance needed for my Porn Fetch project
15
+ config = RuntimeConfig()
@@ -48,4 +48,17 @@ class ProxySSLError(Exception):
48
48
  Raises if a proxy request fails due to self-signed certificates or invalid TLS verification
49
49
  """
50
50
  def __init__(self, message):
51
- self.message = message
51
+ self.message = message
52
+
53
+
54
+ class ResourceGone(Exception):
55
+ """
56
+ Raises if a resource is gone (http 410 error)
57
+ """
58
+ def __init__(self, message):
59
+ self.message = message
60
+
61
+
62
+ class BotProtectionDetected(Exception):
63
+ """Raised when Cloudflare or similar bot protection is detected."""
64
+ pass
@@ -1,32 +1,22 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: eaf_base_api
3
- Version: 2.3.8
3
+ Version: 2.4.2
4
4
  Summary: A base API for EchterAlsFake's Porn APIs
5
- Home-page: https://github.com/EchterAlsFake/eaf_base_api
6
- Author: Johannes Habel
7
- Author-email: EchterAlsFake@proton.me
5
+ Author-email: Johannes Habel <EchterAlsFake@proton.me>
8
6
  License: LGPLv3
7
+ Project-URL: Homepage, https://github.com/EchterAlsFake/eaf_base_api
9
8
  Classifier: License :: OSI Approved :: GNU Lesser General Public License v3 (LGPLv3)
10
9
  Classifier: Programming Language :: Python
10
+ Requires-Python: >=3.6
11
11
  Description-Content-Type: text/markdown
12
12
  License-File: LICENSE
13
13
  Requires-Dist: m3u8
14
14
  Requires-Dist: httpx
15
- Provides-Extra: remux
16
- Requires-Dist: av; extra == "remux"
15
+ Provides-Extra: av
16
+ Requires-Dist: av; extra == "av"
17
17
  Provides-Extra: ffmpeg
18
18
  Requires-Dist: ffmpeg-progress-yield; extra == "ffmpeg"
19
- Dynamic: author
20
- Dynamic: author-email
21
- Dynamic: classifier
22
- Dynamic: description
23
- Dynamic: description-content-type
24
- Dynamic: home-page
25
- Dynamic: license
26
19
  Dynamic: license-file
27
- Dynamic: provides-extra
28
- Dynamic: requires-dist
29
- Dynamic: summary
30
20
 
31
21
  # EAF Base API
32
22
 
@@ -1,6 +1,7 @@
1
1
  LICENSE
2
+ MANIFEST.in
2
3
  README.md
3
- setup.py
4
+ pyproject.toml
4
5
  base_api/__init__.py
5
6
  base_api/base.py
6
7
  base_api/modules/__init__.py
@@ -1,8 +1,8 @@
1
1
  m3u8
2
2
  httpx
3
3
 
4
+ [av]
5
+ av
6
+
4
7
  [ffmpeg]
5
8
  ffmpeg-progress-yield
6
-
7
- [remux]
8
- av
@@ -0,0 +1,3 @@
1
+ base_api
2
+ build
3
+ dist
@@ -0,0 +1,29 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "eaf_base_api"
7
+ version = "2.4.2"
8
+ description = "A base API for EchterAlsFake's Porn APIs"
9
+ readme = "README.md"
10
+ requires-python = ">=3.6"
11
+ license = { text = "LGPLv3" }
12
+ authors = [
13
+ { name = "Johannes Habel", email = "EchterAlsFake@proton.me" }
14
+ ]
15
+ dependencies = [
16
+ "m3u8",
17
+ "httpx",
18
+ ]
19
+ optional-dependencies = {av = ["av"], ffmpeg = ["ffmpeg-progress-yield"]}
20
+ classifiers = [
21
+ "License :: OSI Approved :: GNU Lesser General Public License v3 (LGPLv3)",
22
+ "Programming Language :: Python",
23
+ ]
24
+
25
+ [project.urls]
26
+ Homepage = "https://github.com/EchterAlsFake/eaf_base_api"
27
+
28
+ [tool.setuptools]
29
+ packages = { find = {} }
@@ -1,37 +0,0 @@
1
- import random
2
-
3
- class RuntimeConfig:
4
- def __init__(self):
5
- self.max_cache_items = 200
6
- self.max_retries = 4
7
- self.request_delay = 0
8
- self.timeout = 20
9
- self.max_bandwidth_mb = None # Set speed limit in megabytes per second e.g, 2.0, 3.5 etc...
10
- self.ffmpeg_path = "ffmpeg"
11
- self.proxy = None
12
- self.verify_ssl = True
13
- self.user_agents = [
14
- "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
15
- "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.1",
16
- "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.3",
17
- "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.0.0 Safari/537.3",
18
- "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0."
19
- ]
20
- self.cookies = None
21
- self.headers = {
22
- "User-Agent": random.choice(self.user_agents)
23
- }
24
-
25
- def rotate_user_agent(self):
26
- self.headers["User-Agent"] = random.choice(self.user_agents)
27
-
28
- def as_httpx_kwargs(self):
29
- return {
30
- "proxies": self.proxy,
31
- "verify": self.verify_ssl,
32
- "headers": self.headers,
33
- "timeout": self.timeout
34
- }
35
-
36
- # Singleton instance needed for my Porn Fetch project
37
- config = RuntimeConfig()
@@ -1 +0,0 @@
1
- base_api
@@ -1,29 +0,0 @@
1
- from setuptools import setup, find_packages
2
-
3
- setup(
4
- name="eaf_base_api",
5
- version="2.3.8",
6
- packages=find_packages(),
7
- install_requires=["m3u8", "httpx"],
8
- extras_require={
9
- "remux": ["av"],
10
- "ffmpeg": ["ffmpeg-progress-yield"],
11
- },
12
- entry_points={
13
- 'console_scripts': [
14
- # If you want to create any executable scripts
15
- ],
16
- },
17
- author="Johannes Habel",
18
- author_email="EchterAlsFake@proton.me",
19
- description="A base API for EchterAlsFake's Porn APIs",
20
- long_description=open('README.md').read(),
21
- long_description_content_type='text/markdown',
22
- license="LGPLv3",
23
- url="https://github.com/EchterAlsFake/eaf_base_api",
24
- classifiers=[
25
- # Classifiers help users find your project on PyPI
26
- "License :: OSI Approved :: GNU Lesser General Public License v3 (LGPLv3)",
27
- "Programming Language :: Python",
28
- ],
29
- )
File without changes
File without changes
File without changes