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
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."""