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.
- {eaf_base_api-4.0.0 → eaf_base_api-4.0.1}/PKG-INFO +13 -10
- {eaf_base_api-4.0.0 → eaf_base_api-4.0.1}/README.md +12 -9
- {eaf_base_api-4.0.0 → eaf_base_api-4.0.1}/base_api/base.py +173 -106
- {eaf_base_api-4.0.0 → eaf_base_api-4.0.1}/base_api/modules/static_functions.py +247 -152
- {eaf_base_api-4.0.0 → eaf_base_api-4.0.1}/pyproject.toml +1 -1
- {eaf_base_api-4.0.0 → eaf_base_api-4.0.1}/LICENSE +0 -0
- {eaf_base_api-4.0.0 → eaf_base_api-4.0.1}/base_api/__init__.py +0 -0
- {eaf_base_api-4.0.0 → eaf_base_api-4.0.1}/base_api/modules/__init__.py +0 -0
- {eaf_base_api-4.0.0 → eaf_base_api-4.0.1}/base_api/modules/config.py +0 -0
- {eaf_base_api-4.0.0 → eaf_base_api-4.0.1}/base_api/modules/errors.py +0 -0
- {eaf_base_api-4.0.0 → eaf_base_api-4.0.1}/base_api/modules/logger.py +0 -0
- {eaf_base_api-4.0.0 → eaf_base_api-4.0.1}/base_api/modules/progress_bars.py +0 -0
- {eaf_base_api-4.0.0 → eaf_base_api-4.0.1}/base_api/modules/type_hints.py +0 -0
- {eaf_base_api-4.0.0 → eaf_base_api-4.0.1}/base_api/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: eaf_base_api
|
|
3
|
-
Version: 4.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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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.
|
|
160
|
-
failures independently support `ErrorMode.YIELD`, `ErrorMode.SKIP`,
|
|
161
|
-
`ErrorMode.RAISE`. `RetryPolicy` provides a strict maximum attempt count and
|
|
162
|
-
optional exponential delay;
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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.
|
|
140
|
-
failures independently support `ErrorMode.YIELD`, `ErrorMode.SKIP`,
|
|
141
|
-
`ErrorMode.RAISE`. `RetryPolicy` provides a strict maximum attempt count and
|
|
142
|
-
optional exponential delay;
|
|
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,
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
452
|
-
|
|
453
|
-
|
|
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
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
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
|
-
|
|
464
|
-
|
|
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
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
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.
|
|
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
|
-
"""
|
|
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
|
-
|
|
2053
|
-
|
|
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
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
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
|
-
|
|
2071
|
-
|
|
2076
|
+
if m3u8 is None:
|
|
2077
|
+
raise ModuleNotFoundError(
|
|
2078
|
+
"HLS support requires the 'm3u8' package."
|
|
2079
|
+
)
|
|
2072
2080
|
|
|
2073
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2081
|
-
|
|
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(
|
|
2109
|
+
content = await self.fetch_text(
|
|
2110
|
+
url=m3u8_url
|
|
2111
|
+
)
|
|
2112
|
+
|
|
2084
2113
|
master = m3u8.loads(content)
|
|
2085
|
-
|
|
2086
|
-
|
|
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(
|
|
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(
|
|
2129
|
+
raise PlaylistExtractionError(
|
|
2130
|
+
f"No usable video variants found: {m3u8_url}"
|
|
2131
|
+
)
|
|
2094
2132
|
|
|
2095
|
-
|
|
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
|
-
|
|
2102
|
-
return full_url
|
|
2135
|
+
uri = chosen["uri"]
|
|
2103
2136
|
|
|
2104
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
2175
|
+
|
|
2176
|
+
if inspect.isawaitable(m3u8_url):
|
|
2113
2177
|
m3u8_url = await m3u8_url
|
|
2114
2178
|
|
|
2115
|
-
if not m3u8_url
|
|
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
|
-
|
|
2118
|
-
|
|
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
|
-
|
|
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=
|
|
2268
|
-
max_workers=
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
81
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
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
|
-
|
|
104
|
-
if not available:
|
|
190
|
+
if not available_ints:
|
|
105
191
|
if default_fallback is not None:
|
|
106
|
-
return default_fallback
|
|
107
|
-
raise ValueError(
|
|
192
|
+
return normalize_quality(default_fallback)
|
|
193
|
+
raise ValueError(
|
|
194
|
+
"No valid video qualities are available."
|
|
195
|
+
)
|
|
108
196
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
|
201
|
+
return normalize_quality(default_fallback)
|
|
202
|
+
raise
|
|
147
203
|
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
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
|
-
|
|
159
|
-
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
|
261
|
+
return None
|
|
198
262
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
"
|
|
211
|
-
"
|
|
212
|
-
"
|
|
213
|
-
"
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
232
|
-
return ordered[len(ordered)//2]
|
|
233
|
-
|
|
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
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
-
|
|
256
|
-
return
|
|
340
|
+
def height_from_variant(variant: Any) -> int | None:
|
|
341
|
+
return quality_from_variant(variant)
|
|
257
342
|
|
|
258
343
|
|
|
259
|
-
def
|
|
260
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|