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/models.py ADDED
@@ -0,0 +1,499 @@
1
+ """Immutable data types shared by every layer of utmax.
2
+
3
+ ``models`` is the shared data kernel: ``utmax.core`` imports it, so this module imports
4
+ ``utmax.core`` only inside methods (never at module level) to avoid import cycles.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ from collections.abc import Iterator, Sequence
11
+ from dataclasses import dataclass, field, replace
12
+ from pathlib import Path
13
+ from typing import Generic, Literal, Protocol, TypeVar, overload
14
+
15
+ from utmax.errors import NotTranslatable, TranslationLanguageNotAvailable
16
+
17
+ __all__ = [
18
+ "BulkReport",
19
+ "BulkResult",
20
+ "BulkStatus",
21
+ "Codec",
22
+ "CollectionKind",
23
+ "Container",
24
+ "DownloadResult",
25
+ "Format",
26
+ "FormatName",
27
+ "Language",
28
+ "Progress",
29
+ "ProgressPhase",
30
+ "Quality",
31
+ "Segment",
32
+ "SubtitleMode",
33
+ "Track",
34
+ "TrackFetcher",
35
+ "TrackList",
36
+ "Transcript",
37
+ "VideoEntry",
38
+ "VideoInfo",
39
+ "VideoList",
40
+ "Word",
41
+ ]
42
+
43
+ FormatName = Literal["txt", "srt", "vtt", "json", "pretty"]
44
+ Container = Literal["mp4", "mov", "m4a", "mp3"]
45
+ Quality = Literal["compat", "max"]
46
+ SubtitleMode = Literal["embed", "sidecar", "both"]
47
+ ProgressPhase = Literal["downloading", "muxing", "converting", "finished"]
48
+ Codec = Literal["h264", "av1", "vp9", "aac", "he-aac", "opus", "other"]
49
+ CollectionKind = Literal["all", "videos", "shorts", "live"]
50
+ BulkStatus = Literal["ok", "skipped", "failed", "not_attempted"]
51
+
52
+ T = TypeVar("T")
53
+
54
+
55
+ @dataclass(frozen=True, slots=True)
56
+ class Word:
57
+ """One timed word of an auto-generated track."""
58
+
59
+ text: str
60
+ start: float
61
+
62
+
63
+ @dataclass(frozen=True, slots=True)
64
+ class Segment:
65
+ """One caption cue: ``text`` shown from ``start`` for ``duration`` seconds."""
66
+
67
+ start: float
68
+ duration: float
69
+ text: str
70
+ words: tuple[Word, ...] = ()
71
+
72
+ @property
73
+ def end(self) -> float:
74
+ """When the cue disappears, in seconds."""
75
+ return self.start + self.duration
76
+
77
+
78
+ @dataclass(frozen=True, slots=True)
79
+ class VideoInfo:
80
+ """Basic facts about a video."""
81
+
82
+ video_id: str
83
+ title: str
84
+ channel: str
85
+ channel_id: str
86
+ duration: float
87
+ is_live_content: bool
88
+
89
+ @property
90
+ def url(self) -> str:
91
+ """The canonical watch URL."""
92
+ return f"https://www.youtube.com/watch?v={self.video_id}"
93
+
94
+
95
+ @dataclass(frozen=True, slots=True)
96
+ class Language:
97
+ """A language YouTube can translate a track into."""
98
+
99
+ code: str
100
+ name: str
101
+
102
+
103
+ class TrackFetcher(Protocol):
104
+ """Downloads a track; implemented by the transcripts service."""
105
+
106
+ def fetch_track(self, track: Track, *, preserve_formatting: bool) -> Transcript: ...
107
+
108
+
109
+ @dataclass(frozen=True, slots=True)
110
+ class Track:
111
+ """One subtitle track of a video, as listed by YouTube."""
112
+
113
+ video: VideoInfo
114
+ language_code: str
115
+ language: str
116
+ is_generated: bool
117
+ is_translatable: bool
118
+ vss_id: str = ""
119
+ translation_of: str | None = None
120
+ _url: str = field(default="", repr=False, compare=False)
121
+ _translation_languages: tuple[Language, ...] = field(default=(), repr=False, compare=False)
122
+ _fetcher: TrackFetcher | None = field(default=None, repr=False, compare=False)
123
+
124
+ @property
125
+ def video_id(self) -> str:
126
+ """The ID of the video this track belongs to."""
127
+ return self.video.video_id
128
+
129
+ def fetch(self, *, preserve_formatting: bool = False) -> Transcript:
130
+ """Download this track.
131
+
132
+ Args:
133
+ preserve_formatting: keep ``<b>``, ``<i>`` and ``<u>`` tags instead of plain text.
134
+ """
135
+ if self._fetcher is None:
136
+ raise RuntimeError(
137
+ "This Track is not bound to a client; get tracks from utmax.list_tracks()."
138
+ )
139
+ return self._fetcher.fetch_track(self, preserve_formatting=preserve_formatting)
140
+
141
+ def translate(self, language_code: str) -> Track:
142
+ """This track machine-translated by YouTube (``tlang``).
143
+
144
+ Best effort only: YouTube rate-limits these requests heavily. The track list's
145
+ ``translation_languages`` is checked when YouTube provided one.
146
+ """
147
+ from utmax.core.captions import set_query_param
148
+
149
+ if not self.is_translatable:
150
+ raise NotTranslatable(
151
+ f"YouTube cannot translate the {self.language_code} track of {self.video_id}.",
152
+ video_id=self.video_id,
153
+ )
154
+ names = {language.code: language.name for language in self._translation_languages}
155
+ if names and language_code not in names:
156
+ raise TranslationLanguageNotAvailable(
157
+ f"YouTube cannot translate video {self.video_id} into {language_code!r}.",
158
+ available=tuple(names),
159
+ video_id=self.video_id,
160
+ )
161
+ return replace(
162
+ self,
163
+ language_code=language_code,
164
+ language=names.get(language_code, language_code),
165
+ is_generated=True,
166
+ is_translatable=False,
167
+ vss_id="",
168
+ translation_of=self.language_code,
169
+ _url=set_query_param(self._url, "tlang", language_code),
170
+ )
171
+
172
+
173
+ @dataclass(frozen=True, slots=True)
174
+ class TrackList(Sequence[Track]):
175
+ """All subtitle tracks of a video, in YouTube's order."""
176
+
177
+ video: VideoInfo
178
+ tracks: tuple[Track, ...]
179
+ translation_languages: tuple[Language, ...] = ()
180
+
181
+ @overload
182
+ def __getitem__(self, index: int) -> Track: ...
183
+ @overload
184
+ def __getitem__(self, index: slice) -> tuple[Track, ...]: ...
185
+ def __getitem__(self, index: int | slice) -> Track | tuple[Track, ...]:
186
+ return self.tracks[index]
187
+
188
+ def __len__(self) -> int:
189
+ return len(self.tracks)
190
+
191
+ def __iter__(self) -> Iterator[Track]:
192
+ return iter(self.tracks)
193
+
194
+ @property
195
+ def manual(self) -> tuple[Track, ...]:
196
+ """Tracks written by people."""
197
+ return tuple(track for track in self.tracks if not track.is_generated)
198
+
199
+ @property
200
+ def generated(self) -> tuple[Track, ...]:
201
+ """Tracks produced by YouTube's speech recognition."""
202
+ return tuple(track for track in self.tracks if track.is_generated)
203
+
204
+ def find(
205
+ self,
206
+ languages: Sequence[str] | str | None = None,
207
+ *,
208
+ include_manual: bool = True,
209
+ include_generated: bool = True,
210
+ ) -> Track:
211
+ """Pick the best track; see :func:`utmax.core.selection.select_track` for the rules."""
212
+ from utmax.core.selection import select_track
213
+
214
+ return select_track(
215
+ self.tracks,
216
+ languages,
217
+ include_manual=include_manual,
218
+ include_generated=include_generated,
219
+ )
220
+
221
+
222
+ @dataclass(frozen=True, slots=True)
223
+ class Transcript(Sequence[Segment]):
224
+ """A timed transcript: the segments of one track, or of a translation of one."""
225
+
226
+ video: VideoInfo
227
+ language_code: str
228
+ language: str
229
+ is_generated: bool
230
+ segments: tuple[Segment, ...]
231
+ translated_from: str | None = None
232
+ translator: str | None = None
233
+ source: Transcript | None = field(default=None, repr=False, compare=False)
234
+
235
+ @overload
236
+ def __getitem__(self, index: int) -> Segment: ...
237
+ @overload
238
+ def __getitem__(self, index: slice) -> tuple[Segment, ...]: ...
239
+ def __getitem__(self, index: int | slice) -> Segment | tuple[Segment, ...]:
240
+ return self.segments[index]
241
+
242
+ def __len__(self) -> int:
243
+ return len(self.segments)
244
+
245
+ def __iter__(self) -> Iterator[Segment]:
246
+ return iter(self.segments)
247
+
248
+ @property
249
+ def text(self) -> str:
250
+ """All text, whitespace-collapsed and joined with single spaces."""
251
+ return " ".join(
252
+ " ".join(segment.text.split()) for segment in self.segments if segment.text.strip()
253
+ )
254
+
255
+ @property
256
+ def is_bilingual(self) -> bool:
257
+ """True for bilingual transcripts such as ``en+tr``."""
258
+ return "+" in self.language_code
259
+
260
+ def to_dicts(self) -> list[dict[str, str | float]]:
261
+ """``[{"text", "start", "duration"}, ...]`` like youtube-transcript-api's ``to_raw_data()``."""
262
+ return [
263
+ {"text": segment.text, "start": segment.start, "duration": segment.duration}
264
+ for segment in self.segments
265
+ ]
266
+
267
+ def to(self, format: FormatName) -> str:
268
+ """Render as ``"srt"``, ``"vtt"``, ``"json"``, ``"txt"`` or ``"pretty"``."""
269
+ from utmax.core.formats import render
270
+
271
+ return render(self, format)
272
+
273
+ def to_srt(self) -> str:
274
+ """SubRip text."""
275
+ from utmax.core.formats import to_srt
276
+
277
+ return to_srt(self.segments)
278
+
279
+ def to_vtt(self) -> str:
280
+ """WebVTT text."""
281
+ from utmax.core.formats import to_vtt
282
+
283
+ return to_vtt(self.segments)
284
+
285
+ def to_json(self, *, indent: int | None = 2) -> str:
286
+ """JSON with the metadata and every segment."""
287
+ from utmax.core.formats import to_json
288
+
289
+ return to_json(self, indent=indent)
290
+
291
+ def to_text(self, *, separator: str = " ") -> str:
292
+ """Plain text, segments joined by ``separator``."""
293
+ from utmax.core.formats import to_text
294
+
295
+ return to_text(self.segments, separator=separator)
296
+
297
+ def to_pretty(self) -> str:
298
+ """``[MM:SS] text`` lines (``[HH:MM:SS]`` for videos longer than an hour)."""
299
+ from utmax.core.formats import to_pretty
300
+
301
+ return to_pretty(self.segments)
302
+
303
+ def save(self, path: str | os.PathLike[str], format: FormatName | None = None) -> Path:
304
+ """Write the transcript to ``path``; the extension picks the format unless ``format`` is set.
305
+
306
+ Works the same for originals, translations and bilingual transcripts. The file is UTF-8
307
+ with ``\\n`` line endings and is replaced atomically.
308
+ """
309
+ from utmax.adapters.files import write_text_atomic
310
+ from utmax.core.formats import format_for_path, render
311
+
312
+ return write_text_atomic(path, render(self, format_for_path(path, format)))
313
+
314
+ def merge_sentences(self) -> Transcript:
315
+ """A copy whose cues are regrouped into sentences (best for auto-generated tracks)."""
316
+ from utmax.core.segmentation import merge_sentences
317
+
318
+ return replace(self, segments=merge_sentences(self.segments), source=None)
319
+
320
+
321
+ @dataclass(frozen=True, slots=True)
322
+ class Format:
323
+ """One stream YouTube offers for a video; its URL never leaves utmax."""
324
+
325
+ itag: int
326
+ kind: Literal["video", "audio"]
327
+ container: Literal["mp4", "webm"]
328
+ codec: Codec
329
+ codecs: str
330
+ width: int | None = None
331
+ height: int | None = None
332
+ fps: int | None = None
333
+ bitrate: int = 0
334
+ content_length: int | None = None
335
+ audio_sample_rate: int | None = None
336
+ audio_channels: int | None = None
337
+ is_default_audio: bool = True
338
+ is_drc: bool = False
339
+ last_modified: str = ""
340
+
341
+ @property
342
+ def label(self) -> str:
343
+ """A short description such as ``"137 mp4 h264 1080p25"`` or ``"140 mp4 aac 44.1kHz"``."""
344
+ words = [str(self.itag), self.container, self.codec]
345
+ if self.kind == "video":
346
+ side = min((n for n in (self.width, self.height) if n), default=0)
347
+ words.append(f"{side}p{self.fps or ''}")
348
+ else:
349
+ if self.audio_sample_rate:
350
+ words.append(f"{self.audio_sample_rate / 1000:g}kHz")
351
+ if self.is_drc:
352
+ words.append("drc")
353
+ return " ".join(words)
354
+
355
+
356
+ @dataclass(frozen=True, slots=True)
357
+ class Progress:
358
+ """How far a download has come; passed to ``download(progress=...)`` callbacks."""
359
+
360
+ video_id: str
361
+ phase: ProgressPhase
362
+ bytes_done: int
363
+ bytes_total: int | None
364
+ speed_bps: float | None = None
365
+ eta_seconds: float | None = None
366
+
367
+ @property
368
+ def fraction(self) -> float | None:
369
+ """``bytes_done / bytes_total`` between 0 and 1; ``None`` while the total is unknown."""
370
+ if not self.bytes_total:
371
+ return None
372
+ return min(1.0, self.bytes_done / self.bytes_total)
373
+
374
+
375
+ @dataclass(frozen=True, slots=True)
376
+ class DownloadResult:
377
+ """What :func:`utmax.download` produced."""
378
+
379
+ path: Path
380
+ video: VideoInfo
381
+ container: Container
382
+ video_format: Format | None
383
+ audio_format: Format
384
+ embedded_subtitles: tuple[str, ...] = ()
385
+ sidecars: tuple[Path, ...] = ()
386
+ size_bytes: int = 0
387
+ resumed: bool = False
388
+
389
+
390
+ @dataclass(frozen=True, slots=True)
391
+ class VideoEntry:
392
+ """One video of a playlist or channel, as :func:`utmax.list_videos` lists it."""
393
+
394
+ video_id: str
395
+ title: str
396
+ duration: float | None
397
+ channel: str
398
+ channel_id: str
399
+ index: int
400
+
401
+ @property
402
+ def url(self) -> str:
403
+ """The canonical watch URL."""
404
+ return f"https://www.youtube.com/watch?v={self.video_id}"
405
+
406
+
407
+ @dataclass(frozen=True, slots=True)
408
+ class VideoList(Sequence[VideoEntry]):
409
+ """The videos of a playlist or channel, in YouTube's order.
410
+
411
+ ``source_id`` is the playlist that was listed: the playlist's own ID, or for a channel
412
+ the list YouTube keeps of its uploads (``UU...``, ``UULF...``, ``UUSH...``, ``UULV...``).
413
+ ``video_count`` is the number of videos YouTube says the playlist has (it can include
414
+ private videos, which are not listed); ``None`` when YouTube does not say.
415
+ """
416
+
417
+ title: str
418
+ source_id: str
419
+ kind: CollectionKind
420
+ video_count: int | None
421
+ entries: tuple[VideoEntry, ...]
422
+
423
+ @overload
424
+ def __getitem__(self, index: int) -> VideoEntry: ...
425
+ @overload
426
+ def __getitem__(self, index: slice) -> tuple[VideoEntry, ...]: ...
427
+ def __getitem__(self, index: int | slice) -> VideoEntry | tuple[VideoEntry, ...]:
428
+ return self.entries[index]
429
+
430
+ def __len__(self) -> int:
431
+ return len(self.entries)
432
+
433
+ def __iter__(self) -> Iterator[VideoEntry]:
434
+ return iter(self.entries)
435
+
436
+
437
+ @dataclass(frozen=True, slots=True)
438
+ class BulkResult(Generic[T]):
439
+ """What happened to one video of ``fetch_many``, ``translate_many`` or ``download_many``.
440
+
441
+ ``status`` is ``"ok"`` (``value`` holds the result, ``path`` the file written, if any),
442
+ ``"skipped"`` (``path`` is the file that already existed; ``None`` for a video listed
443
+ twice), ``"failed"`` (``error`` says why) or ``"not_attempted"`` (the run stopped first).
444
+ """
445
+
446
+ video_id: str
447
+ status: BulkStatus
448
+ value: T | None = None
449
+ path: Path | None = None
450
+ error: Exception | None = None
451
+
452
+
453
+ @dataclass(frozen=True, slots=True)
454
+ class BulkReport(Sequence[BulkResult[T]], Generic[T]):
455
+ """Every result of a bulk call, in the order the videos were given."""
456
+
457
+ results: tuple[BulkResult[T], ...]
458
+
459
+ @overload
460
+ def __getitem__(self, index: int) -> BulkResult[T]: ...
461
+ @overload
462
+ def __getitem__(self, index: slice) -> tuple[BulkResult[T], ...]: ...
463
+ def __getitem__(self, index: int | slice) -> BulkResult[T] | tuple[BulkResult[T], ...]:
464
+ return self.results[index]
465
+
466
+ def __len__(self) -> int:
467
+ return len(self.results)
468
+
469
+ def __iter__(self) -> Iterator[BulkResult[T]]:
470
+ return iter(self.results)
471
+
472
+ @property
473
+ def ok(self) -> tuple[BulkResult[T], ...]:
474
+ """The videos that succeeded."""
475
+ return self._with("ok")
476
+
477
+ @property
478
+ def skipped(self) -> tuple[BulkResult[T], ...]:
479
+ """The videos whose file already existed (or that were listed twice)."""
480
+ return self._with("skipped")
481
+
482
+ @property
483
+ def failed(self) -> tuple[BulkResult[T], ...]:
484
+ """The videos that failed; each result's ``error`` says why."""
485
+ return self._with("failed")
486
+
487
+ @property
488
+ def not_attempted(self) -> tuple[BulkResult[T], ...]:
489
+ """The videos never tried because the run stopped (YouTube blocked it, for example)."""
490
+ return self._with("not_attempted")
491
+
492
+ def raise_for_errors(self) -> None:
493
+ """Raise the error of the first failed video; do nothing when none failed."""
494
+ for result in self.results:
495
+ if result.error is not None:
496
+ raise result.error
497
+
498
+ def _with(self, status: BulkStatus) -> tuple[BulkResult[T], ...]:
499
+ return tuple(result for result in self.results if result.status == status)
utmax/providers.py ADDED
@@ -0,0 +1,36 @@
1
+ """The translators behind :func:`utmax.translate`, for direct use or subclassing.
2
+
3
+ Pick one with :func:`utmax.translator` (``"provider=model-id"``) or build it yourself::
4
+
5
+ from utmax.providers import ClaudeTranslator
6
+
7
+ translator = ClaudeTranslator("claude-opus-5", effort="low", batch_items=20)
8
+ tr = utmax.translate(transcript, "tr", model=translator)
9
+
10
+ To use any other model, subclass :class:`Translator` and implement ``name`` and
11
+ ``generate_json``. Provider SDKs are imported only when a translator is created.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from utmax.adapters.providers import (
17
+ ClaudeTranslator,
18
+ EngineOptions,
19
+ GeminiTranslator,
20
+ OpenAITranslator,
21
+ OpenRouterTranslator,
22
+ Translator,
23
+ )
24
+ from utmax.adapters.providers.openai import JsonMode
25
+ from utmax.core.translate.batching import InvalidResponse
26
+
27
+ __all__ = [
28
+ "ClaudeTranslator",
29
+ "EngineOptions",
30
+ "GeminiTranslator",
31
+ "InvalidResponse",
32
+ "JsonMode",
33
+ "OpenAITranslator",
34
+ "OpenRouterTranslator",
35
+ "Translator",
36
+ ]
utmax/py.typed ADDED
File without changes
@@ -0,0 +1 @@
1
+ """Services: orchestrate the pure core and the adapters for each feature."""