eaf_base_api 4.0.0__tar.gz → 4.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: eaf_base_api
3
- Version: 4.0.0
3
+ Version: 4.1.0
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
2075
 
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
-
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
@@ -2217,7 +2284,7 @@ a new Python file, import only m3u8 and see what error you get.
2217
2284
  self.logger.debug("Failed to remove directory %s: %s", path, e)
2218
2285
 
2219
2286
  async def download_segment(self, url: str, timeout: int, stop_event:
2220
- threading.Event | None = None) -> tuple[str, bytes, bool]:
2287
+ asyncio.Event | None = None) -> tuple[str, bytes, bool]:
2221
2288
  """
2222
2289
  Attempt to download a single segment.
2223
2290
  Returns (url, content, success).
@@ -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(
@@ -2455,6 +2522,8 @@ a new Python file, import only m3u8 and see what error you get.
2455
2522
  else:
2456
2523
  self.logger.debug(f"Writing segments to disk. segment_dir={segment_dir} tmp_path={tmp_path}")
2457
2524
 
2525
+ segment_tasks: set[asyncio.Task[Tuple[int, bool, bytes]]] = set()
2526
+ stop_waiter: asyncio.Task[bool] | None = None
2458
2527
  try:
2459
2528
  # Use asyncio.gather to fetch segments concurrently instead of ThreadPoolExecutor
2460
2529
 
@@ -2489,78 +2558,110 @@ a new Python file, import only m3u8 and see what error you get.
2489
2558
  )
2490
2559
  return idx, False, b""
2491
2560
 
2492
- tasks = [fetch_segment_with_semaphore(i, segments[i]) for i in target_indices]
2493
-
2494
- # Use asyncio.as_completed to process results as they come in, similar to wait(FIRST_COMPLETED)
2495
- for coro in asyncio.as_completed(tasks):
2496
- if stop_event is not None and stop_event.is_set():
2497
- cancelled = True
2498
- # The remaining tasks will see the event set and exit quickly
2499
- continue
2561
+ segment_tasks = {
2562
+ asyncio.create_task(
2563
+ fetch_segment_with_semaphore(i, segments[i]),
2564
+ name=f"hls-segment-{i}",
2565
+ )
2566
+ for i in target_indices
2567
+ }
2568
+ stop_waiter = (
2569
+ asyncio.create_task(stop_event.wait(), name="hls-stop-waiter")
2570
+ if stop_event is not None
2571
+ else None
2572
+ )
2500
2573
 
2501
- i, success, data = await coro
2574
+ while segment_tasks:
2575
+ waiters = set(segment_tasks)
2576
+ if stop_waiter is not None:
2577
+ waiters.add(stop_waiter)
2578
+ done, _ = await asyncio.wait(
2579
+ waiters,
2580
+ return_when=asyncio.FIRST_COMPLETED,
2581
+ )
2502
2582
 
2503
- if cancelled:
2504
- continue
2583
+ if stop_waiter is not None and stop_waiter in done:
2584
+ cancelled = True
2585
+ for task in segment_tasks:
2586
+ task.cancel()
2587
+ await asyncio.gather(*segment_tasks, return_exceptions=True)
2588
+ segment_tasks.clear()
2589
+ self.logger.info("Cancelled all in-flight HLS segment requests.")
2590
+ break
2591
+
2592
+ completed_tasks = done.intersection(segment_tasks)
2593
+ for task in completed_tasks:
2594
+ segment_tasks.remove(task)
2595
+ i, success, data = task.result()
2596
+
2597
+ if success and data:
2598
+ downloaded[i] = True # Successfully got segment, mark it as done
2599
+ downloaded_count += 1
2600
+ if segment_dir:
2601
+ # Write to a temp path (good for resuming, but not I/O efficient)
2602
+ seg_path = segment_file_path(segment_dir, i, width)
2603
+ tmp_seg = f"{seg_path}.part"
2604
+ # Offload segment file writing to a thread
2605
+ def write_part(ts_path: str, t_data: bytes) -> None:
2606
+ with open(ts_path, "wb") as f:
2607
+ f.write(t_data)
2608
+ await asyncio.to_thread(write_part, tmp_seg, data)
2609
+ os.replace(tmp_seg, seg_path)
2610
+ else:
2611
+ assert parts is not None
2612
+ parts[i] = data # Keep in memory (I/O efficient)
2613
+
2614
+ progressed += 1 # Fetched +1 segment, so we give back callback
2615
+ if callback:
2616
+ callback(progressed, n)
2617
+ if progressed >= next_progress_log or progressed == n:
2618
+ remaining = n - downloaded_count
2619
+ self.logger.debug(
2620
+ f"Segment progress: processed={progressed}/{n} "
2621
+ f"downloaded={downloaded_count} remaining={remaining}"
2622
+ )
2623
+ next_progress_log += progress_log_step
2505
2624
 
2506
- if success and data:
2507
- downloaded[i] = True # Successfully got segment, mark it as done
2508
- downloaded_count += 1
2509
- if segment_dir:
2510
- # Write to a temp path (good for resuming, but not I/O efficient)
2511
- seg_path = segment_file_path(segment_dir, i, width)
2512
- tmp_seg = f"{seg_path}.part"
2513
- # Offload segment file writing to a thread
2514
- def write_part(ts_path: str, t_data: bytes) -> None:
2515
- with open(ts_path, "wb") as f:
2516
- f.write(t_data)
2517
- await asyncio.to_thread(write_part, tmp_seg, data)
2518
- os.replace(tmp_seg, seg_path)
2519
2625
  else:
2520
- assert parts is not None
2521
- parts[i] = data # Keep in memory (I/O efficient)
2522
-
2523
- progressed += 1 # Fetched +1 segment, so we give back callback
2524
- if callback:
2525
- callback(progressed, n)
2526
- if progressed >= next_progress_log or progressed == n:
2527
- remaining = n - downloaded_count
2528
- self.logger.debug(
2529
- f"Segment progress: processed={progressed}/{n} "
2530
- f"downloaded={downloaded_count} remaining={remaining}"
2531
- )
2532
- next_progress_log += progress_log_step
2533
-
2534
- else:
2535
- # Handling failure (already retried in fetch_segment_with_semaphore)
2536
- progressed += 1
2537
- if callback:
2538
- callback(progressed, n)
2539
- if progressed >= next_progress_log or progressed == n:
2540
- remaining = n - downloaded_count
2541
- self.logger.debug(
2542
- f"Segment progress: processed={progressed}/{n} "
2543
- f"downloaded={downloaded_count} remaining={remaining}"
2544
- )
2545
- next_progress_log += progress_log_step
2546
-
2547
- if not segment_dir and parts is not None:
2548
- chunks_to_write = []
2549
- while next_to_write < n and parts[next_to_write] is not None:
2550
- if parts[next_to_write]:
2551
- chunks_to_write.append(parts[next_to_write])
2552
- next_to_write += 1
2553
- if chunks_to_write:
2554
- # Write memory chunks to thread to prevent IO block
2555
- def write_chunks(fp: Any, list_of_data: List[bytes]) -> None:
2556
- for c_data in list_of_data:
2557
- fp.write(c_data)
2558
- await asyncio.to_thread(write_chunks, cast(Any, out_fp), chunks_to_write)
2626
+ # Handling failure (already retried in fetch_segment_with_semaphore)
2627
+ progressed += 1
2628
+ if callback:
2629
+ callback(progressed, n)
2630
+ if progressed >= next_progress_log or progressed == n:
2631
+ remaining = n - downloaded_count
2632
+ self.logger.debug(
2633
+ f"Segment progress: processed={progressed}/{n} "
2634
+ f"downloaded={downloaded_count} remaining={remaining}"
2635
+ )
2636
+ next_progress_log += progress_log_step
2637
+
2638
+ if not segment_dir and parts is not None:
2639
+ chunks_to_write = []
2640
+ while next_to_write < n and parts[next_to_write] is not None:
2641
+ if parts[next_to_write]:
2642
+ chunks_to_write.append(parts[next_to_write])
2643
+ next_to_write += 1
2644
+ if chunks_to_write:
2645
+ # Write memory chunks to thread to prevent IO block
2646
+ def write_chunks(fp: Any, list_of_data: List[bytes]) -> None:
2647
+ for c_data in list_of_data:
2648
+ fp.write(c_data)
2649
+ await asyncio.to_thread(write_chunks, cast(Any, out_fp), chunks_to_write)
2559
2650
 
2560
2651
  finally:
2652
+ if stop_waiter is not None:
2653
+ stop_waiter.cancel()
2654
+ await asyncio.gather(stop_waiter, return_exceptions=True)
2655
+ if segment_tasks:
2656
+ for task in segment_tasks:
2657
+ task.cancel()
2658
+ await asyncio.gather(*segment_tasks, return_exceptions=True)
2561
2659
  if out_fp is not None:
2562
2660
  out_fp.close()
2563
2661
 
2662
+ if stop_event is not None and stop_event.is_set():
2663
+ cancelled = True
2664
+
2564
2665
  missing = [i for i, ok in enumerate(downloaded) if not ok] # Missing segments
2565
2666
  missing_urls = [segments[i] for i in missing] # Missing URLs of segments
2566
2667
  self.logger.info(
@@ -3,11 +3,13 @@ import re
3
3
  import math
4
4
  import json
5
5
  import unicodedata
6
+ from collections.abc import Iterable
7
+ from dataclasses import asdict
6
8
  from pathlib import PurePath
7
9
  from .type_hints import DownloadState
8
10
  from datetime import timezone, datetime
9
11
  from curl_cffi.requests import Response
10
- from typing import Dict, Any, cast, List, Callable, Tuple, Union
12
+ from typing import Dict, Any, cast, List, Callable, Literal, Union
11
13
  from email.utils import parsedate_to_datetime
12
14
 
13
15
 
@@ -68,196 +70,290 @@ def least_factors(n: int) -> int:
68
70
  return n
69
71
 
70
72
 
71
- def normalize_quality_value(quality: Union[str, int]) -> Union[str, int]:
73
+ type QualityPreference = int | Literal["best", "half", "worst"]
74
+
75
+ QUALITY_LABELS = frozenset({"best", "half", "worst"})
76
+
77
+
78
+ def is_video_playlist(variant: Any) -> bool:
79
+ """Filter out I-frames/audio-only playlists."""
80
+ # m3u8 lib sometimes sets is_iframe if EXT-X-I-FRAME-STREAM-INF is present.
81
+ if getattr(variant, "is_iframe", False):
82
+ return False
83
+
84
+ # If codecs known and contain only audio (mp4-a, ac-3, ec-3, etc.)
85
+ codecs = getattr(variant.stream_info, "codecs", None) if getattr(variant, "stream_info", None) else False
86
+ if codecs:
87
+ # very light heuristic: if no video codec substring, probably audio-only.
88
+ # video: avc1, hvc1, hev1, vp9, av01, dvh
89
+ codecs_text = str(codecs).lower()
90
+ if not any(v in codecs_text for v in ("avc1", "hvc1", "hev1", "av01", "vp9", "dvh")):
91
+ return False
92
+
93
+ return True
94
+
95
+
96
+ def get_segment_index_width(total: int) -> int:
97
+ return max(6, len(str(max(0, total - 1))))
98
+
99
+
100
+ COMMON_QUALITIES = frozenset({
101
+ 144,
102
+ 240,
103
+ 250,
104
+ 360,
105
+ 480,
106
+ 540,
107
+ 720,
108
+ 1080,
109
+ 1440,
110
+ 2160,
111
+ })
112
+ # Kept as an alias for callers that imported the name during the 4.0 rollout.
113
+ ALLOWED_QUALITIES = COMMON_QUALITIES
114
+
115
+
116
+ def validate_quality(value: int) -> int:
117
+ """Validate a normalized quality without restricting provider-specific tiers."""
118
+ if isinstance(value, bool) or not isinstance(value, int):
119
+ raise TypeError(f"Invalid quality type: {type(value).__name__}")
120
+ if value <= 0:
121
+ raise ValueError(f"Invalid video quality: {value!r}")
122
+
123
+ return value
124
+
125
+
126
+ def normalize_quality(value: str | int) -> int:
72
127
  """
73
- quality: represents the quality value that should be normalized
128
+ Convert a quality into a canonical integer.
129
+
130
+ Accepted:
131
+ 720
132
+ "720"
133
+ "720p"
134
+
135
+ Rejected:
136
+ "best"
137
+ "half"
138
+ "worst"
139
+ "720p60"
140
+ zero or negative values
74
141
  """
75
- if isinstance(quality, int):
76
- return quality # If the quality value is already an int, just return it directly
77
142
 
78
- quality = str(quality).lower().strip() # Convert to string, lower and remove white spaces
143
+ if isinstance(value, bool):
144
+ raise TypeError("A boolean is not a valid video quality.")
145
+
146
+ if isinstance(value, int):
147
+ quality = value
148
+
149
+ elif isinstance(value, str):
150
+ value = value.strip().lower()
151
+
152
+ match = re.fullmatch(r"(\d+)[pP]?", value)
153
+
154
+ if not match:
155
+ raise ValueError(
156
+ f"Invalid video quality: {value!r}"
157
+ )
158
+
159
+ quality = int(match.group(1))
160
+
161
+ else:
162
+ raise TypeError(
163
+ f"Invalid quality type: {type(value).__name__}"
164
+ )
165
+
166
+ return validate_quality(quality)
167
+
168
+ def normalize_quality_preference(
169
+ value: str | int,
170
+ ) -> QualityPreference:
171
+
172
+ if isinstance(value, str):
173
+ value = value.strip().lower()
79
174
 
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
175
+ if value in QUALITY_LABELS:
176
+ return cast(QualityPreference, value)
177
+
178
+ return normalize_quality(value)
82
179
 
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
180
 
88
181
 
89
182
  def choose_quality_from_list(
90
- available: List[Union[str, int]],
91
- target: Union[str, int],
92
- default_fallback: int | str | None = None
183
+ available: Iterable[str | int],
184
+ target: str | int,
185
+ default_fallback: str | int | None = None,
93
186
  ) -> int:
94
- """
95
- Selects a video quality from a list based on a target integer< or label.
187
+ """Choose a quality, falling back to the nearest available numeric tier."""
96
188
 
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
- """
189
+ available_ints = normalize_qualities(available)
102
190
 
103
- # 1. Edge Case: Empty List
104
- if not available:
191
+ if not available_ints:
105
192
  if default_fallback is not None:
106
- return default_fallback
107
- raise ValueError("The 'available' list cannot be empty.")
193
+ return normalize_quality(default_fallback)
194
+ raise ValueError(
195
+ "No valid video qualities are available."
196
+ )
108
197
 
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
198
+ try:
199
+ preference = normalize_quality_preference(target)
200
+ except (TypeError, ValueError):
144
201
  if default_fallback is not None:
145
- return default_fallback
146
- raise ValueError(f"Invalid label: '{target}'. Expected 'best', 'worst', 'half', or a number.")
202
+ return normalize_quality(default_fallback)
203
+ raise
147
204
 
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]
205
+ if preference == "best":
206
+ return available_ints[-1]
153
207
 
154
- # Fallback closest: if target is 144 but only 240+ is available,
155
- # all available qualities are > target. The closest is the lowest available.
208
+ if preference == "worst":
156
209
  return available_ints[0]
157
210
 
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.")
211
+ if preference == "half":
212
+ return available_ints[len(available_ints) // 2]
162
213
 
214
+ # Prefer the higher tier when two variants are equally close.
215
+ return min(
216
+ available_ints,
217
+ key=lambda quality: (abs(quality - preference), -quality),
218
+ )
163
219
 
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/...
220
+
221
+ def normalize_qualities(
222
+ values: Iterable[str | int],
223
+ ) -> list[int]:
224
+ """
225
+ Return canonical, unique qualities sorted worst -> best.
168
226
  """
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
227
 
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))
228
+ qualities: set[int] = set()
229
+ for value in values:
230
+ try:
231
+ qualities.add(normalize_quality(value))
232
+ except (TypeError, ValueError):
233
+ # Provider data can contain labels such as "auto" alongside real
234
+ # qualities. They should not make the usable variants disappear.
235
+ continue
178
236
 
179
- # If nothing is found, though this shouldn't happen
180
- return None
237
+ return sorted(qualities)
181
238
 
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
239
 
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
240
+ def quality_from_variant(variant: Any) -> int | None:
241
+ """Extract a quality tier from a landscape or portrait HLS variant."""
242
+ stream_info = getattr(variant, "stream_info", None)
243
+ resolution = getattr(stream_info, "resolution", None)
244
+ if resolution:
245
+ try:
246
+ width, height = resolution
247
+ # Quality names describe the shorter side: 1920x1080 and
248
+ # 1080x1920 are both 1080p.
249
+ return normalize_quality(min(int(width), int(height)))
250
+ except (TypeError, ValueError):
251
+ pass
252
+
253
+ uri = getattr(variant, "uri", None)
254
+ if isinstance(uri, str):
255
+ match = HEIGHT_FROM_URI.search(uri)
256
+ if match:
257
+ try:
258
+ return normalize_quality(match.group(1))
259
+ except (TypeError, ValueError):
260
+ pass
196
261
 
197
- return True
262
+ return None
198
263
 
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):
264
+
265
+ def collect_variants(master: Any) -> list[dict[str, Any]]:
266
+ """Return video variants with normalized, comparable metadata."""
267
+ variants: list[dict[str, Any]] = []
268
+ for variant in getattr(master, "playlists", ()):
269
+ if not is_video_playlist(variant):
204
270
  continue
205
271
 
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
272
+ stream_info = getattr(variant, "stream_info", None)
273
+ variants.append({
274
+ "uri": getattr(variant, "uri", ""),
275
+ "quality": quality_from_variant(variant),
276
+ "bandwidth": int(getattr(stream_info, "bandwidth", 0) or 0),
277
+ "frame_rate": float(getattr(stream_info, "frame_rate", 0.0) or 0.0),
278
+ "resolution": getattr(stream_info, "resolution", None),
279
+ "raw": variant,
216
280
  })
217
- return items
218
281
 
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)
282
+ return variants
225
283
 
226
- if not ordered:
227
- raise ValueError("No video variants available in master playlist.")
228
284
 
229
- elif label == "worst":
285
+ def available_qualities(variants: Iterable[dict[str, Any]]) -> list[int]:
286
+ """Return sorted, unique integer qualities from normalized variants."""
287
+ return normalize_qualities(
288
+ variant["quality"]
289
+ for variant in variants
290
+ if variant.get("quality") is not None
291
+ )
292
+
293
+
294
+ def choose_variant(
295
+ variants: Iterable[dict[str, Any]],
296
+ target: str | int,
297
+ ) -> dict[str, Any]:
298
+ """Select an HLS variant with quality and bandwidth fallbacks."""
299
+ candidates = list(variants)
300
+ if not candidates:
301
+ raise ValueError("No video variants are available.")
302
+
303
+ preference = normalize_quality_preference(target)
304
+ qualities = available_qualities(candidates)
305
+ if qualities:
306
+ selected_quality = choose_quality_from_list(qualities, preference)
307
+ matching = [
308
+ variant
309
+ for variant in candidates
310
+ if variant.get("quality") == selected_quality
311
+ ]
312
+ return max(
313
+ matching,
314
+ key=lambda variant: (
315
+ variant.get("bandwidth", 0),
316
+ variant.get("frame_rate", 0.0),
317
+ ),
318
+ )
319
+
320
+ # Some masters expose only bandwidth. Labels can still be ranked, while a
321
+ # numeric request falls back to the highest-bandwidth usable variant.
322
+ ordered = sorted(
323
+ candidates,
324
+ key=lambda variant: (
325
+ variant.get("bandwidth", 0),
326
+ variant.get("frame_rate", 0.0),
327
+ ),
328
+ )
329
+ if preference == "worst":
230
330
  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.")
331
+ if preference == "half":
332
+ return ordered[len(ordered) // 2]
333
+ return ordered[-1]
237
334
 
238
335
 
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
336
+ # Compatibility wrappers for callers of the pre-4.0 helper names.
337
+ def normalize_quality_value(quality: str | int) -> QualityPreference:
338
+ return normalize_quality_preference(quality)
249
339
 
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
340
 
255
- # If we have no heights at all, fall back to bandwidth ranking
256
- return sorted(variants, key=lambda v: v["bandwidth"])[-1]
341
+ def height_from_variant(variant: Any) -> int | None:
342
+ return quality_from_variant(variant)
257
343
 
258
344
 
259
- def get_segment_index_width(total: int) -> int:
260
- return max(6, len(str(max(0, total - 1))))
345
+ def pick_by_label(
346
+ variants: list[dict[str, Any]],
347
+ label: str,
348
+ ) -> dict[str, Any]:
349
+ return choose_variant(variants, label)
350
+
351
+
352
+ def pick_by_height(
353
+ variants: list[dict[str, Any]],
354
+ target: int,
355
+ ) -> dict[str, Any]:
356
+ return choose_variant(variants, target)
261
357
 
262
358
 
263
359
  def segment_file_path(segment_dir, index: int, width: int) -> str:
@@ -266,8 +362,12 @@ def segment_file_path(segment_dir, index: int, width: int) -> str:
266
362
 
267
363
  def write_segment_state(state_path: str, state: DownloadState) -> None:
268
364
  tmp_path = f"{state_path}.tmp"
365
+ payload = asdict(state)
366
+ for path_key in ("output_path", "segment_dir"):
367
+ if isinstance(payload[path_key], PurePath):
368
+ payload[path_key] = str(payload[path_key])
269
369
  with open(tmp_path, "w", encoding="utf-8") as fp:
270
- json.dump(state, fp, ensure_ascii=True, indent=2, sort_keys=True)
370
+ json.dump(payload, fp, ensure_ascii=True, indent=2, sort_keys=True)
271
371
  os.replace(tmp_path, state_path)
272
372
 
273
373
 
@@ -479,4 +579,4 @@ def strip_title(
479
579
  sanitized = sanitized[:max_length].rstrip(" .")
480
580
 
481
581
  # 8. Return default fallback if sanitization leaves an empty string
482
- return sanitized if sanitized else default_name
582
+ 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.1.0"
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