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,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
@@ -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()