eaf-base-api 4.2.0__tar.gz → 4.2.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {eaf_base_api-4.2.0 → eaf_base_api-4.2.2}/PKG-INFO +30 -2
- {eaf_base_api-4.2.0 → eaf_base_api-4.2.2}/README.md +29 -0
- {eaf_base_api-4.2.0 → eaf_base_api-4.2.2}/base_api/__init__.py +17 -0
- {eaf_base_api-4.2.0 → eaf_base_api-4.2.2}/base_api/base.py +111 -219
- {eaf_base_api-4.2.0 → eaf_base_api-4.2.2}/base_api/modules/config.py +4 -0
- {eaf_base_api-4.2.0 → eaf_base_api-4.2.2}/base_api/modules/errors.py +6 -0
- eaf_base_api-4.2.2/base_api/modules/ffmpeg.py +359 -0
- {eaf_base_api-4.2.0 → eaf_base_api-4.2.2}/base_api/modules/provider.py +5 -0
- {eaf_base_api-4.2.0 → eaf_base_api-4.2.2}/base_api/modules/type_hints.py +1 -0
- eaf_base_api-4.2.2/base_api/modules/urls.py +51 -0
- eaf_base_api-4.2.2/pyproject.toml +48 -0
- eaf_base_api-4.2.0/pyproject.toml → eaf_base_api-4.2.2/pyproject.toml.orig +1 -3
- {eaf_base_api-4.2.0 → eaf_base_api-4.2.2}/LICENSE +0 -0
- {eaf_base_api-4.2.0 → eaf_base_api-4.2.2}/base_api/modules/__init__.py +0 -0
- {eaf_base_api-4.2.0 → eaf_base_api-4.2.2}/base_api/modules/logger.py +0 -0
- {eaf_base_api-4.2.0 → eaf_base_api-4.2.2}/base_api/modules/progress_bars.py +0 -0
- {eaf_base_api-4.2.0 → eaf_base_api-4.2.2}/base_api/modules/static_functions.py +0 -0
- {eaf_base_api-4.2.0 → eaf_base_api-4.2.2}/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.2.
|
|
3
|
+
Version: 4.2.2
|
|
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>
|
|
@@ -11,7 +11,6 @@ Requires-Dist: cachetools>=7.0.0
|
|
|
11
11
|
Requires-Dist: curl-cffi
|
|
12
12
|
Requires-Dist: tenacity>=9.1.2
|
|
13
13
|
Requires-Dist: m3u8 ; extra == 'hls'
|
|
14
|
-
Requires-Dist: av ; python_full_version >= '3.12' and extra == 'hls'
|
|
15
14
|
Requires-Python: >=3.12
|
|
16
15
|
Project-URL: Homepage, https://github.com/EchterAlsFake/eaf_base_api
|
|
17
16
|
Project-URL: Repository, https://github.com/EchterAlsFake/eaf_base_api
|
|
@@ -90,6 +89,35 @@ Site-specific parsing, quality selection, and fallback behavior stay in each
|
|
|
90
89
|
provider. Updated providers require `eaf-base-api>=4.2.0`; update the base library
|
|
91
90
|
alongside them.
|
|
92
91
|
|
|
92
|
+
## FFmpeg remuxing and single-pass metadata tagging (4.3)
|
|
93
|
+
|
|
94
|
+
HLS TS→MP4 remuxing (`DownloadConfigHLS(remux=True)`) now runs directly via an
|
|
95
|
+
`ffmpeg` binary (discovered on `PATH` or configured via `RuntimeConfig.ffmpeg_path`)
|
|
96
|
+
instead of `PyAV`. You can also pass `metadata` (a provider `Video` object or `dict`)
|
|
97
|
+
and `thumbnail` (`bytes` or path) on `DownloadConfigHLS` so `eaf_base_api` embeds
|
|
98
|
+
MP4 container tags and cover art in the **same single FFmpeg remux pass** without
|
|
99
|
+
rewriting the file a second time:
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
from base_api import DownloadConfigHLS, write_metadata_tags
|
|
103
|
+
|
|
104
|
+
config = DownloadConfigHLS(
|
|
105
|
+
quality="best",
|
|
106
|
+
path="./downloads",
|
|
107
|
+
remux=True,
|
|
108
|
+
metadata=video, # Writes title, author, genre, date, description, url, comment
|
|
109
|
+
thumbnail=cover_bytes, # Optional JPEG/PNG cover art embedded in the same pass
|
|
110
|
+
return_report=True,
|
|
111
|
+
)
|
|
112
|
+
report = await video.download(configuration=config)
|
|
113
|
+
print(report.metadata_written) # True when single-pass metadata/cover tagging succeeded
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
For direct MP4 downloads (`DownloadConfigRAW`) or existing MP4 files, use
|
|
117
|
+
`write_metadata_tags(path, metadata=video, thumbnail=cover_bytes)` from
|
|
118
|
+
`base_api.modules.ffmpeg` (or `base_api`). If `ffmpeg` is not installed when
|
|
119
|
+
`remux=True`, `eaf_base_api` logs a warning and preserves the raw MPEG-TS stream.
|
|
120
|
+
|
|
93
121
|
# Documentation (IMPORTANT!)
|
|
94
122
|
> [!IMPORTANT]
|
|
95
123
|
> Configuring eaf_base_api is necessary if you use any of my Porn APIs, because they all depend on this project.
|
|
@@ -70,6 +70,35 @@ Site-specific parsing, quality selection, and fallback behavior stay in each
|
|
|
70
70
|
provider. Updated providers require `eaf-base-api>=4.2.0`; update the base library
|
|
71
71
|
alongside them.
|
|
72
72
|
|
|
73
|
+
## FFmpeg remuxing and single-pass metadata tagging (4.3)
|
|
74
|
+
|
|
75
|
+
HLS TS→MP4 remuxing (`DownloadConfigHLS(remux=True)`) now runs directly via an
|
|
76
|
+
`ffmpeg` binary (discovered on `PATH` or configured via `RuntimeConfig.ffmpeg_path`)
|
|
77
|
+
instead of `PyAV`. You can also pass `metadata` (a provider `Video` object or `dict`)
|
|
78
|
+
and `thumbnail` (`bytes` or path) on `DownloadConfigHLS` so `eaf_base_api` embeds
|
|
79
|
+
MP4 container tags and cover art in the **same single FFmpeg remux pass** without
|
|
80
|
+
rewriting the file a second time:
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
from base_api import DownloadConfigHLS, write_metadata_tags
|
|
84
|
+
|
|
85
|
+
config = DownloadConfigHLS(
|
|
86
|
+
quality="best",
|
|
87
|
+
path="./downloads",
|
|
88
|
+
remux=True,
|
|
89
|
+
metadata=video, # Writes title, author, genre, date, description, url, comment
|
|
90
|
+
thumbnail=cover_bytes, # Optional JPEG/PNG cover art embedded in the same pass
|
|
91
|
+
return_report=True,
|
|
92
|
+
)
|
|
93
|
+
report = await video.download(configuration=config)
|
|
94
|
+
print(report.metadata_written) # True when single-pass metadata/cover tagging succeeded
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
For direct MP4 downloads (`DownloadConfigRAW`) or existing MP4 files, use
|
|
98
|
+
`write_metadata_tags(path, metadata=video, thumbnail=cover_bytes)` from
|
|
99
|
+
`base_api.modules.ffmpeg` (or `base_api`). If `ffmpeg` is not installed when
|
|
100
|
+
`remux=True`, `eaf_base_api` logs a warning and preserves the raw MPEG-TS stream.
|
|
101
|
+
|
|
73
102
|
# Documentation (IMPORTANT!)
|
|
74
103
|
> [!IMPORTANT]
|
|
75
104
|
> Configuring eaf_base_api is necessary if you use any of my Porn APIs, because they all depend on this project.
|
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
__all__ = [
|
|
2
|
+
"append_url_path",
|
|
3
|
+
"profile_base_url",
|
|
4
|
+
"replace_url_path",
|
|
5
|
+
"update_url_query",
|
|
2
6
|
"BaseCore",
|
|
3
7
|
"BaseMedia",
|
|
4
8
|
"Cache",
|
|
@@ -55,15 +59,21 @@ __all__ = [
|
|
|
55
59
|
"ProxyError",
|
|
56
60
|
"UnknownNetworkError",
|
|
57
61
|
"DownloadFailed",
|
|
62
|
+
"MetadataWriteError",
|
|
58
63
|
"VideoUnavailable",
|
|
59
64
|
"ResourceGone",
|
|
60
65
|
"LoginFailed",
|
|
61
66
|
"RegionBlocked",
|
|
62
67
|
"configure_app_logging",
|
|
68
|
+
"find_ffmpeg",
|
|
69
|
+
"has_ffmpeg",
|
|
70
|
+
"remux_ts_to_mp4",
|
|
71
|
+
"write_metadata_tags",
|
|
63
72
|
]
|
|
64
73
|
|
|
65
74
|
|
|
66
75
|
from base_api.modules import errors
|
|
76
|
+
from base_api.modules.urls import append_url_path, profile_base_url, replace_url_path, update_url_query
|
|
67
77
|
from base_api.modules.progress_bars import Callback
|
|
68
78
|
from base_api.modules.errors import (
|
|
69
79
|
DataNotLoadedError,
|
|
@@ -87,11 +97,18 @@ from base_api.modules.errors import (
|
|
|
87
97
|
ProxyError,
|
|
88
98
|
UnknownNetworkError,
|
|
89
99
|
DownloadFailed,
|
|
100
|
+
MetadataWriteError,
|
|
90
101
|
VideoUnavailable,
|
|
91
102
|
ResourceGone,
|
|
92
103
|
LoginFailed,
|
|
93
104
|
RegionBlocked,
|
|
94
105
|
)
|
|
106
|
+
from base_api.modules.ffmpeg import (
|
|
107
|
+
find_ffmpeg,
|
|
108
|
+
has_ffmpeg,
|
|
109
|
+
remux_ts_to_mp4,
|
|
110
|
+
write_metadata_tags,
|
|
111
|
+
)
|
|
95
112
|
from base_api.modules.logger import configure_app_logging
|
|
96
113
|
from base_api.base import (
|
|
97
114
|
BaseCore,
|
|
@@ -5,6 +5,8 @@ import time
|
|
|
5
5
|
import hashlib
|
|
6
6
|
import string
|
|
7
7
|
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
from pathlib import Path
|
|
8
10
|
import asyncio
|
|
9
11
|
import inspect
|
|
10
12
|
import logging
|
|
@@ -27,6 +29,7 @@ from typing import (
|
|
|
27
29
|
|
|
28
30
|
|
|
29
31
|
# 1. Standardize on relative imports
|
|
32
|
+
from base_api.modules.urls import FetchedText
|
|
30
33
|
from base_api.modules.errors import *
|
|
31
34
|
from base_api.modules.type_hints import (
|
|
32
35
|
DownloadReport, ResultOrder, ErrorMode, ErrorAction, ScrapeStage,
|
|
@@ -39,30 +42,26 @@ from base_api.modules.static_functions import (
|
|
|
39
42
|
choose_variant, collect_variants, get_segment_index_width
|
|
40
43
|
)
|
|
41
44
|
from base_api.modules.config import config, RuntimeConfig, DownloadConfigHLS, DownloadConfigRAW, IteratorConfig, make_iterator_config
|
|
45
|
+
from base_api.modules.ffmpeg import find_ffmpeg, remux_ts_to_mp4, write_metadata_tags
|
|
42
46
|
from base_api.modules.progress_bars import Callback
|
|
43
47
|
from base_api.modules.logger import configure_app_logging, get_logger, log_context, ContextLogger
|
|
44
48
|
|
|
45
49
|
# 2. Handle optional dependencies cleanly
|
|
46
50
|
try:
|
|
47
51
|
import m3u8
|
|
48
|
-
|
|
49
|
-
|
|
52
|
+
# Needed for all videos that use HLS streaming. Some do not and use mp4 containers / files instead
|
|
53
|
+
except (ModuleNotFoundError, ImportError):
|
|
54
|
+
m3u8 = None # type: ignore
|
|
50
55
|
|
|
51
|
-
# 3. Handle specific runtime imports
|
|
52
56
|
if TYPE_CHECKING:
|
|
53
|
-
from av.audio.codeccontext import AudioCodecContext
|
|
54
57
|
import m3u8
|
|
55
58
|
|
|
56
|
-
# The following imports are optional, because they depend on per API and I want to be as memory efficient as possible
|
|
57
59
|
|
|
58
|
-
|
|
59
|
-
import m3u8
|
|
60
|
-
# Needed for all videos that use HLS streaming. Some do not and use mp4 containers / files instead
|
|
61
|
-
except (ModuleNotFoundError, ImportError):
|
|
62
|
-
m3u8 = None # type: ignore
|
|
60
|
+
REGEX_CHALLENGE = re.compile(r'var p=(\d+); var s=(\d+);.*?(\d+):1;', re.DOTALL)
|
|
63
61
|
|
|
64
62
|
|
|
65
|
-
|
|
63
|
+
class _ChallengeRetry(NetworkRequestError):
|
|
64
|
+
"""Replay with an updated challenge cookie without network backoff."""
|
|
66
65
|
|
|
67
66
|
|
|
68
67
|
class CachePolicy(StrEnum):
|
|
@@ -102,46 +101,6 @@ def _contains_http_status(error: BaseException, status_code: int) -> bool:
|
|
|
102
101
|
return False
|
|
103
102
|
|
|
104
103
|
|
|
105
|
-
def _normalize_packet_timestamps(
|
|
106
|
-
packet: Any,
|
|
107
|
-
offsets: dict[int, int],
|
|
108
|
-
last_dts: dict[int, int],
|
|
109
|
-
last_durations: dict[int, int],
|
|
110
|
-
) -> int:
|
|
111
|
-
"""Keep remuxed packet timestamps continuous across HLS discontinuities."""
|
|
112
|
-
if packet.dts is None:
|
|
113
|
-
return 0
|
|
114
|
-
|
|
115
|
-
stream_index = packet.stream.index
|
|
116
|
-
raw_dts = packet.dts
|
|
117
|
-
offset = offsets.get(stream_index, 0)
|
|
118
|
-
normalized_dts = raw_dts + offset
|
|
119
|
-
previous_dts = last_dts.get(stream_index)
|
|
120
|
-
correction = 0
|
|
121
|
-
|
|
122
|
-
if previous_dts is not None:
|
|
123
|
-
step = max(1, last_durations.get(stream_index, packet.duration or 1))
|
|
124
|
-
expected_dts = previous_dts + step
|
|
125
|
-
try:
|
|
126
|
-
ten_seconds = max(1, int(10 / float(packet.time_base)))
|
|
127
|
-
except (TypeError, ValueError, ZeroDivisionError):
|
|
128
|
-
ten_seconds = step * 240
|
|
129
|
-
|
|
130
|
-
discontinuity_threshold = max(step * 10, ten_seconds)
|
|
131
|
-
delta = normalized_dts - expected_dts
|
|
132
|
-
if normalized_dts <= previous_dts or abs(delta) > discontinuity_threshold:
|
|
133
|
-
correction = -delta
|
|
134
|
-
offset += correction
|
|
135
|
-
offsets[stream_index] = offset
|
|
136
|
-
normalized_dts = raw_dts + offset
|
|
137
|
-
|
|
138
|
-
packet.dts = normalized_dts
|
|
139
|
-
if packet.pts is not None:
|
|
140
|
-
packet.pts += offset
|
|
141
|
-
last_dts[stream_index] = normalized_dts
|
|
142
|
-
last_durations[stream_index] = max(1, packet.duration or 1)
|
|
143
|
-
return correction
|
|
144
|
-
|
|
145
104
|
|
|
146
105
|
@dataclass(frozen=True, slots=True)
|
|
147
106
|
class RequestCacheKey:
|
|
@@ -464,6 +423,9 @@ class BaseMedia:
|
|
|
464
423
|
url: str
|
|
465
424
|
core: object
|
|
466
425
|
|
|
426
|
+
# Transport metadata is separate from lazily loaded fields and the caller's URL.
|
|
427
|
+
resolved_url: str | None = field(default=None, init=False, repr=False, compare=False)
|
|
428
|
+
|
|
467
429
|
loader_methods: ClassVar[Mapping[str, str]] = {}
|
|
468
430
|
|
|
469
431
|
_load_lock: asyncio.Lock = field(
|
|
@@ -1619,6 +1581,8 @@ class BaseCore:
|
|
|
1619
1581
|
self._inflight_text_requests: dict[RequestCacheKey, asyncio.Future[str]] = {}
|
|
1620
1582
|
self.latest_key: str | None = None
|
|
1621
1583
|
self.latest_key_time: float = 0.0
|
|
1584
|
+
self._challenge_generation = 0
|
|
1585
|
+
self._challenge_ready_at = 0.0
|
|
1622
1586
|
self.last_request_time: float | None = None
|
|
1623
1587
|
self.total_requests: int = 0 # Tracks how many requests have been made
|
|
1624
1588
|
self.session: AsyncSession | None = None
|
|
@@ -1838,6 +1802,8 @@ class BaseCore:
|
|
|
1838
1802
|
|
|
1839
1803
|
def retry_wait(retry_state: Any) -> float:
|
|
1840
1804
|
error = retry_state.outcome.exception() if retry_state.outcome else None
|
|
1805
|
+
if isinstance(error, _ChallengeRetry):
|
|
1806
|
+
return 0.0
|
|
1841
1807
|
if isinstance(error, RateLimitError) and error.retry_after is not None:
|
|
1842
1808
|
return max(0.0, error.retry_after)
|
|
1843
1809
|
return cast(float, exponential_wait(retry_state))
|
|
@@ -1855,13 +1821,23 @@ class BaseCore:
|
|
|
1855
1821
|
try:
|
|
1856
1822
|
await self.enforce_delay()
|
|
1857
1823
|
req_headers = self._merged_headers(headers)
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1824
|
+
async with self.lock:
|
|
1825
|
+
# Pornhub requires a cooldown after computing KEY.
|
|
1826
|
+
# All requests share its deadline; late responses do
|
|
1827
|
+
# not add another sleep. Keeping the deadline also
|
|
1828
|
+
# preserves the cooldown if its waiter is cancelled.
|
|
1829
|
+
loop = asyncio.get_running_loop()
|
|
1830
|
+
while (remaining := self._challenge_ready_at - loop.time()) > 0:
|
|
1831
|
+
await asyncio.sleep(remaining)
|
|
1832
|
+
current_time = asyncio.get_running_loop().time()
|
|
1833
|
+
latest_key = self.latest_key
|
|
1834
|
+
if "KEY" not in session.cookies and latest_key is not None:
|
|
1835
|
+
if current_time - self.latest_key_time < 10:
|
|
1836
|
+
session.cookies.set("KEY", latest_key, domain=".pornhub.com", path="/")
|
|
1837
|
+
# Snapshot before sending: a late response may belong
|
|
1838
|
+
# to the cookie generation preceding another solve.
|
|
1839
|
+
challenge_generation = self._challenge_generation
|
|
1840
|
+
req_cookies = self._merged_cookies(cookies)
|
|
1865
1841
|
|
|
1866
1842
|
self.total_requests += 1
|
|
1867
1843
|
response = await cast(Any, session).request(
|
|
@@ -1886,15 +1862,13 @@ class BaseCore:
|
|
|
1886
1862
|
resp_text = cast(bytes, response.content).decode(enc, errors="replace")
|
|
1887
1863
|
|
|
1888
1864
|
if 'onload="go()"' in resp_text:
|
|
1889
|
-
local_latest = getattr(self, "latest_key", None)
|
|
1890
1865
|
async with self.lock:
|
|
1891
|
-
if
|
|
1866
|
+
if self._challenge_generation != challenge_generation:
|
|
1892
1867
|
self.logger.info("Another task already resolved the challenge! Retrying request with the new cookie.")
|
|
1893
1868
|
if self.latest_key:
|
|
1894
1869
|
session.cookies.set("KEY", self.latest_key, domain=".pornhub.com", path="/")
|
|
1895
1870
|
|
|
1896
|
-
|
|
1897
|
-
raise NetworkRequestError("Retrying request with the new cookie.")
|
|
1871
|
+
raise _ChallengeRetry("Retrying request with the new cookie.")
|
|
1898
1872
|
|
|
1899
1873
|
self.logger.info("Challenge page detected! Solving...")
|
|
1900
1874
|
get_challenge = re.compile(r'go\(\).*?{(.*?)n=l.*?KEY.*?s\+":(\d+):', re.DOTALL)
|
|
@@ -1923,7 +1897,9 @@ class BaseCore:
|
|
|
1923
1897
|
|
|
1924
1898
|
self.latest_key = cookie_value
|
|
1925
1899
|
self.latest_key_time = asyncio.get_running_loop().time()
|
|
1900
|
+
self._challenge_ready_at = self.latest_key_time + 1.5
|
|
1926
1901
|
session.cookies.set("KEY", cookie_value, domain=".pornhub.com", path="/")
|
|
1902
|
+
self._challenge_generation += 1
|
|
1927
1903
|
self.logger.info("RESOLVED CHALLENGE! Injected cookie: %s", cookie_value)
|
|
1928
1904
|
|
|
1929
1905
|
try:
|
|
@@ -1931,8 +1907,7 @@ class BaseCore:
|
|
|
1931
1907
|
except (KeyError, Exception):
|
|
1932
1908
|
pass
|
|
1933
1909
|
|
|
1934
|
-
|
|
1935
|
-
raise NetworkRequestError("Retrying request after solving challenge.")
|
|
1910
|
+
raise _ChallengeRetry("Retrying request after solving challenge.")
|
|
1936
1911
|
except (NetworkRequestError, SecurityAbort):
|
|
1937
1912
|
raise
|
|
1938
1913
|
except Exception as challenge_error:
|
|
@@ -1940,7 +1915,6 @@ class BaseCore:
|
|
|
1940
1915
|
|
|
1941
1916
|
else:
|
|
1942
1917
|
self.logger.error("Detected challenge page, but the regex failed to extract data.")
|
|
1943
|
-
await asyncio.sleep(1.5)
|
|
1944
1918
|
raise ChallengeRegexError("Detected Challenge, but regex couldn't extract, report this!")
|
|
1945
1919
|
|
|
1946
1920
|
|
|
@@ -1979,6 +1953,10 @@ class BaseCore:
|
|
|
1979
1953
|
f"HTTP {status} for {url}", status_code=status, url=url
|
|
1980
1954
|
)
|
|
1981
1955
|
|
|
1956
|
+
except _ChallengeRetry:
|
|
1957
|
+
# Cookie refresh is expected control flow, not a network
|
|
1958
|
+
# failure. The retry controller still bounds replays.
|
|
1959
|
+
raise
|
|
1982
1960
|
except RequestsError as e:
|
|
1983
1961
|
err_str = str(e).lower()
|
|
1984
1962
|
self.logger.error("Request error for URL %s: %s", url, e, exc_info=True)
|
|
@@ -2123,7 +2101,10 @@ class BaseCore:
|
|
|
2123
2101
|
params=params,
|
|
2124
2102
|
retry_non_idempotent=retry_non_idempotent,
|
|
2125
2103
|
)
|
|
2126
|
-
content =
|
|
2104
|
+
content = FetchedText(
|
|
2105
|
+
self._decode_response(response, url, self.logger),
|
|
2106
|
+
url=getattr(response, "url", None) or url,
|
|
2107
|
+
)
|
|
2127
2108
|
if key is not None:
|
|
2128
2109
|
self.cache.set_response(key, content)
|
|
2129
2110
|
assert pending is not None
|
|
@@ -2947,14 +2928,25 @@ class BaseCore:
|
|
|
2947
2928
|
self.logger.info(f"Remuxing TS to MP4: input={tmp_path} output={path}")
|
|
2948
2929
|
# Offload heavy CPU/IO bound task
|
|
2949
2930
|
try:
|
|
2950
|
-
|
|
2931
|
+
report.metadata_written = bool(
|
|
2932
|
+
await asyncio.to_thread(
|
|
2933
|
+
self._convert_ts_to_mp4,
|
|
2934
|
+
tmp_path,
|
|
2935
|
+
path,
|
|
2936
|
+
callback_remux,
|
|
2937
|
+
ios_support,
|
|
2938
|
+
configuration.metadata,
|
|
2939
|
+
configuration.thumbnail,
|
|
2940
|
+
)
|
|
2941
|
+
)
|
|
2942
|
+
configuration.metadata_written = report.metadata_written
|
|
2951
2943
|
except Exception:
|
|
2952
2944
|
self._safe_remove(path)
|
|
2953
2945
|
raise
|
|
2954
2946
|
# This is important, because not all players can play MPEG-TS AND I want to write
|
|
2955
2947
|
# metadata to the files, and this doesn't work without a container.
|
|
2956
2948
|
self._safe_remove(tmp_path)
|
|
2957
|
-
self.logger.info(f"Remux completed: output={path}")
|
|
2949
|
+
self.logger.info(f"Remux completed: output={path} metadata_written={report.metadata_written}")
|
|
2958
2950
|
|
|
2959
2951
|
else:
|
|
2960
2952
|
self.logger.debug("Remux disabled; moving temporary file into place.")
|
|
@@ -2993,8 +2985,16 @@ class BaseCore:
|
|
|
2993
2985
|
raise DownloadFailed(f"HLS download failed for {pre_resolved_m3u8}: {e}",
|
|
2994
2986
|
url=pre_resolved_m3u8) from e
|
|
2995
2987
|
|
|
2996
|
-
def _convert_ts_to_mp4(
|
|
2997
|
-
|
|
2988
|
+
def _convert_ts_to_mp4(
|
|
2989
|
+
self,
|
|
2990
|
+
input_path: str,
|
|
2991
|
+
output_path: str,
|
|
2992
|
+
callback: Callable[[int, int], None] | None = None,
|
|
2993
|
+
ios_support: bool = False,
|
|
2994
|
+
metadata: Any = None,
|
|
2995
|
+
thumbnail: Any = None,
|
|
2996
|
+
) -> bool:
|
|
2997
|
+
del ios_support # Kept for signature compatibility; FFmpeg copies AAC/H.264 with +faststart
|
|
2998
2998
|
start_ts = time.perf_counter()
|
|
2999
2999
|
self.logger.info("Remux start: input=%s output=%s", input_path, output_path)
|
|
3000
3000
|
|
|
@@ -3002,167 +3002,59 @@ class BaseCore:
|
|
|
3002
3002
|
input_size = os.path.getsize(input_path)
|
|
3003
3003
|
self.logger.debug("Remux input size: %s bytes", input_size)
|
|
3004
3004
|
except Exception as e:
|
|
3005
|
+
input_size = 0
|
|
3005
3006
|
self.logger.debug("Remux input size unavailable for %s: %s", input_path, e, exc_info=True)
|
|
3006
3007
|
|
|
3007
|
-
|
|
3008
|
-
|
|
3009
|
-
|
|
3010
|
-
|
|
3011
|
-
|
|
3012
|
-
self.logger.error("PyAV import failed for remux: %s", e, exc_info=True)
|
|
3013
|
-
raise ModuleNotFoundError(
|
|
3014
|
-
f"PyAV is required for remuxing. Install with pip install av. Not supported on Termux! {e}") from e
|
|
3015
|
-
|
|
3016
|
-
self.logger.debug("Opening input for remux: %s", input_path)
|
|
3017
|
-
input_ = av_open(input_path)
|
|
3018
|
-
fmt_name = (input_.format.name or "").lower()
|
|
3019
|
-
self.logger.info("Input format detected: %s", fmt_name or '<unknown>')
|
|
3020
|
-
|
|
3021
|
-
if fmt_name == "mpegts":
|
|
3022
|
-
# Fix 1: Suppress the stub mismatch for av.open
|
|
3023
|
-
output = av_open(output_path, mode="w", format="mp4",
|
|
3024
|
-
options={"movflags": "faststart"}) # type: ignore[arg-type]
|
|
3025
|
-
|
|
3026
|
-
# --- VIDEO ---
|
|
3027
|
-
in_video = input_.streams.video[0]
|
|
3028
|
-
out_video = output.add_stream_from_template(template=in_video)
|
|
3029
|
-
self.logger.debug(
|
|
3030
|
-
"Video stream: codec=%s bit_rate=%s",
|
|
3031
|
-
getattr(in_video.codec_context, 'name', None), getattr(in_video.codec_context, 'bit_rate', None)
|
|
3008
|
+
ffmpeg_bin = find_ffmpeg(getattr(self.configuration, "ffmpeg_path", None))
|
|
3009
|
+
if not ffmpeg_bin:
|
|
3010
|
+
raise FileNotFoundError(
|
|
3011
|
+
"FFmpeg executable not found for remuxing. Install FFmpeg on PATH, set "
|
|
3012
|
+
"PORN_FETCH_FFMPEG_BINARY, or bundle it in third_party/."
|
|
3032
3013
|
)
|
|
3033
3014
|
|
|
3034
|
-
|
|
3035
|
-
|
|
3036
|
-
|
|
3037
|
-
transcode_audio = False
|
|
3038
|
-
resampler = None
|
|
3039
|
-
|
|
3040
|
-
if in_audio:
|
|
3041
|
-
# Fix 3: Explicitly narrow out None
|
|
3042
|
-
assert in_audio is not None
|
|
3043
|
-
|
|
3044
|
-
# Fix 2: Cast context to AudioCodecContext so IDE knows about sample_rate and layout
|
|
3045
|
-
audio_ctx = cast('AudioCodecContext', in_audio.codec_context)
|
|
3046
|
-
|
|
3047
|
-
copy_ok = {"aac"} if ios_support else {"aac", "alac", "mp3"}
|
|
3048
|
-
codec_name = (audio_ctx.name or "").lower()
|
|
3049
|
-
sample_rate = audio_ctx.sample_rate or 0
|
|
3050
|
-
layout_name = audio_ctx.layout.name if getattr(audio_ctx, "layout", None) else "unknown"
|
|
3051
|
-
|
|
3052
|
-
self.logger.debug(
|
|
3053
|
-
"Audio stream: codec=%s sample_rate=%s layout=%s", codec_name, sample_rate, layout_name
|
|
3054
|
-
)
|
|
3055
|
-
|
|
3056
|
-
if codec_name in copy_ok:
|
|
3057
|
-
out_audio = output.add_stream_from_template(template=in_audio)
|
|
3058
|
-
self.logger.info("Audio codec MP4-compatible; remuxing without transcoding.")
|
|
3059
|
-
else:
|
|
3060
|
-
transcode_audio = True
|
|
3061
|
-
sample_rate = audio_ctx.sample_rate or 48000
|
|
3062
|
-
layout = audio_ctx.layout.name if getattr(audio_ctx, "layout", None) else "stereo"
|
|
3063
|
-
|
|
3064
|
-
out_audio = output.add_stream("aac", rate=sample_rate)
|
|
3065
|
-
self.logger.info("Transcoding audio to AAC: sample_rate=%s layout=%s"), sample_rate, layout
|
|
3066
|
-
|
|
3067
|
-
try:
|
|
3068
|
-
out_audio.layout = layout
|
|
3069
|
-
except Exception as exc:
|
|
3070
|
-
self.logger.warning("Failed to set audio layout while remuxing %s to %s: %s", input_path, output_path, exc, exc_info=True)
|
|
3071
|
-
pass
|
|
3072
|
-
|
|
3073
|
-
resampler = AudioResampler(format="fltp", layout=layout, rate=sample_rate)
|
|
3074
|
-
else:
|
|
3075
|
-
self.logger.info("No audio stream detected; remuxing video only.")
|
|
3076
|
-
|
|
3077
|
-
# --- DEMUX ---
|
|
3078
|
-
demux_streams = [in_video] + ([in_audio] if in_audio else [])
|
|
3079
|
-
packets = input_.demux(demux_streams)
|
|
3015
|
+
self.logger.info("Using FFmpeg binary for TS to MP4 remux: %s", ffmpeg_bin)
|
|
3016
|
+
metadata_written = False
|
|
3017
|
+
has_meta = metadata is not None or thumbnail is not None
|
|
3080
3018
|
|
|
3019
|
+
if has_meta:
|
|
3081
3020
|
try:
|
|
3082
|
-
|
|
3021
|
+
remux_ts_to_mp4(
|
|
3022
|
+
input_path,
|
|
3023
|
+
output_path,
|
|
3024
|
+
ffmpeg_bin=ffmpeg_bin,
|
|
3025
|
+
metadata=metadata,
|
|
3026
|
+
thumbnail=thumbnail,
|
|
3027
|
+
)
|
|
3028
|
+
metadata_written = True
|
|
3083
3029
|
except Exception as exc:
|
|
3084
|
-
self.logger.warning(
|
|
3085
|
-
|
|
3086
|
-
|
|
3087
|
-
self.logger.info("Demuxing packets: total_bytes=%s", total)
|
|
3088
|
-
progress_step = max(1, total // 10) if total else 0
|
|
3089
|
-
next_progress_log = progress_step if progress_step else 0
|
|
3090
|
-
current_progress = 0
|
|
3091
|
-
timestamp_offsets: dict[int, int] = {}
|
|
3092
|
-
last_dts: dict[int, int] = {}
|
|
3093
|
-
last_durations: dict[int, int] = {}
|
|
3094
|
-
|
|
3095
|
-
for idx, packet in enumerate(packets):
|
|
3096
|
-
pkt_size = getattr(packet, "size", 0) or 0
|
|
3097
|
-
current_progress += pkt_size
|
|
3098
|
-
|
|
3099
|
-
if packet.dts is None:
|
|
3100
|
-
if callback:
|
|
3101
|
-
callback(current_progress, total)
|
|
3102
|
-
continue
|
|
3103
|
-
|
|
3104
|
-
timestamp_correction = _normalize_packet_timestamps(
|
|
3105
|
-
packet,
|
|
3106
|
-
timestamp_offsets,
|
|
3107
|
-
last_dts,
|
|
3108
|
-
last_durations,
|
|
3030
|
+
self.logger.warning(
|
|
3031
|
+
"Single-pass remux with metadata failed (%s); falling back to plain remux.",
|
|
3032
|
+
exc,
|
|
3109
3033
|
)
|
|
3110
|
-
|
|
3111
|
-
self.logger.info(
|
|
3112
|
-
"Normalized HLS timestamp discontinuity: stream=%s correction=%s time_base=%s",
|
|
3113
|
-
packet.stream.index,
|
|
3114
|
-
timestamp_correction,
|
|
3115
|
-
packet.time_base,
|
|
3116
|
-
)
|
|
3117
|
-
|
|
3118
|
-
if packet.stream == in_video:
|
|
3119
|
-
packet.stream = out_video
|
|
3120
|
-
output.mux(packet)
|
|
3034
|
+
self._safe_remove(output_path)
|
|
3121
3035
|
|
|
3122
|
-
|
|
3123
|
-
|
|
3124
|
-
|
|
3125
|
-
|
|
3126
|
-
|
|
3127
|
-
|
|
3128
|
-
for frame in packet.decode():
|
|
3129
|
-
# Fix 4: Ensure the frame is recognized as an AudioFrame
|
|
3130
|
-
if not isinstance(frame, av.audio.frame.AudioFrame):
|
|
3131
|
-
continue
|
|
3132
|
-
|
|
3133
|
-
frames = resampler.resample(frame) if resampler else [frame]
|
|
3134
|
-
for f in frames:
|
|
3135
|
-
for enc_pkt in out_audio.encode(f):
|
|
3136
|
-
output.mux(enc_pkt)
|
|
3137
|
-
|
|
3138
|
-
if callback:
|
|
3139
|
-
callback(current_progress, total)
|
|
3140
|
-
if progress_step and current_progress >= next_progress_log:
|
|
3141
|
-
self.logger.debug("Remux progress: bytes=%s/%s", current_progress, total)
|
|
3142
|
-
next_progress_log += progress_step
|
|
3143
|
-
|
|
3144
|
-
if transcode_audio and out_audio:
|
|
3145
|
-
self.logger.debug("Flushing AAC encoder.")
|
|
3146
|
-
for enc_pkt in out_audio.encode(None):
|
|
3147
|
-
output.mux(enc_pkt)
|
|
3148
|
-
|
|
3149
|
-
input_.close()
|
|
3150
|
-
output.close()
|
|
3151
|
-
elapsed = time.perf_counter() - start_ts
|
|
3036
|
+
if not metadata_written:
|
|
3037
|
+
remux_ts_to_mp4(
|
|
3038
|
+
input_path,
|
|
3039
|
+
output_path,
|
|
3040
|
+
ffmpeg_bin=ffmpeg_bin,
|
|
3041
|
+
)
|
|
3152
3042
|
|
|
3043
|
+
if callback:
|
|
3153
3044
|
try:
|
|
3154
|
-
|
|
3155
|
-
|
|
3156
|
-
|
|
3157
|
-
|
|
3158
|
-
self.logger.info("Remux complete: output=%s elapsed=%s.2fs (size unavailable: %s)", output_path,
|
|
3159
|
-
elapsed, e, exc_info=True)
|
|
3045
|
+
total = input_size or os.path.getsize(output_path)
|
|
3046
|
+
callback(total, total)
|
|
3047
|
+
except Exception:
|
|
3048
|
+
pass
|
|
3160
3049
|
|
|
3161
|
-
|
|
3162
|
-
|
|
3163
|
-
|
|
3164
|
-
|
|
3165
|
-
|
|
3050
|
+
elapsed = time.perf_counter() - start_ts
|
|
3051
|
+
self.logger.info(
|
|
3052
|
+
"Remux complete: output=%s metadata_written=%s elapsed=%.2fs",
|
|
3053
|
+
output_path,
|
|
3054
|
+
metadata_written,
|
|
3055
|
+
elapsed,
|
|
3056
|
+
)
|
|
3057
|
+
return metadata_written
|
|
3166
3058
|
|
|
3167
3059
|
async def legacy_download(self, url: str, configuration: DownloadConfigRAW) -> bool:
|
|
3168
3060
|
"""Download a raw stream, preserving the URL and original failure traceback."""
|
|
@@ -40,6 +40,7 @@ class RuntimeConfig:
|
|
|
40
40
|
self.videos_concurrency: int = 5
|
|
41
41
|
self.pages_concurrency: int = 2
|
|
42
42
|
self.interface: str | None = None # IP Address of the network interface you want to bind to
|
|
43
|
+
self.ffmpeg_path: str | None = None # Optional explicit path to an FFmpeg executable
|
|
43
44
|
self.ip_resolve: int | None = (
|
|
44
45
|
int(os.environ["CURL_IPRESOLVE"])
|
|
45
46
|
if "CURL_IPRESOLVE" in os.environ and os.environ["CURL_IPRESOLVE"].isdigit()
|
|
@@ -57,6 +58,9 @@ class BaseConfigDownload:
|
|
|
57
58
|
callback: callback_hint = None
|
|
58
59
|
no_title: bool = False
|
|
59
60
|
stop_event: asyncio.Event | None = None
|
|
61
|
+
metadata: Any = None
|
|
62
|
+
thumbnail: Any = None
|
|
63
|
+
metadata_written: bool = False
|
|
60
64
|
|
|
61
65
|
|
|
62
66
|
@dataclass
|
|
@@ -388,6 +388,12 @@ class DownloadFailed(ScraperException):
|
|
|
388
388
|
pass
|
|
389
389
|
|
|
390
390
|
|
|
391
|
+
class MetadataWriteError(BaseScraperError):
|
|
392
|
+
"""Raised when writing MP4 metadata tags or cover art fails."""
|
|
393
|
+
pass
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
|
|
391
397
|
class VideoUnavailable(ScraperException):
|
|
392
398
|
"""Raised when a video is unavailable or has been removed."""
|
|
393
399
|
pass
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
"""FFmpeg binary discovery, MPEG-TS to MP4 remuxing, and MP4 metadata tagging."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import inspect
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
import tempfile
|
|
11
|
+
from collections.abc import Mapping
|
|
12
|
+
from datetime import date, datetime
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from base_api.modules.config import config
|
|
17
|
+
from base_api.modules.errors import MetadataWriteError
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger("base_api.ffmpeg")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _find_original_executable() -> Path | None:
|
|
23
|
+
"""Return the true executable path, ignoring onefile or temp extractions."""
|
|
24
|
+
try:
|
|
25
|
+
import __compiled__ # type: ignore[import-not-found]
|
|
26
|
+
orig = getattr(__compiled__, "original_argv0", None)
|
|
27
|
+
if orig and Path(orig).exists():
|
|
28
|
+
return Path(orig).resolve()
|
|
29
|
+
except (ImportError, OSError, TypeError):
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
if appimage := os.environ.get("APPIMAGE"):
|
|
33
|
+
if Path(appimage).exists():
|
|
34
|
+
return Path(appimage).resolve()
|
|
35
|
+
|
|
36
|
+
if sys.argv and sys.argv[0] and Path(sys.argv[0]).exists():
|
|
37
|
+
return Path(sys.argv[0]).resolve()
|
|
38
|
+
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _is_executable_file(candidate: Path) -> bool:
|
|
43
|
+
return candidate.is_file() and (sys.platform == "win32" or os.access(candidate, os.X_OK))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def find_ffmpeg(
|
|
47
|
+
ffmpeg_bin: Path | str | None = None,
|
|
48
|
+
*,
|
|
49
|
+
original_executable: Path | None = None,
|
|
50
|
+
extra_roots: tuple[Path, ...] = (),
|
|
51
|
+
) -> Path | None:
|
|
52
|
+
"""Discover an explicit, bundled, or system FFmpeg executable."""
|
|
53
|
+
binary_name = "ffmpeg.exe" if sys.platform == "win32" else "ffmpeg"
|
|
54
|
+
|
|
55
|
+
# 1. Explicit argument, runtime config, or environment variable override
|
|
56
|
+
for explicit in (
|
|
57
|
+
ffmpeg_bin,
|
|
58
|
+
getattr(config, "ffmpeg_path", None),
|
|
59
|
+
os.environ.get("PORN_FETCH_FFMPEG_BINARY"),
|
|
60
|
+
os.environ.get("EAF_FFMPEG_BINARY"),
|
|
61
|
+
):
|
|
62
|
+
if explicit:
|
|
63
|
+
candidate = Path(explicit).expanduser()
|
|
64
|
+
if _is_executable_file(candidate):
|
|
65
|
+
return candidate.resolve()
|
|
66
|
+
|
|
67
|
+
candidates: list[Path] = []
|
|
68
|
+
|
|
69
|
+
# 2. Paths relative to current running executable
|
|
70
|
+
orig = original_executable if original_executable is not None else _find_original_executable()
|
|
71
|
+
if orig is not None:
|
|
72
|
+
exec_dir = orig.parent
|
|
73
|
+
candidates.append(exec_dir / "third_party" / binary_name)
|
|
74
|
+
candidates.append(exec_dir / binary_name)
|
|
75
|
+
# macOS App bundle: Contents/MacOS/
|
|
76
|
+
candidates.append(exec_dir / "Contents" / "MacOS" / "third_party" / binary_name)
|
|
77
|
+
candidates.append(exec_dir / "Contents" / "MacOS" / binary_name)
|
|
78
|
+
if exec_dir.name == "MacOS" and exec_dir.parent.name == "Contents":
|
|
79
|
+
candidates.append(exec_dir.parent.parent.parent / "third_party" / binary_name)
|
|
80
|
+
|
|
81
|
+
# 3. Project / caller roots and working directory
|
|
82
|
+
roots: list[Path] = list(extra_roots)
|
|
83
|
+
roots.append(Path.cwd())
|
|
84
|
+
roots.append(Path(__file__).resolve().parents[2])
|
|
85
|
+
if sys.executable:
|
|
86
|
+
roots.append(Path(sys.executable).resolve().parent)
|
|
87
|
+
|
|
88
|
+
for root in roots:
|
|
89
|
+
candidates.append(root / "third_party" / binary_name)
|
|
90
|
+
candidates.append(root / binary_name)
|
|
91
|
+
|
|
92
|
+
for cand in candidates:
|
|
93
|
+
if _is_executable_file(cand):
|
|
94
|
+
return cand.resolve()
|
|
95
|
+
|
|
96
|
+
# 4. System PATH fallback
|
|
97
|
+
if in_path := shutil.which(binary_name) or shutil.which("ffmpeg"):
|
|
98
|
+
p = Path(in_path)
|
|
99
|
+
if p.is_file():
|
|
100
|
+
return p.resolve()
|
|
101
|
+
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def has_ffmpeg(
|
|
106
|
+
ffmpeg_bin: Path | str | None = None,
|
|
107
|
+
*,
|
|
108
|
+
original_executable: Path | None = None,
|
|
109
|
+
extra_roots: tuple[Path, ...] = (),
|
|
110
|
+
) -> bool:
|
|
111
|
+
"""Return True if an FFmpeg binary is available on the system or bundled."""
|
|
112
|
+
return (
|
|
113
|
+
find_ffmpeg(
|
|
114
|
+
ffmpeg_bin,
|
|
115
|
+
original_executable=original_executable,
|
|
116
|
+
extra_roots=extra_roots,
|
|
117
|
+
)
|
|
118
|
+
is not None
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _safe_getattr(obj: Any, name: str) -> Any:
|
|
123
|
+
"""Safely read an attribute without triggering unawaited coroutine warnings or lazy-load errors."""
|
|
124
|
+
try:
|
|
125
|
+
val = getattr(obj, name, None)
|
|
126
|
+
except Exception:
|
|
127
|
+
return None
|
|
128
|
+
if inspect.iscoroutine(val):
|
|
129
|
+
val.close()
|
|
130
|
+
return None
|
|
131
|
+
if callable(val):
|
|
132
|
+
return None
|
|
133
|
+
return val
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _format_metadata_value(value: Any) -> str | None:
|
|
137
|
+
if value is None:
|
|
138
|
+
return None
|
|
139
|
+
if isinstance(value, (datetime, date)):
|
|
140
|
+
return value.isoformat()
|
|
141
|
+
if isinstance(value, Mapping):
|
|
142
|
+
name = value.get("name") or value.get("username") or value.get("title")
|
|
143
|
+
return _format_metadata_value(name)
|
|
144
|
+
name_attr = _safe_getattr(value, "name") or _safe_getattr(value, "username")
|
|
145
|
+
if name_attr is not None and not isinstance(value, (str, bytes)):
|
|
146
|
+
return _format_metadata_value(name_attr)
|
|
147
|
+
text = str(value).strip()
|
|
148
|
+
return text if text else None
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _extract_metadata_fields(data: Any) -> dict[str, str]:
|
|
152
|
+
"""Extract normalized FFmpeg metadata key-value pairs from an object or mapping."""
|
|
153
|
+
if data is None:
|
|
154
|
+
return {}
|
|
155
|
+
|
|
156
|
+
if isinstance(data, Mapping):
|
|
157
|
+
raw_fields = {
|
|
158
|
+
"title": data.get("title"),
|
|
159
|
+
"artist": data.get("artist") if "artist" in data else data.get("author"),
|
|
160
|
+
"genre": data.get("genre", "XXX"),
|
|
161
|
+
"date": data.get("date") if "date" in data else data.get("publish_date"),
|
|
162
|
+
}
|
|
163
|
+
for key, val in data.items():
|
|
164
|
+
if key not in {"title", "artist", "author", "genre", "date", "publish_date", "thumbnail_data", "thumbnail"}:
|
|
165
|
+
raw_fields[str(key)] = val
|
|
166
|
+
else:
|
|
167
|
+
artist = (
|
|
168
|
+
_safe_getattr(data, "artist")
|
|
169
|
+
or _safe_getattr(data, "author")
|
|
170
|
+
or _safe_getattr(data, "author_information")
|
|
171
|
+
)
|
|
172
|
+
raw_fields = {
|
|
173
|
+
"title": _safe_getattr(data, "title"),
|
|
174
|
+
"artist": artist,
|
|
175
|
+
"genre": _safe_getattr(data, "genre") or "XXX",
|
|
176
|
+
"date": _safe_getattr(data, "date") or _safe_getattr(data, "publish_date"),
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
resolved: dict[str, str] = {}
|
|
180
|
+
for key, val in raw_fields.items():
|
|
181
|
+
formatted = _format_metadata_value(val)
|
|
182
|
+
if formatted is not None:
|
|
183
|
+
resolved[key] = formatted
|
|
184
|
+
return resolved
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _extract_thumbnail_bytes(data: Any, explicit_thumbnail: bytes | Path | str | None = None) -> bytes | None:
|
|
188
|
+
candidate = explicit_thumbnail
|
|
189
|
+
if candidate is None and data is not None:
|
|
190
|
+
if isinstance(data, Mapping):
|
|
191
|
+
candidate = data.get("thumbnail_data") or data.get("thumbnail")
|
|
192
|
+
else:
|
|
193
|
+
candidate = _safe_getattr(data, "thumbnail_data")
|
|
194
|
+
|
|
195
|
+
if isinstance(candidate, (str, Path)):
|
|
196
|
+
p = Path(candidate)
|
|
197
|
+
if p.is_file():
|
|
198
|
+
return p.read_bytes()
|
|
199
|
+
return None
|
|
200
|
+
|
|
201
|
+
if isinstance(candidate, bytes) and len(candidate) > 0:
|
|
202
|
+
return candidate
|
|
203
|
+
return None
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def remux_ts_to_mp4(
|
|
207
|
+
input_path: Path | str,
|
|
208
|
+
output_path: Path | str,
|
|
209
|
+
ffmpeg_bin: Path | str | None = None,
|
|
210
|
+
*,
|
|
211
|
+
metadata: Any = None,
|
|
212
|
+
thumbnail: bytes | Path | str | None = None,
|
|
213
|
+
extra_roots: tuple[Path, ...] = (),
|
|
214
|
+
) -> bool:
|
|
215
|
+
"""
|
|
216
|
+
Remux an MPEG-TS container into a faststart MP4 container using FFmpeg.
|
|
217
|
+
|
|
218
|
+
When *metadata* or *thumbnail* is supplied, tags and attached cover art are
|
|
219
|
+
written in the same single FFmpeg pass to avoid a second full-file rewrite.
|
|
220
|
+
"""
|
|
221
|
+
ffmpeg = find_ffmpeg(ffmpeg_bin, extra_roots=extra_roots)
|
|
222
|
+
if not ffmpeg:
|
|
223
|
+
raise FileNotFoundError("FFmpeg executable not found. Install FFmpeg or bundle it in third_party/.")
|
|
224
|
+
|
|
225
|
+
in_p = Path(input_path).resolve()
|
|
226
|
+
out_p = Path(output_path).resolve()
|
|
227
|
+
out_p.parent.mkdir(parents=True, exist_ok=True)
|
|
228
|
+
|
|
229
|
+
thumb_bytes = _extract_thumbnail_bytes(metadata, thumbnail)
|
|
230
|
+
meta_fields = _extract_metadata_fields(metadata)
|
|
231
|
+
temporary_thumb: Path | None = None
|
|
232
|
+
|
|
233
|
+
try:
|
|
234
|
+
cmd = [
|
|
235
|
+
str(ffmpeg),
|
|
236
|
+
"-y",
|
|
237
|
+
"-v", "error",
|
|
238
|
+
"-i", str(in_p),
|
|
239
|
+
]
|
|
240
|
+
|
|
241
|
+
if thumb_bytes:
|
|
242
|
+
suffix = ".png" if thumb_bytes.startswith(b"\x89PNG\r\n\x1a\n") else ".jpg"
|
|
243
|
+
with tempfile.NamedTemporaryFile(dir=out_p.parent, delete=False, suffix=suffix) as tf:
|
|
244
|
+
tf.write(thumb_bytes)
|
|
245
|
+
temporary_thumb = Path(tf.name)
|
|
246
|
+
|
|
247
|
+
cmd.extend([
|
|
248
|
+
"-i", str(temporary_thumb),
|
|
249
|
+
"-map", "0:v:0",
|
|
250
|
+
"-map", "0:a?",
|
|
251
|
+
"-map", "1:v:0",
|
|
252
|
+
"-c", "copy",
|
|
253
|
+
"-disposition:v:1", "attached_pic",
|
|
254
|
+
])
|
|
255
|
+
else:
|
|
256
|
+
cmd.extend(["-c", "copy"])
|
|
257
|
+
|
|
258
|
+
for key, val in meta_fields.items():
|
|
259
|
+
cmd.extend(["-metadata", f"{key}={val}"])
|
|
260
|
+
|
|
261
|
+
cmd.extend([
|
|
262
|
+
"-movflags", "+faststart",
|
|
263
|
+
str(out_p),
|
|
264
|
+
])
|
|
265
|
+
|
|
266
|
+
proc = subprocess.run(cmd, capture_output=True, text=True)
|
|
267
|
+
if proc.returncode != 0:
|
|
268
|
+
raise RuntimeError(f"FFmpeg stream-copy remux failed: {proc.stderr.strip()}")
|
|
269
|
+
|
|
270
|
+
return True
|
|
271
|
+
finally:
|
|
272
|
+
if temporary_thumb and temporary_thumb.exists():
|
|
273
|
+
try:
|
|
274
|
+
temporary_thumb.unlink(missing_ok=True)
|
|
275
|
+
except OSError:
|
|
276
|
+
pass
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def write_metadata_tags(
|
|
280
|
+
path: str | Path,
|
|
281
|
+
data: Any,
|
|
282
|
+
ffmpeg_bin: Path | str | None = None,
|
|
283
|
+
*,
|
|
284
|
+
thumbnail: bytes | Path | str | None = None,
|
|
285
|
+
extra_roots: tuple[Path, ...] = (),
|
|
286
|
+
) -> bool:
|
|
287
|
+
"""Write tags and optional cover thumbnail into an existing MP4 file using FFmpeg."""
|
|
288
|
+
ffmpeg = find_ffmpeg(ffmpeg_bin, extra_roots=extra_roots)
|
|
289
|
+
if not ffmpeg:
|
|
290
|
+
logger.warning("FFmpeg not found; skipping MP4 metadata tagging.")
|
|
291
|
+
return False
|
|
292
|
+
|
|
293
|
+
target_path = Path(path).resolve()
|
|
294
|
+
if not target_path.is_file():
|
|
295
|
+
return False
|
|
296
|
+
|
|
297
|
+
directory = target_path.parent
|
|
298
|
+
temporary_output = directory / f".tmp_meta_{target_path.name}"
|
|
299
|
+
temporary_thumb: Path | None = None
|
|
300
|
+
|
|
301
|
+
thumb_bytes = _extract_thumbnail_bytes(data, thumbnail)
|
|
302
|
+
meta_fields = _extract_metadata_fields(data)
|
|
303
|
+
|
|
304
|
+
try:
|
|
305
|
+
cmd = [
|
|
306
|
+
str(ffmpeg),
|
|
307
|
+
"-y",
|
|
308
|
+
"-v", "error",
|
|
309
|
+
"-i", str(target_path),
|
|
310
|
+
]
|
|
311
|
+
|
|
312
|
+
if thumb_bytes:
|
|
313
|
+
suffix = ".png" if thumb_bytes.startswith(b"\x89PNG\r\n\x1a\n") else ".jpg"
|
|
314
|
+
with tempfile.NamedTemporaryFile(dir=directory, delete=False, suffix=suffix) as tf:
|
|
315
|
+
tf.write(thumb_bytes)
|
|
316
|
+
temporary_thumb = Path(tf.name)
|
|
317
|
+
|
|
318
|
+
cmd.extend([
|
|
319
|
+
"-i", str(temporary_thumb),
|
|
320
|
+
"-map", "0:v:0",
|
|
321
|
+
"-map", "0:a?",
|
|
322
|
+
"-map", "1:v:0",
|
|
323
|
+
"-c", "copy",
|
|
324
|
+
"-disposition:v:1", "attached_pic",
|
|
325
|
+
])
|
|
326
|
+
else:
|
|
327
|
+
cmd.extend([
|
|
328
|
+
"-map", "0",
|
|
329
|
+
"-c", "copy",
|
|
330
|
+
])
|
|
331
|
+
|
|
332
|
+
for key, val in meta_fields.items():
|
|
333
|
+
cmd.extend(["-metadata", f"{key}={val}"])
|
|
334
|
+
|
|
335
|
+
cmd.extend(["-movflags", "+faststart", str(temporary_output)])
|
|
336
|
+
|
|
337
|
+
proc = subprocess.run(cmd, capture_output=True, text=True)
|
|
338
|
+
if proc.returncode != 0:
|
|
339
|
+
raise MetadataWriteError(f"FFmpeg tagging failed: {proc.stderr.strip()}")
|
|
340
|
+
|
|
341
|
+
os.replace(temporary_output, target_path)
|
|
342
|
+
return True
|
|
343
|
+
|
|
344
|
+
except Exception as error:
|
|
345
|
+
if isinstance(error, MetadataWriteError):
|
|
346
|
+
raise
|
|
347
|
+
raise MetadataWriteError(str(error)) from error
|
|
348
|
+
|
|
349
|
+
finally:
|
|
350
|
+
if temporary_output.exists():
|
|
351
|
+
try:
|
|
352
|
+
temporary_output.unlink(missing_ok=True)
|
|
353
|
+
except OSError:
|
|
354
|
+
pass
|
|
355
|
+
if temporary_thumb and temporary_thumb.exists():
|
|
356
|
+
try:
|
|
357
|
+
temporary_thumb.unlink(missing_ok=True)
|
|
358
|
+
except OSError:
|
|
359
|
+
pass
|
|
@@ -7,6 +7,7 @@ from functools import wraps
|
|
|
7
7
|
|
|
8
8
|
from base_api.modules import errors
|
|
9
9
|
from base_api.modules.logger import class_name, get_logger, log_context
|
|
10
|
+
from base_api.modules.urls import same_site_url
|
|
10
11
|
|
|
11
12
|
|
|
12
13
|
async def fetch_content(core, url: str, *, logger, owner=None, get_json=False,
|
|
@@ -20,6 +21,10 @@ async def fetch_content(core, url: str, *, logger, owner=None, get_json=False,
|
|
|
20
21
|
with log_context(owner, url):
|
|
21
22
|
try:
|
|
22
23
|
content = await core.fetch_text(url)
|
|
24
|
+
resolved = getattr(content, "url", None)
|
|
25
|
+
if (isinstance(resolved, str) and owner is not None
|
|
26
|
+
and getattr(owner, "url", None) == url and same_site_url(url, resolved)):
|
|
27
|
+
owner.resolved_url = resolved
|
|
23
28
|
return json.loads(content, strict=False) if get_json else content
|
|
24
29
|
except errors.DownloadCancelled:
|
|
25
30
|
raise
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Small URL operations shared by provider pagination and profile adapters."""
|
|
2
|
+
|
|
3
|
+
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def update_url_query(url: str, **changes: str | int | None) -> str:
|
|
7
|
+
"""Replace named query keys without losing filters or treating anchors as HTTP."""
|
|
8
|
+
parsed = urlsplit(url)
|
|
9
|
+
pairs = [(key, value) for key, value in parse_qsl(parsed.query, keep_blank_values=True) if key not in changes]
|
|
10
|
+
pairs.extend((key, str(value)) for key, value in changes.items() if value is not None)
|
|
11
|
+
return urlunsplit(parsed._replace(query=urlencode(pairs), fragment=""))
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def replace_url_path(url: str, path: str) -> str:
|
|
15
|
+
parsed = urlsplit(url)
|
|
16
|
+
return urlunsplit(parsed._replace(path=path, fragment=""))
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def append_url_path(url: str, *parts: str | int) -> str:
|
|
20
|
+
path = urlsplit(url).path.rstrip("/")
|
|
21
|
+
return replace_url_path(url, path + "/" + "/".join(str(part).strip("/") for part in parts))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def profile_base_url(url: str, *, depth: int = 2) -> str:
|
|
25
|
+
"""Remove a provider's tab/page suffix, retaining its host, slug and filters."""
|
|
26
|
+
parsed = urlsplit(url)
|
|
27
|
+
parts = [part for part in parsed.path.split("/") if part]
|
|
28
|
+
if len(parts) < depth:
|
|
29
|
+
raise ValueError("An individual profile URL with a name or ID is required")
|
|
30
|
+
return update_url_query(replace_url_path(url, "/" + "/".join(parts[:depth])), page=None)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def same_site_url(original: str, resolved: str) -> bool:
|
|
34
|
+
"""Accept response metadata on the same site, including localized subdomains."""
|
|
35
|
+
before, after = urlsplit(original), urlsplit(resolved)
|
|
36
|
+
if after.scheme not in {"http", "https"} or not before.hostname or not after.hostname:
|
|
37
|
+
return False
|
|
38
|
+
# The supported providers use these two-label domains, except RedTube Brazil.
|
|
39
|
+
depth = 3 if before.hostname.endswith(".com.br") else 2
|
|
40
|
+
return before.hostname.split(".")[-depth:] == after.hostname.split(".")[-depth:]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class FetchedText(str):
|
|
44
|
+
"""A string that retains response identity through text caches and parsers."""
|
|
45
|
+
|
|
46
|
+
url: str
|
|
47
|
+
|
|
48
|
+
def __new__(cls, content: str, *, url: str):
|
|
49
|
+
value = super().__new__(cls, content)
|
|
50
|
+
value.url = url
|
|
51
|
+
return value
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["uv_build>=0.9.30,<0.10.0"]
|
|
3
|
+
build-backend = "uv_build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "eaf_base_api"
|
|
7
|
+
version = "4.2.2"
|
|
8
|
+
description = "A base API for EchterAlsFake's Porn APIs"
|
|
9
|
+
requires-python = ">=3.12"
|
|
10
|
+
license = "AGPL-3.0-or-later"
|
|
11
|
+
license-files = ["LICENSE*"]
|
|
12
|
+
classifiers = ["Programming Language :: Python"]
|
|
13
|
+
dependencies = [
|
|
14
|
+
"cachetools>=7.0.0",
|
|
15
|
+
"curl_cffi",
|
|
16
|
+
"tenacity>=9.1.2",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
[project.readme]
|
|
20
|
+
file = "README.md"
|
|
21
|
+
content-type = "text/markdown"
|
|
22
|
+
|
|
23
|
+
[[project.authors]]
|
|
24
|
+
name = "Johannes Habel"
|
|
25
|
+
email = "EchterAlsFake@proton.me"
|
|
26
|
+
|
|
27
|
+
[project.optional-dependencies]
|
|
28
|
+
hls = ["m3u8"]
|
|
29
|
+
|
|
30
|
+
[project.urls]
|
|
31
|
+
Homepage = "https://github.com/EchterAlsFake/eaf_base_api"
|
|
32
|
+
Repository = "https://github.com/EchterAlsFake/eaf_base_api"
|
|
33
|
+
|
|
34
|
+
[tool.uv]
|
|
35
|
+
publish-url = "https://upload.pypi.org/legacy/"
|
|
36
|
+
|
|
37
|
+
[tool.uv.build-backend]
|
|
38
|
+
module-root = ""
|
|
39
|
+
module-name = "base_api"
|
|
40
|
+
|
|
41
|
+
[dependency-groups]
|
|
42
|
+
dev = [
|
|
43
|
+
"mypy",
|
|
44
|
+
"types-m3u8",
|
|
45
|
+
"pylint",
|
|
46
|
+
"pytest",
|
|
47
|
+
"pytest-asyncio",
|
|
48
|
+
]
|
|
@@ -4,7 +4,7 @@ build-backend = "uv_build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "eaf_base_api"
|
|
7
|
-
version = "4.2.
|
|
7
|
+
version = "4.2.2"
|
|
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"
|
|
@@ -28,8 +28,6 @@ dependencies = [
|
|
|
28
28
|
[project.optional-dependencies]
|
|
29
29
|
hls = [
|
|
30
30
|
"m3u8",
|
|
31
|
-
# av needs Python >= 3.10, so guard it (otherwise `pip install ...[hls]` fails on 3.8/3.9)
|
|
32
|
-
"av; python_version >= '3.12'",
|
|
33
31
|
]
|
|
34
32
|
|
|
35
33
|
[project.urls]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|