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.
@@ -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.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
- except ImportError:
49
- m3u8 = None
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
- 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
60
+ REGEX_CHALLENGE = re.compile(r'var p=(\d+); var s=(\d+);.*?(\d+):1;', re.DOTALL)
63
61
 
64
62
 
65
- REGEX_CHALLENGE = re.compile(r'var p=(\d+); var s=(\d+);.*?(\d+):1;', re.DOTALL)
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
- 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="/")
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 getattr(self, "latest_key", None) != local_latest:
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
- await asyncio.sleep(1.5)
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
- await asyncio.sleep(1.5)
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 = self._decode_response(response, url, self.logger)
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
- await asyncio.to_thread(self._convert_ts_to_mp4, tmp_path, path, callback_remux, ios_support)
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(self, input_path: str, output_path: str,
2997
- callback: Callable[[int, int], None] | None = None, ios_support: bool = False) -> None:
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
- 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)
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
- # --- 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)
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
- total = os.path.getsize(input_path)
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("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,
3030
+ self.logger.warning(
3031
+ "Single-pass remux with metadata failed (%s); falling back to plain remux.",
3032
+ exc,
3109
3033
  )
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)
3034
+ self._safe_remove(output_path)
3121
3035
 
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
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
- 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)
3045
+ total = input_size or os.path.getsize(output_path)
3046
+ callback(total, total)
3047
+ except Exception:
3048
+ pass
3160
3049
 
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)
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
@@ -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,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.0"
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