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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: eaf_base_api
3
- Version: 4.2.0
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
- except ImportError:
49
- m3u8 = None
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
- try:
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
- REGEX_CHALLENGE = re.compile(r'var p=(\d+); var s=(\d+);.*?(\d+):1;', re.DOTALL)
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
- req_cookies = self._merged_cookies(cookies)
1859
-
1860
- current_time = asyncio.get_running_loop().time()
1861
- latest_key = self.latest_key
1862
- if "KEY" not in session.cookies and latest_key is not None:
1863
- if current_time - self.latest_key_time < 10:
1864
- session.cookies.set("KEY", latest_key, domain=".pornhub.com", path="/")
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 getattr(self, "latest_key", None) != local_latest:
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
- await asyncio.sleep(1.5)
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
- await asyncio.sleep(1.5)
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
- await asyncio.to_thread(self._convert_ts_to_mp4, tmp_path, path, callback_remux, ios_support)
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(self, input_path: str, output_path: str,
2997
- callback: Callable[[int, int], None] | None = None, ios_support: bool = False) -> None:
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
- try:
3008
- from av import open as av_open # type: ignore[import-not-found]
3009
- from av.audio.resampler import AudioResampler # type: ignore[import-not-found]
3010
- import av.audio.frame # Used for runtime isinstance check
3011
- except (ModuleNotFoundError, ImportError) as e:
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
- # --- AUDIO ---
3035
- in_audio = next((s for s in input_.streams if s.type == "audio"), None)
3036
- out_audio = None
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
- total = os.path.getsize(input_path)
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("Failed to read input size for remux progress: %s: %s", input_path, exc, exc_info=True)
3085
- total = 100
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
- if timestamp_correction:
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
- elif in_audio and packet.stream == in_audio:
3123
- if not transcode_audio:
3124
- packet.stream = out_audio
3125
- output.mux(packet)
3126
- else:
3127
- assert out_audio is not None
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
- out_size = os.path.getsize(output_path)
3155
- self.logger.info("Remux complete: output=%s size=%s bytes elapsed=%s.2f", output_path, out_size,
3156
- elapsed)
3157
- except Exception as e:
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
- else:
3162
- self.logger.info("Stream seems to be already in MP4! Skipping remux...")
3163
- os.rename(input_path, output_path)
3164
- elapsed = time.perf_counter() - start_ts
3165
- self.logger.info("Remux skipped; file moved. elapsed=%s.2f", elapsed)
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
@@ -36,6 +36,7 @@ class DownloadReport:
36
36
  segment_state_path: Path | str | None
37
37
  start_segment: int
38
38
  quality: str | int
39
+ metadata_written: bool = False
39
40
 
40
41
  def __getitem__(self, key: str) -> Any:
41
42
  return getattr(self, key)
@@ -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.0"
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