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/__init__.py
ADDED
|
@@ -0,0 +1,651 @@
|
|
|
1
|
+
"""u-transcript max: YouTube transcripts, AI translation and downloads with zero dependencies.
|
|
2
|
+
|
|
3
|
+
Quick start::
|
|
4
|
+
|
|
5
|
+
import utmax
|
|
6
|
+
|
|
7
|
+
transcript = utmax.fetch("https://youtu.be/dQw4w9WgXcQ")
|
|
8
|
+
transcript.save("rick.srt")
|
|
9
|
+
|
|
10
|
+
turkish = utmax.translate(transcript, "tr", model="claude=claude-opus-5")
|
|
11
|
+
utmax.bilingual(transcript, turkish).save("rick.en+tr.srt")
|
|
12
|
+
|
|
13
|
+
utmax.download("dQw4w9WgXcQ", "rick.mp4") # H.264 + AAC + English subtitles
|
|
14
|
+
|
|
15
|
+
videos = utmax.list_videos("@RickAstleyYT", kind="videos", limit=20)
|
|
16
|
+
utmax.fetch_many(videos, out_dir="subs") # one .srt per video
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import logging
|
|
22
|
+
import os
|
|
23
|
+
import threading
|
|
24
|
+
from collections.abc import Callable, Iterable, Sequence
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
from utmax._version import __version__
|
|
28
|
+
from utmax.client import Client
|
|
29
|
+
from utmax.core.downloads import DEFAULT_CHUNK_SIZE
|
|
30
|
+
from utmax.errors import (
|
|
31
|
+
AgeRestricted,
|
|
32
|
+
CollectionNotFound,
|
|
33
|
+
CollectionUnavailable,
|
|
34
|
+
DownloadCancelled,
|
|
35
|
+
DownloadError,
|
|
36
|
+
DownloadIncomplete,
|
|
37
|
+
FailedToCreateConsentCookie,
|
|
38
|
+
FFmpegError,
|
|
39
|
+
FFmpegFailed,
|
|
40
|
+
FFmpegNotFound,
|
|
41
|
+
FormatNotAvailable,
|
|
42
|
+
InvalidModelSpec,
|
|
43
|
+
InvalidOption,
|
|
44
|
+
InvalidSource,
|
|
45
|
+
InvalidVideoId,
|
|
46
|
+
IpBlocked,
|
|
47
|
+
MissingExtra,
|
|
48
|
+
MuxError,
|
|
49
|
+
NetworkError,
|
|
50
|
+
NoTranscriptFound,
|
|
51
|
+
NotTranslatable,
|
|
52
|
+
OutputExists,
|
|
53
|
+
PoTokenRequired,
|
|
54
|
+
ProviderAuthError,
|
|
55
|
+
ProviderError,
|
|
56
|
+
ProviderNotInstalled,
|
|
57
|
+
ProviderRateLimited,
|
|
58
|
+
RequestBlocked,
|
|
59
|
+
StreamForbidden,
|
|
60
|
+
TranscriptsDisabled,
|
|
61
|
+
TranslationError,
|
|
62
|
+
TranslationLanguageNotAvailable,
|
|
63
|
+
TranslationMismatch,
|
|
64
|
+
TranslationRefused,
|
|
65
|
+
UnsupportedFormat,
|
|
66
|
+
UTMaxError,
|
|
67
|
+
VideoUnavailable,
|
|
68
|
+
VideoUnplayable,
|
|
69
|
+
YouTubeDataUnparsable,
|
|
70
|
+
YouTubeError,
|
|
71
|
+
YouTubeRequestFailed,
|
|
72
|
+
)
|
|
73
|
+
from utmax.models import (
|
|
74
|
+
BulkReport,
|
|
75
|
+
BulkResult,
|
|
76
|
+
CollectionKind,
|
|
77
|
+
Container,
|
|
78
|
+
DownloadResult,
|
|
79
|
+
Format,
|
|
80
|
+
FormatName,
|
|
81
|
+
Language,
|
|
82
|
+
Progress,
|
|
83
|
+
Quality,
|
|
84
|
+
Segment,
|
|
85
|
+
SubtitleMode,
|
|
86
|
+
Track,
|
|
87
|
+
TrackList,
|
|
88
|
+
Transcript,
|
|
89
|
+
VideoEntry,
|
|
90
|
+
VideoInfo,
|
|
91
|
+
VideoList,
|
|
92
|
+
Word,
|
|
93
|
+
)
|
|
94
|
+
from utmax.providers import Translator
|
|
95
|
+
from utmax.services.bulk import DEFAULT_DOWNLOAD_NAME, DEFAULT_TRANSCRIPT_NAME
|
|
96
|
+
|
|
97
|
+
__all__ = [
|
|
98
|
+
"AgeRestricted",
|
|
99
|
+
"BulkReport",
|
|
100
|
+
"BulkResult",
|
|
101
|
+
"Client",
|
|
102
|
+
"CollectionKind",
|
|
103
|
+
"CollectionNotFound",
|
|
104
|
+
"CollectionUnavailable",
|
|
105
|
+
"Container",
|
|
106
|
+
"DownloadCancelled",
|
|
107
|
+
"DownloadError",
|
|
108
|
+
"DownloadIncomplete",
|
|
109
|
+
"DownloadResult",
|
|
110
|
+
"FFmpegError",
|
|
111
|
+
"FFmpegFailed",
|
|
112
|
+
"FFmpegNotFound",
|
|
113
|
+
"FailedToCreateConsentCookie",
|
|
114
|
+
"Format",
|
|
115
|
+
"FormatName",
|
|
116
|
+
"FormatNotAvailable",
|
|
117
|
+
"InvalidModelSpec",
|
|
118
|
+
"InvalidOption",
|
|
119
|
+
"InvalidSource",
|
|
120
|
+
"InvalidVideoId",
|
|
121
|
+
"IpBlocked",
|
|
122
|
+
"Language",
|
|
123
|
+
"MissingExtra",
|
|
124
|
+
"MuxError",
|
|
125
|
+
"NetworkError",
|
|
126
|
+
"NoTranscriptFound",
|
|
127
|
+
"NotTranslatable",
|
|
128
|
+
"OutputExists",
|
|
129
|
+
"PoTokenRequired",
|
|
130
|
+
"Progress",
|
|
131
|
+
"ProviderAuthError",
|
|
132
|
+
"ProviderError",
|
|
133
|
+
"ProviderNotInstalled",
|
|
134
|
+
"ProviderRateLimited",
|
|
135
|
+
"RequestBlocked",
|
|
136
|
+
"Segment",
|
|
137
|
+
"StreamForbidden",
|
|
138
|
+
"Track",
|
|
139
|
+
"TrackList",
|
|
140
|
+
"Transcript",
|
|
141
|
+
"TranscriptsDisabled",
|
|
142
|
+
"TranslationError",
|
|
143
|
+
"TranslationLanguageNotAvailable",
|
|
144
|
+
"TranslationMismatch",
|
|
145
|
+
"TranslationRefused",
|
|
146
|
+
"Translator",
|
|
147
|
+
"UTMaxError",
|
|
148
|
+
"UnsupportedFormat",
|
|
149
|
+
"VideoEntry",
|
|
150
|
+
"VideoInfo",
|
|
151
|
+
"VideoList",
|
|
152
|
+
"VideoUnavailable",
|
|
153
|
+
"VideoUnplayable",
|
|
154
|
+
"Word",
|
|
155
|
+
"YouTubeDataUnparsable",
|
|
156
|
+
"YouTubeError",
|
|
157
|
+
"YouTubeRequestFailed",
|
|
158
|
+
"__version__",
|
|
159
|
+
"bilingual",
|
|
160
|
+
"download",
|
|
161
|
+
"download_many",
|
|
162
|
+
"fetch",
|
|
163
|
+
"fetch_many",
|
|
164
|
+
"list_tracks",
|
|
165
|
+
"list_videos",
|
|
166
|
+
"translate",
|
|
167
|
+
"translate_many",
|
|
168
|
+
"translator",
|
|
169
|
+
"video_info",
|
|
170
|
+
]
|
|
171
|
+
|
|
172
|
+
logging.getLogger("utmax").addHandler(logging.NullHandler())
|
|
173
|
+
|
|
174
|
+
_default_client: Client | None = None
|
|
175
|
+
_default_lock = threading.Lock()
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _client() -> Client:
|
|
179
|
+
"""The shared default client, created on first use (thread-safe)."""
|
|
180
|
+
global _default_client # noqa: PLW0603
|
|
181
|
+
client = _default_client
|
|
182
|
+
if client is None:
|
|
183
|
+
with _default_lock:
|
|
184
|
+
if _default_client is None:
|
|
185
|
+
_default_client = Client()
|
|
186
|
+
client = _default_client
|
|
187
|
+
return client
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def fetch(
|
|
191
|
+
video: str,
|
|
192
|
+
languages: Sequence[str] | str | None = None,
|
|
193
|
+
*,
|
|
194
|
+
include_manual: bool = True,
|
|
195
|
+
include_generated: bool = True,
|
|
196
|
+
preserve_formatting: bool = False,
|
|
197
|
+
youtube_translation: str | None = None,
|
|
198
|
+
) -> Transcript:
|
|
199
|
+
"""Fetch the best subtitle track of a video.
|
|
200
|
+
|
|
201
|
+
Args:
|
|
202
|
+
video: a video ID or any YouTube URL (watch, youtu.be, shorts, live, embed …).
|
|
203
|
+
languages: language codes in order of preference, e.g. ``["tr", "en"]``; ``de`` also
|
|
204
|
+
matches ``de-DE``. By default the video's spoken language is used.
|
|
205
|
+
include_manual: consider subtitles written by people.
|
|
206
|
+
include_generated: consider YouTube's auto-generated subtitles.
|
|
207
|
+
preserve_formatting: keep ``<b>``, ``<i>`` and ``<u>`` tags.
|
|
208
|
+
youtube_translation: ask YouTube to machine-translate the chosen track (best effort,
|
|
209
|
+
often rate-limited; :func:`translate` translates with AI instead).
|
|
210
|
+
|
|
211
|
+
Manual subtitles win over auto-generated ones, and YouTube's translation is never used
|
|
212
|
+
unless you ask for it.
|
|
213
|
+
|
|
214
|
+
Raises:
|
|
215
|
+
InvalidVideoId: ``video`` holds no video ID (checked before any request).
|
|
216
|
+
NoTranscriptFound: no track matches ``languages`` or the filters; lists what exists.
|
|
217
|
+
TranscriptsDisabled: the video has no subtitles.
|
|
218
|
+
VideoUnavailable, VideoUnplayable, AgeRestricted: YouTube will not serve the video.
|
|
219
|
+
RequestBlocked, IpBlocked: YouTube is blocking or rate-limiting this IP address.
|
|
220
|
+
NetworkError: the network failed after retries.
|
|
221
|
+
"""
|
|
222
|
+
return _client().fetch(
|
|
223
|
+
video,
|
|
224
|
+
languages,
|
|
225
|
+
include_manual=include_manual,
|
|
226
|
+
include_generated=include_generated,
|
|
227
|
+
preserve_formatting=preserve_formatting,
|
|
228
|
+
youtube_translation=youtube_translation,
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def list_tracks(video: str) -> TrackList:
|
|
233
|
+
"""Every subtitle track of a video, in YouTube's order."""
|
|
234
|
+
return _client().list_tracks(video)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def video_info(video: str) -> VideoInfo:
|
|
238
|
+
"""Title, channel and duration of a video."""
|
|
239
|
+
return _client().video_info(video)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def translator(model: str, **options: Any) -> Translator:
|
|
243
|
+
"""The AI translator for ``model``, written ``"provider=model-id"``.
|
|
244
|
+
|
|
245
|
+
Providers: ``claude`` (Anthropic), ``openai`` (OpenAI, or any OpenAI-compatible server via
|
|
246
|
+
``base_url``), ``gemini`` (Google) and ``openrouter``. There is no default model::
|
|
247
|
+
|
|
248
|
+
utmax.translator("claude=claude-opus-5", effort="low")
|
|
249
|
+
utmax.translator("openai=llama3.1:8b", base_url="http://localhost:11434/v1") # Ollama
|
|
250
|
+
|
|
251
|
+
Args:
|
|
252
|
+
model: ``"provider=model-id"``; only the first ``=`` separates the two parts.
|
|
253
|
+
**options: provider options (``api_key``, ``effort``, ``max_tokens``, ``base_url``,
|
|
254
|
+
``json_mode``, ``app_name``, ``retry_attempts``, ``client``) and engine options
|
|
255
|
+
(``batch_chars``, ``batch_items``, ``context_items``, ``concurrency``,
|
|
256
|
+
``max_attempts``); see :mod:`utmax.providers`.
|
|
257
|
+
|
|
258
|
+
Raises:
|
|
259
|
+
InvalidModelSpec: ``model`` is not ``"provider=model-id"`` with a known provider.
|
|
260
|
+
InvalidOption: an option is out of range, or ``base_url`` is used without ``openai``.
|
|
261
|
+
ProviderNotInstalled: the provider's SDK is missing; the message names the extra.
|
|
262
|
+
ProviderAuthError: no API key was found.
|
|
263
|
+
"""
|
|
264
|
+
return _client().translator(model, **options)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def translate(
|
|
268
|
+
transcript: Transcript,
|
|
269
|
+
to: str,
|
|
270
|
+
*,
|
|
271
|
+
model: str | Translator,
|
|
272
|
+
instructions: str | None = None,
|
|
273
|
+
resegment: bool | None = None,
|
|
274
|
+
**options: Any,
|
|
275
|
+
) -> Transcript:
|
|
276
|
+
"""Translate a transcript with AI, keeping every timing.
|
|
277
|
+
|
|
278
|
+
Example::
|
|
279
|
+
|
|
280
|
+
turkish = utmax.translate(transcript, "tr", model="claude=claude-opus-5")
|
|
281
|
+
turkish.save("rick.tr.srt")
|
|
282
|
+
|
|
283
|
+
Args:
|
|
284
|
+
transcript: the transcript to translate, usually from :func:`fetch`.
|
|
285
|
+
to: the target language code, such as ``"tr"``, ``"de"`` or ``"pt-BR"``.
|
|
286
|
+
model: ``"provider=model-id"`` (see :func:`translator`) or a
|
|
287
|
+
:class:`~utmax.providers.Translator`.
|
|
288
|
+
instructions: extra guidance for the model, e.g. ``"Use informal Turkish."``.
|
|
289
|
+
resegment: merge cues into sentences before translating; by default only
|
|
290
|
+
auto-generated transcripts are merged.
|
|
291
|
+
**options: passed to :func:`translator` when ``model`` is a string.
|
|
292
|
+
|
|
293
|
+
The result keeps the source timings and records ``translated_from``, ``translator`` and
|
|
294
|
+
the exact ``source`` cues; every format works for it, and :func:`bilingual` combines it
|
|
295
|
+
with the original. A result is returned only when every cue was translated.
|
|
296
|
+
|
|
297
|
+
Raises:
|
|
298
|
+
InvalidOption: ``to`` is not a language code, the transcript is bilingual, or options
|
|
299
|
+
were given together with a Translator instance.
|
|
300
|
+
TranslationMismatch: the model kept returning unusable answers for a cue.
|
|
301
|
+
TranslationRefused: the model or its safety system declined the content.
|
|
302
|
+
ProviderAuthError, ProviderRateLimited, ProviderError: the provider failed.
|
|
303
|
+
"""
|
|
304
|
+
return _client().translate(
|
|
305
|
+
transcript,
|
|
306
|
+
to,
|
|
307
|
+
model=model,
|
|
308
|
+
instructions=instructions,
|
|
309
|
+
resegment=resegment,
|
|
310
|
+
**options,
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def bilingual(
|
|
315
|
+
original: Transcript, translation: Transcript, *, translation_first: bool = False
|
|
316
|
+
) -> Transcript:
|
|
317
|
+
"""One transcript showing both languages: the original line on top, the translation below.
|
|
318
|
+
|
|
319
|
+
Example::
|
|
320
|
+
|
|
321
|
+
utmax.bilingual(transcript, turkish).save("rick.en+tr.vtt")
|
|
322
|
+
|
|
323
|
+
``translation_first=True`` puts the translation on top. The language code is
|
|
324
|
+
``"<original>+<translation>"`` (``"en+tr"``) and every format works for the result.
|
|
325
|
+
|
|
326
|
+
Raises:
|
|
327
|
+
InvalidOption: one of the transcripts is already bilingual.
|
|
328
|
+
"""
|
|
329
|
+
return _client().bilingual(original, translation, translation_first=translation_first)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def download(
|
|
333
|
+
video: str,
|
|
334
|
+
path: str | os.PathLike[str],
|
|
335
|
+
*,
|
|
336
|
+
format: Container | None = None,
|
|
337
|
+
quality: Quality = "compat",
|
|
338
|
+
subtitles: Sequence[str | Transcript] | None = None,
|
|
339
|
+
subtitle_mode: SubtitleMode = "embed",
|
|
340
|
+
default_subtitle: str | None = None,
|
|
341
|
+
connections: int = 4,
|
|
342
|
+
chunk_size: int = DEFAULT_CHUNK_SIZE,
|
|
343
|
+
resume: bool = True,
|
|
344
|
+
overwrite: bool = False,
|
|
345
|
+
ffmpeg: str | os.PathLike[str] | None = None,
|
|
346
|
+
progress: Callable[[Progress], None] | None = None,
|
|
347
|
+
cancel: threading.Event | None = None,
|
|
348
|
+
) -> DownloadResult:
|
|
349
|
+
"""Download a video (``.mp4``, ``.mov``) or its audio (``.m4a``, ``.mp3``) with subtitles.
|
|
350
|
+
|
|
351
|
+
Examples::
|
|
352
|
+
|
|
353
|
+
utmax.download("dQw4w9WgXcQ", "rick.mp4") # H.264 up to 1080p, AAC, English subtitles
|
|
354
|
+
utmax.download("dQw4w9WgXcQ", "rick.m4a") # audio only; no ffmpeg needed
|
|
355
|
+
utmax.download("dQw4w9WgXcQ", "videos/", quality="max") # AV1 up to 4K, named by title
|
|
356
|
+
utmax.download("dQw4w9WgXcQ", "rick.mp4", subtitles=[english, turkish])
|
|
357
|
+
|
|
358
|
+
Args:
|
|
359
|
+
video: a video ID or any YouTube URL.
|
|
360
|
+
path: the output file; its extension picks the type (``.mp4``, ``.mov``, ``.m4a``,
|
|
361
|
+
``.mp3``). A folder (an existing one, or a path ending with ``/``) gets
|
|
362
|
+
``"{title} [{video_id}].{ext}"``.
|
|
363
|
+
format: the file type when ``path`` is a folder or has no extension; it must match the
|
|
364
|
+
extension otherwise.
|
|
365
|
+
quality: ``"compat"`` (H.264 up to 1080p, plays everywhere) or ``"max"`` (AV1 or
|
|
366
|
+
H.264 up to 2160p; ``.mp4`` only).
|
|
367
|
+
subtitles: language codes and/or transcripts (translations and bilingual ones too).
|
|
368
|
+
``None`` embeds the spoken-language track in videos and adds nothing to audio;
|
|
369
|
+
``[]`` adds none. Codes are chosen like :func:`fetch`, never with YouTube's own
|
|
370
|
+
translation.
|
|
371
|
+
subtitle_mode: ``"embed"`` (toggleable tracks in the video), ``"sidecar"`` (``.srt``
|
|
372
|
+
files next to it) or ``"both"``; audio files always get sidecar files.
|
|
373
|
+
default_subtitle: the language code of the embedded track shown by default (else the
|
|
374
|
+
first one).
|
|
375
|
+
connections: parallel connections, 1 to 16.
|
|
376
|
+
chunk_size: bytes per range request, at least 256 KiB.
|
|
377
|
+
resume: continue an interrupted download from its ``.part`` files.
|
|
378
|
+
overwrite: replace existing files instead of raising :class:`OutputExists`.
|
|
379
|
+
ffmpeg: the ffmpeg executable for ``.mp3`` (default: ``$UTMAX_FFMPEG``, then ``PATH``).
|
|
380
|
+
progress: called with a :class:`Progress` at most four times a second, never in
|
|
381
|
+
parallel; keep it quick. An exception it raises stops the download.
|
|
382
|
+
cancel: set this event to stop; the ``.part`` files stay, so a new call resumes.
|
|
383
|
+
|
|
384
|
+
Streams are downloaded into ``<file>.<itag>.part`` files next to the target and then
|
|
385
|
+
combined, so the disk briefly holds about twice the file size; the parts are deleted only
|
|
386
|
+
after success. Stream URLs work only from the IP address that requested them, so do not
|
|
387
|
+
switch proxies or VPNs during a download.
|
|
388
|
+
|
|
389
|
+
Raises:
|
|
390
|
+
InvalidVideoId, InvalidOption, UnsupportedFormat: bad arguments, before any request
|
|
391
|
+
(subtitle problems, such as a ``default_subtitle`` that is not embedded, before any
|
|
392
|
+
media byte).
|
|
393
|
+
OutputExists: the file or a subtitle file exists and ``overwrite`` is false.
|
|
394
|
+
FFmpegNotFound: ``.mp3`` without a usable ffmpeg (before any request).
|
|
395
|
+
NoTranscriptFound: a requested subtitle language does not exist (before any media byte).
|
|
396
|
+
FormatNotAvailable: no stream fits the type and quality (live streams, for example).
|
|
397
|
+
StreamForbidden, DownloadIncomplete, NetworkError: the download failed; call again to
|
|
398
|
+
resume.
|
|
399
|
+
DownloadCancelled: ``cancel`` was set.
|
|
400
|
+
MuxError, FFmpegFailed: the file could not be assembled.
|
|
401
|
+
VideoUnavailable, VideoUnplayable, AgeRestricted, RequestBlocked: YouTube refused.
|
|
402
|
+
"""
|
|
403
|
+
return _client().download(
|
|
404
|
+
video,
|
|
405
|
+
path,
|
|
406
|
+
format=format,
|
|
407
|
+
quality=quality,
|
|
408
|
+
subtitles=subtitles,
|
|
409
|
+
subtitle_mode=subtitle_mode,
|
|
410
|
+
default_subtitle=default_subtitle,
|
|
411
|
+
connections=connections,
|
|
412
|
+
chunk_size=chunk_size,
|
|
413
|
+
resume=resume,
|
|
414
|
+
overwrite=overwrite,
|
|
415
|
+
ffmpeg=ffmpeg,
|
|
416
|
+
progress=progress,
|
|
417
|
+
cancel=cancel,
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def list_videos(
|
|
422
|
+
source: str, *, kind: CollectionKind | None = None, limit: int | None = None
|
|
423
|
+
) -> VideoList:
|
|
424
|
+
"""The videos of a playlist or a channel, in YouTube's order.
|
|
425
|
+
|
|
426
|
+
Examples::
|
|
427
|
+
|
|
428
|
+
videos = utmax.list_videos("https://www.youtube.com/playlist?list=PL...")
|
|
429
|
+
shorts = utmax.list_videos("https://www.youtube.com/@RickAstleyYT/shorts")
|
|
430
|
+
latest = utmax.list_videos("@RickAstleyYT", kind="videos", limit=50)
|
|
431
|
+
report = utmax.fetch_many(videos, out_dir="subs")
|
|
432
|
+
|
|
433
|
+
Args:
|
|
434
|
+
source: a playlist URL or ID, a channel URL (``/@handle``, ``/channel/UC...``,
|
|
435
|
+
``/c/name``, ``/user/name``), an ``@handle`` or a channel ID.
|
|
436
|
+
kind: for channels, ``"all"`` uploads, long-form ``"videos"``, ``"shorts"`` or past
|
|
437
|
+
``"live"`` streams. By default a channel link's tab chooses (``/videos``,
|
|
438
|
+
``/shorts``, ``/streams``), and a link without one lists every upload. Playlists
|
|
439
|
+
are always listed whole.
|
|
440
|
+
limit: stop after this many videos; ``None`` lists everything (at most 1000 pages).
|
|
441
|
+
|
|
442
|
+
Each :class:`VideoEntry` has the video ID, title, duration (``None`` when YouTube does not
|
|
443
|
+
show one), channel and its 1-based position. Private and deleted videos are left out and a
|
|
444
|
+
video listed twice appears once. A channel without Shorts or live streams gives an empty
|
|
445
|
+
list for those kinds.
|
|
446
|
+
|
|
447
|
+
utmax lists a playlist with the ANDROID_VR client, which lists all of it, and lists it once
|
|
448
|
+
more with the WEB client when ANDROID_VR fails. WEB shows at most 100 Shorts of a channel
|
|
449
|
+
and hides an occasional video, so a listing that fell back to WEB can hold fewer videos
|
|
450
|
+
than ``VideoList.video_count``. A listing that ends by itself (not at ``limit``) before that
|
|
451
|
+
count is returned as it is, and a warning on the ``utmax.youtube`` logger says so:
|
|
452
|
+
``listed 100 of the 297 videos YouTube counts; the list may be incomplete``.
|
|
453
|
+
|
|
454
|
+
Raises:
|
|
455
|
+
InvalidSource: ``source`` names no playlist or channel (checked before any request).
|
|
456
|
+
InvalidOption: ``kind`` or ``limit`` is invalid, or ``kind`` was given for a playlist.
|
|
457
|
+
CollectionUnavailable: a Mix (``RD...``) or another playlist YouTube will not list.
|
|
458
|
+
CollectionNotFound: the playlist or channel does not exist or is private.
|
|
459
|
+
RequestBlocked, IpBlocked: YouTube is blocking or rate-limiting this IP address.
|
|
460
|
+
NetworkError: the network failed after retries.
|
|
461
|
+
"""
|
|
462
|
+
return _client().list_videos(source, kind=kind, limit=limit)
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def fetch_many(
|
|
466
|
+
videos: Iterable[str | VideoEntry],
|
|
467
|
+
*,
|
|
468
|
+
out_dir: str | os.PathLike[str] | None = None,
|
|
469
|
+
format: FormatName = "srt",
|
|
470
|
+
languages: Sequence[str] | str | None = None,
|
|
471
|
+
include_manual: bool = True,
|
|
472
|
+
include_generated: bool = True,
|
|
473
|
+
concurrency: int = 4,
|
|
474
|
+
skip_existing: bool = True,
|
|
475
|
+
filename: str = DEFAULT_TRANSCRIPT_NAME,
|
|
476
|
+
progress: Callable[[BulkResult[Transcript]], None] | None = None,
|
|
477
|
+
) -> BulkReport[Transcript]:
|
|
478
|
+
"""Fetch the transcripts of many videos, optionally saving each one as a file.
|
|
479
|
+
|
|
480
|
+
Example::
|
|
481
|
+
|
|
482
|
+
report = utmax.fetch_many(utmax.list_videos("@RickAstleyYT", limit=20), out_dir="subs")
|
|
483
|
+
print(len(report.ok), "saved,", len(report.failed), "failed")
|
|
484
|
+
|
|
485
|
+
Args:
|
|
486
|
+
videos: video IDs, URLs and/or entries of a :func:`list_videos` result.
|
|
487
|
+
out_dir: the folder for the files (created when missing); ``None`` keeps the
|
|
488
|
+
transcripts in memory only (``result.value``).
|
|
489
|
+
format: ``"srt"``, ``"vtt"``, ``"json"``, ``"txt"`` or ``"pretty"`` (saved as ``.txt``).
|
|
490
|
+
languages: language codes in order of preference, chosen per video as in
|
|
491
|
+
:func:`fetch`; ``include_manual`` and ``include_generated`` work as there too.
|
|
492
|
+
concurrency: how many videos are fetched at the same time, 1 to 16.
|
|
493
|
+
skip_existing: skip a video, without any request, when ``out_dir`` already holds its
|
|
494
|
+
file: a name the template gives with its video ID, whatever the title, channel and
|
|
495
|
+
``{index}`` (a new upload shifts every position in a channel listing) or, without
|
|
496
|
+
``languages``, the language (a file in another language than ``languages`` asks
|
|
497
|
+
for does not count).
|
|
498
|
+
filename: the file-name template. Fields: ``{video_id}`` (required), ``{title}``,
|
|
499
|
+
``{channel}``, ``{index}`` (the position in ``videos``, or in the listing for
|
|
500
|
+
:class:`VideoEntry` items), ``{language_code}`` and ``{ext}``. Format specs such
|
|
501
|
+
as ``{index:03d}`` work.
|
|
502
|
+
progress: called with each video's :class:`BulkResult` as soon as it is known, never
|
|
503
|
+
in parallel. An exception it raises stops the run like Ctrl-C and propagates, with
|
|
504
|
+
no report.
|
|
505
|
+
|
|
506
|
+
The :class:`BulkReport` holds one result per video, in the order given: ``"ok"``,
|
|
507
|
+
``"skipped"``, ``"failed"`` (with its ``error``) or ``"not_attempted"``. A failure never
|
|
508
|
+
stops the other videos, except that when YouTube blocks the IP address the videos not
|
|
509
|
+
started yet are not attempted. ``report.raise_for_errors()`` raises the first failure.
|
|
510
|
+
|
|
511
|
+
Raises:
|
|
512
|
+
InvalidOption, UnsupportedFormat: bad arguments (before any request).
|
|
513
|
+
KeyboardInterrupt: Ctrl-C; videos not started yet are dropped.
|
|
514
|
+
"""
|
|
515
|
+
return _client().fetch_many(
|
|
516
|
+
videos,
|
|
517
|
+
out_dir=out_dir,
|
|
518
|
+
format=format,
|
|
519
|
+
languages=languages,
|
|
520
|
+
include_manual=include_manual,
|
|
521
|
+
include_generated=include_generated,
|
|
522
|
+
concurrency=concurrency,
|
|
523
|
+
skip_existing=skip_existing,
|
|
524
|
+
filename=filename,
|
|
525
|
+
progress=progress,
|
|
526
|
+
)
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def translate_many(
|
|
530
|
+
videos: Iterable[str | VideoEntry],
|
|
531
|
+
to: str,
|
|
532
|
+
*,
|
|
533
|
+
model: str | Translator,
|
|
534
|
+
out_dir: str | os.PathLike[str] | None = None,
|
|
535
|
+
format: FormatName = "srt",
|
|
536
|
+
languages: Sequence[str] | str | None = None,
|
|
537
|
+
bilingual: bool = False,
|
|
538
|
+
instructions: str | None = None,
|
|
539
|
+
resegment: bool | None = None,
|
|
540
|
+
concurrency: int = 2,
|
|
541
|
+
skip_existing: bool = True,
|
|
542
|
+
filename: str = DEFAULT_TRANSCRIPT_NAME,
|
|
543
|
+
progress: Callable[[BulkResult[Transcript]], None] | None = None,
|
|
544
|
+
**options: Any,
|
|
545
|
+
) -> BulkReport[Transcript]:
|
|
546
|
+
"""Fetch the transcripts of many videos and translate them with one AI translator.
|
|
547
|
+
|
|
548
|
+
Example::
|
|
549
|
+
|
|
550
|
+
videos = utmax.list_videos("@RickAstleyYT", limit=20)
|
|
551
|
+
utmax.translate_many(videos, "tr", model="claude=claude-opus-5", out_dir="subs")
|
|
552
|
+
|
|
553
|
+
Args:
|
|
554
|
+
videos: video IDs, URLs and/or entries of a :func:`list_videos` result.
|
|
555
|
+
to: the target language code, such as ``"tr"``.
|
|
556
|
+
model: ``"provider=model-id"`` or a :class:`~utmax.providers.Translator`, as in
|
|
557
|
+
:func:`translate`.
|
|
558
|
+
out_dir, format, concurrency, skip_existing, filename, progress: as in
|
|
559
|
+
:func:`fetch_many`; ``{language_code}`` is the target language, or
|
|
560
|
+
``"<source>+<target>"`` (such as ``"en+tr"``) with ``bilingual``.
|
|
561
|
+
languages: the source track, chosen per video as in :func:`fetch`.
|
|
562
|
+
bilingual: return (and save) bilingual transcripts, the original line on top.
|
|
563
|
+
instructions, resegment: as in :func:`translate`.
|
|
564
|
+
**options: passed to :func:`translator` when ``model`` is a string.
|
|
565
|
+
|
|
566
|
+
The same translator serves every video. ``concurrency`` counts videos; each video's cues
|
|
567
|
+
are translated in up to the translator's own ``concurrency`` batches at a time (set it with
|
|
568
|
+
:func:`translator`, for example ``utmax.translator(model, concurrency=2)``). Besides a
|
|
569
|
+
block by YouTube, a rejected API key stops the run: the videos not started yet are then
|
|
570
|
+
``"not_attempted"``. Ctrl-C waits for the translations already running to finish.
|
|
571
|
+
|
|
572
|
+
Raises:
|
|
573
|
+
InvalidOption, InvalidModelSpec, UnsupportedFormat: bad arguments (before any request).
|
|
574
|
+
ProviderNotInstalled, ProviderAuthError: the translator cannot be created.
|
|
575
|
+
KeyboardInterrupt: Ctrl-C; videos not started yet are dropped.
|
|
576
|
+
"""
|
|
577
|
+
return _client().translate_many(
|
|
578
|
+
videos,
|
|
579
|
+
to,
|
|
580
|
+
model=model,
|
|
581
|
+
out_dir=out_dir,
|
|
582
|
+
format=format,
|
|
583
|
+
languages=languages,
|
|
584
|
+
bilingual=bilingual,
|
|
585
|
+
instructions=instructions,
|
|
586
|
+
resegment=resegment,
|
|
587
|
+
concurrency=concurrency,
|
|
588
|
+
skip_existing=skip_existing,
|
|
589
|
+
filename=filename,
|
|
590
|
+
progress=progress,
|
|
591
|
+
**options,
|
|
592
|
+
)
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def download_many(
|
|
596
|
+
videos: Iterable[str | VideoEntry],
|
|
597
|
+
out_dir: str | os.PathLike[str],
|
|
598
|
+
*,
|
|
599
|
+
format: Container = "mp4",
|
|
600
|
+
quality: Quality = "compat",
|
|
601
|
+
subtitles: Sequence[str] | None = None,
|
|
602
|
+
subtitle_mode: SubtitleMode = "embed",
|
|
603
|
+
concurrency: int = 2,
|
|
604
|
+
skip_existing: bool = True,
|
|
605
|
+
filename: str = DEFAULT_DOWNLOAD_NAME,
|
|
606
|
+
ffmpeg: str | os.PathLike[str] | None = None,
|
|
607
|
+
progress: Callable[[BulkResult[DownloadResult]], None] | None = None,
|
|
608
|
+
) -> BulkReport[DownloadResult]:
|
|
609
|
+
"""Download many videos, or their audio, into one folder.
|
|
610
|
+
|
|
611
|
+
Example::
|
|
612
|
+
|
|
613
|
+
videos = utmax.list_videos("@RickAstleyYT", kind="videos")
|
|
614
|
+
utmax.download_many(videos, "rick", format="m4a")
|
|
615
|
+
|
|
616
|
+
Args:
|
|
617
|
+
videos: video IDs, URLs and/or entries of a :func:`list_videos` result.
|
|
618
|
+
out_dir: the folder for the files (created when missing).
|
|
619
|
+
format, quality, subtitles, subtitle_mode, ffmpeg: as in :func:`download`, for every
|
|
620
|
+
video; ``subtitles`` takes language codes only.
|
|
621
|
+
concurrency: how many videos are downloaded at the same time, 1 to 16 (each with four
|
|
622
|
+
connections).
|
|
623
|
+
skip_existing: skip a video, without any request, when ``out_dir`` already holds its
|
|
624
|
+
file; ``False`` downloads it again and replaces the file. Interrupted downloads
|
|
625
|
+
resume either way.
|
|
626
|
+
filename: the file-name template, as in :func:`fetch_many` but without
|
|
627
|
+
``{language_code}``; the default is ``"{title} [{video_id}].{ext}"``.
|
|
628
|
+
progress: called with each video's :class:`BulkResult` as soon as it is known; an
|
|
629
|
+
exception it raises stops the run (running downloads keep their ``.part`` files).
|
|
630
|
+
|
|
631
|
+
The :class:`BulkReport` works as for :func:`fetch_many`; each ``value`` is the video's
|
|
632
|
+
:class:`DownloadResult`.
|
|
633
|
+
|
|
634
|
+
Raises:
|
|
635
|
+
InvalidOption, UnsupportedFormat: bad arguments (before any request).
|
|
636
|
+
FFmpegNotFound: ``format="mp3"`` without a usable ffmpeg (before any request).
|
|
637
|
+
KeyboardInterrupt: Ctrl-C; running downloads stop and keep their ``.part`` files.
|
|
638
|
+
"""
|
|
639
|
+
return _client().download_many(
|
|
640
|
+
videos,
|
|
641
|
+
out_dir,
|
|
642
|
+
format=format,
|
|
643
|
+
quality=quality,
|
|
644
|
+
subtitles=subtitles,
|
|
645
|
+
subtitle_mode=subtitle_mode,
|
|
646
|
+
concurrency=concurrency,
|
|
647
|
+
skip_existing=skip_existing,
|
|
648
|
+
filename=filename,
|
|
649
|
+
ffmpeg=ffmpeg,
|
|
650
|
+
progress=progress,
|
|
651
|
+
)
|
utmax/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0a1"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Adapters: every piece of I/O utmax performs (network, files) lives in this package."""
|