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