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.
- u_transcript_max-0.1.0a1.dist-info/METADATA +67 -0
- u_transcript_max-0.1.0a1.dist-info/RECORD +77 -0
- u_transcript_max-0.1.0a1.dist-info/WHEEL +4 -0
- u_transcript_max-0.1.0a1.dist-info/entry_points.txt +2 -0
- u_transcript_max-0.1.0a1.dist-info/licenses/LICENSE +21 -0
- utmax/__init__.py +651 -0
- utmax/_version.py +1 -0
- utmax/adapters/__init__.py +1 -0
- utmax/adapters/downloader.py +479 -0
- utmax/adapters/ffmpeg.py +174 -0
- utmax/adapters/files.py +130 -0
- utmax/adapters/http.py +285 -0
- utmax/adapters/innertube.py +207 -0
- utmax/adapters/providers/__init__.py +45 -0
- utmax/adapters/providers/base.py +177 -0
- utmax/adapters/providers/claude.py +108 -0
- utmax/adapters/providers/gemini.py +133 -0
- utmax/adapters/providers/openai.py +169 -0
- utmax/adapters/providers/openrouter.py +59 -0
- utmax/adapters/watch_page.py +73 -0
- utmax/client.py +300 -0
- utmax/compat/__init__.py +134 -0
- utmax/compat/_api.py +203 -0
- utmax/compat/_bridge.py +212 -0
- utmax/compat/_errors.py +300 -0
- utmax/compat/_settings.py +13 -0
- utmax/compat/_transcripts.py +440 -0
- utmax/compat/formatters.py +241 -0
- utmax/compat/proxies.py +129 -0
- utmax/core/__init__.py +6 -0
- utmax/core/bilingual.py +107 -0
- utmax/core/browse.py +397 -0
- utmax/core/captions.py +251 -0
- utmax/core/clients.py +113 -0
- utmax/core/downloads.py +165 -0
- utmax/core/filenames.py +298 -0
- utmax/core/formats.py +198 -0
- utmax/core/ids.py +210 -0
- utmax/core/languages.py +371 -0
- utmax/core/media/__init__.py +7 -0
- utmax/core/media/boxes.py +266 -0
- utmax/core/media/fmp4.py +394 -0
- utmax/core/media/moov.py +299 -0
- utmax/core/media/mux.py +423 -0
- utmax/core/media/progressive.py +267 -0
- utmax/core/media/tables.py +129 -0
- utmax/core/media/tx3g.py +191 -0
- utmax/core/playability.py +62 -0
- utmax/core/player.py +90 -0
- utmax/core/retry.py +45 -0
- utmax/core/segmentation.py +104 -0
- utmax/core/selection.py +92 -0
- utmax/core/streams.py +264 -0
- utmax/core/translate/__init__.py +1 -0
- utmax/core/translate/batching.py +157 -0
- utmax/core/translate/data/protocol.json +17 -0
- utmax/core/translate/data/request.schema.json +49 -0
- utmax/core/translate/data/response.schema.json +19 -0
- utmax/core/translate/data/system_prompt.txt +21 -0
- utmax/core/translate/protocol.py +101 -0
- utmax/core/translate/spec.py +49 -0
- utmax/core/ytdata.py +32 -0
- utmax/errors.py +523 -0
- utmax/mcp/__init__.py +30 -0
- utmax/mcp/__main__.py +6 -0
- utmax/mcp/config.py +56 -0
- utmax/mcp/server.py +626 -0
- utmax/models.py +499 -0
- utmax/providers.py +36 -0
- utmax/py.typed +0 -0
- utmax/services/__init__.py +1 -0
- utmax/services/bulk.py +454 -0
- utmax/services/collections.py +183 -0
- utmax/services/download.py +375 -0
- utmax/services/transcripts.py +87 -0
- utmax/services/translation.py +211 -0
- utmax/transport.py +81 -0
utmax/adapters/files.py
ADDED
|
@@ -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)
|