eaf_base_api 4.0.0__tar.gz → 4.0.1__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: 4.0.0
3
+ Version: 4.0.1
4
4
  Summary: A base API for EchterAlsFake's Porn APIs
5
5
  Author: Johannes Habel
6
6
  Author-email: Johannes Habel <EchterAlsFake@proton.me>
@@ -136,15 +136,18 @@ extractor order is available with `ResultOrder.ORIGINAL`.
136
136
 
137
137
  ```python
138
138
  from base_api import Helper, ResultOrder
139
+ from base_api.modules.config import IteratorConfig
139
140
 
140
141
  helper = Helper(core=core, constructor=Video)
141
142
  stream = helper.iterator(
142
143
  page_urls,
143
144
  extractor_videos,
144
- max_page_concurrency=3,
145
- max_item_concurrency=20,
146
- load_fields=("title", "available_qualities"),
147
- order=ResultOrder.COMPLETION, # The default.
145
+ iterator_config=IteratorConfig(
146
+ max_page_concurrency=3,
147
+ max_item_concurrency=20,
148
+ load_specific_fields=("title", "available_qualities"),
149
+ order=ResultOrder.COMPLETION, # The default.
150
+ ),
148
151
  )
149
152
 
150
153
  # The context manager guarantees immediate task cleanup if this loop breaks early.
@@ -156,11 +159,11 @@ async with stream:
156
159
  video = result.unwrap()
157
160
  ```
158
161
 
159
- Use `order=ResultOrder.ORIGINAL` when presentation order matters. Page and item
160
- failures independently support `ErrorMode.YIELD`, `ErrorMode.SKIP`, or
161
- `ErrorMode.RAISE`. `RetryPolicy` provides a strict maximum attempt count and
162
- optional exponential delay; error handlers return an `ErrorAction` and cannot
163
- create an unbounded retry loop.
162
+ Use `IteratorConfig(order=ResultOrder.ORIGINAL)` when presentation order matters.
163
+ Page and item failures independently support `ErrorMode.YIELD`, `ErrorMode.SKIP`,
164
+ or `ErrorMode.RAISE`. `RetryPolicy` provides a strict maximum attempt count and
165
+ optional exponential delay; the independent page and item handlers return an
166
+ `ErrorAction` and cannot create an unbounded retry loop.
164
167
 
165
168
  # Can I use this for myself?
166
169
  Yes, you can, but I may change stuff here and there from time to time, and it would maybe break your project.
@@ -116,15 +116,18 @@ extractor order is available with `ResultOrder.ORIGINAL`.
116
116
 
117
117
  ```python
118
118
  from base_api import Helper, ResultOrder
119
+ from base_api.modules.config import IteratorConfig
119
120
 
120
121
  helper = Helper(core=core, constructor=Video)
121
122
  stream = helper.iterator(
122
123
  page_urls,
123
124
  extractor_videos,
124
- max_page_concurrency=3,
125
- max_item_concurrency=20,
126
- load_fields=("title", "available_qualities"),
127
- order=ResultOrder.COMPLETION, # The default.
125
+ iterator_config=IteratorConfig(
126
+ max_page_concurrency=3,
127
+ max_item_concurrency=20,
128
+ load_specific_fields=("title", "available_qualities"),
129
+ order=ResultOrder.COMPLETION, # The default.
130
+ ),
128
131
  )
129
132
 
130
133
  # The context manager guarantees immediate task cleanup if this loop breaks early.
@@ -136,11 +139,11 @@ async with stream:
136
139
  video = result.unwrap()
137
140
  ```
138
141
 
139
- Use `order=ResultOrder.ORIGINAL` when presentation order matters. Page and item
140
- failures independently support `ErrorMode.YIELD`, `ErrorMode.SKIP`, or
141
- `ErrorMode.RAISE`. `RetryPolicy` provides a strict maximum attempt count and
142
- optional exponential delay; error handlers return an `ErrorAction` and cannot
143
- create an unbounded retry loop.
142
+ Use `IteratorConfig(order=ResultOrder.ORIGINAL)` when presentation order matters.
143
+ Page and item failures independently support `ErrorMode.YIELD`, `ErrorMode.SKIP`,
144
+ or `ErrorMode.RAISE`. `RetryPolicy` provides a strict maximum attempt count and
145
+ optional exponential delay; the independent page and item handlers return an
146
+ `ErrorAction` and cannot create an unbounded retry loop.
144
147
 
145
148
  # Can I use this for myself?
146
149
  Yes, you can, but I may change stuff here and there from time to time, and it would maybe break your project.
@@ -5,7 +5,6 @@ import time
5
5
  import hashlib
6
6
  import string
7
7
  import shutil
8
- import random
9
8
  import asyncio
10
9
  import inspect
11
10
  import logging
@@ -14,7 +13,6 @@ import threading
14
13
  from collections import deque
15
14
  from collections.abc import Iterable, Mapping, Sequence
16
15
  from enum import StrEnum
17
- from functools import lru_cache
18
16
  from urllib.parse import urljoin
19
17
  from dataclasses import MISSING, dataclass, field, fields
20
18
  from curl_cffi import CurlOpt # Used for DNS over HTTPS
@@ -36,11 +34,9 @@ from base_api.modules.type_hints import (
36
34
  )
37
35
  from base_api.modules.static_functions import (
38
36
  load_segment_state, parse_retry_after, log_precondition_failed,
39
- write_segment_state, build_segment_state, get_segment_index_width,
40
- segment_file_path, is_video_playlist, height_from_variant,
41
- pick_by_height, normalize_quality_value,
42
- parse_challenge, other_challenge, least_factors,
43
- collect_variants, pick_by_label
37
+ write_segment_state, build_segment_state, segment_file_path,
38
+ parse_challenge, other_challenge, least_factors, available_qualities,
39
+ choose_variant, collect_variants, get_segment_index_width
44
40
  )
45
41
  from base_api.modules.config import config, RuntimeConfig, DownloadConfigHLS, DownloadConfigRAW, IteratorConfig
46
42
  from base_api.modules.progress_bars import Callback
@@ -66,9 +62,6 @@ except (ModuleNotFoundError, ImportError):
66
62
  m3u8 = None # type: ignore
67
63
 
68
64
 
69
- UA_DESKTOP_CHROME = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) "
70
- "Chrome/122.0.0.0 Safari/537.36")
71
-
72
65
  REGEX_CHALLENGE = re.compile(r'var p=(\d+); var s=(\d+);.*?(\d+):1;', re.DOTALL)
73
66
 
74
67
 
@@ -448,46 +441,47 @@ class BaseMedia:
448
441
  default_factory=dict, init=False, repr=False, compare=False
449
442
  )
450
443
 
451
- def __getattribute__(self, name: str) -> Any:
452
- """
453
- Reject only the private unloaded sentinel, never a legitimate ``None``.
444
+ if not TYPE_CHECKING:
445
+ def __getattribute__(self, name: str) -> Any:
446
+ """
447
+ Reject only the private unloaded sentinel, never a legitimate ``None``.
454
448
 
455
- Attribute access remains synchronous and therefore cannot initiate network
456
- I/O. The exception tells callers exactly which field and sources to pass
457
- to ``load_fields`` or ``load_sources``.
458
- """
459
- value = object.__getattribute__(self, name)
460
- if value is not _UNLOADED:
461
- return value
449
+ Attribute access remains synchronous and therefore cannot initiate network
450
+ I/O. The exception tells callers exactly which field and sources to pass
451
+ to ``load_fields`` or ``load_sources``.
452
+ """
453
+ value = object.__getattribute__(self, name)
454
+ if value is not _UNLOADED:
455
+ return value
462
456
 
463
- # Because we override the __getattribute__ method, we need to call object.__getattribute__.
464
- # If I'd use self.<something> we would get a recursion error as the function would call itself infinitely
457
+ # Because we override the __getattribute__ method, we need to call object.__getattribute__.
458
+ # If I'd use self.<something> we would get a recursion error as the function would call itself infinitely
465
459
 
466
- model_type = type(self)
467
- """
468
- Explanation why model_type exists:
469
- So, basically when we build the schema (down below) this takes some time. However, this function actually
470
- caches the fully resolved dataclass. So if we are going over a thousand Video objects usually we would need
471
- to reconstruct the thousand video objects, well, a thousand times.
472
-
473
- By giving the model_type with self, we can tell the cache: Yo, this is a Video object. See if you have
474
- already processed this and if yes it will use this instead of processing it each time again.
475
- Might only save a few milliseconds but I ain't Ubisoft XDDD
476
- """
477
- schema = _media_schema(model_type)
478
- sources = schema.field_sources.get(name, ()) # E.g., ("api", "html") for name=title (PornHub API as an example)
479
- url = object.__getattribute__(self, "url") # The actual URL to fetch e.g., https://example.com/video/id?=
480
- all_source_errors = object.__getattribute__(self, "_source_errors")
481
- relevant_errors = {
482
- source: all_source_errors[source]
483
- for source in sources
484
- if source in all_source_errors
485
- } # Basically just checks which sources had an error e.g., if html failed fetching this will return html
486
- raise DataNotLoadedError(
487
- model_type.__name__, name, url, sources, relevant_errors
488
- # Raises a custom exception that tells you which attribute failed with its associated source and the error
489
- # that happened along with the actual URL (so that I can replicate it when you report it)
490
- )
460
+ model_type = type(self)
461
+ """
462
+ Explanation why model_type exists:
463
+ So, basically when we build the schema (down below) this takes some time. However, this function actually
464
+ caches the fully resolved dataclass. So if we are going over a thousand Video objects usually we would need
465
+ to reconstruct the thousand video objects, well, a thousand times.
466
+
467
+ By giving the model_type with self, we can tell the cache: Yo, this is a Video object. See if you have
468
+ already processed this and if yes it will use this instead of processing it each time again.
469
+ Might only save a few milliseconds but I ain't Ubisoft XDDD
470
+ """
471
+ schema = _media_schema(model_type)
472
+ sources = schema.field_sources.get(name, ()) # E.g., ("api", "html") for name=title (PornHub API as an example)
473
+ url = object.__getattribute__(self, "url") # The actual URL to fetch e.g., https://example.com/video/id?=
474
+ all_source_errors = object.__getattribute__(self, "_source_errors")
475
+ relevant_errors = {
476
+ source: all_source_errors[source]
477
+ for source in sources
478
+ if source in all_source_errors
479
+ } # Basically just checks which sources had an error e.g., if html failed fetching this will return html
480
+ raise DataNotLoadedError(
481
+ model_type.__name__, name, url, sources, relevant_errors
482
+ # Raises a custom exception that tells you which attribute failed with its associated source and the error
483
+ # that happened along with the actual URL (so that I can replicate it when you report it)
484
+ )
491
485
 
492
486
  def __repr__(self) -> str:
493
487
  """Represent identity and load state without touching unresolved fields."""
@@ -1089,7 +1083,7 @@ class Helper(Generic[MediaT]):
1089
1083
  page_retry = iterator_config.page_retry
1090
1084
  item_retry = iterator_config.item_retry
1091
1085
  page_error_handler = iterator_config.page_error_handler
1092
- item_error_handler = iterator_config.page_error_handler
1086
+ item_error_handler = iterator_config.item_error_handler
1093
1087
  load_fields = iterator_config.load_specific_fields
1094
1088
  load_sources = iterator_config.load_specific_sources
1095
1089
  page_request_method = iterator_config._page_request_method
@@ -1550,9 +1544,7 @@ class BaseCore:
1550
1544
  self.cache = cache if cache is not None else Cache(self.configuration)
1551
1545
  self.logger = configure_app_logging("BASE API - [BaseCore]", log_file=None, level=logging.ERROR)
1552
1546
  self.default_headers = {
1553
- "User-Agent": UA_DESKTOP_CHROME,
1554
1547
  "Accept-Language": self.configuration.locale,
1555
- "Accept-Encoding": "gzip, deflate, br"
1556
1548
  }
1557
1549
 
1558
1550
  async def __aenter__(self) -> Self:
@@ -1576,6 +1568,10 @@ class BaseCore:
1576
1568
  http_port=log_port)
1577
1569
 
1578
1570
  def initialize_session(self) -> None:
1571
+ """Initialize the owned HTTP session once for the current lifecycle."""
1572
+ if self.session is not None:
1573
+ return
1574
+
1579
1575
  verify = self.configuration.verify_ssl
1580
1576
 
1581
1577
  curl_options: Dict[CurlOpt, Union[bytes, int]] = {}
@@ -1609,19 +1605,20 @@ class BaseCore:
1609
1605
  proxy=proxy,
1610
1606
  timeout=self.configuration.timeout,
1611
1607
  verify=verify,
1608
+ ja3=js3,
1609
+ http_version=http_version,
1612
1610
  impersonate=impersonation,
1613
1611
  curl_options=curl_options,
1614
- http_version=http_version,
1615
- ja3=js3,
1616
1612
  proxy_auth=p_auth,
1617
- trust_env=trust_env
1613
+ trust_env=trust_env,
1614
+ cookies=self.configuration.cookies,
1618
1615
  )
1619
1616
  # Ensure our defaults are on the session
1620
1617
  assert self.session is not None
1621
1618
  self.session.headers.update(self.default_headers)
1622
1619
 
1623
1620
  async def enforce_delay(self) -> None:
1624
- """Enforces the specified delay in config.request_delay (only if > 0)."""
1621
+ """Enforce this core's configured delay between requests, when enabled."""
1625
1622
  delay = self.configuration.request_delay
1626
1623
  if delay and delay > 0:
1627
1624
  async with self._delay_lock:
@@ -1714,6 +1711,7 @@ class BaseCore:
1714
1711
  exponential_wait = wait_exponential_jitter(
1715
1712
  initial=self.configuration.request_retry_initial_delay,
1716
1713
  max=self.configuration.request_retry_max_delay,
1714
+ exp_base=self.configuration.request_multiplier,
1717
1715
  jitter=self.configuration.request_retry_jitter,
1718
1716
  )
1719
1717
 
@@ -2048,89 +2046,158 @@ class BaseCore:
2048
2046
  )
2049
2047
  return cast(bytes, response.content)
2050
2048
 
2051
-
2052
- @lru_cache(maxsize=250)
2053
- async def get_m3u8_by_quality(self, m3u8_url: str, quality: Union[str, int]) -> str:
2049
+ async def get_m3u8_by_quality(
2050
+ self,
2051
+ m3u8_url: str,
2052
+ quality: str | int,
2053
+ ) -> str:
2054
2054
  """
2055
2055
  Return the media-playlist URL for the requested quality.
2056
2056
 
2057
- quality:
2058
- - 'best' | 'half' | 'worst'
2059
- - 1080 / '1080' / '1080p' (and similar)
2057
+ Supported preferences:
2058
+ best
2059
+ half
2060
+ worst
2061
+
2062
+ Supported explicit qualities:
2063
+ 144
2064
+ 240
2065
+ 360
2066
+ 480
2067
+ 540
2068
+ 720
2069
+ 1080
2070
+ 1440
2071
+ 2160
2072
+
2073
+ Numeric strings such as "1080" and "1080p" are accepted too.
2060
2074
  """
2061
- if m3u8 is None:
2062
- raise ModuleNotFoundError(f"""
2063
- Using m3u8 is optional depending whether you use HLS videos or static videos. It seems like you are trying to download
2064
- from HLS. Please install m3u8 using: `pip install m3u8`.
2065
-
2066
- If this does not fix the issue, there's an import error related to your environment. In this case please create
2067
- a new Python file, import only m3u8 and see what error you get.
2068
- """)
2069
2075
 
2070
- # Resolve master content
2071
- assert m3u8 is not None
2076
+ if m3u8 is None:
2077
+ raise ModuleNotFoundError(
2078
+ "HLS support requires the 'm3u8' package."
2079
+ )
2072
2080
 
2073
- if inspect.iscoroutinefunction(m3u8_url) or (callable(m3u8_url) and not isinstance(m3u8_url, str)):
2081
+ # Resolve callable / awaitable sources.
2082
+ if (
2083
+ inspect.iscoroutinefunction(m3u8_url)
2084
+ or (
2085
+ callable(m3u8_url)
2086
+ and not isinstance(m3u8_url, str)
2087
+ )
2088
+ ):
2074
2089
  m3u8_url = m3u8_url()
2075
- if inspect.iscoroutine(m3u8_url) or inspect.isawaitable(m3u8_url):
2090
+
2091
+ if inspect.isawaitable(m3u8_url):
2076
2092
  m3u8_url = await m3u8_url
2077
2093
 
2094
+ if not isinstance(m3u8_url, str):
2095
+ raise TypeError(
2096
+ "m3u8_url must resolve to a string."
2097
+ )
2098
+
2099
+ # Load master playlist.
2078
2100
  if m3u8_url.lstrip().startswith("#EXTM3U"):
2079
2101
  master = m3u8.loads(m3u8_url)
2080
- self.logger.debug("Resolved inline/custom m3u8 master content.")
2081
- base_for_join = "" # URIs should be absolute in inline cases; join will handle if relative
2102
+ base_url = None
2103
+
2104
+ self.logger.debug(
2105
+ "Resolved inline/custom m3u8 master content."
2106
+ )
2107
+
2082
2108
  else:
2083
- content = await self.fetch_text(url=m3u8_url)
2109
+ content = await self.fetch_text(
2110
+ url=m3u8_url
2111
+ )
2112
+
2084
2113
  master = m3u8.loads(content)
2085
- base_for_join = m3u8_url
2086
- self.logger.debug("Resolved m3u8 master: %s", m3u8_url)
2114
+ base_url = m3u8_url
2115
+
2116
+ self.logger.debug(
2117
+ "Resolved m3u8 master: %s",
2118
+ m3u8_url,
2119
+ )
2087
2120
 
2088
2121
  if not master.is_variant:
2089
- raise PlaylistExtractionError(f"Playlist is not a master Playlist: {m3u8_url}")
2122
+ raise PlaylistExtractionError(
2123
+ f"Playlist is not a master playlist: {m3u8_url}"
2124
+ )
2090
2125
 
2091
2126
  variants = collect_variants(master)
2127
+
2092
2128
  if not variants:
2093
- raise PlaylistExtractionError(f"No usable variants found in master Playlist: {m3u8_url}, {master}")
2129
+ raise PlaylistExtractionError(
2130
+ f"No usable video variants found: {m3u8_url}"
2131
+ )
2094
2132
 
2095
- q = normalize_quality_value(quality)
2096
- if isinstance(q, str): # 'best'/'half'/'worst'
2097
- chosen = pick_by_label(variants, q)
2098
- else: # numeric height like 1080, 720, etc.
2099
- chosen = pick_by_height(variants, q)
2133
+ chosen = choose_variant(variants, quality)
2100
2134
 
2101
- full_url = urljoin(base_for_join or m3u8_url, chosen["uri"])
2102
- return full_url
2135
+ uri = chosen["uri"]
2103
2136
 
2104
- async def list_available_qualities(self, m3u8_url: str) -> List[int]:
2137
+ # Master was fetched from a URL.
2138
+ if base_url is not None:
2139
+ return urljoin(
2140
+ base_url,
2141
+ uri,
2142
+ )
2143
+
2144
+ # Inline playlist containing an absolute variant URL.
2145
+ if uri.startswith(("http://", "https://")):
2146
+ return uri
2147
+
2148
+ raise PlaylistExtractionError(
2149
+ "Inline HLS master contains relative variant URLs, "
2150
+ "so they cannot be resolved without a base URL."
2151
+ )
2152
+
2153
+ async def list_available_qualities(
2154
+ self,
2155
+ m3u8_url: str,
2156
+ ) -> list[int]:
2105
2157
  """
2106
- Inspect the master playlist and return sorted unique heights (e.g., [240, 360, 480, 720, 1080]).
2158
+ Inspect an HLS master playlist and return canonical,
2159
+ sorted, unique qualities.
2107
2160
  """
2108
- assert m3u8 is not None
2109
2161
 
2110
- if inspect.iscoroutinefunction(m3u8_url) or (callable(m3u8_url) and not isinstance(m3u8_url, str)):
2162
+ if m3u8 is None:
2163
+ raise ModuleNotFoundError(
2164
+ "HLS support requires the 'm3u8' package."
2165
+ )
2166
+
2167
+ if (
2168
+ inspect.iscoroutinefunction(m3u8_url)
2169
+ or (
2170
+ callable(m3u8_url)
2171
+ and not isinstance(m3u8_url, str)
2172
+ )
2173
+ ):
2111
2174
  m3u8_url = m3u8_url()
2112
- if inspect.iscoroutine(m3u8_url) or inspect.isawaitable(m3u8_url):
2175
+
2176
+ if inspect.isawaitable(m3u8_url):
2113
2177
  m3u8_url = await m3u8_url
2114
2178
 
2115
- if not m3u8_url.startswith("https://"):
2179
+ if not isinstance(m3u8_url, str):
2180
+ raise TypeError(
2181
+ "m3u8_url must resolve to a string."
2182
+ )
2183
+
2184
+ if m3u8_url.lstrip().startswith("#EXTM3U"):
2116
2185
  master = m3u8.loads(m3u8_url)
2117
- else:
2118
- content = await self.fetch_text(url=m3u8_url)
2186
+
2187
+ elif m3u8_url.startswith(("http://", "https://")):
2188
+ content = await self.fetch_text(
2189
+ url=m3u8_url
2190
+ )
2191
+
2119
2192
  master = m3u8.loads(content)
2120
2193
 
2194
+ else:
2195
+ master = m3u8.loads(m3u8_url)
2196
+
2121
2197
  if not master.is_variant:
2122
2198
  return []
2123
2199
 
2124
- heights = {h for h in (height_from_variant(v) for v in master.playlists) if h is not None}
2125
- if heights:
2126
- return sorted(heights)
2127
- # fallback: bandwidth-only (roughly infer tiers)
2128
- by_bw = sorted(
2129
- (getattr(v.stream_info, "bandwidth", 0) for v in master.playlists if is_video_playlist(v)),
2130
- key=int
2131
- )
2132
- # Return rank numbers instead of heights if we truly can't infer—kept simple:
2133
- return [i for i, _ in enumerate(by_bw, start=1)]
2200
+ return available_qualities(collect_variants(master))
2134
2201
 
2135
2202
  async def get_segments(self, m3u8_url_master: str, quality: Union[str, int]) -> List[str]:
2136
2203
  assert m3u8 is not None
@@ -2264,8 +2331,8 @@ a new Python file, import only m3u8 and see what error you get.
2264
2331
  return await self.threaded_download(
2265
2332
  configuration=configuration,
2266
2333
  pre_resolved_m3u8=m3u8_url,
2267
- timeout=config.timeout,
2268
- max_workers=config.max_workers_download
2334
+ timeout=self.configuration.timeout,
2335
+ max_workers=self.configuration.max_workers_download,
2269
2336
  )
2270
2337
 
2271
2338
  async def threaded_download(
@@ -3,11 +3,12 @@ import re
3
3
  import math
4
4
  import json
5
5
  import unicodedata
6
+ from collections.abc import Iterable
6
7
  from pathlib import PurePath
7
8
  from .type_hints import DownloadState
8
9
  from datetime import timezone, datetime
9
10
  from curl_cffi.requests import Response
10
- from typing import Dict, Any, cast, List, Callable, Tuple, Union
11
+ from typing import Dict, Any, cast, List, Callable, Literal, Union
11
12
  from email.utils import parsedate_to_datetime
12
13
 
13
14
 
@@ -68,196 +69,290 @@ def least_factors(n: int) -> int:
68
69
  return n
69
70
 
70
71
 
71
- def normalize_quality_value(quality: Union[str, int]) -> Union[str, int]:
72
+ type QualityPreference = int | Literal["best", "half", "worst"]
73
+
74
+ QUALITY_LABELS = frozenset({"best", "half", "worst"})
75
+
76
+
77
+ def is_video_playlist(variant: Any) -> bool:
78
+ """Filter out I-frames/audio-only playlists."""
79
+ # m3u8 lib sometimes sets is_iframe if EXT-X-I-FRAME-STREAM-INF is present.
80
+ if getattr(variant, "is_iframe", False):
81
+ return False
82
+
83
+ # If codecs known and contain only audio (mp4-a, ac-3, ec-3, etc.)
84
+ codecs = getattr(variant.stream_info, "codecs", None) if getattr(variant, "stream_info", None) else False
85
+ if codecs:
86
+ # very light heuristic: if no video codec substring, probably audio-only.
87
+ # video: avc1, hvc1, hev1, vp9, av01, dvh
88
+ codecs_text = str(codecs).lower()
89
+ if not any(v in codecs_text for v in ("avc1", "hvc1", "hev1", "av01", "vp9", "dvh")):
90
+ return False
91
+
92
+ return True
93
+
94
+
95
+ def get_segment_index_width(total: int) -> int:
96
+ return max(6, len(str(max(0, total - 1))))
97
+
98
+
99
+ COMMON_QUALITIES = frozenset({
100
+ 144,
101
+ 240,
102
+ 250,
103
+ 360,
104
+ 480,
105
+ 540,
106
+ 720,
107
+ 1080,
108
+ 1440,
109
+ 2160,
110
+ })
111
+ # Kept as an alias for callers that imported the name during the 4.0 rollout.
112
+ ALLOWED_QUALITIES = COMMON_QUALITIES
113
+
114
+
115
+ def validate_quality(value: int) -> int:
116
+ """Validate a normalized quality without restricting provider-specific tiers."""
117
+ if isinstance(value, bool) or not isinstance(value, int):
118
+ raise TypeError(f"Invalid quality type: {type(value).__name__}")
119
+ if value <= 0:
120
+ raise ValueError(f"Invalid video quality: {value!r}")
121
+
122
+ return value
123
+
124
+
125
+ def normalize_quality(value: str | int) -> int:
72
126
  """
73
- quality: represents the quality value that should be normalized
127
+ Convert a quality into a canonical integer.
128
+
129
+ Accepted:
130
+ 720
131
+ "720"
132
+ "720p"
133
+
134
+ Rejected:
135
+ "best"
136
+ "half"
137
+ "worst"
138
+ "720p60"
139
+ zero or negative values
74
140
  """
75
- if isinstance(quality, int):
76
- return quality # If the quality value is already an int, just return it directly
77
141
 
78
- quality = str(quality).lower().strip() # Convert to string, lower and remove white spaces
142
+ if isinstance(value, bool):
143
+ raise TypeError("A boolean is not a valid video quality.")
144
+
145
+ if isinstance(value, int):
146
+ quality = value
147
+
148
+ elif isinstance(value, str):
149
+ value = value.strip().lower()
150
+
151
+ match = re.fullmatch(r"(\d+)[pP]?", value)
152
+
153
+ if not match:
154
+ raise ValueError(
155
+ f"Invalid video quality: {value!r}"
156
+ )
157
+
158
+ quality = int(match.group(1))
159
+
160
+ else:
161
+ raise TypeError(
162
+ f"Invalid quality type: {type(value).__name__}"
163
+ )
164
+
165
+ return validate_quality(quality)
166
+
167
+ def normalize_quality_preference(
168
+ value: str | int,
169
+ ) -> QualityPreference:
170
+
171
+ if isinstance(value, str):
172
+ value = value.strip().lower()
79
173
 
80
- if quality in {"best", "half", "worst"}:
81
- return quality # best, half and worst are also accepted values and will be further resolved in other functions
174
+ if value in QUALITY_LABELS:
175
+ return cast(QualityPreference, value)
176
+
177
+ return normalize_quality(value)
82
178
 
83
- m = re.search(r'(\d{3,4})', quality) # Search for int values that fit 144p-2160p values. return as int
84
- if m:
85
- return int(m.group(1))
86
- raise ValueError(f"Invalid quality: {quality}")
87
179
 
88
180
 
89
181
  def choose_quality_from_list(
90
- available: List[Union[str, int]],
91
- target: Union[str, int],
92
- default_fallback: int | str | None = None
182
+ available: Iterable[str | int],
183
+ target: str | int,
184
+ default_fallback: str | int | None = None,
93
185
  ) -> int:
94
- """
95
- Selects a video quality from a list based on a target integer< or label.
186
+ """Choose a quality, falling back to the nearest available numeric tier."""
96
187
 
97
- :param available: List of available qualities (e.g., [240, "360", "1080p"]).
98
- :param target: Numeric target (highest ≤ target) or label ("best", "worst", "half").
99
- :param default_fallback: Optional integer to return if all parsing fails.
100
- :return: The selected quality as an integer.
101
- """
188
+ available_ints = normalize_qualities(available)
102
189
 
103
- # 1. Edge Case: Empty List
104
- if not available:
190
+ if not available_ints:
105
191
  if default_fallback is not None:
106
- return default_fallback
107
- raise ValueError("The 'available' list cannot be empty.")
192
+ return normalize_quality(default_fallback)
193
+ raise ValueError(
194
+ "No valid video qualities are available."
195
+ )
108
196
 
109
- # 2. Data Sanitization: Filter out unparseable values safely
110
- valid_ints = set()
111
- for x in available:
112
- try:
113
- # Handle common string formats gracefully (e.g., "1080p" -> 1080)
114
- if isinstance(x, str):
115
- x = x.lower().rstrip('p')
116
- valid_ints.add(int(x))
117
- except (ValueError, TypeError):
118
- continue # Skip unparseable garbage rather than crashing
119
-
120
- if not valid_ints:
121
- if default_fallback is not None:
122
- return default_fallback
123
- raise ValueError("No valid numeric qualities found in 'available'.")
124
-
125
- # Sort the sanitized list (e.g., [144, 240, 360, 720, 1080])
126
- available_ints = sorted(valid_ints)
127
-
128
- # 3. Edge Case: User passes a numeric string as a target (e.g., "1080")
129
- if isinstance(target, str) and target.isdigit():
130
- target = int(target)
131
-
132
- # 4. Handle String Targets
133
- if isinstance(target, str):
134
- target = target.lower()
135
- if target == "best":
136
- return available_ints[-1]
137
- if target == "worst":
138
- return available_ints[0]
139
- if target == "half":
140
- # Middle index. Examples: len=3 (idx 1), len=4 (idx 2, rounds up)
141
- return available_ints[len(available_ints) // 2]
142
-
143
- # Fallback for unrecognized string labels
197
+ try:
198
+ preference = normalize_quality_preference(target)
199
+ except (TypeError, ValueError):
144
200
  if default_fallback is not None:
145
- return default_fallback
146
- raise ValueError(f"Invalid label: '{target}'. Expected 'best', 'worst', 'half', or a number.")
201
+ return normalize_quality(default_fallback)
202
+ raise
147
203
 
148
- # 5. Handle Numeric Targets (highest ≤ target, else closest)
149
- if isinstance(target, (int, float)):
150
- le = [h for h in available_ints if h <= target]
151
- if le:
152
- return le[-1]
204
+ if preference == "best":
205
+ return available_ints[-1]
153
206
 
154
- # Fallback closest: if target is 144 but only 240+ is available,
155
- # all available qualities are > target. The closest is the lowest available.
207
+ if preference == "worst":
156
208
  return available_ints[0]
157
209
 
158
- # Catch-all for entirely wrong target types (e.g., lists, dicts)
159
- if default_fallback is not None:
160
- return default_fallback
161
- raise TypeError("Target must be a string or integer.")
210
+ if preference == "half":
211
+ return available_ints[len(available_ints) // 2]
162
212
 
213
+ # Prefer the higher tier when two variants are equally close.
214
+ return min(
215
+ available_ints,
216
+ key=lambda quality: (abs(quality - preference), -quality),
217
+ )
163
218
 
164
- def height_from_variant(variant: Any) -> int | None:
165
- """Extract height from a variant:
166
- 1) stream_info.resolution (w, h)
167
- 2) URI pattern like .../720p/...
219
+
220
+ def normalize_qualities(
221
+ values: Iterable[str | int],
222
+ ) -> list[int]:
223
+ """
224
+ Return canonical, unique qualities sorted worst -> best.
168
225
  """
169
- if getattr(variant, "stream_info", None) and variant.stream_info.resolution:
170
- _, h = variant.stream_info.resolution # (width, height)
171
- return int(h) # -> returns the height of a variant of a m3u8 master playlist
172
226
 
173
- # Fallback to search with a regex pattern
174
- if variant.uri:
175
- m = HEIGHT_FROM_URI.search(variant.uri)
176
- if m:
177
- return int(m.group(1))
227
+ qualities: set[int] = set()
228
+ for value in values:
229
+ try:
230
+ qualities.add(normalize_quality(value))
231
+ except (TypeError, ValueError):
232
+ # Provider data can contain labels such as "auto" alongside real
233
+ # qualities. They should not make the usable variants disappear.
234
+ continue
178
235
 
179
- # If nothing is found, though this shouldn't happen
180
- return None
236
+ return sorted(qualities)
181
237
 
182
- def is_video_playlist(variant: Any) -> bool:
183
- """Filter out I-frames/audio-only playlists."""
184
- # m3u8 lib sometimes sets is_iframe if EXT-X-I-FRAME-STREAM-INF is present.
185
- if getattr(variant, "is_iframe", False):
186
- return False
187
238
 
188
- # If codecs known and contain only audio (mp4-a, ac-3, ec-3, etc.)
189
- codecs = getattr(variant.stream_info, "codecs", None) if getattr(variant, "stream_info", None) else False
190
- if codecs:
191
- # very light heuristic: if no video codec substring, probably audio-only.
192
- # video: avc1, hvc1, hev1, vp9, av01, dvh
193
- assert isinstance(codecs, str)
194
- if not any(v in codecs.lower() for v in ("avc1", "hvc1", "hev1", "av01", "vp9", "dvh")):
195
- return False
239
+ def quality_from_variant(variant: Any) -> int | None:
240
+ """Extract a quality tier from a landscape or portrait HLS variant."""
241
+ stream_info = getattr(variant, "stream_info", None)
242
+ resolution = getattr(stream_info, "resolution", None)
243
+ if resolution:
244
+ try:
245
+ width, height = resolution
246
+ # Quality names describe the shorter side: 1920x1080 and
247
+ # 1080x1920 are both 1080p.
248
+ return normalize_quality(min(int(width), int(height)))
249
+ except (TypeError, ValueError):
250
+ pass
251
+
252
+ uri = getattr(variant, "uri", None)
253
+ if isinstance(uri, str):
254
+ match = HEIGHT_FROM_URI.search(uri)
255
+ if match:
256
+ try:
257
+ return normalize_quality(match.group(1))
258
+ except (TypeError, ValueError):
259
+ pass
196
260
 
197
- return True
261
+ return None
198
262
 
199
- def collect_variants(master: Any) -> List[Dict[str, Any]]:
200
- """Normalize playlist variants to a comparable list."""
201
- items: List[Dict[str, Any]] = []
202
- for v in master.playlists:
203
- if not is_video_playlist(v):
263
+
264
+ def collect_variants(master: Any) -> list[dict[str, Any]]:
265
+ """Return video variants with normalized, comparable metadata."""
266
+ variants: list[dict[str, Any]] = []
267
+ for variant in getattr(master, "playlists", ()):
268
+ if not is_video_playlist(variant):
204
269
  continue
205
270
 
206
- h = height_from_variant(v)
207
- bw = getattr(v.stream_info, "bandwidth", 0) if getattr(v, "stream_info", None) else 0
208
- fr = getattr(v.stream_info, "frame_rate", 0.0) if getattr(v, "stream_info", None) else 0.0
209
- items.append({
210
- "uri": v.uri,
211
- "height": h, # may be None
212
- "bandwidth": int(bw or 0),
213
- "frame_rate": float(fr or 0.0),
214
- "resolution": getattr(v.stream_info, "resolution", None) if getattr(v, "stream_info", None) else None,
215
- "raw": v
271
+ stream_info = getattr(variant, "stream_info", None)
272
+ variants.append({
273
+ "uri": getattr(variant, "uri", ""),
274
+ "quality": quality_from_variant(variant),
275
+ "bandwidth": int(getattr(stream_info, "bandwidth", 0) or 0),
276
+ "frame_rate": float(getattr(stream_info, "frame_rate", 0.0) or 0.0),
277
+ "resolution": getattr(stream_info, "resolution", None),
278
+ "raw": variant,
216
279
  })
217
- return items
218
280
 
219
- def pick_by_label(variants: List[Dict[str, Any]], label: str) -> Dict[str, Any]:
220
- """best / worst / half based on a combined rank by (height, bandwidth)."""
221
- # rank by height first, then bandwidth as tiebreaker
222
- def key_fn(v: Dict[str, Any]) -> Tuple[int, int]:
223
- return v["height"] or 0, v["bandwidth"]
224
- ordered = sorted(variants, key=key_fn)
281
+ return variants
225
282
 
226
- if not ordered:
227
- raise ValueError("No video variants available in master playlist.")
228
283
 
229
- elif label == "worst":
284
+ def available_qualities(variants: Iterable[dict[str, Any]]) -> list[int]:
285
+ """Return sorted, unique integer qualities from normalized variants."""
286
+ return normalize_qualities(
287
+ variant["quality"]
288
+ for variant in variants
289
+ if variant.get("quality") is not None
290
+ )
291
+
292
+
293
+ def choose_variant(
294
+ variants: Iterable[dict[str, Any]],
295
+ target: str | int,
296
+ ) -> dict[str, Any]:
297
+ """Select an HLS variant with quality and bandwidth fallbacks."""
298
+ candidates = list(variants)
299
+ if not candidates:
300
+ raise ValueError("No video variants are available.")
301
+
302
+ preference = normalize_quality_preference(target)
303
+ qualities = available_qualities(candidates)
304
+ if qualities:
305
+ selected_quality = choose_quality_from_list(qualities, preference)
306
+ matching = [
307
+ variant
308
+ for variant in candidates
309
+ if variant.get("quality") == selected_quality
310
+ ]
311
+ return max(
312
+ matching,
313
+ key=lambda variant: (
314
+ variant.get("bandwidth", 0),
315
+ variant.get("frame_rate", 0.0),
316
+ ),
317
+ )
318
+
319
+ # Some masters expose only bandwidth. Labels can still be ranked, while a
320
+ # numeric request falls back to the highest-bandwidth usable variant.
321
+ ordered = sorted(
322
+ candidates,
323
+ key=lambda variant: (
324
+ variant.get("bandwidth", 0),
325
+ variant.get("frame_rate", 0.0),
326
+ ),
327
+ )
328
+ if preference == "worst":
230
329
  return ordered[0]
231
- elif label == "half":
232
- return ordered[len(ordered)//2]
233
- elif label == "best":
234
- return ordered[-1]
235
- else:
236
- raise ValueError("Invalid quality label.")
330
+ if preference == "half":
331
+ return ordered[len(ordered) // 2]
332
+ return ordered[-1]
237
333
 
238
334
 
239
- def pick_by_height(variants: List[Dict[str, Any]], target: int) -> Dict[str, Any]:
240
- """Choose the highest height target; else closest by absolute diff (ties -> higher)."""
241
- with_height = [v for v in variants if v["height"] is not None]
242
- if with_height:
243
- # Prefer height <= target
244
- below_eq = [v for v in with_height if v["height"] <= target]
245
- if below_eq:
246
- # Among same height, prefer higher bandwidth then higher fps
247
- best = sorted(below_eq, key=lambda v: (v["height"], v["bandwidth"], v["frame_rate"]))[-1]
248
- return best
335
+ # Compatibility wrappers for callers of the pre-4.0 helper names.
336
+ def normalize_quality_value(quality: str | int) -> QualityPreference:
337
+ return normalize_quality_preference(quality)
249
338
 
250
- # Fallback: closest by absolute diff; ties -> higher height
251
- def diff_key(v: Dict[str, Any]) -> Tuple[int, int, int, float]:
252
- return abs((v["height"] or 0) - target), -(v["height"] or 0), v["bandwidth"], v["frame_rate"]
253
- return sorted(with_height, key=diff_key)[0]
254
339
 
255
- # If we have no heights at all, fall back to bandwidth ranking
256
- return sorted(variants, key=lambda v: v["bandwidth"])[-1]
340
+ def height_from_variant(variant: Any) -> int | None:
341
+ return quality_from_variant(variant)
257
342
 
258
343
 
259
- def get_segment_index_width(total: int) -> int:
260
- return max(6, len(str(max(0, total - 1))))
344
+ def pick_by_label(
345
+ variants: list[dict[str, Any]],
346
+ label: str,
347
+ ) -> dict[str, Any]:
348
+ return choose_variant(variants, label)
349
+
350
+
351
+ def pick_by_height(
352
+ variants: list[dict[str, Any]],
353
+ target: int,
354
+ ) -> dict[str, Any]:
355
+ return choose_variant(variants, target)
261
356
 
262
357
 
263
358
  def segment_file_path(segment_dir, index: int, width: int) -> str:
@@ -479,4 +574,4 @@ def strip_title(
479
574
  sanitized = sanitized[:max_length].rstrip(" .")
480
575
 
481
576
  # 8. Return default fallback if sanitization leaves an empty string
482
- return sanitized if sanitized else default_name
577
+ return sanitized if sanitized else default_name
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "eaf_base_api"
7
- version = "4.0.0"
7
+ version = "4.0.1"
8
8
  description = "A base API for EchterAlsFake's Porn APIs"
9
9
  readme = { file = "README.md", content-type = "text/markdown" }
10
10
  requires-python = ">=3.12"
File without changes