eaf-base-api 2.4.0__tar.gz → 2.4.3__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.0
3
+ Version: 2.4.3
4
4
  Summary: A base API for EchterAlsFake's Porn APIs
5
5
  Author-email: Johannes Habel <EchterAlsFake@proton.me>
6
6
  License: LGPLv3
@@ -9,7 +9,7 @@ import logging
9
9
  import traceback
10
10
  import threading
11
11
 
12
- from typing import Union
12
+ from typing import Any, Dict, List, Optional, Union
13
13
  from functools import lru_cache
14
14
  from urllib.parse import urljoin
15
15
  from concurrent.futures import ThreadPoolExecutor, as_completed
@@ -33,6 +33,111 @@ user_agents = [
33
33
  ]
34
34
 
35
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
+
36
141
 
37
142
  # Default formatter
38
143
  formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
@@ -150,6 +255,7 @@ LOG_COLORS = {
150
255
  }
151
256
  RESET_COLOR = "\033[0m"
152
257
 
258
+
153
259
  class ColoredFormatter(logging.Formatter):
154
260
  def format(self, record):
155
261
  log_message = super().format(record)
@@ -299,7 +405,7 @@ class BaseCore:
299
405
  proxy_ip = self.config.proxy
300
406
  pattern = re.compile(
301
407
  r'^(?P<scheme>http|socks5)://'
302
- r'(?:(?:\w+:\w+)@)?' # optional user:pass@
408
+ r'(?:\w+:\w+@)?' # optional user:pass@
303
409
  r'(?P<host>[a-zA-Z0-9.-]+)'
304
410
  r':(?P<port>\d{1,5})$'
305
411
  )
@@ -371,6 +477,8 @@ class BaseCore:
371
477
  bypass_kill_switch: bool = False, # prevents infinite loop
372
478
  data: dict = None,
373
479
  method: str = "GET",
480
+ headers: dict = None,
481
+ json: dict = None,
374
482
  ) -> Union[bytes, str, httpx.Response, None]:
375
483
  """
376
484
  Fetches content in UTF-8 Text, Bytes, or as a stream using multiple request attempts,
@@ -398,8 +506,11 @@ class BaseCore:
398
506
  self.check_kill_switch()
399
507
 
400
508
  # Perform the request with stream handling
509
+ if headers is None:
510
+ headers = self.session.headers.copy()
511
+
401
512
  response = self.session.request(method=method, url=url, timeout=timeout, cookies=cookies,
402
- follow_redirects=allow_redirects, data=data)
513
+ follow_redirects=allow_redirects, data=data, headers=headers, json=json)
403
514
  self.total_requests += 1
404
515
 
405
516
  # NEW: bot/challenge detection
@@ -527,7 +638,7 @@ class BaseCore:
527
638
 
528
639
  self.logger.error(f"Failed to fetch URL {url} after {self.config.max_retries} attempts.")
529
640
  if last_error:
530
- raise last_error
641
+ pass
531
642
  return None # Return None if all attempts fail
532
643
 
533
644
  @classmethod
@@ -561,67 +672,88 @@ class BaseCore:
561
672
  return sanitized[:max_length]
562
673
 
563
674
  @lru_cache(maxsize=250)
564
- def get_m3u8_by_quality(self, m3u8_url: str, quality: str) -> str:
565
- """Fetches the m3u8 URL for a given quality by extracting all possible sub-m3u8 URLs from the primary
566
- m3u8 playlist"""
567
- if "#" in m3u8_url:
568
- playlist = m3u8.loads(m3u8_url)
569
- self.logger.debug("Resolved custom m3u8")
675
+ def get_m3u8_by_quality(self, m3u8_url: str, quality: Union[str, int]) -> str:
676
+ """
677
+ Return the media-playlist URL for the requested quality.
570
678
 
679
+ quality:
680
+ - 'best' | 'half' | 'worst'
681
+ - 1080 / '1080' / '1080p' (and similar)
682
+ """
683
+ # Resolve master content
684
+ if m3u8_url.lstrip().startswith("#EXTM3U"):
685
+ master = m3u8.loads(m3u8_url)
686
+ self.logger.debug("Resolved inline/custom m3u8 master content.")
687
+ base_for_join = "" # URIs should be absolute in inline cases; join will handle if relative
571
688
  else:
572
- playlist_content = self.fetch(url=m3u8_url)
573
- playlist = m3u8.loads(playlist_content)
574
- self.logger.debug(f"Resolved m3u8 playlist: {m3u8_url}")
575
-
576
- if not playlist.is_variant:
577
- raise ValueError("Provided URL is not a master playlist.")
689
+ content = self.fetch(url=m3u8_url)
690
+ master = m3u8.loads(content)
691
+ base_for_join = m3u8_url
692
+ self.logger.debug(f"Resolved m3u8 master: {m3u8_url}")
578
693
 
579
- # Extract available qualities and URLs
580
- qualities = {
581
- (variant.stream_info.resolution or "unknown"): variant.uri
582
- for variant in playlist.playlists
583
- } # Fetches the available qualities
694
+ if not master.is_variant:
695
+ raise ValueError("Provided URL/content is not a master playlist.")
584
696
 
585
- # Sort qualities by resolution (width x height)
586
- sorted_qualities = sorted(
587
- qualities.items(),
588
- key=lambda x: (x[0][0] * x[0][1]) if x[0] != "unknown" else 0
589
- ) # Sorts qualities to support different HLS naming schemes
697
+ variants = _collect_variants(master)
698
+ if not variants:
699
+ raise ValueError("No usable video variants found in master playlist.")
590
700
 
591
- # Select based on quality preference
592
- if quality == "best":
593
- url_tmp = sorted_qualities[-1][1] # Highest resolution URL
701
+ q = _normalize_quality(quality)
702
+ if isinstance(q, str): # 'best'/'half'/'worst'
703
+ chosen = _pick_by_label(variants, q)
704
+ else: # numeric height like 1080, 720, etc.
705
+ chosen = _pick_by_height(variants, q)
594
706
 
595
- elif quality == "worst":
596
- url_tmp = sorted_qualities[0][1] # Lowest resolution URL
597
-
598
- elif quality == "half":
599
- url_tmp = sorted_qualities[len(sorted_qualities) // 2][1] # Mid-quality URL
707
+ full_url = urljoin(base_for_join or m3u8_url, chosen["uri"])
708
+ return full_url
600
709
 
710
+ def list_available_qualities(self, m3u8_url: str) -> List[int]:
711
+ """
712
+ Inspect the master playlist and return sorted unique heights (e.g., [240, 360, 480, 720, 1080]).
713
+ """
714
+ if m3u8_url.lstrip().startswith("#EXTM3U"):
715
+ master = m3u8.loads(m3u8_url)
601
716
  else:
602
- raise ValueError("Invalid quality provided.")
603
-
604
- full_url = urljoin(m3u8_url, url_tmp) # Merges the primary with the new URL to get the full URL path
605
- return full_url
717
+ content = self.fetch(url=m3u8_url)
718
+ master = m3u8.loads(content)
719
+
720
+ if not master.is_variant:
721
+ return []
722
+
723
+ heights = {h for h in (_height_from_variant(v) for v in master.playlists) if h is not None}
724
+ if heights:
725
+ return sorted(heights)
726
+ # fallback: bandwidth-only (roughly infer tiers)
727
+ by_bw = sorted(
728
+ (getattr(v.stream_info, "bandwidth", 0) for v in master.playlists if _is_video_playlist(v)),
729
+ key=int
730
+ )
731
+ # Return rank numbers instead of heights if we truly can't infer—kept simple:
732
+ return [i for i, _ in enumerate(by_bw, start=1)]
606
733
 
607
- @lru_cache(maxsize=250)
608
- def get_segments(self, m3u8_url_master: str, quality: str) -> list:
609
- """Gets all video segments from a given quality and the primary m3u8 URL"""
734
+ def get_segments(self, m3u8_url_master: str, quality: Union[str, int]) -> list:
610
735
  m3u8_url = self.get_m3u8_by_quality(m3u8_url=m3u8_url_master, quality=quality)
611
- self.logger.debug(f"Trying to fetch segment from m3u8 ->: {m3u8_url}")
736
+ self.logger.debug(f"Trying to fetch segment from m3u8 -> {m3u8_url}")
612
737
  content = self.fetch(url=m3u8_url)
613
738
 
739
+ segments = []
614
740
  m3u8_processed = m3u8.loads(content)
615
741
 
616
742
  if m3u8_processed.is_variant:
617
- self.logger.warning("The M3U8 Playlist is not a media playlist. Trying to resolve to the first m3u8...")
743
+ self.logger.warning("Media playlist expected; got variant. Resolving to first sub-playlist...")
618
744
  new_m3u8 = urljoin(m3u8_url, m3u8_processed.playlists[0].uri)
619
745
  self.logger.info(f"Resolved to new URL: {new_m3u8}")
620
746
  content = self.fetch(url=new_m3u8)
621
747
  m3u8_processed = m3u8.loads(content)
622
748
 
623
- segments = [urljoin(m3u8_url, seg.uri) for seg in m3u8_processed.segments]
624
- self.logger.debug(f"Fetched {len(segments)} segments from m3u8 URL")
749
+ if getattr(m3u8_processed, "segment_map", None):
750
+ init_uri = m3u8_processed.segment_map[0].uri
751
+ init_url = urljoin(m3u8_url, init_uri)
752
+ segments.append(init_url)
753
+ self.logger.debug(f"Found init segment: {init_url}")
754
+
755
+ segments.extend(urljoin(m3u8_url, seg.uri) for seg in m3u8_processed.segments)
756
+ self.logger.debug(f"Fetched {len(segments)} segments from m3u8 URL (including init if present)")
625
757
  return segments
626
758
 
627
759
  def download_segment(self, url: str, timeout: int) -> tuple[str, bytes, bool]:
@@ -728,13 +860,13 @@ class BaseCore:
728
860
 
729
861
  def _convert_ts_to_mp4(self, input_path: str, output_path: str, callback=None):
730
862
  try:
731
- import av
863
+ from av import open as av_open
732
864
  from av.audio.resampler import AudioResampler
733
- except (ModuleNotFoundError, ImportError):
734
- raise ModuleNotFoundError("PyAV is required for remuxing. Install with pip install av. Not supported on Termux!")
865
+ except (ModuleNotFoundError, ImportError) as e:
866
+ raise ModuleNotFoundError(f"PyAV is required for remuxing. Install with pip install av. Not supported on Termux! {e}")
735
867
 
736
- input_ = av.open(input_path)
737
- output = av.open(output_path, mode="w", format="mp4", options={"movflags": "faststart"})
868
+ input_ = av_open(input_path)
869
+ output = av_open(output_path, mode="w", format="mp4", options={"movflags": "faststart"})
738
870
 
739
871
  # --- VIDEO: keep your exact remux approach ---
740
872
  in_video = input_.streams.video[0]
@@ -848,39 +980,91 @@ class BaseCore:
848
980
 
849
981
  return False
850
982
 
851
- def legacy_download(self, path: str, url: str, callback=None) -> bool:
983
+ def legacy_download(self, path: str, url: str, callback=None,
984
+ chunk_size: int = 1 << 20, # 1 MiB
985
+ max_retries: int = 5,
986
+ read_timeout: float = 120.0) -> bool:
852
987
  """
853
- Download a file using streaming, with support for progress updates.
988
+ Download a file using streaming with stall tolerance and resume.
989
+ Assumes self.session is an httpx.Client.
854
990
  """
855
991
  try:
856
- with self.session.stream("GET", url, timeout=30) as response: # Use a reasonable timeout
857
- response.raise_for_status()
858
- file_size = int(response.headers.get('content-length', 0))
859
-
860
- if callback is None:
861
- progress_bar = Callback()
862
-
863
- downloaded_so_far = 0
864
-
865
- # Open file for writing
866
- with open(path, 'wb') as file:
867
- for chunk in response.iter_bytes(chunk_size=1024):
868
- if not chunk:
869
- break # End of stream
870
-
871
- file.write(chunk)
872
- downloaded_so_far += len(chunk)
992
+ # progress UI fallback
993
+ progress_bar = None
994
+ if callback is None:
995
+ progress_bar = Callback()
873
996
 
874
- # Update progress
875
- if callback:
876
- callback(downloaded_so_far, file_size)
877
- else:
878
- progress_bar.text_progress_bar(downloaded=downloaded_so_far, total=file_size)
997
+ # how many bytes we already have (for resume)
998
+ downloaded_so_far = os.path.getsize(path) if os.path.exists(path) else 0
999
+ etag = None
1000
+ attempt = 0
879
1001
 
880
- if not callback:
881
- del progress_bar
1002
+ # prefer a longer read timeout to ride out throttling pauses
1003
+ timeout = httpx.Timeout(connect=10.0, read=read_timeout, write=30.0, pool=30.0)
882
1004
 
883
- return True
1005
+ while True:
1006
+ headers = {
1007
+ }
1008
+ if downloaded_so_far:
1009
+ headers["Range"] = f"bytes={downloaded_so_far}-"
1010
+
1011
+ # stream the next segment (or whole file if starting fresh)
1012
+ with self.session.stream("GET", url, headers=headers,
1013
+ follow_redirects=True, timeout=timeout) as response:
1014
+ # If we asked for a Range and got 200, server ignored it: start over.
1015
+ if downloaded_so_far and response.status_code == 200:
1016
+ downloaded_so_far = 0 # restart from scratch
1017
+ response.raise_for_status()
1018
+
1019
+ # track ETag to detect mid-download content changes
1020
+ etag_cur = response.headers.get("ETag")
1021
+ if etag is None:
1022
+ etag = etag_cur
1023
+ elif etag_cur and etag_cur != etag:
1024
+ raise RuntimeError("Remote content changed during download")
1025
+
1026
+ total = None
1027
+ # Prefer Content-Range total when resuming; else fall back to Content-Length
1028
+ cr = response.headers.get("Content-Range") # e.g., "bytes start-end/total"
1029
+ if cr and "/" in cr:
1030
+ try:
1031
+ total = int(cr.rsplit("/", 1)[1])
1032
+ except ValueError:
1033
+ total = None
1034
+ if total is None:
1035
+ try:
1036
+ total = int(response.headers.get("Content-Length", "0")) or None
1037
+ except ValueError:
1038
+ total = None
1039
+
1040
+ mode = "ab" if downloaded_so_far else "wb"
1041
+ with open(path, mode) as file:
1042
+ try:
1043
+ for chunk in response.iter_bytes(chunk_size=chunk_size):
1044
+ if not chunk:
1045
+ continue
1046
+ file.write(chunk)
1047
+ downloaded_so_far += len(chunk)
1048
+
1049
+ # update progress
1050
+ if callback:
1051
+ callback(downloaded_so_far, total or 0)
1052
+
1053
+ else:
1054
+ progress_bar.text_progress_bar(downloaded=downloaded_so_far, total=total or 0)
1055
+
1056
+ # finished successfully
1057
+ if progress_bar:
1058
+ del progress_bar
1059
+ return True
1060
+
1061
+ except httpx.ReadTimeout:
1062
+ # stall: back off and retry from current offset
1063
+ attempt += 1
1064
+ if attempt > max_retries:
1065
+ raise
1066
+ time.sleep(min(2 ** attempt, 30))
1067
+ continue # loop retries with Range # let him coook!!!
884
1068
 
885
1069
  except httpx.StreamClosed:
886
1070
  self.logger.error(f"Stream for: {url} was closed. This should not happen...")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: eaf_base_api
3
- Version: 2.4.0
3
+ Version: 2.4.3
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.0"
7
+ version = "2.4.3"
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