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,73 @@
1
+ """Last resort: read the InnerTube API key from the watch page, passing the EU consent wall."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ from utmax.core.clients import DESKTOP_USER_AGENT
8
+ from utmax.errors import (
9
+ FailedToCreateConsentCookie,
10
+ IpBlocked,
11
+ YouTubeDataUnparsable,
12
+ YouTubeRequestFailed,
13
+ )
14
+ from utmax.transport import HttpRequest, Transport
15
+
16
+ __all__ = ["extract_api_key", "fetch_api_key"]
17
+
18
+ _WATCH_URL = "https://www.youtube.com/watch?v={video_id}"
19
+ _API_KEY = re.compile(r'"INNERTUBE_API_KEY":\s*"([a-zA-Z0-9_-]+)"')
20
+ _CONSENT_FORM = 'action="https://consent.youtube.com/s"'
21
+ _CONSENT_VALUE = re.compile(r'name="v" value="(.*?)"')
22
+ _HEADERS = {
23
+ "User-Agent": DESKTOP_USER_AGENT,
24
+ "Accept-Language": "en-US,en;q=0.9",
25
+ "Accept-Encoding": "gzip",
26
+ }
27
+
28
+
29
+ def fetch_api_key(transport: Transport, video_id: str) -> str:
30
+ """Download the watch page (accepting cookies for this call only) and read the API key."""
31
+ page = _watch_page(transport, video_id, cookie=None)
32
+ if _CONSENT_FORM in page:
33
+ match = _CONSENT_VALUE.search(page)
34
+ if match is None:
35
+ raise FailedToCreateConsentCookie(
36
+ "Could not read YouTube's cookie-consent form.", video_id=video_id
37
+ )
38
+ page = _watch_page(transport, video_id, cookie=f"CONSENT=YES+{match.group(1)}")
39
+ if _CONSENT_FORM in page:
40
+ raise FailedToCreateConsentCookie(
41
+ "YouTube kept asking for cookie consent.", video_id=video_id
42
+ )
43
+ return extract_api_key(page, video_id=video_id)
44
+
45
+
46
+ def extract_api_key(page: str, *, video_id: str) -> str:
47
+ """The ``INNERTUBE_API_KEY`` embedded in a watch page."""
48
+ match = _API_KEY.search(page)
49
+ if match is not None:
50
+ return match.group(1)
51
+ if 'class="g-recaptcha"' in page:
52
+ raise IpBlocked("YouTube answered with a CAPTCHA page.", video_id=video_id)
53
+ raise YouTubeDataUnparsable(
54
+ "Could not find the InnerTube API key on the watch page.", video_id=video_id
55
+ )
56
+
57
+
58
+ def _watch_page(transport: Transport, video_id: str, *, cookie: str | None) -> str:
59
+ headers = dict(_HEADERS)
60
+ if cookie is not None:
61
+ headers["Cookie"] = cookie
62
+ response = transport.send(HttpRequest("GET", _WATCH_URL.format(video_id=video_id), headers))
63
+ if response.status == 429:
64
+ raise IpBlocked(
65
+ "YouTube rate-limited the watch page request (HTTP 429).", video_id=video_id
66
+ )
67
+ if response.status != 200:
68
+ raise YouTubeRequestFailed(
69
+ f"The watch page answered HTTP {response.status}.",
70
+ status_code=response.status,
71
+ video_id=video_id,
72
+ )
73
+ return response.text
utmax/client.py ADDED
@@ -0,0 +1,300 @@
1
+ """The Client: configuration plus one connection pipeline that every call goes through."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import threading
7
+ from collections.abc import Callable, Iterable, Sequence
8
+ from functools import partial
9
+ from typing import Any, Self
10
+
11
+ from utmax.adapters.http import RetryingTransport, UrllibTransport, open_stream
12
+ from utmax.adapters.innertube import InnerTubeClient
13
+ from utmax.adapters.providers import Translator, create_translator
14
+ from utmax.core.bilingual import bilingual
15
+ from utmax.core.downloads import DEFAULT_CHUNK_SIZE
16
+ from utmax.errors import InvalidOption
17
+ from utmax.models import (
18
+ BulkReport,
19
+ BulkResult,
20
+ CollectionKind,
21
+ Container,
22
+ DownloadResult,
23
+ FormatName,
24
+ Progress,
25
+ Quality,
26
+ SubtitleMode,
27
+ TrackList,
28
+ Transcript,
29
+ VideoEntry,
30
+ VideoInfo,
31
+ VideoList,
32
+ )
33
+ from utmax.services.bulk import DEFAULT_DOWNLOAD_NAME, DEFAULT_TRANSCRIPT_NAME, BulkService
34
+ from utmax.services.collections import CollectionService
35
+ from utmax.services.download import DownloadOptions, DownloadService
36
+ from utmax.services.transcripts import TranscriptService
37
+ from utmax.services.translation import translate
38
+ from utmax.transport import Transport
39
+
40
+ __all__ = ["Client"]
41
+
42
+
43
+ class Client:
44
+ """A configured, thread-safe connection to YouTube.
45
+
46
+ Module-level functions such as :func:`utmax.fetch` share a default ``Client``. Create your
47
+ own for a proxy, timeouts or a retry policy::
48
+
49
+ with utmax.Client(proxy="http://user:pass@host:8080", timeout=20) as client:
50
+ transcript = client.fetch("dQw4w9WgXcQ")
51
+
52
+ Args:
53
+ proxy: ``http://[user:password@]host:port`` used for all YouTube traffic.
54
+ timeout: seconds before a request is abandoned.
55
+ retries: extra attempts for timeouts, connection resets and HTTP 408/5xx.
56
+ block_retries: extra attempts, each on a new connection, when YouTube blocks the IP
57
+ (useful with rotating proxies).
58
+ force_ipv4: connect over IPv4 only; try it when downloads fail with StreamForbidden on a
59
+ machine with both IPv4 and IPv6, because stream URLs are bound to one address.
60
+ transport: a custom :class:`utmax.transport.Transport` replacing the whole HTTP stack;
61
+ ``timeout`` and ``retries`` are then ignored and ``proxy`` must not be set.
62
+ """
63
+
64
+ def __init__(
65
+ self,
66
+ *,
67
+ proxy: str | None = None,
68
+ timeout: float = 30.0,
69
+ retries: int = 2,
70
+ block_retries: int = 0,
71
+ force_ipv4: bool = False,
72
+ transport: Transport | None = None,
73
+ ) -> None:
74
+ if timeout <= 0:
75
+ raise InvalidOption("timeout must be a positive number of seconds.")
76
+ if retries < 0 or block_retries < 0:
77
+ raise InvalidOption("retries and block_retries cannot be negative.")
78
+ if transport is not None and proxy is not None:
79
+ raise InvalidOption(
80
+ "Pass either proxy= or transport=, not both; configure the proxy in your transport."
81
+ )
82
+ if transport is not None and force_ipv4:
83
+ raise InvalidOption(
84
+ "force_ipv4 applies to utmax's own HTTP stack; configure IPv4 in your transport."
85
+ )
86
+ if transport is None:
87
+ transport = RetryingTransport(
88
+ UrllibTransport(proxy=proxy, timeout=timeout, force_ipv4=force_ipv4),
89
+ retries=retries,
90
+ )
91
+ self._transport = transport
92
+ innertube = InnerTubeClient(transport, block_retries=block_retries)
93
+ self._transcripts = TranscriptService(innertube)
94
+ self._downloads = DownloadService(
95
+ innertube, self._transcripts, partial(open_stream, transport)
96
+ )
97
+ self._collections = CollectionService(innertube)
98
+ self._bulk = BulkService(self._transcripts, self._downloads)
99
+
100
+ def fetch(
101
+ self,
102
+ video: str,
103
+ languages: Sequence[str] | str | None = None,
104
+ *,
105
+ include_manual: bool = True,
106
+ include_generated: bool = True,
107
+ preserve_formatting: bool = False,
108
+ youtube_translation: str | None = None,
109
+ ) -> Transcript:
110
+ """Fetch the best subtitle track of ``video``; see :func:`utmax.fetch`."""
111
+ return self._transcripts.fetch(
112
+ video,
113
+ languages,
114
+ include_manual=include_manual,
115
+ include_generated=include_generated,
116
+ preserve_formatting=preserve_formatting,
117
+ youtube_translation=youtube_translation,
118
+ )
119
+
120
+ def list_tracks(self, video: str) -> TrackList:
121
+ """Every subtitle track of ``video``, in YouTube's order."""
122
+ return self._transcripts.list_tracks(video)
123
+
124
+ def video_info(self, video: str) -> VideoInfo:
125
+ """Title, channel and duration of ``video``."""
126
+ return self._transcripts.video_info(video)
127
+
128
+ def translator(self, model: str, **options: Any) -> Translator:
129
+ """The translator for ``"provider=model-id"``; see :func:`utmax.translator`."""
130
+ return create_translator(model, **options)
131
+
132
+ def translate(
133
+ self,
134
+ transcript: Transcript,
135
+ to: str,
136
+ *,
137
+ model: str | Translator,
138
+ instructions: str | None = None,
139
+ resegment: bool | None = None,
140
+ **options: Any,
141
+ ) -> Transcript:
142
+ """Translate ``transcript`` with an AI model; see :func:`utmax.translate`."""
143
+ return translate(
144
+ transcript,
145
+ to,
146
+ model=model,
147
+ instructions=instructions,
148
+ resegment=resegment,
149
+ **options,
150
+ )
151
+
152
+ def bilingual(
153
+ self, original: Transcript, translation: Transcript, *, translation_first: bool = False
154
+ ) -> Transcript:
155
+ """Combine a transcript and its translation; see :func:`utmax.bilingual`."""
156
+ return bilingual(original, translation, translation_first=translation_first)
157
+
158
+ def download(
159
+ self,
160
+ video: str,
161
+ path: str | os.PathLike[str],
162
+ *,
163
+ format: Container | None = None,
164
+ quality: Quality = "compat",
165
+ subtitles: Sequence[str | Transcript] | None = None,
166
+ subtitle_mode: SubtitleMode = "embed",
167
+ default_subtitle: str | None = None,
168
+ connections: int = 4,
169
+ chunk_size: int = DEFAULT_CHUNK_SIZE,
170
+ resume: bool = True,
171
+ overwrite: bool = False,
172
+ ffmpeg: str | os.PathLike[str] | None = None,
173
+ progress: Callable[[Progress], None] | None = None,
174
+ cancel: threading.Event | None = None,
175
+ ) -> DownloadResult:
176
+ """Download a video or its audio; see :func:`utmax.download`."""
177
+ options = DownloadOptions(
178
+ format=format,
179
+ quality=quality,
180
+ subtitles=subtitles,
181
+ subtitle_mode=subtitle_mode,
182
+ default_subtitle=default_subtitle,
183
+ connections=connections,
184
+ chunk_size=chunk_size,
185
+ resume=resume,
186
+ overwrite=overwrite,
187
+ ffmpeg=ffmpeg,
188
+ progress=progress,
189
+ cancel=cancel,
190
+ )
191
+ return self._downloads.download(video, path, options)
192
+
193
+ def list_videos(
194
+ self, source: str, *, kind: CollectionKind | None = None, limit: int | None = None
195
+ ) -> VideoList:
196
+ """The videos of a playlist or channel; see :func:`utmax.list_videos`."""
197
+ return self._collections.list_videos(source, kind=kind, limit=limit)
198
+
199
+ def fetch_many(
200
+ self,
201
+ videos: Iterable[str | VideoEntry],
202
+ *,
203
+ out_dir: str | os.PathLike[str] | None = None,
204
+ format: FormatName = "srt",
205
+ languages: Sequence[str] | str | None = None,
206
+ include_manual: bool = True,
207
+ include_generated: bool = True,
208
+ concurrency: int = 4,
209
+ skip_existing: bool = True,
210
+ filename: str = DEFAULT_TRANSCRIPT_NAME,
211
+ progress: Callable[[BulkResult[Transcript]], None] | None = None,
212
+ ) -> BulkReport[Transcript]:
213
+ """Fetch the transcripts of many videos; see :func:`utmax.fetch_many`."""
214
+ return self._bulk.fetch_many(
215
+ videos,
216
+ out_dir=out_dir,
217
+ format=format,
218
+ languages=languages,
219
+ include_manual=include_manual,
220
+ include_generated=include_generated,
221
+ concurrency=concurrency,
222
+ skip_existing=skip_existing,
223
+ filename=filename,
224
+ progress=progress,
225
+ )
226
+
227
+ def translate_many(
228
+ self,
229
+ videos: Iterable[str | VideoEntry],
230
+ to: str,
231
+ *,
232
+ model: str | Translator,
233
+ out_dir: str | os.PathLike[str] | None = None,
234
+ format: FormatName = "srt",
235
+ languages: Sequence[str] | str | None = None,
236
+ bilingual: bool = False,
237
+ instructions: str | None = None,
238
+ resegment: bool | None = None,
239
+ concurrency: int = 2,
240
+ skip_existing: bool = True,
241
+ filename: str = DEFAULT_TRANSCRIPT_NAME,
242
+ progress: Callable[[BulkResult[Transcript]], None] | None = None,
243
+ **options: Any,
244
+ ) -> BulkReport[Transcript]:
245
+ """Fetch and translate many transcripts; see :func:`utmax.translate_many`."""
246
+ return self._bulk.translate_many(
247
+ videos,
248
+ to,
249
+ model=model,
250
+ out_dir=out_dir,
251
+ format=format,
252
+ languages=languages,
253
+ bilingual=bilingual,
254
+ instructions=instructions,
255
+ resegment=resegment,
256
+ concurrency=concurrency,
257
+ skip_existing=skip_existing,
258
+ filename=filename,
259
+ progress=progress,
260
+ **options,
261
+ )
262
+
263
+ def download_many(
264
+ self,
265
+ videos: Iterable[str | VideoEntry],
266
+ out_dir: str | os.PathLike[str],
267
+ *,
268
+ format: Container = "mp4",
269
+ quality: Quality = "compat",
270
+ subtitles: Sequence[str] | None = None,
271
+ subtitle_mode: SubtitleMode = "embed",
272
+ concurrency: int = 2,
273
+ skip_existing: bool = True,
274
+ filename: str = DEFAULT_DOWNLOAD_NAME,
275
+ ffmpeg: str | os.PathLike[str] | None = None,
276
+ progress: Callable[[BulkResult[DownloadResult]], None] | None = None,
277
+ ) -> BulkReport[DownloadResult]:
278
+ """Download many videos into a folder; see :func:`utmax.download_many`."""
279
+ return self._bulk.download_many(
280
+ videos,
281
+ out_dir,
282
+ format=format,
283
+ quality=quality,
284
+ subtitles=subtitles,
285
+ subtitle_mode=subtitle_mode,
286
+ concurrency=concurrency,
287
+ skip_existing=skip_existing,
288
+ filename=filename,
289
+ ffmpeg=ffmpeg,
290
+ progress=progress,
291
+ )
292
+
293
+ def close(self) -> None:
294
+ """Release resources; utmax keeps no open connections today, so this does nothing yet."""
295
+
296
+ def __enter__(self) -> Self:
297
+ return self
298
+
299
+ def __exit__(self, *exc_info: object) -> None:
300
+ self.close()
@@ -0,0 +1,134 @@
1
+ """A drop-in replacement for youtube-transcript-api 1.2.4 that runs on utmax.
2
+
3
+ Change the import and the rest of the code stays as it is::
4
+
5
+ from utmax.compat import YouTubeTranscriptApi # was: from youtube_transcript_api import ...
6
+
7
+ transcript = YouTubeTranscriptApi().fetch("dQw4w9WgXcQ", languages=["de", "en"])
8
+
9
+ The modules mirror youtube-transcript-api's (``utmax.compat.formatters``,
10
+ ``utmax.compat.proxies``, ...) with the same classes, signatures, attributes and messages. The
11
+ class methods of version 0.6 (``get_transcript``, ``get_transcripts``, ``list_transcripts``) and
12
+ its exception names work too. Unlike the original, ``YouTubeTranscriptApi`` is thread-safe,
13
+ accepts video URLs, and needs no third-party packages.
14
+
15
+ For libraries that import ``youtube_transcript_api`` themselves, call :func:`install` once,
16
+ before they import it.
17
+ """
18
+
19
+ # utmax.compat reproduces the interface, messages and output formats of youtube-transcript-api
20
+ # (https://github.com/jdepoix/youtube-transcript-api), which is distributed under this license:
21
+ #
22
+ # MIT License
23
+ #
24
+ # Copyright (c) 2018 Jonas Depoix
25
+ #
26
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
27
+ # of this software and associated documentation files (the "Software"), to deal
28
+ # in the Software without restriction, including without limitation the rights
29
+ # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
30
+ # copies of the Software, and to permit persons to whom the Software is
31
+ # furnished to do so, subject to the following conditions:
32
+ #
33
+ # The above copyright notice and this permission notice shall be included in all
34
+ # copies or substantial portions of the Software.
35
+ #
36
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
37
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
38
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
39
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
40
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
41
+ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
42
+ # SOFTWARE.
43
+
44
+ from __future__ import annotations
45
+
46
+ import sys
47
+
48
+ from utmax.compat import _api, _errors, _settings, _transcripts, formatters, proxies
49
+ from utmax.compat._api import YouTubeTranscriptApi
50
+ from utmax.compat._errors import (
51
+ AgeRestricted,
52
+ CookieError,
53
+ CookieInvalid,
54
+ CookiePathInvalid,
55
+ CookiesInvalid,
56
+ CouldNotRetrieveTranscript,
57
+ FailedToCreateConsentCookie,
58
+ InvalidVideoId,
59
+ IpBlocked,
60
+ NoTranscriptAvailable,
61
+ NoTranscriptFound,
62
+ NotTranslatable,
63
+ PoTokenRequired,
64
+ RequestBlocked,
65
+ TooManyRequests,
66
+ TranscriptsDisabled,
67
+ TranslationLanguageNotAvailable,
68
+ VideoUnavailable,
69
+ VideoUnplayable,
70
+ YouTubeDataUnparsable,
71
+ YouTubeRequestFailed,
72
+ YouTubeTranscriptApiException,
73
+ )
74
+ from utmax.compat._transcripts import (
75
+ FetchedTranscript,
76
+ FetchedTranscriptSnippet,
77
+ Transcript,
78
+ TranscriptList,
79
+ )
80
+
81
+ __all__ = [
82
+ "AgeRestricted",
83
+ "CookieError",
84
+ "CookieInvalid",
85
+ "CookiePathInvalid",
86
+ "CookiesInvalid",
87
+ "CouldNotRetrieveTranscript",
88
+ "FailedToCreateConsentCookie",
89
+ "FetchedTranscript",
90
+ "FetchedTranscriptSnippet",
91
+ "InvalidVideoId",
92
+ "IpBlocked",
93
+ "NoTranscriptAvailable",
94
+ "NoTranscriptFound",
95
+ "NotTranslatable",
96
+ "PoTokenRequired",
97
+ "RequestBlocked",
98
+ "TooManyRequests",
99
+ "Transcript",
100
+ "TranscriptList",
101
+ "TranscriptsDisabled",
102
+ "TranslationLanguageNotAvailable",
103
+ "VideoUnavailable",
104
+ "VideoUnplayable",
105
+ "YouTubeDataUnparsable",
106
+ "YouTubeRequestFailed",
107
+ "YouTubeTranscriptApi",
108
+ "YouTubeTranscriptApiException",
109
+ "install",
110
+ ]
111
+
112
+ _MODULE = "youtube_transcript_api"
113
+
114
+
115
+ def install() -> None:
116
+ """Make ``import youtube_transcript_api`` load utmax.compat.
117
+
118
+ For libraries that import youtube-transcript-api themselves (such as LangChain's YouTube
119
+ loader): call it once, before they import it. It registers ``youtube_transcript_api`` and
120
+ its modules in ``sys.modules``, replacing a real installation for every import that
121
+ follows; modules that imported it earlier keep what they have. Calling it again changes
122
+ nothing.
123
+ """
124
+ package = sys.modules[__name__]
125
+ modules = {
126
+ _MODULE: package,
127
+ f"{_MODULE}._api": _api,
128
+ f"{_MODULE}._errors": _errors,
129
+ f"{_MODULE}._settings": _settings,
130
+ f"{_MODULE}._transcripts": _transcripts,
131
+ f"{_MODULE}.formatters": formatters,
132
+ f"{_MODULE}.proxies": proxies,
133
+ }
134
+ sys.modules.update(modules)