eaf-base-api 2.4.0__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.
@@ -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.2
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
  )
@@ -527,7 +633,7 @@ class BaseCore:
527
633
 
528
634
  self.logger.error(f"Failed to fetch URL {url} after {self.config.max_retries} attempts.")
529
635
  if last_error:
530
- raise last_error
636
+ pass
531
637
  return None # Return None if all attempts fail
532
638
 
533
639
  @classmethod
@@ -561,67 +667,88 @@ class BaseCore:
561
667
  return sanitized[:max_length]
562
668
 
563
669
  @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")
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.
570
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
571
683
  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.")
578
-
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
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}")
584
688
 
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
689
+ if not master.is_variant:
690
+ raise ValueError("Provided URL/content is not a master playlist.")
590
691
 
591
- # Select based on quality preference
592
- if quality == "best":
593
- 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.")
594
695
 
595
- elif quality == "worst":
596
- 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)
597
701
 
598
- elif quality == "half":
599
- 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
600
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)
601
711
  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
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)]
606
728
 
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"""
729
+ def get_segments(self, m3u8_url_master: str, quality: Union[str, int]) -> list:
610
730
  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}")
731
+ self.logger.debug(f"Trying to fetch segment from m3u8 -> {m3u8_url}")
612
732
  content = self.fetch(url=m3u8_url)
613
733
 
734
+ segments = []
614
735
  m3u8_processed = m3u8.loads(content)
615
736
 
616
737
  if m3u8_processed.is_variant:
617
- 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...")
618
739
  new_m3u8 = urljoin(m3u8_url, m3u8_processed.playlists[0].uri)
619
740
  self.logger.info(f"Resolved to new URL: {new_m3u8}")
620
741
  content = self.fetch(url=new_m3u8)
621
742
  m3u8_processed = m3u8.loads(content)
622
743
 
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")
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)")
625
752
  return segments
626
753
 
627
754
  def download_segment(self, url: str, timeout: int) -> tuple[str, bytes, bool]:
@@ -728,13 +855,13 @@ class BaseCore:
728
855
 
729
856
  def _convert_ts_to_mp4(self, input_path: str, output_path: str, callback=None):
730
857
  try:
731
- import av
858
+ from av import open as av_open
732
859
  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!")
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}")
735
862
 
736
- input_ = av.open(input_path)
737
- output = av.open(output_path, mode="w", format="mp4", options={"movflags": "faststart"})
863
+ input_ = av_open(input_path)
864
+ output = av_open(output_path, mode="w", format="mp4", options={"movflags": "faststart"})
738
865
 
739
866
  # --- VIDEO: keep your exact remux approach ---
740
867
  in_video = input_.streams.video[0]
@@ -848,39 +975,91 @@ class BaseCore:
848
975
 
849
976
  return False
850
977
 
851
- 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:
852
982
  """
853
- 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.
854
985
  """
855
986
  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)
987
+ # progress UI fallback
988
+ progress_bar = None
989
+ if callback is None:
990
+ progress_bar = Callback()
873
991
 
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)
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
879
996
 
880
- if not callback:
881
- del progress_bar
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)
882
999
 
883
- 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!!!
884
1063
 
885
1064
  except httpx.StreamClosed:
886
1065
  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.2
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.2"
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