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
@@ -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
+ ]