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
|
@@ -0,0 +1,479 @@
|
|
|
1
|
+
"""Parallel, resumable downloads of media streams into ``.part`` files.
|
|
2
|
+
|
|
3
|
+
Each stream is split into byte ranges (:func:`utmax.core.downloads.plan_chunks`); the ranges
|
|
4
|
+
of all streams form one queue in ascending offset order, served by up to ``connections``
|
|
5
|
+
threads that write through their own file handles. Finished ranges are recorded in
|
|
6
|
+
``<part>.json`` (when the download starts, at most once a second, and when it stops), so a
|
|
7
|
+
later run with the same stream reuses them. Rejected or expiring URLs are refreshed once for
|
|
8
|
+
all threads; a failed range is retried from its first missing byte.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import logging
|
|
14
|
+
import random
|
|
15
|
+
import threading
|
|
16
|
+
import time
|
|
17
|
+
from collections import deque
|
|
18
|
+
from collections.abc import Callable, Sequence
|
|
19
|
+
from dataclasses import dataclass, field, replace
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import BinaryIO
|
|
22
|
+
|
|
23
|
+
from utmax.adapters.files import write_text_atomic
|
|
24
|
+
from utmax.core.downloads import (
|
|
25
|
+
DEFAULT_CHUNK_SIZE,
|
|
26
|
+
Chunk,
|
|
27
|
+
PartState,
|
|
28
|
+
RateMeter,
|
|
29
|
+
Throttle,
|
|
30
|
+
content_range_total,
|
|
31
|
+
expires_soon,
|
|
32
|
+
plan_chunks,
|
|
33
|
+
range_header,
|
|
34
|
+
)
|
|
35
|
+
from utmax.core.retry import backoff_delay, is_transient_status
|
|
36
|
+
from utmax.core.streams import Stream
|
|
37
|
+
from utmax.errors import DownloadCancelled, DownloadIncomplete, NetworkError, StreamForbidden
|
|
38
|
+
from utmax.models import Progress, ProgressPhase
|
|
39
|
+
from utmax.transport import HttpRequest, HttpStream
|
|
40
|
+
|
|
41
|
+
__all__ = [
|
|
42
|
+
"READ_SIZE",
|
|
43
|
+
"STATE_FLUSH_INTERVAL",
|
|
44
|
+
"Downloader",
|
|
45
|
+
"Job",
|
|
46
|
+
"Opener",
|
|
47
|
+
"ProgressReporter",
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
log = logging.getLogger("utmax.download")
|
|
51
|
+
|
|
52
|
+
READ_SIZE = 256 * 1024
|
|
53
|
+
STATE_FLUSH_INTERVAL = 1.0
|
|
54
|
+
_JOIN_STEP = 0.1
|
|
55
|
+
|
|
56
|
+
Opener = Callable[[HttpRequest], HttpStream]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class ProgressReporter:
|
|
60
|
+
"""Turns byte counts into :class:`~utmax.models.Progress` callbacks.
|
|
61
|
+
|
|
62
|
+
Calls are serialized and throttled to one per ``interval`` seconds; the first report of
|
|
63
|
+
each phase and every ``force=True`` report always go through.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
def __init__(
|
|
67
|
+
self,
|
|
68
|
+
video_id: str,
|
|
69
|
+
callback: Callable[[Progress], None] | None,
|
|
70
|
+
*,
|
|
71
|
+
clock: Callable[[], float] = time.monotonic,
|
|
72
|
+
interval: float = 0.25,
|
|
73
|
+
) -> None:
|
|
74
|
+
self._video_id = video_id
|
|
75
|
+
self._callback = callback
|
|
76
|
+
self._clock = clock
|
|
77
|
+
self._throttle = Throttle(interval)
|
|
78
|
+
self._meter = RateMeter()
|
|
79
|
+
self._phase: ProgressPhase | None = None
|
|
80
|
+
self._lock = threading.Lock()
|
|
81
|
+
|
|
82
|
+
def report(
|
|
83
|
+
self, phase: ProgressPhase, done: int, total: int | None, *, force: bool = False
|
|
84
|
+
) -> None:
|
|
85
|
+
"""Report ``done`` of ``total`` bytes (``None`` when unknown) in ``phase``."""
|
|
86
|
+
if self._callback is None:
|
|
87
|
+
return
|
|
88
|
+
with self._lock:
|
|
89
|
+
now = self._clock()
|
|
90
|
+
if phase != self._phase:
|
|
91
|
+
self._phase, self._meter, force = phase, RateMeter(), True
|
|
92
|
+
speed, eta = self._meter.update(now, done, total)
|
|
93
|
+
if self._throttle.ready(now, force=force):
|
|
94
|
+
self._callback(Progress(self._video_id, phase, done, total, speed, eta))
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass(frozen=True, slots=True)
|
|
98
|
+
class Job:
|
|
99
|
+
"""One stream to download into ``part``; its resume state lives in ``part`` + ``.json``."""
|
|
100
|
+
|
|
101
|
+
stream: Stream
|
|
102
|
+
part: Path
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def state_path(self) -> Path:
|
|
106
|
+
return self.part.with_name(f"{self.part.name}.json")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass(slots=True)
|
|
110
|
+
class _Target:
|
|
111
|
+
"""A job while it runs (``stream`` changes when URLs are refreshed)."""
|
|
112
|
+
|
|
113
|
+
job: Job
|
|
114
|
+
stream: Stream
|
|
115
|
+
size: int
|
|
116
|
+
chunks: tuple[Chunk, ...]
|
|
117
|
+
state: PartState
|
|
118
|
+
completed: set[int] = field(default_factory=set)
|
|
119
|
+
reused: int = 0
|
|
120
|
+
saved_at: float = 0.0
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class Downloader:
|
|
124
|
+
"""Downloads streams completely, in parallel byte ranges and resumably (see module docs).
|
|
125
|
+
|
|
126
|
+
One ``Downloader`` runs one download at a time; create one per download.
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
def __init__(
|
|
130
|
+
self,
|
|
131
|
+
opener: Opener,
|
|
132
|
+
*,
|
|
133
|
+
connections: int = 4,
|
|
134
|
+
chunk_size: int = DEFAULT_CHUNK_SIZE,
|
|
135
|
+
refresh: Callable[[], Sequence[Stream]] | None = None,
|
|
136
|
+
reporter: ProgressReporter | None = None,
|
|
137
|
+
cancel: threading.Event | None = None,
|
|
138
|
+
attempts: int = 5,
|
|
139
|
+
max_refreshes: int = 3,
|
|
140
|
+
clock: Callable[[], float] = time.time,
|
|
141
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
142
|
+
rng: random.Random | None = None,
|
|
143
|
+
) -> None:
|
|
144
|
+
self._opener = opener
|
|
145
|
+
self._connections = connections
|
|
146
|
+
self._chunk_size = chunk_size
|
|
147
|
+
self._fresh_streams = refresh
|
|
148
|
+
self._reporter = reporter
|
|
149
|
+
self._cancel = cancel
|
|
150
|
+
self._attempts = attempts
|
|
151
|
+
self._max_refreshes = max_refreshes
|
|
152
|
+
self._clock = clock
|
|
153
|
+
self._sleep = sleep
|
|
154
|
+
self._rng = rng or random.Random()
|
|
155
|
+
self._lock = threading.Lock()
|
|
156
|
+
self._refresh_lock = threading.Lock()
|
|
157
|
+
self._stop = threading.Event()
|
|
158
|
+
self._targets: list[_Target] = []
|
|
159
|
+
self._queue: deque[tuple[_Target, Chunk]] = deque()
|
|
160
|
+
self._errors: list[BaseException] = []
|
|
161
|
+
self._video_id = ""
|
|
162
|
+
self._generation = 0
|
|
163
|
+
self._refreshes = 0
|
|
164
|
+
self._done = 0
|
|
165
|
+
self._total = 0
|
|
166
|
+
|
|
167
|
+
def run(self, jobs: Sequence[Job], *, video_id: str, resume: bool = True) -> bool:
|
|
168
|
+
"""Download every job; return ``True`` when finished ranges of an earlier run were reused.
|
|
169
|
+
|
|
170
|
+
Errors in any thread, and Ctrl-C, stop every thread and save the resume state before
|
|
171
|
+
they propagate.
|
|
172
|
+
|
|
173
|
+
Raises:
|
|
174
|
+
DownloadCancelled: ``cancel`` was set; the parts stay for a resume.
|
|
175
|
+
StreamForbidden: YouTube kept answering 403 after ``max_refreshes`` fresh URLs.
|
|
176
|
+
DownloadIncomplete: a stream kept failing, changed on YouTube's side or answered
|
|
177
|
+
an unexpected status.
|
|
178
|
+
NetworkError: the connection kept failing.
|
|
179
|
+
"""
|
|
180
|
+
self._video_id = video_id
|
|
181
|
+
self._stop.clear()
|
|
182
|
+
self._errors.clear()
|
|
183
|
+
self._generation = self._refreshes = 0
|
|
184
|
+
self._targets = [self._prepare(job, resume=resume) for job in jobs]
|
|
185
|
+
self._total = sum(target.size for target in self._targets)
|
|
186
|
+
self._done = sum(target.reused for target in self._targets)
|
|
187
|
+
pending = [
|
|
188
|
+
(chunk.start, number, target, chunk)
|
|
189
|
+
for number, target in enumerate(self._targets)
|
|
190
|
+
for chunk in target.chunks
|
|
191
|
+
if chunk.index not in target.completed
|
|
192
|
+
]
|
|
193
|
+
pending.sort(key=lambda item: (item[0], item[1]))
|
|
194
|
+
self._queue = deque((target, chunk) for _, _, target, chunk in pending)
|
|
195
|
+
self._report(force=True)
|
|
196
|
+
workers = [
|
|
197
|
+
threading.Thread(target=self._work, name=f"utmax-download-{number}", daemon=True)
|
|
198
|
+
for number in range(min(self._connections, len(self._queue)))
|
|
199
|
+
]
|
|
200
|
+
for worker in workers:
|
|
201
|
+
worker.start()
|
|
202
|
+
try:
|
|
203
|
+
for worker in workers:
|
|
204
|
+
while worker.is_alive():
|
|
205
|
+
worker.join(_JOIN_STEP)
|
|
206
|
+
finally:
|
|
207
|
+
self._stop.set()
|
|
208
|
+
for worker in workers:
|
|
209
|
+
worker.join()
|
|
210
|
+
self._save_all()
|
|
211
|
+
if self._errors:
|
|
212
|
+
raise self._errors[0]
|
|
213
|
+
if self._cancelled():
|
|
214
|
+
raise DownloadCancelled(
|
|
215
|
+
f"The download of video {video_id} was cancelled; run it again to resume.",
|
|
216
|
+
video_id=video_id,
|
|
217
|
+
)
|
|
218
|
+
self._report(force=True)
|
|
219
|
+
return any(target.reused for target in self._targets)
|
|
220
|
+
|
|
221
|
+
def _prepare(self, job: Job, *, resume: bool) -> _Target:
|
|
222
|
+
stream = job.stream
|
|
223
|
+
size = stream.format.content_length
|
|
224
|
+
if size is None:
|
|
225
|
+
size = self._probe_size(stream)
|
|
226
|
+
chunks = plan_chunks(size, self._chunk_size)
|
|
227
|
+
state = PartState(
|
|
228
|
+
self._video_id, stream.format.itag, size, stream.format.last_modified, self._chunk_size
|
|
229
|
+
)
|
|
230
|
+
target = _Target(job, stream, size, chunks, state)
|
|
231
|
+
saved = self._load(job) if resume else None
|
|
232
|
+
if saved is not None and saved.matches(state) and _size_of(job.part) == size:
|
|
233
|
+
target.completed = {index for index in saved.completed if 0 <= index < len(chunks)}
|
|
234
|
+
target.reused = sum(chunks[index].size for index in target.completed)
|
|
235
|
+
log.info(
|
|
236
|
+
"resuming stream %d of %s: %d of %d bytes are already here",
|
|
237
|
+
stream.format.itag,
|
|
238
|
+
self._video_id,
|
|
239
|
+
target.reused,
|
|
240
|
+
size,
|
|
241
|
+
)
|
|
242
|
+
else:
|
|
243
|
+
job.part.parent.mkdir(parents=True, exist_ok=True)
|
|
244
|
+
with job.part.open("wb") as handle:
|
|
245
|
+
handle.truncate(size)
|
|
246
|
+
self._save(target)
|
|
247
|
+
return target
|
|
248
|
+
|
|
249
|
+
def _probe_size(self, stream: Stream) -> int:
|
|
250
|
+
"""The size of a stream without ``contentLength``: ask for its first byte."""
|
|
251
|
+
body = self._opener(self._request(stream, "bytes=0-0"))
|
|
252
|
+
try:
|
|
253
|
+
total = (
|
|
254
|
+
content_range_total(body.header("Content-Range")) if body.status == 206 else None
|
|
255
|
+
)
|
|
256
|
+
finally:
|
|
257
|
+
body.close()
|
|
258
|
+
if total is None:
|
|
259
|
+
raise DownloadIncomplete(
|
|
260
|
+
f"Could not learn the size of stream {stream.format.itag} of video "
|
|
261
|
+
f"{self._video_id} (HTTP {body.status}).",
|
|
262
|
+
video_id=self._video_id,
|
|
263
|
+
)
|
|
264
|
+
return total
|
|
265
|
+
|
|
266
|
+
def _work(self) -> None:
|
|
267
|
+
try:
|
|
268
|
+
while not self._should_stop():
|
|
269
|
+
with self._lock:
|
|
270
|
+
if not self._queue:
|
|
271
|
+
return
|
|
272
|
+
target, chunk = self._queue.popleft()
|
|
273
|
+
self._fetch(target, chunk)
|
|
274
|
+
except BaseException as error: # stop every thread; run() re-raises the first error
|
|
275
|
+
with self._lock:
|
|
276
|
+
self._errors.append(error)
|
|
277
|
+
self._stop.set()
|
|
278
|
+
|
|
279
|
+
def _fetch(self, target: _Target, chunk: Chunk) -> None:
|
|
280
|
+
"""Download one range, continuing from its first missing byte, then mark it finished."""
|
|
281
|
+
position, failures, replanned = chunk.start, 0, False
|
|
282
|
+
with target.job.part.open("r+b") as handle:
|
|
283
|
+
while position < chunk.end:
|
|
284
|
+
if self._should_stop():
|
|
285
|
+
return
|
|
286
|
+
stream, generation = self._current(target)
|
|
287
|
+
try:
|
|
288
|
+
body = self._opener(self._request(stream, range_header(position, chunk.end)))
|
|
289
|
+
except NetworkError as error:
|
|
290
|
+
failures = self._retry(failures, error, target)
|
|
291
|
+
continue
|
|
292
|
+
try:
|
|
293
|
+
status = body.status
|
|
294
|
+
whole = position == 0 and chunk.end == target.size
|
|
295
|
+
if status == 206 or (status == 200 and whole):
|
|
296
|
+
start = position
|
|
297
|
+
position, copy_error = self._copy(body, handle, position, chunk.end)
|
|
298
|
+
if position > start:
|
|
299
|
+
failures = 0
|
|
300
|
+
elif not self._should_stop():
|
|
301
|
+
failures = self._retry(
|
|
302
|
+
failures, copy_error or self._short(target), target
|
|
303
|
+
)
|
|
304
|
+
elif status == 403:
|
|
305
|
+
self._refresh(generation, target)
|
|
306
|
+
elif status == 416 and not replanned:
|
|
307
|
+
replanned = True
|
|
308
|
+
self._refresh(generation, target, required=False)
|
|
309
|
+
elif is_transient_status(status) or status == 429:
|
|
310
|
+
problem = self._bad_status(target, status, position, chunk.end)
|
|
311
|
+
failures = self._retry(failures, problem, target)
|
|
312
|
+
else:
|
|
313
|
+
raise self._bad_status(target, status, position, chunk.end)
|
|
314
|
+
finally:
|
|
315
|
+
body.close()
|
|
316
|
+
self._finish(target, chunk)
|
|
317
|
+
|
|
318
|
+
def _copy(
|
|
319
|
+
self, body: HttpStream, handle: BinaryIO, position: int, end: int
|
|
320
|
+
) -> tuple[int, NetworkError | None]:
|
|
321
|
+
"""Write the body at ``position``; return where it stopped and the error, if any."""
|
|
322
|
+
handle.seek(position)
|
|
323
|
+
try:
|
|
324
|
+
while position < end and not self._should_stop():
|
|
325
|
+
data = body.read(min(READ_SIZE, end - position))
|
|
326
|
+
if not data:
|
|
327
|
+
break
|
|
328
|
+
handle.write(data)
|
|
329
|
+
position += len(data)
|
|
330
|
+
self._advance(len(data))
|
|
331
|
+
except NetworkError as error:
|
|
332
|
+
return position, error
|
|
333
|
+
return position, None
|
|
334
|
+
|
|
335
|
+
def _current(self, target: _Target) -> tuple[Stream, int]:
|
|
336
|
+
"""The stream to use now, refreshed first when its URL is about to expire."""
|
|
337
|
+
with self._lock:
|
|
338
|
+
stream, generation = target.stream, self._generation
|
|
339
|
+
if expires_soon(stream.expires_at, self._clock()):
|
|
340
|
+
self._refresh(generation, target, required=False)
|
|
341
|
+
with self._lock:
|
|
342
|
+
stream, generation = target.stream, self._generation
|
|
343
|
+
return stream, generation
|
|
344
|
+
|
|
345
|
+
def _refresh(self, seen: int, target: _Target, *, required: bool = True) -> None:
|
|
346
|
+
"""Fetch fresh URLs for every stream, once for all threads that used generation ``seen``."""
|
|
347
|
+
with self._refresh_lock:
|
|
348
|
+
if self._generation != seen:
|
|
349
|
+
return
|
|
350
|
+
if self._fresh_streams is None or self._refreshes >= self._max_refreshes:
|
|
351
|
+
if not required:
|
|
352
|
+
return
|
|
353
|
+
itag = target.stream.format.itag
|
|
354
|
+
raise StreamForbidden(
|
|
355
|
+
f"YouTube refused stream {itag} of video {self._video_id} (HTTP 403) "
|
|
356
|
+
f"after {self._refreshes} fresh URLs.",
|
|
357
|
+
itag=itag,
|
|
358
|
+
video_id=self._video_id,
|
|
359
|
+
)
|
|
360
|
+
self._refreshes += 1
|
|
361
|
+
log.info(
|
|
362
|
+
"getting fresh stream URLs for %s (%d/%d)",
|
|
363
|
+
self._video_id,
|
|
364
|
+
self._refreshes,
|
|
365
|
+
self._max_refreshes,
|
|
366
|
+
)
|
|
367
|
+
fresh = self._fresh_streams()
|
|
368
|
+
with self._lock:
|
|
369
|
+
for each in self._targets:
|
|
370
|
+
each.stream = self._match(each, fresh)
|
|
371
|
+
self._generation += 1
|
|
372
|
+
|
|
373
|
+
def _match(self, target: _Target, fresh: Sequence[Stream]) -> Stream:
|
|
374
|
+
"""The fresh stream with the same itag, size and version as ``target``'s."""
|
|
375
|
+
old = target.stream.format
|
|
376
|
+
for stream in fresh:
|
|
377
|
+
new = stream.format
|
|
378
|
+
if (
|
|
379
|
+
stream.url
|
|
380
|
+
and new.itag == old.itag
|
|
381
|
+
and new.last_modified == old.last_modified
|
|
382
|
+
and (new.content_length or target.size) == target.size
|
|
383
|
+
):
|
|
384
|
+
return stream
|
|
385
|
+
raise DownloadIncomplete(
|
|
386
|
+
f"Stream {old.itag} of video {self._video_id} changed on YouTube during the "
|
|
387
|
+
"download; run it again to start over.",
|
|
388
|
+
video_id=self._video_id,
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
def _retry(self, failures: int, error: Exception, target: _Target) -> int:
|
|
392
|
+
"""Wait before the next attempt, or give up after ``attempts`` failures in a row."""
|
|
393
|
+
itag = target.stream.format.itag
|
|
394
|
+
if failures + 1 >= self._attempts:
|
|
395
|
+
if isinstance(error, NetworkError):
|
|
396
|
+
raise error
|
|
397
|
+
raise DownloadIncomplete(
|
|
398
|
+
f"Stream {itag} of video {self._video_id} kept failing: {error}",
|
|
399
|
+
video_id=self._video_id,
|
|
400
|
+
) from error
|
|
401
|
+
delay = backoff_delay(failures, self._rng)
|
|
402
|
+
log.info("stream %d of %s: %s; retrying in %.1fs", itag, self._video_id, error, delay)
|
|
403
|
+
self._sleep(delay)
|
|
404
|
+
return failures + 1
|
|
405
|
+
|
|
406
|
+
def _short(self, target: _Target) -> DownloadIncomplete:
|
|
407
|
+
return DownloadIncomplete(
|
|
408
|
+
f"Stream {target.stream.format.itag} of video {self._video_id} sent no data.",
|
|
409
|
+
video_id=self._video_id,
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
def _bad_status(
|
|
413
|
+
self, target: _Target, status: int, position: int, end: int
|
|
414
|
+
) -> DownloadIncomplete:
|
|
415
|
+
detail = (
|
|
416
|
+
"ignored the byte range request (HTTP 200)"
|
|
417
|
+
if status == 200
|
|
418
|
+
else f"answered HTTP {status} for bytes {position}-{end - 1}"
|
|
419
|
+
)
|
|
420
|
+
return DownloadIncomplete(
|
|
421
|
+
f"Stream {target.stream.format.itag} of video {self._video_id} {detail}.",
|
|
422
|
+
video_id=self._video_id,
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
def _advance(self, count: int) -> None:
|
|
426
|
+
with self._lock:
|
|
427
|
+
self._done += count
|
|
428
|
+
self._report()
|
|
429
|
+
|
|
430
|
+
def _finish(self, target: _Target, chunk: Chunk) -> None:
|
|
431
|
+
with self._lock:
|
|
432
|
+
target.completed.add(chunk.index)
|
|
433
|
+
if self._clock() - target.saved_at >= STATE_FLUSH_INTERVAL:
|
|
434
|
+
self._save(target)
|
|
435
|
+
|
|
436
|
+
def _report(self, *, force: bool = False) -> None:
|
|
437
|
+
if self._reporter is not None:
|
|
438
|
+
self._reporter.report("downloading", self._done, self._total, force=force)
|
|
439
|
+
|
|
440
|
+
def _save(self, target: _Target) -> None:
|
|
441
|
+
state = replace(target.state, completed=frozenset(target.completed))
|
|
442
|
+
write_text_atomic(target.job.state_path, state.to_json())
|
|
443
|
+
target.saved_at = self._clock()
|
|
444
|
+
|
|
445
|
+
def _save_all(self) -> None:
|
|
446
|
+
with self._lock:
|
|
447
|
+
for target in self._targets:
|
|
448
|
+
try:
|
|
449
|
+
self._save(target)
|
|
450
|
+
except OSError as error:
|
|
451
|
+
log.warning("could not save %s: %s", target.job.state_path.name, error)
|
|
452
|
+
|
|
453
|
+
def _load(self, job: Job) -> PartState | None:
|
|
454
|
+
try:
|
|
455
|
+
return PartState.from_json(job.state_path.read_text(encoding="utf-8"))
|
|
456
|
+
except (OSError, UnicodeDecodeError):
|
|
457
|
+
return None
|
|
458
|
+
|
|
459
|
+
def _cancelled(self) -> bool:
|
|
460
|
+
return self._cancel is not None and self._cancel.is_set()
|
|
461
|
+
|
|
462
|
+
def _should_stop(self) -> bool:
|
|
463
|
+
return self._stop.is_set() or self._cancelled()
|
|
464
|
+
|
|
465
|
+
@staticmethod
|
|
466
|
+
def _request(stream: Stream, byte_range: str) -> HttpRequest:
|
|
467
|
+
headers = {
|
|
468
|
+
"User-Agent": stream.user_agent,
|
|
469
|
+
"Accept-Encoding": "identity",
|
|
470
|
+
"Range": byte_range,
|
|
471
|
+
}
|
|
472
|
+
return HttpRequest("GET", stream.url, headers)
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def _size_of(path: Path) -> int:
|
|
476
|
+
try:
|
|
477
|
+
return path.stat().st_size
|
|
478
|
+
except OSError:
|
|
479
|
+
return -1
|
utmax/adapters/ffmpeg.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""ffmpeg, which utmax needs only to turn AAC audio into MP3."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
import threading
|
|
10
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, Protocol
|
|
13
|
+
|
|
14
|
+
from utmax.errors import DownloadCancelled, FFmpegFailed, FFmpegNotFound
|
|
15
|
+
|
|
16
|
+
__all__ = ["FFMPEG_ENV", "STDERR_TAIL", "FFmpeg", "Process", "Spawn", "locate_ffmpeg"]
|
|
17
|
+
|
|
18
|
+
log = logging.getLogger("utmax.download")
|
|
19
|
+
|
|
20
|
+
FFMPEG_ENV = "UTMAX_FFMPEG"
|
|
21
|
+
STDERR_TAIL = 2000
|
|
22
|
+
_NO_WINDOW: int = getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
|
23
|
+
_mp3_support: dict[str, bool] = {}
|
|
24
|
+
_mp3_lock = threading.Lock()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Process(Protocol):
|
|
28
|
+
"""The part of :class:`subprocess.Popen` utmax uses (tests pass fakes)."""
|
|
29
|
+
|
|
30
|
+
returncode: int | None
|
|
31
|
+
|
|
32
|
+
def communicate(self, input: Any = None, timeout: float | None = None) -> tuple[Any, Any]: ...
|
|
33
|
+
|
|
34
|
+
def poll(self) -> int | None: ...
|
|
35
|
+
|
|
36
|
+
def terminate(self) -> None: ...
|
|
37
|
+
|
|
38
|
+
def kill(self) -> None: ...
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
Spawn = Callable[..., Process]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def locate_ffmpeg(
|
|
45
|
+
explicit: str | os.PathLike[str] | None = None,
|
|
46
|
+
*,
|
|
47
|
+
environ: Mapping[str, str] = os.environ,
|
|
48
|
+
which: Callable[[str], str | None] = shutil.which,
|
|
49
|
+
) -> str:
|
|
50
|
+
"""The ffmpeg to run: ``explicit``, else ``$UTMAX_FFMPEG``, else ``ffmpeg`` on ``PATH``.
|
|
51
|
+
|
|
52
|
+
Raises:
|
|
53
|
+
FFmpegNotFound: that executable does not exist.
|
|
54
|
+
"""
|
|
55
|
+
if explicit is not None:
|
|
56
|
+
name, origin = os.fspath(explicit), "the ffmpeg= argument"
|
|
57
|
+
elif environ.get(FFMPEG_ENV):
|
|
58
|
+
name, origin = environ[FFMPEG_ENV], f"${FFMPEG_ENV}"
|
|
59
|
+
else:
|
|
60
|
+
name, origin = "ffmpeg", "PATH"
|
|
61
|
+
found = which(name)
|
|
62
|
+
if found is None:
|
|
63
|
+
raise FFmpegNotFound(
|
|
64
|
+
f"ffmpeg was not found (looked for {name!r} from {origin}); "
|
|
65
|
+
"it is needed only for .mp3 files."
|
|
66
|
+
)
|
|
67
|
+
return found
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class FFmpeg:
|
|
71
|
+
"""One ffmpeg executable, run without a console window, stdin or terminal output."""
|
|
72
|
+
|
|
73
|
+
def __init__(
|
|
74
|
+
self, executable: str, *, spawn: Spawn = subprocess.Popen, poll_interval: float = 0.1
|
|
75
|
+
) -> None:
|
|
76
|
+
self.executable = executable
|
|
77
|
+
self._spawn = spawn
|
|
78
|
+
self._poll = poll_interval
|
|
79
|
+
|
|
80
|
+
def check_mp3(self) -> None:
|
|
81
|
+
"""Make sure this ffmpeg can write MP3 (probed once per executable).
|
|
82
|
+
|
|
83
|
+
Raises:
|
|
84
|
+
FFmpegNotFound: it cannot run, or it was built without ``libmp3lame``.
|
|
85
|
+
"""
|
|
86
|
+
with _mp3_lock:
|
|
87
|
+
supported = _mp3_support.get(self.executable)
|
|
88
|
+
if supported is None:
|
|
89
|
+
_, stdout, _ = self._run([self.executable, "-hide_banner", "-encoders"], None)
|
|
90
|
+
supported = b"libmp3lame" in stdout
|
|
91
|
+
_mp3_support[self.executable] = supported
|
|
92
|
+
if not supported:
|
|
93
|
+
raise FFmpegNotFound(
|
|
94
|
+
f"{self.executable} cannot write MP3: it was built without libmp3lame.",
|
|
95
|
+
suggestion="Install an ffmpeg build that includes libmp3lame, or download .m4a.",
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
def to_mp3(self, source: Path, target: Path, *, cancel: threading.Event | None = None) -> None:
|
|
99
|
+
"""Convert the first audio stream of ``source`` to MP3 (LAME VBR quality 2) at ``target``.
|
|
100
|
+
|
|
101
|
+
Raises:
|
|
102
|
+
FFmpegFailed: ffmpeg exited with an error; ``stderr_tail`` holds its last words.
|
|
103
|
+
DownloadCancelled: ``cancel`` was set; ffmpeg was stopped.
|
|
104
|
+
"""
|
|
105
|
+
args = [
|
|
106
|
+
self.executable,
|
|
107
|
+
"-hide_banner",
|
|
108
|
+
"-nostdin",
|
|
109
|
+
"-loglevel",
|
|
110
|
+
"error",
|
|
111
|
+
"-y",
|
|
112
|
+
"-i",
|
|
113
|
+
str(source),
|
|
114
|
+
"-map",
|
|
115
|
+
"0:a:0",
|
|
116
|
+
"-vn",
|
|
117
|
+
"-c:a",
|
|
118
|
+
"libmp3lame",
|
|
119
|
+
"-q:a",
|
|
120
|
+
"2",
|
|
121
|
+
"-map_metadata",
|
|
122
|
+
"-1",
|
|
123
|
+
"-f",
|
|
124
|
+
"mp3",
|
|
125
|
+
str(target),
|
|
126
|
+
]
|
|
127
|
+
log.info("converting %s to MP3 with %s", source.name, self.executable)
|
|
128
|
+
returncode, _, stderr = self._run(args, cancel)
|
|
129
|
+
if returncode != 0:
|
|
130
|
+
tail = stderr.decode("utf-8", errors="replace").strip()[-STDERR_TAIL:]
|
|
131
|
+
raise FFmpegFailed(
|
|
132
|
+
f"ffmpeg exited with code {returncode} while writing {target.name}.",
|
|
133
|
+
returncode=returncode,
|
|
134
|
+
stderr_tail=tail,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
def _run(self, args: Sequence[str], cancel: threading.Event | None) -> tuple[int, bytes, bytes]:
|
|
138
|
+
try:
|
|
139
|
+
process = self._spawn(
|
|
140
|
+
list(args),
|
|
141
|
+
stdin=subprocess.DEVNULL,
|
|
142
|
+
stdout=subprocess.PIPE,
|
|
143
|
+
stderr=subprocess.PIPE,
|
|
144
|
+
creationflags=_NO_WINDOW,
|
|
145
|
+
)
|
|
146
|
+
except OSError as error:
|
|
147
|
+
raise FFmpegNotFound(f"Could not run {self.executable}: {error}") from error
|
|
148
|
+
try:
|
|
149
|
+
while True:
|
|
150
|
+
if cancel is not None and cancel.is_set():
|
|
151
|
+
raise DownloadCancelled(
|
|
152
|
+
"The MP3 conversion was cancelled; run the download again to finish it."
|
|
153
|
+
)
|
|
154
|
+
try:
|
|
155
|
+
stdout, stderr = process.communicate(timeout=self._poll)
|
|
156
|
+
except subprocess.TimeoutExpired:
|
|
157
|
+
continue
|
|
158
|
+
code = process.returncode
|
|
159
|
+
return (0 if code is None else code), stdout or b"", stderr or b""
|
|
160
|
+
except BaseException:
|
|
161
|
+
_stop(process)
|
|
162
|
+
raise
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _stop(process: Process) -> None:
|
|
166
|
+
"""Stop a running ffmpeg, killing it when it ignores ``terminate``."""
|
|
167
|
+
if process.poll() is not None:
|
|
168
|
+
return
|
|
169
|
+
process.terminate()
|
|
170
|
+
try:
|
|
171
|
+
process.communicate(timeout=5)
|
|
172
|
+
except subprocess.TimeoutExpired:
|
|
173
|
+
process.kill()
|
|
174
|
+
process.communicate()
|