u-transcript-max 0.1.0a1__py3-none-any.whl

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.
Files changed (77) hide show
  1. u_transcript_max-0.1.0a1.dist-info/METADATA +67 -0
  2. u_transcript_max-0.1.0a1.dist-info/RECORD +77 -0
  3. u_transcript_max-0.1.0a1.dist-info/WHEEL +4 -0
  4. u_transcript_max-0.1.0a1.dist-info/entry_points.txt +2 -0
  5. u_transcript_max-0.1.0a1.dist-info/licenses/LICENSE +21 -0
  6. utmax/__init__.py +651 -0
  7. utmax/_version.py +1 -0
  8. utmax/adapters/__init__.py +1 -0
  9. utmax/adapters/downloader.py +479 -0
  10. utmax/adapters/ffmpeg.py +174 -0
  11. utmax/adapters/files.py +130 -0
  12. utmax/adapters/http.py +285 -0
  13. utmax/adapters/innertube.py +207 -0
  14. utmax/adapters/providers/__init__.py +45 -0
  15. utmax/adapters/providers/base.py +177 -0
  16. utmax/adapters/providers/claude.py +108 -0
  17. utmax/adapters/providers/gemini.py +133 -0
  18. utmax/adapters/providers/openai.py +169 -0
  19. utmax/adapters/providers/openrouter.py +59 -0
  20. utmax/adapters/watch_page.py +73 -0
  21. utmax/client.py +300 -0
  22. utmax/compat/__init__.py +134 -0
  23. utmax/compat/_api.py +203 -0
  24. utmax/compat/_bridge.py +212 -0
  25. utmax/compat/_errors.py +300 -0
  26. utmax/compat/_settings.py +13 -0
  27. utmax/compat/_transcripts.py +440 -0
  28. utmax/compat/formatters.py +241 -0
  29. utmax/compat/proxies.py +129 -0
  30. utmax/core/__init__.py +6 -0
  31. utmax/core/bilingual.py +107 -0
  32. utmax/core/browse.py +397 -0
  33. utmax/core/captions.py +251 -0
  34. utmax/core/clients.py +113 -0
  35. utmax/core/downloads.py +165 -0
  36. utmax/core/filenames.py +298 -0
  37. utmax/core/formats.py +198 -0
  38. utmax/core/ids.py +210 -0
  39. utmax/core/languages.py +371 -0
  40. utmax/core/media/__init__.py +7 -0
  41. utmax/core/media/boxes.py +266 -0
  42. utmax/core/media/fmp4.py +394 -0
  43. utmax/core/media/moov.py +299 -0
  44. utmax/core/media/mux.py +423 -0
  45. utmax/core/media/progressive.py +267 -0
  46. utmax/core/media/tables.py +129 -0
  47. utmax/core/media/tx3g.py +191 -0
  48. utmax/core/playability.py +62 -0
  49. utmax/core/player.py +90 -0
  50. utmax/core/retry.py +45 -0
  51. utmax/core/segmentation.py +104 -0
  52. utmax/core/selection.py +92 -0
  53. utmax/core/streams.py +264 -0
  54. utmax/core/translate/__init__.py +1 -0
  55. utmax/core/translate/batching.py +157 -0
  56. utmax/core/translate/data/protocol.json +17 -0
  57. utmax/core/translate/data/request.schema.json +49 -0
  58. utmax/core/translate/data/response.schema.json +19 -0
  59. utmax/core/translate/data/system_prompt.txt +21 -0
  60. utmax/core/translate/protocol.py +101 -0
  61. utmax/core/translate/spec.py +49 -0
  62. utmax/core/ytdata.py +32 -0
  63. utmax/errors.py +523 -0
  64. utmax/mcp/__init__.py +30 -0
  65. utmax/mcp/__main__.py +6 -0
  66. utmax/mcp/config.py +56 -0
  67. utmax/mcp/server.py +626 -0
  68. utmax/models.py +499 -0
  69. utmax/providers.py +36 -0
  70. utmax/py.typed +0 -0
  71. utmax/services/__init__.py +1 -0
  72. utmax/services/bulk.py +454 -0
  73. utmax/services/collections.py +183 -0
  74. utmax/services/download.py +375 -0
  75. utmax/services/transcripts.py +87 -0
  76. utmax/services/translation.py +211 -0
  77. utmax/transport.py +81 -0
@@ -0,0 +1,130 @@
1
+ """Filesystem helpers: atomic writes that tolerate Windows file locks, and muxer output."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import io
6
+ import os
7
+ import secrets
8
+ import time
9
+ from collections.abc import Callable, Iterator, Sequence
10
+ from pathlib import Path
11
+ from typing import Self
12
+
13
+ from utmax.core.media.boxes import ByteSource, read_exact
14
+ from utmax.core.media.mux import Blob, CopyOp, MuxPlan
15
+
16
+ __all__ = [
17
+ "COPY_BLOCK_SIZE",
18
+ "REPLACE_RETRY_DELAYS",
19
+ "FileByteSource",
20
+ "replace_with_retry",
21
+ "write_mux_plan",
22
+ "write_text_atomic",
23
+ ]
24
+
25
+ REPLACE_RETRY_DELAYS = (0.1, 0.2, 0.4, 0.8, 1.6, 3.2)
26
+ COPY_BLOCK_SIZE = 1 << 20
27
+
28
+
29
+ def write_text_atomic(path: str | os.PathLike[str], text: str) -> Path:
30
+ """Write ``text`` as UTF-8 with ``\\n`` newlines; readers never see a half-written file."""
31
+ target = Path(path)
32
+ target.parent.mkdir(parents=True, exist_ok=True)
33
+ temporary = target.with_name(f".{target.name}.{secrets.token_hex(4)}.tmp")
34
+ try:
35
+ with temporary.open("x", encoding="utf-8", newline="\n") as handle:
36
+ handle.write(text)
37
+ handle.flush()
38
+ os.fsync(handle.fileno())
39
+ replace_with_retry(temporary, target)
40
+ except BaseException:
41
+ temporary.unlink(missing_ok=True)
42
+ raise
43
+ return target
44
+
45
+
46
+ def replace_with_retry(
47
+ source: Path,
48
+ target: Path,
49
+ *,
50
+ replace: Callable[[Path, Path], None] = os.replace,
51
+ sleep: Callable[[float], None] = time.sleep,
52
+ ) -> None:
53
+ """``os.replace`` that retries while antivirus or indexers briefly hold the file (Windows)."""
54
+ for delay in REPLACE_RETRY_DELAYS:
55
+ try:
56
+ replace(source, target)
57
+ except PermissionError:
58
+ sleep(delay)
59
+ else:
60
+ return
61
+ replace(source, target)
62
+
63
+
64
+ class FileByteSource:
65
+ """A :class:`~utmax.core.media.boxes.ByteSource` over a file on disk; not thread-safe."""
66
+
67
+ def __init__(self, path: str | os.PathLike[str]) -> None:
68
+ self.path = Path(path)
69
+ self._handle: io.BufferedReader = self.path.open("rb")
70
+ self.size = os.fstat(self._handle.fileno()).st_size
71
+
72
+ def read(self, offset: int, n: int) -> bytes:
73
+ self._handle.seek(offset)
74
+ return self._handle.read(n)
75
+
76
+ def close(self) -> None:
77
+ self._handle.close()
78
+
79
+ def __enter__(self) -> Self:
80
+ return self
81
+
82
+ def __exit__(self, *exc_info: object) -> None:
83
+ self.close()
84
+
85
+
86
+ def write_mux_plan(
87
+ plan: MuxPlan,
88
+ sources: Sequence[ByteSource],
89
+ path: str | os.PathLike[str],
90
+ *,
91
+ progress: Callable[[int, int], None] | None = None,
92
+ ) -> Path:
93
+ """Write the file ``plan`` describes to ``path`` and return ``path``.
94
+
95
+ Copies stream in 1 MiB blocks into a temporary file next to ``path``, which is flushed to
96
+ disk and then atomically replaces ``path`` (retrying while Windows holds a lock); on any
97
+ error the temporary file is removed. ``progress(written, total)`` runs after each block.
98
+
99
+ Raises:
100
+ MuxError: an input is shorter than the plan expects.
101
+ """
102
+ target = Path(path)
103
+ target.parent.mkdir(parents=True, exist_ok=True)
104
+ temporary = target.with_name(f".{target.name}.{secrets.token_hex(4)}.tmp")
105
+ total = plan.size
106
+ written = 0
107
+ try:
108
+ with temporary.open("xb") as handle:
109
+ for op in plan.ops:
110
+ for block in _blocks(op, sources):
111
+ handle.write(block)
112
+ written += len(block)
113
+ if progress is not None:
114
+ progress(written, total)
115
+ handle.flush()
116
+ os.fsync(handle.fileno())
117
+ replace_with_retry(temporary, target)
118
+ except BaseException:
119
+ temporary.unlink(missing_ok=True)
120
+ raise
121
+ return target
122
+
123
+
124
+ def _blocks(op: CopyOp | Blob, sources: Sequence[ByteSource]) -> Iterator[bytes]:
125
+ if isinstance(op, Blob):
126
+ yield op.data
127
+ return
128
+ end = op.offset + op.size
129
+ for start in range(op.offset, end, COPY_BLOCK_SIZE):
130
+ yield read_exact(sources[op.source], start, min(COPY_BLOCK_SIZE, end - start))
utmax/adapters/http.py ADDED
@@ -0,0 +1,285 @@
1
+ """The only module that talks to the network: a urllib transport plus a retry wrapper."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import gzip
6
+ import http.client
7
+ import io
8
+ import logging
9
+ import random
10
+ import socket
11
+ import ssl
12
+ import time
13
+ import urllib.error
14
+ import urllib.request
15
+ import zlib
16
+ from collections.abc import Callable, Mapping
17
+ from datetime import UTC, datetime
18
+ from typing import IO
19
+ from urllib.parse import urlsplit
20
+
21
+ from utmax.core.retry import backoff_delay, is_transient_status, parse_retry_after
22
+ from utmax.errors import InvalidOption, NetworkError
23
+ from utmax.transport import HttpRequest, HttpResponse, HttpStream, StreamingTransport, Transport
24
+
25
+ __all__ = ["BufferedStream", "RetryingTransport", "UrllibTransport", "open_stream", "redact"]
26
+
27
+ log = logging.getLogger("utmax.http")
28
+
29
+ _NETWORK_ERRORS = (OSError, http.client.HTTPException, EOFError, zlib.error)
30
+
31
+ # Binding outgoing sockets here makes every IPv6 address fail, so only IPv4 is tried.
32
+ _IPV4_ONLY = ("0.0.0.0", 0)
33
+
34
+
35
+ class UrllibTransport:
36
+ """Sends requests with the standard library; every request uses a fresh connection.
37
+
38
+ ``force_ipv4=True`` connects over IPv4 only. Stream URLs are bound to the address that
39
+ requested them, so machines whose IPv4 and IPv6 addresses differ may need it.
40
+ """
41
+
42
+ def __init__(
43
+ self, *, proxy: str | None = None, timeout: float = 30.0, force_ipv4: bool = False
44
+ ) -> None:
45
+ context = ssl.create_default_context()
46
+ handlers: list[urllib.request.BaseHandler] = (
47
+ [_IPv4HTTPHandler(), _IPv4HTTPSHandler(context)]
48
+ if force_ipv4
49
+ else [urllib.request.HTTPSHandler(context=context)]
50
+ )
51
+ if proxy is not None:
52
+ _check_proxy(proxy)
53
+ handlers.append(urllib.request.ProxyHandler({"http": proxy, "https": proxy}))
54
+ self._opener = urllib.request.build_opener(*handlers)
55
+ self._timeout = timeout
56
+
57
+ def send(self, request: HttpRequest) -> HttpResponse:
58
+ status: int
59
+ headers: dict[str, str]
60
+ body: bytes
61
+ try:
62
+ with self._opener.open(_prepare(request), timeout=self._timeout) as response:
63
+ status = response.status
64
+ headers = dict(response.headers.items())
65
+ body = response.read()
66
+ except urllib.error.HTTPError as error:
67
+ with error:
68
+ status = error.code
69
+ headers = dict(error.headers.items()) if error.headers else {}
70
+ body = error.read()
71
+ if _header(headers, "content-encoding").lower() == "gzip":
72
+ body = gzip.decompress(body)
73
+ log.debug("%s %s -> %d", request.method, redact(request.url), status)
74
+ return HttpResponse(status=status, url=request.url, headers=headers, body=body)
75
+
76
+ def stream(self, request: HttpRequest) -> HttpStream:
77
+ """Open ``request`` and hand back its body unread and never decompressed.
78
+
79
+ Raises:
80
+ NetworkError: the connection could not be made.
81
+ """
82
+ try:
83
+ response = self._opener.open(_prepare(request), timeout=self._timeout)
84
+ except urllib.error.HTTPError as error:
85
+ log.debug("%s %s -> %d (stream)", request.method, redact(request.url), error.code)
86
+ headers = dict(error.headers.items()) if error.headers else {}
87
+ return _UrllibStream(error, error.code, headers, request)
88
+ except _NETWORK_ERRORS as error:
89
+ raise _network_error(error, request) from error
90
+ log.debug("%s %s -> %d (stream)", request.method, redact(request.url), response.status)
91
+ return _UrllibStream(response, response.status, dict(response.headers.items()), request)
92
+
93
+
94
+ class RetryingTransport:
95
+ """Retries timeouts, connection resets and HTTP 408/5xx with exponential backoff."""
96
+
97
+ def __init__(
98
+ self,
99
+ inner: Transport,
100
+ *,
101
+ retries: int = 2,
102
+ sleep: Callable[[float], None] = time.sleep,
103
+ rng: random.Random | None = None,
104
+ clock: Callable[[], datetime] | None = None,
105
+ ) -> None:
106
+ self._inner = inner
107
+ self._retries = retries
108
+ self._sleep = sleep
109
+ self._rng = rng or random.Random()
110
+ self._clock = clock or _utc_now
111
+
112
+ def send(self, request: HttpRequest) -> HttpResponse:
113
+ attempt = 0
114
+ while True:
115
+ try:
116
+ response = self._inner.send(request)
117
+ except _NETWORK_ERRORS as error:
118
+ if attempt >= self._retries or not _is_transient(error):
119
+ raise _network_error(error, request) from error
120
+ delay = backoff_delay(attempt, self._rng)
121
+ log.info(
122
+ "%s %s failed (%s); retrying in %.1fs",
123
+ request.method,
124
+ redact(request.url),
125
+ error,
126
+ delay,
127
+ )
128
+ else:
129
+ if attempt >= self._retries or not is_transient_status(response.status):
130
+ return response
131
+ retry_after = parse_retry_after(response.header("retry-after"), now=self._clock())
132
+ delay = backoff_delay(attempt, self._rng) if retry_after is None else retry_after
133
+ log.info(
134
+ "%s %s answered %d; retrying in %.1fs",
135
+ request.method,
136
+ redact(request.url),
137
+ response.status,
138
+ delay,
139
+ )
140
+ self._sleep(delay)
141
+ attempt += 1
142
+
143
+ def stream(self, request: HttpRequest) -> HttpStream:
144
+ """Stream through the inner transport; the downloader retries byte ranges itself."""
145
+ return open_stream(self._inner, request)
146
+
147
+
148
+ def redact(url: str) -> str:
149
+ """``url`` without credentials or query string, safe for logs and error messages."""
150
+ try:
151
+ parts = urlsplit(url)
152
+ port = f":{parts.port}" if parts.port else ""
153
+ except ValueError:
154
+ return "<invalid url>"
155
+ return f"{parts.scheme}://{parts.hostname or ''}{port}{parts.path}"
156
+
157
+
158
+ def open_stream(transport: Transport, request: HttpRequest) -> HttpStream:
159
+ """Stream ``request`` when ``transport`` can; otherwise send it and serve the body from memory."""
160
+ if isinstance(transport, StreamingTransport):
161
+ return transport.stream(request)
162
+ return BufferedStream(transport.send(request))
163
+
164
+
165
+ class BufferedStream:
166
+ """An :class:`~utmax.transport.HttpStream` over a response that is already in memory."""
167
+
168
+ def __init__(self, response: HttpResponse) -> None:
169
+ self.status = response.status
170
+ self._response = response
171
+ self._body = io.BytesIO(response.body)
172
+
173
+ def header(self, name: str) -> str | None:
174
+ return self._response.header(name)
175
+
176
+ def read(self, n: int) -> bytes:
177
+ return self._body.read(n)
178
+
179
+ def close(self) -> None:
180
+ self._body.close()
181
+
182
+
183
+ class _UrllibStream:
184
+ """A urllib response (or HTTP error response) whose body is read in pieces."""
185
+
186
+ def __init__(
187
+ self, body: IO[bytes], status: int, headers: Mapping[str, str], request: HttpRequest
188
+ ) -> None:
189
+ self.status = status
190
+ self._body = body
191
+ self._headers = {key.lower(): value for key, value in headers.items()}
192
+ self._request = request
193
+
194
+ def header(self, name: str) -> str | None:
195
+ return self._headers.get(name.lower())
196
+
197
+ def read(self, n: int) -> bytes:
198
+ try:
199
+ return self._body.read(n)
200
+ except _NETWORK_ERRORS as error:
201
+ raise _network_error(error, self._request) from error
202
+
203
+ def close(self) -> None:
204
+ self._body.close()
205
+
206
+
207
+ class _IPv4HTTPHandler(urllib.request.HTTPHandler):
208
+ def http_open(self, req: urllib.request.Request) -> http.client.HTTPResponse:
209
+ return self.do_open(http.client.HTTPConnection, req, source_address=_IPV4_ONLY)
210
+
211
+
212
+ class _IPv4HTTPSHandler(urllib.request.HTTPSHandler):
213
+ def __init__(self, context: ssl.SSLContext) -> None:
214
+ super().__init__(context=context)
215
+ self._tls_context = context
216
+
217
+ def https_open(self, req: urllib.request.Request) -> http.client.HTTPResponse:
218
+ return self.do_open(
219
+ http.client.HTTPSConnection,
220
+ req,
221
+ context=self._tls_context,
222
+ source_address=_IPV4_ONLY,
223
+ )
224
+
225
+
226
+ def _prepare(request: HttpRequest) -> urllib.request.Request:
227
+ return urllib.request.Request(
228
+ request.url, data=request.body, headers=dict(request.headers), method=request.method
229
+ )
230
+
231
+
232
+ def _check_proxy(proxy: str) -> None:
233
+ try:
234
+ parts = urlsplit(proxy)
235
+ host = parts.hostname
236
+ except ValueError:
237
+ host = None
238
+ if not proxy.startswith("http://") or not host:
239
+ raise InvalidOption(
240
+ f"Unsupported proxy URL {redact(proxy)!r}: use http://[user:password@]host:port "
241
+ "(HTTPS traffic is tunnelled through it).",
242
+ suggestion="For SOCKS or https:// proxies, pass your own transport= to utmax.Client.",
243
+ )
244
+
245
+
246
+ def _header(headers: Mapping[str, str], name: str) -> str:
247
+ return next((value for key, value in headers.items() if key.lower() == name), "")
248
+
249
+
250
+ def _is_transient(error: BaseException) -> bool:
251
+ reason: object = error.reason if isinstance(error, urllib.error.URLError) else error
252
+ if isinstance(reason, ssl.SSLCertVerificationError):
253
+ return False
254
+ if isinstance(reason, socket.gaierror):
255
+ return reason.errno == socket.EAI_AGAIN
256
+ return isinstance(
257
+ reason,
258
+ (
259
+ TimeoutError,
260
+ ConnectionError,
261
+ ssl.SSLError,
262
+ http.client.HTTPException,
263
+ EOFError,
264
+ gzip.BadGzipFile,
265
+ zlib.error,
266
+ ),
267
+ )
268
+
269
+
270
+ def _network_error(error: BaseException, request: HttpRequest) -> NetworkError:
271
+ reason = error.reason if isinstance(error, urllib.error.URLError) else error
272
+ message = f"Could not complete {request.method} {redact(request.url)}: {reason}"
273
+ if isinstance(reason, ssl.SSLCertVerificationError):
274
+ return NetworkError(
275
+ message,
276
+ suggestion=(
277
+ "TLS certificate verification failed; a proxy or antivirus may be "
278
+ "intercepting HTTPS traffic."
279
+ ),
280
+ )
281
+ return NetworkError(message)
282
+
283
+
284
+ def _utc_now() -> datetime:
285
+ return datetime.now(UTC)
@@ -0,0 +1,207 @@
1
+ """InnerTube (YouTube's internal API): player requests with client fallback, captions, and
2
+ the ``browse`` and ``navigation/resolve_url`` requests behind playlists and channels."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import logging
8
+ from collections.abc import Callable
9
+ from functools import partial
10
+ from typing import Any, TypeVar
11
+
12
+ from utmax.adapters.watch_page import fetch_api_key
13
+ from utmax.core.captions import caption_url, check_caption_url
14
+ from utmax.core.clients import ANDROID, DESKTOP_USER_AGENT, ORDER, ClientProfile, Purpose
15
+ from utmax.core.playability import check_playability
16
+ from utmax.core.player import PlayerData, parse_player_response
17
+ from utmax.errors import (
18
+ IpBlocked,
19
+ RequestBlocked,
20
+ VideoUnplayable,
21
+ YouTubeDataUnparsable,
22
+ YouTubeError,
23
+ YouTubeRequestFailed,
24
+ )
25
+ from utmax.transport import HttpRequest, HttpResponse, Transport
26
+
27
+ __all__ = ["API_BASE", "InnerTubeClient"]
28
+
29
+ API_BASE = "https://www.youtube.com/youtubei/v1"
30
+ log = logging.getLogger("utmax.youtube")
31
+ T = TypeVar("T")
32
+
33
+ _HTTP_LEVEL_FAILURES = (YouTubeRequestFailed, YouTubeDataUnparsable)
34
+
35
+
36
+ class InnerTubeClient:
37
+ """Talks to InnerTube with a chain of client profiles (see ``utmax.core.clients``)."""
38
+
39
+ def __init__(self, transport: Transport, *, block_retries: int = 0) -> None:
40
+ self._transport = transport
41
+ self._block_retries = block_retries
42
+
43
+ def player(self, video_id: str, *, purpose: Purpose = "captions") -> PlayerData:
44
+ """A playable player response, trying each profile for ``purpose`` in order.
45
+
46
+ For ``"streams"``, a response without a direct MP4 stream URL counts as a failed profile.
47
+ """
48
+ profiles = ORDER[purpose]
49
+ failures: list[YouTubeError] = []
50
+ http_failures = 0
51
+ for profile in profiles:
52
+ try:
53
+ return self._with_block_retries(
54
+ partial(self._playable, profile, video_id, None, purpose=purpose)
55
+ )
56
+ except _HTTP_LEVEL_FAILURES as error:
57
+ http_failures += 1
58
+ failures.append(error)
59
+ except (VideoUnplayable, RequestBlocked) as error:
60
+ if isinstance(error, IpBlocked):
61
+ raise
62
+ failures.append(error)
63
+ log.info("InnerTube client %s failed for %s: %s", profile.name, video_id, failures[-1])
64
+ if http_failures == len(profiles):
65
+ log.info("every InnerTube client failed at the HTTP level; trying the watch page")
66
+ api_key = fetch_api_key(self._transport, video_id)
67
+ return self._with_block_retries(
68
+ partial(self._playable, ANDROID, video_id, api_key, purpose=purpose)
69
+ )
70
+ raise failures[0]
71
+
72
+ def player_json(
73
+ self, profile: ClientProfile, video_id: str, *, api_key: str | None = None
74
+ ) -> dict[str, Any]:
75
+ """The raw player response of one profile (low level; used by the fixture recorder)."""
76
+ url = f"{API_BASE}/player?prettyPrint=false"
77
+ if api_key is not None:
78
+ url += f"&key={api_key}"
79
+ payload = {
80
+ "context": profile.context_payload(),
81
+ "videoId": video_id,
82
+ "contentCheckOk": True,
83
+ "racyCheckOk": True,
84
+ }
85
+ body = json.dumps(payload).encode("utf-8")
86
+ response = self._transport.send(HttpRequest("POST", url, profile.request_headers(), body))
87
+ return _json_object(response, video_id=video_id)
88
+
89
+ def browse(
90
+ self,
91
+ profile: ClientProfile,
92
+ *,
93
+ browse_id: str | None = None,
94
+ continuation: str | None = None,
95
+ ) -> dict[str, Any]:
96
+ """One raw ``browse`` answer of ``profile``: the first page of ``browse_id`` (``"VL"``
97
+ plus a playlist ID), or the page behind a ``continuation`` token."""
98
+ payload: dict[str, Any] = {"context": profile.context_payload()}
99
+ if browse_id is not None:
100
+ payload["browseId"] = browse_id
101
+ if continuation is not None:
102
+ payload["continuation"] = continuation
103
+ return self._with_block_retries(partial(self._post, profile, "browse", payload))
104
+
105
+ def resolve_url(self, profile: ClientProfile, url: str) -> dict[str, Any]:
106
+ """The raw ``navigation/resolve_url`` answer of ``profile`` for a YouTube ``url``, such as
107
+ a channel's ``https://www.youtube.com/@handle``."""
108
+ payload = {"context": profile.context_payload(), "url": url}
109
+ return self._with_block_retries(
110
+ partial(self._post, profile, "navigation/resolve_url", payload)
111
+ )
112
+
113
+ def fetch_captions(
114
+ self, base_url: str, *, video_id: str, fmt: str | None = "json3"
115
+ ) -> HttpResponse:
116
+ """Download a caption track as ``fmt``; ``None`` asks for YouTube's legacy XML."""
117
+ check_caption_url(base_url, video_id=video_id)
118
+ url = caption_url(base_url, fmt=fmt)
119
+ return self._with_block_retries(partial(self._caption_response, url, video_id))
120
+
121
+ def _post(
122
+ self, profile: ClientProfile, endpoint: str, payload: dict[str, Any]
123
+ ) -> dict[str, Any]:
124
+ url = f"{API_BASE}/{endpoint}?prettyPrint=false"
125
+ body = json.dumps(payload).encode("utf-8")
126
+ response = self._transport.send(HttpRequest("POST", url, profile.request_headers(), body))
127
+ return _json_object(response)
128
+
129
+ def _playable(
130
+ self, profile: ClientProfile, video_id: str, api_key: str | None, *, purpose: Purpose
131
+ ) -> PlayerData:
132
+ data = self.player_json(profile, video_id, api_key=api_key)
133
+ player = parse_player_response(data, video_id=video_id)
134
+ check_playability(player.playability, video_id=video_id)
135
+ if purpose == "streams" and not any(
136
+ stream.url
137
+ and stream.format.container == "mp4"
138
+ and not stream.drm
139
+ and not stream.progressive
140
+ for stream in player.streams
141
+ ):
142
+ raise YouTubeDataUnparsable(
143
+ f"The {profile.name} client returned no direct MP4 stream URLs for {video_id}.",
144
+ video_id=video_id,
145
+ )
146
+ return player
147
+
148
+ def _caption_response(self, url: str, video_id: str) -> HttpResponse:
149
+ headers = {
150
+ "User-Agent": DESKTOP_USER_AGENT,
151
+ "Accept-Language": "en-US,en;q=0.9",
152
+ "Accept-Encoding": "gzip",
153
+ }
154
+ response = self._transport.send(HttpRequest("GET", url, headers))
155
+ if response.status == 429:
156
+ hint = (
157
+ "YouTube's own translation is heavily rate-limited; use AI translation or a proxy."
158
+ if "tlang=" in url
159
+ else None
160
+ )
161
+ raise IpBlocked(
162
+ "YouTube rate-limited the caption download (HTTP 429).",
163
+ video_id=video_id,
164
+ suggestion=hint,
165
+ )
166
+ if response.status != 200:
167
+ raise YouTubeRequestFailed(
168
+ f"The caption download answered HTTP {response.status}.",
169
+ status_code=response.status,
170
+ video_id=video_id,
171
+ )
172
+ return response
173
+
174
+ def _with_block_retries(self, action: Callable[[], T]) -> T:
175
+ attempt = 0
176
+ while True:
177
+ try:
178
+ return action()
179
+ except RequestBlocked as error:
180
+ if attempt >= self._block_retries:
181
+ raise
182
+ attempt += 1
183
+ log.info(
184
+ "blocked by YouTube (%s); retry %d/%d", error, attempt, self._block_retries
185
+ )
186
+
187
+
188
+ def _json_object(response: HttpResponse, *, video_id: str | None = None) -> dict[str, Any]:
189
+ if response.status == 429:
190
+ raise IpBlocked("YouTube rate-limited this IP address (HTTP 429).", video_id=video_id)
191
+ if response.status != 200:
192
+ raise YouTubeRequestFailed(
193
+ f"InnerTube answered HTTP {response.status}.",
194
+ status_code=response.status,
195
+ video_id=video_id,
196
+ )
197
+ try:
198
+ data = response.json()
199
+ except ValueError:
200
+ raise YouTubeDataUnparsable(
201
+ "InnerTube returned a response that is not JSON.", video_id=video_id
202
+ ) from None
203
+ if not isinstance(data, dict):
204
+ raise YouTubeDataUnparsable(
205
+ "InnerTube returned an unexpected JSON value.", video_id=video_id
206
+ )
207
+ return data
@@ -0,0 +1,45 @@
1
+ """AI translation providers: the Translator base class and one adapter per provider SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from utmax.adapters.providers.base import EngineOptions, SDKTranslator, Translator
8
+ from utmax.adapters.providers.claude import ClaudeTranslator
9
+ from utmax.adapters.providers.gemini import GeminiTranslator
10
+ from utmax.adapters.providers.openai import OpenAITranslator
11
+ from utmax.adapters.providers.openrouter import OpenRouterTranslator
12
+ from utmax.core.translate.spec import parse_model_spec
13
+ from utmax.errors import InvalidOption
14
+
15
+ __all__ = [
16
+ "TRANSLATORS",
17
+ "ClaudeTranslator",
18
+ "EngineOptions",
19
+ "GeminiTranslator",
20
+ "OpenAITranslator",
21
+ "OpenRouterTranslator",
22
+ "Translator",
23
+ "create_translator",
24
+ ]
25
+
26
+ TRANSLATORS: dict[str, type[SDKTranslator]] = {
27
+ "claude": ClaudeTranslator,
28
+ "openai": OpenAITranslator,
29
+ "gemini": GeminiTranslator,
30
+ "openrouter": OpenRouterTranslator,
31
+ }
32
+
33
+
34
+ def create_translator(model: str, **options: Any) -> Translator:
35
+ """The built-in translator for ``model`` (``"provider=model-id"``); see :func:`utmax.translator`."""
36
+ spec = parse_model_spec(model)
37
+ if "base_url" in options and spec.provider != "openai":
38
+ raise InvalidOption(
39
+ f"base_url only works with the openai provider, not {spec.provider}.",
40
+ suggestion=(
41
+ 'Use "openai=<model-id>" with base_url=... for Ollama, LM Studio and other '
42
+ "OpenAI-compatible servers."
43
+ ),
44
+ )
45
+ return TRANSLATORS[spec.provider](spec.model, **options)