eaf-base-api 4.2.0__tar.gz → 4.2.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.2.0 → eaf_base_api-4.2.1}/PKG-INFO +30 -2
- {eaf_base_api-4.2.0 → eaf_base_api-4.2.1}/README.md +29 -0
- {eaf_base_api-4.2.0 → eaf_base_api-4.2.1}/base_api/__init__.py +12 -0
- {eaf_base_api-4.2.0 → eaf_base_api-4.2.1}/base_api/base.py +103 -218
- {eaf_base_api-4.2.0 → eaf_base_api-4.2.1}/base_api/modules/config.py +4 -0
- {eaf_base_api-4.2.0 → eaf_base_api-4.2.1}/base_api/modules/errors.py +6 -0
- eaf_base_api-4.2.1/base_api/modules/ffmpeg.py +359 -0
- {eaf_base_api-4.2.0 → eaf_base_api-4.2.1}/base_api/modules/type_hints.py +1 -0
- eaf_base_api-4.2.1/pyproject.toml +48 -0
- eaf_base_api-4.2.0/pyproject.toml → eaf_base_api-4.2.1/pyproject.toml.orig +1 -3
- {eaf_base_api-4.2.0 → eaf_base_api-4.2.1}/LICENSE +0 -0
- {eaf_base_api-4.2.0 → eaf_base_api-4.2.1}/base_api/modules/__init__.py +0 -0
- {eaf_base_api-4.2.0 → eaf_base_api-4.2.1}/base_api/modules/logger.py +0 -0
- {eaf_base_api-4.2.0 → eaf_base_api-4.2.1}/base_api/modules/progress_bars.py +0 -0
- {eaf_base_api-4.2.0 → eaf_base_api-4.2.1}/base_api/modules/provider.py +0 -0
- {eaf_base_api-4.2.0 → eaf_base_api-4.2.1}/base_api/modules/static_functions.py +0 -0
- {eaf_base_api-4.2.0 → eaf_base_api-4.2.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.2.
|
|
3
|
+
Version: 4.2.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>
|
|
@@ -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.
|
|
@@ -55,11 +55,16 @@ __all__ = [
|
|
|
55
55
|
"ProxyError",
|
|
56
56
|
"UnknownNetworkError",
|
|
57
57
|
"DownloadFailed",
|
|
58
|
+
"MetadataWriteError",
|
|
58
59
|
"VideoUnavailable",
|
|
59
60
|
"ResourceGone",
|
|
60
61
|
"LoginFailed",
|
|
61
62
|
"RegionBlocked",
|
|
62
63
|
"configure_app_logging",
|
|
64
|
+
"find_ffmpeg",
|
|
65
|
+
"has_ffmpeg",
|
|
66
|
+
"remux_ts_to_mp4",
|
|
67
|
+
"write_metadata_tags",
|
|
63
68
|
]
|
|
64
69
|
|
|
65
70
|
|
|
@@ -87,11 +92,18 @@ from base_api.modules.errors import (
|
|
|
87
92
|
ProxyError,
|
|
88
93
|
UnknownNetworkError,
|
|
89
94
|
DownloadFailed,
|
|
95
|
+
MetadataWriteError,
|
|
90
96
|
VideoUnavailable,
|
|
91
97
|
ResourceGone,
|
|
92
98
|
LoginFailed,
|
|
93
99
|
RegionBlocked,
|
|
94
100
|
)
|
|
101
|
+
from base_api.modules.ffmpeg import (
|
|
102
|
+
find_ffmpeg,
|
|
103
|
+
has_ffmpeg,
|
|
104
|
+
remux_ts_to_mp4,
|
|
105
|
+
write_metadata_tags,
|
|
106
|
+
)
|
|
95
107
|
from base_api.modules.logger import configure_app_logging
|
|
96
108
|
from base_api.base import (
|
|
97
109
|
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
|
|
@@ -39,30 +41,26 @@ from base_api.modules.static_functions import (
|
|
|
39
41
|
choose_variant, collect_variants, get_segment_index_width
|
|
40
42
|
)
|
|
41
43
|
from base_api.modules.config import config, RuntimeConfig, DownloadConfigHLS, DownloadConfigRAW, IteratorConfig, make_iterator_config
|
|
44
|
+
from base_api.modules.ffmpeg import find_ffmpeg, remux_ts_to_mp4, write_metadata_tags
|
|
42
45
|
from base_api.modules.progress_bars import Callback
|
|
43
46
|
from base_api.modules.logger import configure_app_logging, get_logger, log_context, ContextLogger
|
|
44
47
|
|
|
45
48
|
# 2. Handle optional dependencies cleanly
|
|
46
49
|
try:
|
|
47
50
|
import m3u8
|
|
48
|
-
|
|
49
|
-
|
|
51
|
+
# Needed for all videos that use HLS streaming. Some do not and use mp4 containers / files instead
|
|
52
|
+
except (ModuleNotFoundError, ImportError):
|
|
53
|
+
m3u8 = None # type: ignore
|
|
50
54
|
|
|
51
|
-
# 3. Handle specific runtime imports
|
|
52
55
|
if TYPE_CHECKING:
|
|
53
|
-
from av.audio.codeccontext import AudioCodecContext
|
|
54
56
|
import m3u8
|
|
55
57
|
|
|
56
|
-
# The following imports are optional, because they depend on per API and I want to be as memory efficient as possible
|
|
57
58
|
|
|
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
|
|
59
|
+
REGEX_CHALLENGE = re.compile(r'var p=(\d+); var s=(\d+);.*?(\d+):1;', re.DOTALL)
|
|
63
60
|
|
|
64
61
|
|
|
65
|
-
|
|
62
|
+
class _ChallengeRetry(NetworkRequestError):
|
|
63
|
+
"""Replay with an updated challenge cookie without network backoff."""
|
|
66
64
|
|
|
67
65
|
|
|
68
66
|
class CachePolicy(StrEnum):
|
|
@@ -102,46 +100,6 @@ def _contains_http_status(error: BaseException, status_code: int) -> bool:
|
|
|
102
100
|
return False
|
|
103
101
|
|
|
104
102
|
|
|
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
103
|
|
|
146
104
|
@dataclass(frozen=True, slots=True)
|
|
147
105
|
class RequestCacheKey:
|
|
@@ -1619,6 +1577,8 @@ class BaseCore:
|
|
|
1619
1577
|
self._inflight_text_requests: dict[RequestCacheKey, asyncio.Future[str]] = {}
|
|
1620
1578
|
self.latest_key: str | None = None
|
|
1621
1579
|
self.latest_key_time: float = 0.0
|
|
1580
|
+
self._challenge_generation = 0
|
|
1581
|
+
self._challenge_ready_at = 0.0
|
|
1622
1582
|
self.last_request_time: float | None = None
|
|
1623
1583
|
self.total_requests: int = 0 # Tracks how many requests have been made
|
|
1624
1584
|
self.session: AsyncSession | None = None
|
|
@@ -1838,6 +1798,8 @@ class BaseCore:
|
|
|
1838
1798
|
|
|
1839
1799
|
def retry_wait(retry_state: Any) -> float:
|
|
1840
1800
|
error = retry_state.outcome.exception() if retry_state.outcome else None
|
|
1801
|
+
if isinstance(error, _ChallengeRetry):
|
|
1802
|
+
return 0.0
|
|
1841
1803
|
if isinstance(error, RateLimitError) and error.retry_after is not None:
|
|
1842
1804
|
return max(0.0, error.retry_after)
|
|
1843
1805
|
return cast(float, exponential_wait(retry_state))
|
|
@@ -1855,13 +1817,23 @@ class BaseCore:
|
|
|
1855
1817
|
try:
|
|
1856
1818
|
await self.enforce_delay()
|
|
1857
1819
|
req_headers = self._merged_headers(headers)
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1820
|
+
async with self.lock:
|
|
1821
|
+
# Pornhub requires a cooldown after computing KEY.
|
|
1822
|
+
# All requests share its deadline; late responses do
|
|
1823
|
+
# not add another sleep. Keeping the deadline also
|
|
1824
|
+
# preserves the cooldown if its waiter is cancelled.
|
|
1825
|
+
loop = asyncio.get_running_loop()
|
|
1826
|
+
while (remaining := self._challenge_ready_at - loop.time()) > 0:
|
|
1827
|
+
await asyncio.sleep(remaining)
|
|
1828
|
+
current_time = asyncio.get_running_loop().time()
|
|
1829
|
+
latest_key = self.latest_key
|
|
1830
|
+
if "KEY" not in session.cookies and latest_key is not None:
|
|
1831
|
+
if current_time - self.latest_key_time < 10:
|
|
1832
|
+
session.cookies.set("KEY", latest_key, domain=".pornhub.com", path="/")
|
|
1833
|
+
# Snapshot before sending: a late response may belong
|
|
1834
|
+
# to the cookie generation preceding another solve.
|
|
1835
|
+
challenge_generation = self._challenge_generation
|
|
1836
|
+
req_cookies = self._merged_cookies(cookies)
|
|
1865
1837
|
|
|
1866
1838
|
self.total_requests += 1
|
|
1867
1839
|
response = await cast(Any, session).request(
|
|
@@ -1886,15 +1858,13 @@ class BaseCore:
|
|
|
1886
1858
|
resp_text = cast(bytes, response.content).decode(enc, errors="replace")
|
|
1887
1859
|
|
|
1888
1860
|
if 'onload="go()"' in resp_text:
|
|
1889
|
-
local_latest = getattr(self, "latest_key", None)
|
|
1890
1861
|
async with self.lock:
|
|
1891
|
-
if
|
|
1862
|
+
if self._challenge_generation != challenge_generation:
|
|
1892
1863
|
self.logger.info("Another task already resolved the challenge! Retrying request with the new cookie.")
|
|
1893
1864
|
if self.latest_key:
|
|
1894
1865
|
session.cookies.set("KEY", self.latest_key, domain=".pornhub.com", path="/")
|
|
1895
1866
|
|
|
1896
|
-
|
|
1897
|
-
raise NetworkRequestError("Retrying request with the new cookie.")
|
|
1867
|
+
raise _ChallengeRetry("Retrying request with the new cookie.")
|
|
1898
1868
|
|
|
1899
1869
|
self.logger.info("Challenge page detected! Solving...")
|
|
1900
1870
|
get_challenge = re.compile(r'go\(\).*?{(.*?)n=l.*?KEY.*?s\+":(\d+):', re.DOTALL)
|
|
@@ -1923,7 +1893,9 @@ class BaseCore:
|
|
|
1923
1893
|
|
|
1924
1894
|
self.latest_key = cookie_value
|
|
1925
1895
|
self.latest_key_time = asyncio.get_running_loop().time()
|
|
1896
|
+
self._challenge_ready_at = self.latest_key_time + 1.5
|
|
1926
1897
|
session.cookies.set("KEY", cookie_value, domain=".pornhub.com", path="/")
|
|
1898
|
+
self._challenge_generation += 1
|
|
1927
1899
|
self.logger.info("RESOLVED CHALLENGE! Injected cookie: %s", cookie_value)
|
|
1928
1900
|
|
|
1929
1901
|
try:
|
|
@@ -1931,8 +1903,7 @@ class BaseCore:
|
|
|
1931
1903
|
except (KeyError, Exception):
|
|
1932
1904
|
pass
|
|
1933
1905
|
|
|
1934
|
-
|
|
1935
|
-
raise NetworkRequestError("Retrying request after solving challenge.")
|
|
1906
|
+
raise _ChallengeRetry("Retrying request after solving challenge.")
|
|
1936
1907
|
except (NetworkRequestError, SecurityAbort):
|
|
1937
1908
|
raise
|
|
1938
1909
|
except Exception as challenge_error:
|
|
@@ -1940,7 +1911,6 @@ class BaseCore:
|
|
|
1940
1911
|
|
|
1941
1912
|
else:
|
|
1942
1913
|
self.logger.error("Detected challenge page, but the regex failed to extract data.")
|
|
1943
|
-
await asyncio.sleep(1.5)
|
|
1944
1914
|
raise ChallengeRegexError("Detected Challenge, but regex couldn't extract, report this!")
|
|
1945
1915
|
|
|
1946
1916
|
|
|
@@ -1979,6 +1949,10 @@ class BaseCore:
|
|
|
1979
1949
|
f"HTTP {status} for {url}", status_code=status, url=url
|
|
1980
1950
|
)
|
|
1981
1951
|
|
|
1952
|
+
except _ChallengeRetry:
|
|
1953
|
+
# Cookie refresh is expected control flow, not a network
|
|
1954
|
+
# failure. The retry controller still bounds replays.
|
|
1955
|
+
raise
|
|
1982
1956
|
except RequestsError as e:
|
|
1983
1957
|
err_str = str(e).lower()
|
|
1984
1958
|
self.logger.error("Request error for URL %s: %s", url, e, exc_info=True)
|
|
@@ -2947,14 +2921,25 @@ class BaseCore:
|
|
|
2947
2921
|
self.logger.info(f"Remuxing TS to MP4: input={tmp_path} output={path}")
|
|
2948
2922
|
# Offload heavy CPU/IO bound task
|
|
2949
2923
|
try:
|
|
2950
|
-
|
|
2924
|
+
report.metadata_written = bool(
|
|
2925
|
+
await asyncio.to_thread(
|
|
2926
|
+
self._convert_ts_to_mp4,
|
|
2927
|
+
tmp_path,
|
|
2928
|
+
path,
|
|
2929
|
+
callback_remux,
|
|
2930
|
+
ios_support,
|
|
2931
|
+
configuration.metadata,
|
|
2932
|
+
configuration.thumbnail,
|
|
2933
|
+
)
|
|
2934
|
+
)
|
|
2935
|
+
configuration.metadata_written = report.metadata_written
|
|
2951
2936
|
except Exception:
|
|
2952
2937
|
self._safe_remove(path)
|
|
2953
2938
|
raise
|
|
2954
2939
|
# This is important, because not all players can play MPEG-TS AND I want to write
|
|
2955
2940
|
# metadata to the files, and this doesn't work without a container.
|
|
2956
2941
|
self._safe_remove(tmp_path)
|
|
2957
|
-
self.logger.info(f"Remux completed: output={path}")
|
|
2942
|
+
self.logger.info(f"Remux completed: output={path} metadata_written={report.metadata_written}")
|
|
2958
2943
|
|
|
2959
2944
|
else:
|
|
2960
2945
|
self.logger.debug("Remux disabled; moving temporary file into place.")
|
|
@@ -2993,8 +2978,16 @@ class BaseCore:
|
|
|
2993
2978
|
raise DownloadFailed(f"HLS download failed for {pre_resolved_m3u8}: {e}",
|
|
2994
2979
|
url=pre_resolved_m3u8) from e
|
|
2995
2980
|
|
|
2996
|
-
def _convert_ts_to_mp4(
|
|
2997
|
-
|
|
2981
|
+
def _convert_ts_to_mp4(
|
|
2982
|
+
self,
|
|
2983
|
+
input_path: str,
|
|
2984
|
+
output_path: str,
|
|
2985
|
+
callback: Callable[[int, int], None] | None = None,
|
|
2986
|
+
ios_support: bool = False,
|
|
2987
|
+
metadata: Any = None,
|
|
2988
|
+
thumbnail: Any = None,
|
|
2989
|
+
) -> bool:
|
|
2990
|
+
del ios_support # Kept for signature compatibility; FFmpeg copies AAC/H.264 with +faststart
|
|
2998
2991
|
start_ts = time.perf_counter()
|
|
2999
2992
|
self.logger.info("Remux start: input=%s output=%s", input_path, output_path)
|
|
3000
2993
|
|
|
@@ -3002,167 +2995,59 @@ class BaseCore:
|
|
|
3002
2995
|
input_size = os.path.getsize(input_path)
|
|
3003
2996
|
self.logger.debug("Remux input size: %s bytes", input_size)
|
|
3004
2997
|
except Exception as e:
|
|
2998
|
+
input_size = 0
|
|
3005
2999
|
self.logger.debug("Remux input size unavailable for %s: %s", input_path, e, exc_info=True)
|
|
3006
3000
|
|
|
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)
|
|
3001
|
+
ffmpeg_bin = find_ffmpeg(getattr(self.configuration, "ffmpeg_path", None))
|
|
3002
|
+
if not ffmpeg_bin:
|
|
3003
|
+
raise FileNotFoundError(
|
|
3004
|
+
"FFmpeg executable not found for remuxing. Install FFmpeg on PATH, set "
|
|
3005
|
+
"PORN_FETCH_FFMPEG_BINARY, or bundle it in third_party/."
|
|
3032
3006
|
)
|
|
3033
3007
|
|
|
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)
|
|
3008
|
+
self.logger.info("Using FFmpeg binary for TS to MP4 remux: %s", ffmpeg_bin)
|
|
3009
|
+
metadata_written = False
|
|
3010
|
+
has_meta = metadata is not None or thumbnail is not None
|
|
3080
3011
|
|
|
3012
|
+
if has_meta:
|
|
3081
3013
|
try:
|
|
3082
|
-
|
|
3014
|
+
remux_ts_to_mp4(
|
|
3015
|
+
input_path,
|
|
3016
|
+
output_path,
|
|
3017
|
+
ffmpeg_bin=ffmpeg_bin,
|
|
3018
|
+
metadata=metadata,
|
|
3019
|
+
thumbnail=thumbnail,
|
|
3020
|
+
)
|
|
3021
|
+
metadata_written = True
|
|
3083
3022
|
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,
|
|
3023
|
+
self.logger.warning(
|
|
3024
|
+
"Single-pass remux with metadata failed (%s); falling back to plain remux.",
|
|
3025
|
+
exc,
|
|
3109
3026
|
)
|
|
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)
|
|
3027
|
+
self._safe_remove(output_path)
|
|
3121
3028
|
|
|
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
|
|
3029
|
+
if not metadata_written:
|
|
3030
|
+
remux_ts_to_mp4(
|
|
3031
|
+
input_path,
|
|
3032
|
+
output_path,
|
|
3033
|
+
ffmpeg_bin=ffmpeg_bin,
|
|
3034
|
+
)
|
|
3152
3035
|
|
|
3036
|
+
if callback:
|
|
3153
3037
|
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)
|
|
3038
|
+
total = input_size or os.path.getsize(output_path)
|
|
3039
|
+
callback(total, total)
|
|
3040
|
+
except Exception:
|
|
3041
|
+
pass
|
|
3160
3042
|
|
|
3161
|
-
|
|
3162
|
-
|
|
3163
|
-
|
|
3164
|
-
|
|
3165
|
-
|
|
3043
|
+
elapsed = time.perf_counter() - start_ts
|
|
3044
|
+
self.logger.info(
|
|
3045
|
+
"Remux complete: output=%s metadata_written=%s elapsed=%.2fs",
|
|
3046
|
+
output_path,
|
|
3047
|
+
metadata_written,
|
|
3048
|
+
elapsed,
|
|
3049
|
+
)
|
|
3050
|
+
return metadata_written
|
|
3166
3051
|
|
|
3167
3052
|
async def legacy_download(self, url: str, configuration: DownloadConfigRAW) -> bool:
|
|
3168
3053
|
"""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
|
|
@@ -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.1"
|
|
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.1"
|
|
8
8
|
description = "A base API for EchterAlsFake's Porn APIs"
|
|
9
9
|
readme = { file = "README.md", content-type = "text/markdown" }
|
|
10
10
|
requires-python = ">=3.12"
|
|
@@ -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
|
|
File without changes
|