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
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
"""Transcripts and transcript lists, compatible with youtube-transcript-api 1.2.4.
|
|
2
|
+
|
|
3
|
+
utmax lists the tracks with its own InnerTube clients and downloads YouTube's legacy XML
|
|
4
|
+
captions, which youtube-transcript-api parses too, so snippets are the same.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from collections.abc import Iterable, Iterator
|
|
11
|
+
from dataclasses import asdict, dataclass
|
|
12
|
+
from html import unescape
|
|
13
|
+
from itertools import chain
|
|
14
|
+
from typing import Any, ClassVar, overload
|
|
15
|
+
from xml.etree import ElementTree
|
|
16
|
+
|
|
17
|
+
from utmax.compat._bridge import Connection, connection_for, make_transport
|
|
18
|
+
from utmax.compat._errors import (
|
|
19
|
+
AgeRestricted,
|
|
20
|
+
FailedToCreateConsentCookie,
|
|
21
|
+
InvalidVideoId,
|
|
22
|
+
IpBlocked,
|
|
23
|
+
NoTranscriptFound,
|
|
24
|
+
NotTranslatable,
|
|
25
|
+
PoTokenRequired,
|
|
26
|
+
RequestBlocked,
|
|
27
|
+
TranscriptsDisabled,
|
|
28
|
+
TranslationLanguageNotAvailable,
|
|
29
|
+
VideoUnavailable,
|
|
30
|
+
VideoUnplayable,
|
|
31
|
+
YouTubeDataUnparsable,
|
|
32
|
+
YouTubeRequestFailed,
|
|
33
|
+
)
|
|
34
|
+
from utmax.compat._settings import INNERTUBE_API_URL, INNERTUBE_CONTEXT, WATCH_URL
|
|
35
|
+
from utmax.compat.proxies import ProxyConfig
|
|
36
|
+
from utmax.core.player import PlayerData, parse_player_response
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"INNERTUBE_API_URL",
|
|
40
|
+
"INNERTUBE_CONTEXT",
|
|
41
|
+
"WATCH_URL",
|
|
42
|
+
"AgeRestricted",
|
|
43
|
+
"FailedToCreateConsentCookie",
|
|
44
|
+
"FetchedTranscript",
|
|
45
|
+
"FetchedTranscriptSnippet",
|
|
46
|
+
"InvalidVideoId",
|
|
47
|
+
"IpBlocked",
|
|
48
|
+
"NoTranscriptFound",
|
|
49
|
+
"NotTranslatable",
|
|
50
|
+
"PoTokenRequired",
|
|
51
|
+
"ProxyConfig",
|
|
52
|
+
"RequestBlocked",
|
|
53
|
+
"Transcript",
|
|
54
|
+
"TranscriptList",
|
|
55
|
+
"TranscriptListFetcher",
|
|
56
|
+
"TranscriptsDisabled",
|
|
57
|
+
"TranslationLanguageNotAvailable",
|
|
58
|
+
"VideoUnavailable",
|
|
59
|
+
"VideoUnplayable",
|
|
60
|
+
"YouTubeDataUnparsable",
|
|
61
|
+
"YouTubeRequestFailed",
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
_SNIPPET_KEYS = ("text", "start", "duration")
|
|
65
|
+
_UNSAFE_XML = re.compile(r"<!\s*(?:DOCTYPE|ENTITY)", re.IGNORECASE)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class FetchedTranscriptSnippet:
|
|
70
|
+
"""One snippet of a transcript.
|
|
71
|
+
|
|
72
|
+
Beyond youtube-transcript-api 1.2.4, a snippet also reads like the dictionary version 0.6
|
|
73
|
+
returned (``snippet["text"]``, ``.get()``, ``.keys()``, ``.items()``, ``dict(snippet)``), so
|
|
74
|
+
code written for 0.6 keeps working on ``Transcript.fetch()``.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
text: str
|
|
78
|
+
start: float
|
|
79
|
+
"""
|
|
80
|
+
The timestamp at which this transcript snippet appears on screen in seconds.
|
|
81
|
+
"""
|
|
82
|
+
duration: float
|
|
83
|
+
"""
|
|
84
|
+
The duration of how long the snippet in seconds. Be aware that this is not the
|
|
85
|
+
duration of the transcribed speech, but how long the snippet stays on screen.
|
|
86
|
+
Therefore, there can be overlaps between snippets!
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
def __getitem__(self, key: str) -> str | float:
|
|
90
|
+
if key not in _SNIPPET_KEYS:
|
|
91
|
+
raise KeyError(key)
|
|
92
|
+
value: str | float = getattr(self, key)
|
|
93
|
+
return value
|
|
94
|
+
|
|
95
|
+
def __contains__(self, key: object) -> bool:
|
|
96
|
+
return key in _SNIPPET_KEYS
|
|
97
|
+
|
|
98
|
+
def __iter__(self) -> Iterator[str]:
|
|
99
|
+
return iter(_SNIPPET_KEYS)
|
|
100
|
+
|
|
101
|
+
def __len__(self) -> int:
|
|
102
|
+
return len(_SNIPPET_KEYS)
|
|
103
|
+
|
|
104
|
+
def get(self, key: str, default: Any = None) -> Any:
|
|
105
|
+
"""The value of ``key``, or ``default`` for another key."""
|
|
106
|
+
return self[key] if key in _SNIPPET_KEYS else default
|
|
107
|
+
|
|
108
|
+
def keys(self) -> tuple[str, ...]:
|
|
109
|
+
"""``("text", "start", "duration")``."""
|
|
110
|
+
return _SNIPPET_KEYS
|
|
111
|
+
|
|
112
|
+
def values(self) -> list[str | float]:
|
|
113
|
+
"""The text, start and duration."""
|
|
114
|
+
return [self[key] for key in _SNIPPET_KEYS]
|
|
115
|
+
|
|
116
|
+
def items(self) -> list[tuple[str, str | float]]:
|
|
117
|
+
"""The keys with their values."""
|
|
118
|
+
return [(key, self[key]) for key in _SNIPPET_KEYS]
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@dataclass
|
|
122
|
+
class FetchedTranscript:
|
|
123
|
+
"""
|
|
124
|
+
Represents a fetched transcript. This object is iterable, which allows you to
|
|
125
|
+
iterate over the transcript snippets.
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
snippets: list[FetchedTranscriptSnippet]
|
|
129
|
+
video_id: str
|
|
130
|
+
language: str
|
|
131
|
+
language_code: str
|
|
132
|
+
is_generated: bool
|
|
133
|
+
|
|
134
|
+
def __iter__(self) -> Iterator[FetchedTranscriptSnippet]:
|
|
135
|
+
return iter(self.snippets)
|
|
136
|
+
|
|
137
|
+
@overload
|
|
138
|
+
def __getitem__(self, index: int) -> FetchedTranscriptSnippet: ...
|
|
139
|
+
@overload
|
|
140
|
+
def __getitem__(self, index: slice) -> list[FetchedTranscriptSnippet]: ...
|
|
141
|
+
def __getitem__(
|
|
142
|
+
self, index: int | slice
|
|
143
|
+
) -> FetchedTranscriptSnippet | list[FetchedTranscriptSnippet]:
|
|
144
|
+
return self.snippets[index]
|
|
145
|
+
|
|
146
|
+
def __len__(self) -> int:
|
|
147
|
+
return len(self.snippets)
|
|
148
|
+
|
|
149
|
+
def to_raw_data(self) -> list[dict[str, Any]]:
|
|
150
|
+
return [asdict(snippet) for snippet in self]
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@dataclass
|
|
154
|
+
class _TranslationLanguage:
|
|
155
|
+
language: str
|
|
156
|
+
language_code: str
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class Transcript:
|
|
160
|
+
def __init__( # noqa: PLR0917
|
|
161
|
+
self,
|
|
162
|
+
http_client: Any,
|
|
163
|
+
video_id: str,
|
|
164
|
+
url: str,
|
|
165
|
+
language: str,
|
|
166
|
+
language_code: str,
|
|
167
|
+
is_generated: bool,
|
|
168
|
+
translation_languages: list[_TranslationLanguage],
|
|
169
|
+
) -> None:
|
|
170
|
+
"""
|
|
171
|
+
You probably don't want to initialize this directly. Usually you'll access Transcript objects using a
|
|
172
|
+
TranscriptList.
|
|
173
|
+
"""
|
|
174
|
+
self._http_client = http_client
|
|
175
|
+
self.video_id = video_id
|
|
176
|
+
self._url = url
|
|
177
|
+
self.language = language
|
|
178
|
+
self.language_code = language_code
|
|
179
|
+
self.is_generated = is_generated
|
|
180
|
+
self.translation_languages = translation_languages
|
|
181
|
+
self._translation_languages_dict = {
|
|
182
|
+
translation_language.language_code: translation_language.language
|
|
183
|
+
for translation_language in translation_languages
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
def fetch(self, preserve_formatting: bool = False) -> FetchedTranscript:
|
|
187
|
+
"""
|
|
188
|
+
Loads the actual transcript data.
|
|
189
|
+
:param preserve_formatting: whether to keep select HTML text formatting
|
|
190
|
+
"""
|
|
191
|
+
if "&exp=xpe" in self._url:
|
|
192
|
+
raise PoTokenRequired(self.video_id)
|
|
193
|
+
raw_data = connection_for(self._http_client).caption_text(self._url, self.video_id)
|
|
194
|
+
try:
|
|
195
|
+
snippets = _TranscriptParser(preserve_formatting=preserve_formatting).parse(raw_data)
|
|
196
|
+
except (ElementTree.ParseError, KeyError, ValueError) as error:
|
|
197
|
+
raise YouTubeDataUnparsable(self.video_id) from error
|
|
198
|
+
return FetchedTranscript(
|
|
199
|
+
snippets=snippets,
|
|
200
|
+
video_id=self.video_id,
|
|
201
|
+
language=self.language,
|
|
202
|
+
language_code=self.language_code,
|
|
203
|
+
is_generated=self.is_generated,
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
def __str__(self) -> str:
|
|
207
|
+
return '{language_code} ("{language}"){translation_description}'.format(
|
|
208
|
+
language=self.language,
|
|
209
|
+
language_code=self.language_code,
|
|
210
|
+
translation_description="[TRANSLATABLE]" if self.is_translatable else "",
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
@property
|
|
214
|
+
def is_translatable(self) -> bool:
|
|
215
|
+
return len(self.translation_languages) > 0
|
|
216
|
+
|
|
217
|
+
def translate(self, language_code: str) -> Transcript:
|
|
218
|
+
if not self.is_translatable:
|
|
219
|
+
raise NotTranslatable(self.video_id)
|
|
220
|
+
|
|
221
|
+
if language_code not in self._translation_languages_dict:
|
|
222
|
+
raise TranslationLanguageNotAvailable(self.video_id)
|
|
223
|
+
|
|
224
|
+
return Transcript(
|
|
225
|
+
self._http_client,
|
|
226
|
+
self.video_id,
|
|
227
|
+
f"{self._url}&tlang={language_code}",
|
|
228
|
+
self._translation_languages_dict[language_code],
|
|
229
|
+
language_code,
|
|
230
|
+
True,
|
|
231
|
+
[],
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
class TranscriptList:
|
|
236
|
+
"""
|
|
237
|
+
This object represents a list of transcripts. It can be iterated over to list all transcripts which are available
|
|
238
|
+
for a given YouTube video. Also, it provides functionality to search for a transcript in a given language.
|
|
239
|
+
"""
|
|
240
|
+
|
|
241
|
+
def __init__(
|
|
242
|
+
self,
|
|
243
|
+
video_id: str,
|
|
244
|
+
manually_created_transcripts: dict[str, Transcript],
|
|
245
|
+
generated_transcripts: dict[str, Transcript],
|
|
246
|
+
translation_languages: list[_TranslationLanguage],
|
|
247
|
+
) -> None:
|
|
248
|
+
"""
|
|
249
|
+
The constructor is only for internal use. Use the static build method instead.
|
|
250
|
+
|
|
251
|
+
:param video_id: the id of the video this TranscriptList is for
|
|
252
|
+
:param manually_created_transcripts: dict mapping language codes to the manually created transcripts
|
|
253
|
+
:param generated_transcripts: dict mapping language codes to the generated transcripts
|
|
254
|
+
:param translation_languages: list of languages which can be used for translatable languages
|
|
255
|
+
"""
|
|
256
|
+
self.video_id = video_id
|
|
257
|
+
self._manually_created_transcripts = manually_created_transcripts
|
|
258
|
+
self._generated_transcripts = generated_transcripts
|
|
259
|
+
self._translation_languages = translation_languages
|
|
260
|
+
|
|
261
|
+
@staticmethod
|
|
262
|
+
def build(http_client: Any, video_id: str, captions_json: dict[str, Any]) -> TranscriptList:
|
|
263
|
+
"""
|
|
264
|
+
Factory method for TranscriptList.
|
|
265
|
+
|
|
266
|
+
:param http_client: http client which is used to make the transcript retrieving http calls
|
|
267
|
+
:param video_id: the id of the video this TranscriptList is for
|
|
268
|
+
:param captions_json: the ``playerCaptionsTracklistRenderer`` of a player response
|
|
269
|
+
:return: the created TranscriptList
|
|
270
|
+
"""
|
|
271
|
+
player = parse_player_response(
|
|
272
|
+
{"captions": {"playerCaptionsTracklistRenderer": captions_json}}, video_id=video_id
|
|
273
|
+
)
|
|
274
|
+
return _transcript_list(http_client, video_id, player)
|
|
275
|
+
|
|
276
|
+
def __iter__(self) -> Iterator[Transcript]:
|
|
277
|
+
return chain(
|
|
278
|
+
self._manually_created_transcripts.values(),
|
|
279
|
+
self._generated_transcripts.values(),
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
def find_transcript(self, language_codes: Iterable[str]) -> Transcript:
|
|
283
|
+
"""
|
|
284
|
+
Finds a transcript for a given language code. Manually created transcripts are returned first and only if none
|
|
285
|
+
are found, generated transcripts are used. If you only want generated transcripts use
|
|
286
|
+
`find_manually_created_transcript` instead.
|
|
287
|
+
|
|
288
|
+
:param language_codes: A list of language codes in a descending priority. For example, if this is set to
|
|
289
|
+
['de', 'en'] it will first try to fetch the german transcript (de) and then fetch the english transcript (en) if
|
|
290
|
+
it fails to do so.
|
|
291
|
+
:return: the found Transcript
|
|
292
|
+
"""
|
|
293
|
+
return self._find_transcript(
|
|
294
|
+
language_codes,
|
|
295
|
+
[self._manually_created_transcripts, self._generated_transcripts],
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
def find_generated_transcript(self, language_codes: Iterable[str]) -> Transcript:
|
|
299
|
+
"""
|
|
300
|
+
Finds an automatically generated transcript for a given language code.
|
|
301
|
+
|
|
302
|
+
:param language_codes: A list of language codes in a descending priority. For example, if this is set to
|
|
303
|
+
['de', 'en'] it will first try to fetch the german transcript (de) and then fetch the english transcript (en) if
|
|
304
|
+
it fails to do so.
|
|
305
|
+
:return: the found Transcript
|
|
306
|
+
"""
|
|
307
|
+
return self._find_transcript(language_codes, [self._generated_transcripts])
|
|
308
|
+
|
|
309
|
+
def find_manually_created_transcript(self, language_codes: Iterable[str]) -> Transcript:
|
|
310
|
+
"""
|
|
311
|
+
Finds a manually created transcript for a given language code.
|
|
312
|
+
|
|
313
|
+
:param language_codes: A list of language codes in a descending priority. For example, if this is set to
|
|
314
|
+
['de', 'en'] it will first try to fetch the german transcript (de) and then fetch the english transcript (en) if
|
|
315
|
+
it fails to do so.
|
|
316
|
+
:return: the found Transcript
|
|
317
|
+
"""
|
|
318
|
+
return self._find_transcript(language_codes, [self._manually_created_transcripts])
|
|
319
|
+
|
|
320
|
+
def _find_transcript(
|
|
321
|
+
self,
|
|
322
|
+
language_codes: Iterable[str],
|
|
323
|
+
transcript_dicts: list[dict[str, Transcript]],
|
|
324
|
+
) -> Transcript:
|
|
325
|
+
for language_code in language_codes:
|
|
326
|
+
for transcript_dict in transcript_dicts:
|
|
327
|
+
if language_code in transcript_dict:
|
|
328
|
+
return transcript_dict[language_code]
|
|
329
|
+
|
|
330
|
+
raise NoTranscriptFound(self.video_id, language_codes, self)
|
|
331
|
+
|
|
332
|
+
def __str__(self) -> str:
|
|
333
|
+
return (
|
|
334
|
+
"For this video ({video_id}) transcripts are available in the following languages:\n\n"
|
|
335
|
+
"(MANUALLY CREATED)\n"
|
|
336
|
+
"{available_manually_created_transcript_languages}\n\n"
|
|
337
|
+
"(GENERATED)\n"
|
|
338
|
+
"{available_generated_transcripts}\n\n"
|
|
339
|
+
"(TRANSLATION LANGUAGES)\n"
|
|
340
|
+
"{available_translation_languages}"
|
|
341
|
+
).format(
|
|
342
|
+
video_id=self.video_id,
|
|
343
|
+
available_manually_created_transcript_languages=self._get_language_description(
|
|
344
|
+
str(transcript) for transcript in self._manually_created_transcripts.values()
|
|
345
|
+
),
|
|
346
|
+
available_generated_transcripts=self._get_language_description(
|
|
347
|
+
str(transcript) for transcript in self._generated_transcripts.values()
|
|
348
|
+
),
|
|
349
|
+
available_translation_languages=self._get_language_description(
|
|
350
|
+
f'{translation_language.language_code} ("{translation_language.language}")'
|
|
351
|
+
for translation_language in self._translation_languages
|
|
352
|
+
),
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
def _get_language_description(self, transcript_strings: Iterable[str]) -> str:
|
|
356
|
+
description = "\n".join(f" - {transcript}" for transcript in transcript_strings)
|
|
357
|
+
return description if description else "None"
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
class TranscriptListFetcher:
|
|
361
|
+
"""Lists the transcripts of a video through utmax's InnerTube clients."""
|
|
362
|
+
|
|
363
|
+
def __init__(self, http_client: Any, proxy_config: ProxyConfig | None) -> None:
|
|
364
|
+
self._http_client = http_client
|
|
365
|
+
self._proxy_config = proxy_config
|
|
366
|
+
self._connection = Connection(
|
|
367
|
+
make_transport(http_client, proxy_config), proxy_config=proxy_config
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
def fetch(self, video_id: str) -> TranscriptList:
|
|
371
|
+
video_id, player = self._connection.player(video_id)
|
|
372
|
+
return _transcript_list(self._connection, video_id, player)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
class _TranscriptParser:
|
|
376
|
+
_FORMATTING_TAGS: ClassVar[list[str]] = [
|
|
377
|
+
"strong", # important
|
|
378
|
+
"em", # emphasized
|
|
379
|
+
"b", # bold
|
|
380
|
+
"i", # italic
|
|
381
|
+
"mark", # marked
|
|
382
|
+
"small", # smaller
|
|
383
|
+
"del", # deleted
|
|
384
|
+
"ins", # inserted
|
|
385
|
+
"sub", # subscript
|
|
386
|
+
"sup", # superscript
|
|
387
|
+
]
|
|
388
|
+
|
|
389
|
+
def __init__(self, preserve_formatting: bool = False) -> None:
|
|
390
|
+
self._html_regex = self._get_html_regex(preserve_formatting)
|
|
391
|
+
|
|
392
|
+
def _get_html_regex(self, preserve_formatting: bool) -> re.Pattern[str]:
|
|
393
|
+
if preserve_formatting:
|
|
394
|
+
formats_regex = "|".join(self._FORMATTING_TAGS)
|
|
395
|
+
formats_regex = r"<\/?(?!\/?(" + formats_regex + r")\b).*?\b>"
|
|
396
|
+
html_regex = re.compile(formats_regex, re.IGNORECASE)
|
|
397
|
+
else:
|
|
398
|
+
html_regex = re.compile(r"<[^>]*>", re.IGNORECASE)
|
|
399
|
+
return html_regex
|
|
400
|
+
|
|
401
|
+
def parse(self, raw_data: str) -> list[FetchedTranscriptSnippet]:
|
|
402
|
+
if _UNSAFE_XML.search(raw_data):
|
|
403
|
+
raise ValueError("refusing caption XML that declares a DOCTYPE or entities")
|
|
404
|
+
return [
|
|
405
|
+
FetchedTranscriptSnippet(
|
|
406
|
+
text=re.sub(self._html_regex, "", unescape(xml_element.text)),
|
|
407
|
+
start=float(xml_element.attrib["start"]),
|
|
408
|
+
duration=float(xml_element.attrib.get("dur", "0.0")),
|
|
409
|
+
)
|
|
410
|
+
for xml_element in ElementTree.fromstring(raw_data)
|
|
411
|
+
if xml_element.text is not None
|
|
412
|
+
]
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def _transcript_list(http_client: Any, video_id: str, player: PlayerData) -> TranscriptList:
|
|
416
|
+
"""Build the list as youtube-transcript-api does: one transcript per language code and kind
|
|
417
|
+
(a later track replaces an earlier one), the ``fmt`` dropped from each URL, and the
|
|
418
|
+
translation languages only on translatable tracks."""
|
|
419
|
+
translation_languages = [
|
|
420
|
+
_TranslationLanguage(language=language.name, language_code=language.code)
|
|
421
|
+
for language in player.translation_languages
|
|
422
|
+
]
|
|
423
|
+
manually_created_transcripts: dict[str, Transcript] = {}
|
|
424
|
+
generated_transcripts: dict[str, Transcript] = {}
|
|
425
|
+
for track in player.caption_tracks or ():
|
|
426
|
+
transcript_dict = (
|
|
427
|
+
generated_transcripts if track.is_generated else manually_created_transcripts
|
|
428
|
+
)
|
|
429
|
+
transcript_dict[track.language_code] = Transcript(
|
|
430
|
+
http_client,
|
|
431
|
+
video_id,
|
|
432
|
+
track.base_url.replace("&fmt=srv3", ""),
|
|
433
|
+
track.name,
|
|
434
|
+
track.language_code,
|
|
435
|
+
track.is_generated,
|
|
436
|
+
translation_languages if track.is_translatable else [],
|
|
437
|
+
)
|
|
438
|
+
return TranscriptList(
|
|
439
|
+
video_id, manually_created_transcripts, generated_transcripts, translation_languages
|
|
440
|
+
)
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"""Transcript formatters, compatible with ``youtube_transcript_api.formatters`` 1.2.4.
|
|
2
|
+
|
|
3
|
+
The output is identical to youtube-transcript-api's. Besides a ``FetchedTranscript``, every
|
|
4
|
+
formatter also accepts what version 0.6 returned: a list of ``{"text", "start", "duration"}``
|
|
5
|
+
dictionaries.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import pprint
|
|
12
|
+
from collections.abc import Iterable, Mapping, Sequence
|
|
13
|
+
from typing import Any, ClassVar
|
|
14
|
+
|
|
15
|
+
from utmax.compat._transcripts import FetchedTranscript, FetchedTranscriptSnippet
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"FetchedTranscript",
|
|
19
|
+
"FetchedTranscriptSnippet",
|
|
20
|
+
"Formatter",
|
|
21
|
+
"FormatterLoader",
|
|
22
|
+
"JSONFormatter",
|
|
23
|
+
"PrettyPrintFormatter",
|
|
24
|
+
"SRTFormatter",
|
|
25
|
+
"TextFormatter",
|
|
26
|
+
"WebVTTFormatter",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
AnyTranscript = FetchedTranscript | Sequence[Mapping[str, Any]]
|
|
30
|
+
"""A ``FetchedTranscript``, or the list of dictionaries youtube-transcript-api 0.6 returned."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Formatter:
|
|
34
|
+
"""Formatter should be used as an abstract base class.
|
|
35
|
+
|
|
36
|
+
Formatter classes should inherit from this class and implement
|
|
37
|
+
their own .format() method which should return a string. A
|
|
38
|
+
transcript is represented by a List of Dictionary items.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def format_transcript(self, transcript: AnyTranscript, **kwargs: Any) -> str:
|
|
42
|
+
raise NotImplementedError(
|
|
43
|
+
"A subclass of Formatter must implement their own .format_transcript() method."
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
def format_transcripts(self, transcripts: Sequence[AnyTranscript], **kwargs: Any) -> str:
|
|
47
|
+
raise NotImplementedError(
|
|
48
|
+
"A subclass of Formatter must implement their own .format_transcripts() method."
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class PrettyPrintFormatter(Formatter):
|
|
53
|
+
def format_transcript(self, transcript: AnyTranscript, **kwargs: Any) -> str:
|
|
54
|
+
"""Pretty prints a transcript.
|
|
55
|
+
|
|
56
|
+
:param transcript:
|
|
57
|
+
:return: A pretty printed string representation of the transcript.
|
|
58
|
+
"""
|
|
59
|
+
return pprint.pformat(_raw_data(transcript), **kwargs)
|
|
60
|
+
|
|
61
|
+
def format_transcripts(self, transcripts: Sequence[AnyTranscript], **kwargs: Any) -> str:
|
|
62
|
+
"""Converts a list of transcripts into a JSON string.
|
|
63
|
+
|
|
64
|
+
:param transcripts:
|
|
65
|
+
:return: A JSON string representation of the transcript.
|
|
66
|
+
"""
|
|
67
|
+
return pprint.pformat([_raw_data(transcript) for transcript in transcripts], **kwargs)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class JSONFormatter(Formatter):
|
|
71
|
+
def format_transcript(self, transcript: AnyTranscript, **kwargs: Any) -> str:
|
|
72
|
+
"""Converts a transcript into a JSON string.
|
|
73
|
+
|
|
74
|
+
:param transcript:
|
|
75
|
+
:return: A JSON string representation of the transcript.
|
|
76
|
+
"""
|
|
77
|
+
return json.dumps(_raw_data(transcript), **kwargs)
|
|
78
|
+
|
|
79
|
+
def format_transcripts(self, transcripts: Sequence[AnyTranscript], **kwargs: Any) -> str:
|
|
80
|
+
"""Converts a list of transcripts into a JSON string.
|
|
81
|
+
|
|
82
|
+
:param transcripts:
|
|
83
|
+
:return: A JSON string representation of the transcript.
|
|
84
|
+
"""
|
|
85
|
+
return json.dumps([_raw_data(transcript) for transcript in transcripts], **kwargs)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class TextFormatter(Formatter):
|
|
89
|
+
def format_transcript(self, transcript: AnyTranscript, **kwargs: Any) -> str:
|
|
90
|
+
"""Converts a transcript into plain text with no timestamps.
|
|
91
|
+
|
|
92
|
+
:param transcript:
|
|
93
|
+
:return: all transcript text lines separated by newline breaks.
|
|
94
|
+
"""
|
|
95
|
+
return "\n".join(line.text for line in _snippets(transcript))
|
|
96
|
+
|
|
97
|
+
def format_transcripts(self, transcripts: Sequence[AnyTranscript], **kwargs: Any) -> str:
|
|
98
|
+
"""Converts a list of transcripts into plain text with no timestamps.
|
|
99
|
+
|
|
100
|
+
:param transcripts:
|
|
101
|
+
:return: all transcript text lines separated by newline breaks.
|
|
102
|
+
"""
|
|
103
|
+
return "\n\n\n".join(
|
|
104
|
+
[self.format_transcript(transcript, **kwargs) for transcript in transcripts]
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class _TextBasedFormatter(TextFormatter):
|
|
109
|
+
def _format_timestamp(self, hours: int, mins: int, secs: int, ms: int) -> str:
|
|
110
|
+
raise NotImplementedError(
|
|
111
|
+
"A subclass of _TextBasedFormatter must implement their own .format_timestamp() method."
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
def _format_transcript_header(self, lines: Iterable[str]) -> str:
|
|
115
|
+
raise NotImplementedError(
|
|
116
|
+
"A subclass of _TextBasedFormatter must implement "
|
|
117
|
+
"their own _format_transcript_header method."
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
def _format_transcript_helper(
|
|
121
|
+
self, i: int, time_text: str, snippet: FetchedTranscriptSnippet
|
|
122
|
+
) -> str:
|
|
123
|
+
raise NotImplementedError(
|
|
124
|
+
"A subclass of _TextBasedFormatter must implement "
|
|
125
|
+
"their own _format_transcript_helper method."
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
def _seconds_to_timestamp(self, time: float) -> str:
|
|
129
|
+
"""Helper that converts `time` into a transcript cue timestamp.
|
|
130
|
+
|
|
131
|
+
:reference: https://www.w3.org/TR/webvtt1/#webvtt-timestamp
|
|
132
|
+
|
|
133
|
+
:param time: a float representing time in seconds.
|
|
134
|
+
:type time: float
|
|
135
|
+
:return: a string formatted as a cue timestamp, 'HH:MM:SS.MS'
|
|
136
|
+
:example:
|
|
137
|
+
>>> self._seconds_to_timestamp(6.93)
|
|
138
|
+
'00:00:06.930'
|
|
139
|
+
"""
|
|
140
|
+
time = float(time)
|
|
141
|
+
hours_float, remainder = divmod(time, 3600)
|
|
142
|
+
mins_float, secs_float = divmod(remainder, 60)
|
|
143
|
+
hours, mins, secs = int(hours_float), int(mins_float), int(secs_float)
|
|
144
|
+
ms = int(round((time - int(time)) * 1000, 2))
|
|
145
|
+
return self._format_timestamp(hours, mins, secs, ms)
|
|
146
|
+
|
|
147
|
+
def format_transcript(self, transcript: AnyTranscript, **kwargs: Any) -> str:
|
|
148
|
+
"""A basic implementation of WEBVTT/SRT formatting.
|
|
149
|
+
|
|
150
|
+
:param transcript:
|
|
151
|
+
:reference:
|
|
152
|
+
https://www.w3.org/TR/webvtt1/#introduction-caption
|
|
153
|
+
https://www.3playmedia.com/blog/create-srt-file/
|
|
154
|
+
"""
|
|
155
|
+
snippets = _snippets(transcript)
|
|
156
|
+
lines = []
|
|
157
|
+
for i, line in enumerate(snippets):
|
|
158
|
+
end = line.start + line.duration
|
|
159
|
+
time_text = "{} --> {}".format(
|
|
160
|
+
self._seconds_to_timestamp(line.start),
|
|
161
|
+
self._seconds_to_timestamp(
|
|
162
|
+
snippets[i + 1].start
|
|
163
|
+
if i < len(snippets) - 1 and snippets[i + 1].start < end
|
|
164
|
+
else end
|
|
165
|
+
),
|
|
166
|
+
)
|
|
167
|
+
lines.append(self._format_transcript_helper(i, time_text, line))
|
|
168
|
+
|
|
169
|
+
return self._format_transcript_header(lines)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class SRTFormatter(_TextBasedFormatter):
|
|
173
|
+
def _format_timestamp(self, hours: int, mins: int, secs: int, ms: int) -> str:
|
|
174
|
+
return f"{hours:02d}:{mins:02d}:{secs:02d},{ms:03d}"
|
|
175
|
+
|
|
176
|
+
def _format_transcript_header(self, lines: Iterable[str]) -> str:
|
|
177
|
+
return "\n\n".join(lines) + "\n"
|
|
178
|
+
|
|
179
|
+
def _format_transcript_helper(
|
|
180
|
+
self, i: int, time_text: str, snippet: FetchedTranscriptSnippet
|
|
181
|
+
) -> str:
|
|
182
|
+
return f"{i + 1}\n{time_text}\n{snippet.text}"
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class WebVTTFormatter(_TextBasedFormatter):
|
|
186
|
+
def _format_timestamp(self, hours: int, mins: int, secs: int, ms: int) -> str:
|
|
187
|
+
return f"{hours:02d}:{mins:02d}:{secs:02d}.{ms:03d}"
|
|
188
|
+
|
|
189
|
+
def _format_transcript_header(self, lines: Iterable[str]) -> str:
|
|
190
|
+
return "WEBVTT\n\n" + "\n\n".join(lines) + "\n"
|
|
191
|
+
|
|
192
|
+
def _format_transcript_helper(
|
|
193
|
+
self, i: int, time_text: str, snippet: FetchedTranscriptSnippet
|
|
194
|
+
) -> str:
|
|
195
|
+
return f"{time_text}\n{snippet.text}"
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
class FormatterLoader:
|
|
199
|
+
TYPES: ClassVar[dict[str, type[Formatter]]] = {
|
|
200
|
+
"json": JSONFormatter,
|
|
201
|
+
"pretty": PrettyPrintFormatter,
|
|
202
|
+
"text": TextFormatter,
|
|
203
|
+
"webvtt": WebVTTFormatter,
|
|
204
|
+
"srt": SRTFormatter,
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
class UnknownFormatterType(Exception):
|
|
208
|
+
def __init__(self, formatter_type: str) -> None:
|
|
209
|
+
super().__init__(
|
|
210
|
+
f"The format '{formatter_type}' is not supported. "
|
|
211
|
+
f"Choose one of the following formats: {', '.join(FormatterLoader.TYPES.keys())}"
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
def load(self, formatter_type: str = "pretty") -> Formatter:
|
|
215
|
+
"""
|
|
216
|
+
Loads the Formatter for the given formatter type.
|
|
217
|
+
|
|
218
|
+
:param formatter_type:
|
|
219
|
+
:return: Formatter object
|
|
220
|
+
"""
|
|
221
|
+
if formatter_type not in FormatterLoader.TYPES:
|
|
222
|
+
raise FormatterLoader.UnknownFormatterType(formatter_type)
|
|
223
|
+
return FormatterLoader.TYPES[formatter_type]()
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _raw_data(transcript: AnyTranscript) -> Any:
|
|
227
|
+
"""What the JSON and pretty formatters print: ``to_raw_data()``, or 0.6's list as given."""
|
|
228
|
+
to_raw_data = getattr(transcript, "to_raw_data", None)
|
|
229
|
+
if callable(to_raw_data):
|
|
230
|
+
return to_raw_data()
|
|
231
|
+
return list(transcript)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _snippets(transcript: AnyTranscript) -> list[FetchedTranscriptSnippet]:
|
|
235
|
+
"""The snippets of a transcript; 0.6's dictionaries become snippets."""
|
|
236
|
+
return [
|
|
237
|
+
FetchedTranscriptSnippet(text=line["text"], start=line["start"], duration=line["duration"])
|
|
238
|
+
if isinstance(line, Mapping)
|
|
239
|
+
else line
|
|
240
|
+
for line in transcript
|
|
241
|
+
]
|