u-transcript-max 0.1.0a1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. u_transcript_max-0.1.0a1.dist-info/METADATA +67 -0
  2. u_transcript_max-0.1.0a1.dist-info/RECORD +77 -0
  3. u_transcript_max-0.1.0a1.dist-info/WHEEL +4 -0
  4. u_transcript_max-0.1.0a1.dist-info/entry_points.txt +2 -0
  5. u_transcript_max-0.1.0a1.dist-info/licenses/LICENSE +21 -0
  6. utmax/__init__.py +651 -0
  7. utmax/_version.py +1 -0
  8. utmax/adapters/__init__.py +1 -0
  9. utmax/adapters/downloader.py +479 -0
  10. utmax/adapters/ffmpeg.py +174 -0
  11. utmax/adapters/files.py +130 -0
  12. utmax/adapters/http.py +285 -0
  13. utmax/adapters/innertube.py +207 -0
  14. utmax/adapters/providers/__init__.py +45 -0
  15. utmax/adapters/providers/base.py +177 -0
  16. utmax/adapters/providers/claude.py +108 -0
  17. utmax/adapters/providers/gemini.py +133 -0
  18. utmax/adapters/providers/openai.py +169 -0
  19. utmax/adapters/providers/openrouter.py +59 -0
  20. utmax/adapters/watch_page.py +73 -0
  21. utmax/client.py +300 -0
  22. utmax/compat/__init__.py +134 -0
  23. utmax/compat/_api.py +203 -0
  24. utmax/compat/_bridge.py +212 -0
  25. utmax/compat/_errors.py +300 -0
  26. utmax/compat/_settings.py +13 -0
  27. utmax/compat/_transcripts.py +440 -0
  28. utmax/compat/formatters.py +241 -0
  29. utmax/compat/proxies.py +129 -0
  30. utmax/core/__init__.py +6 -0
  31. utmax/core/bilingual.py +107 -0
  32. utmax/core/browse.py +397 -0
  33. utmax/core/captions.py +251 -0
  34. utmax/core/clients.py +113 -0
  35. utmax/core/downloads.py +165 -0
  36. utmax/core/filenames.py +298 -0
  37. utmax/core/formats.py +198 -0
  38. utmax/core/ids.py +210 -0
  39. utmax/core/languages.py +371 -0
  40. utmax/core/media/__init__.py +7 -0
  41. utmax/core/media/boxes.py +266 -0
  42. utmax/core/media/fmp4.py +394 -0
  43. utmax/core/media/moov.py +299 -0
  44. utmax/core/media/mux.py +423 -0
  45. utmax/core/media/progressive.py +267 -0
  46. utmax/core/media/tables.py +129 -0
  47. utmax/core/media/tx3g.py +191 -0
  48. utmax/core/playability.py +62 -0
  49. utmax/core/player.py +90 -0
  50. utmax/core/retry.py +45 -0
  51. utmax/core/segmentation.py +104 -0
  52. utmax/core/selection.py +92 -0
  53. utmax/core/streams.py +264 -0
  54. utmax/core/translate/__init__.py +1 -0
  55. utmax/core/translate/batching.py +157 -0
  56. utmax/core/translate/data/protocol.json +17 -0
  57. utmax/core/translate/data/request.schema.json +49 -0
  58. utmax/core/translate/data/response.schema.json +19 -0
  59. utmax/core/translate/data/system_prompt.txt +21 -0
  60. utmax/core/translate/protocol.py +101 -0
  61. utmax/core/translate/spec.py +49 -0
  62. utmax/core/ytdata.py +32 -0
  63. utmax/errors.py +523 -0
  64. utmax/mcp/__init__.py +30 -0
  65. utmax/mcp/__main__.py +6 -0
  66. utmax/mcp/config.py +56 -0
  67. utmax/mcp/server.py +626 -0
  68. utmax/models.py +499 -0
  69. utmax/providers.py +36 -0
  70. utmax/py.typed +0 -0
  71. utmax/services/__init__.py +1 -0
  72. utmax/services/bulk.py +454 -0
  73. utmax/services/collections.py +183 -0
  74. utmax/services/download.py +375 -0
  75. utmax/services/transcripts.py +87 -0
  76. utmax/services/translation.py +211 -0
  77. utmax/transport.py +81 -0
utmax/compat/_api.py ADDED
@@ -0,0 +1,203 @@
1
+ """``YouTubeTranscriptApi``, compatible with youtube-transcript-api 1.2.4 and the class methods
2
+ of 0.6."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import builtins
7
+ import os
8
+ import warnings
9
+ from collections.abc import Iterable, Mapping
10
+ from typing import Any
11
+
12
+ from utmax.compat._transcripts import FetchedTranscript, TranscriptList, TranscriptListFetcher
13
+ from utmax.compat.proxies import GenericProxyConfig, ProxyConfig
14
+
15
+ __all__ = [
16
+ "FetchedTranscript",
17
+ "ProxyConfig",
18
+ "TranscriptList",
19
+ "TranscriptListFetcher",
20
+ "YouTubeTranscriptApi",
21
+ ]
22
+
23
+ _LegacyProxies = ProxyConfig | Mapping[str, str] | None
24
+ _RawTranscript = builtins.list[dict[str, Any]]
25
+
26
+
27
+ class YouTubeTranscriptApi:
28
+ """Fetches transcripts with utmax; a drop-in replacement for youtube-transcript-api's class.
29
+
30
+ Unlike youtube-transcript-api, an instance is thread-safe (unless ``http_client`` is not),
31
+ and every method also accepts YouTube video URLs.
32
+
33
+ Args:
34
+ proxy_config: proxies for all requests, such as ``GenericProxyConfig`` or
35
+ ``WebshareProxyConfig``. Without ``http_client`` only ``http://`` proxies work.
36
+ http_client: a ``requests.Session``-like object that sends every request instead of
37
+ utmax's own HTTP code: use it for SOCKS or HTTPS proxies, custom certificates or
38
+ shared cookies.
39
+ """
40
+
41
+ def __init__(
42
+ self,
43
+ proxy_config: ProxyConfig | None = None,
44
+ http_client: Any = None,
45
+ ) -> None:
46
+ if http_client is not None:
47
+ http_client.headers.update({"Accept-Language": "en-US"})
48
+ if proxy_config is not None:
49
+ http_client.proxies = proxy_config.to_requests_dict()
50
+ if proxy_config.prevent_keeping_connections_alive:
51
+ http_client.headers.update({"Connection": "close"})
52
+ self._fetcher = TranscriptListFetcher(http_client, proxy_config=proxy_config)
53
+
54
+ def fetch(
55
+ self,
56
+ video_id: str,
57
+ languages: Iterable[str] = ("en",),
58
+ preserve_formatting: bool = False,
59
+ ) -> FetchedTranscript:
60
+ """
61
+ Retrieves the transcript for a single video. This is just a shortcut for
62
+ calling:
63
+ `YouTubeTranscriptApi().list(video_id).find_transcript(languages).fetch(preserve_formatting=preserve_formatting)`
64
+
65
+ :param video_id: the ID or URL of the video you want to retrieve the transcript for.
66
+ :param languages: A list of language codes in a descending priority. For
67
+ example, if this is set to ["de", "en"] it will first try to fetch the
68
+ german transcript (de) and then fetch the english transcript (en) if
69
+ it fails to do so. This defaults to ["en"].
70
+ :param preserve_formatting: whether to keep select HTML text formatting
71
+ """
72
+ return (
73
+ self.list(video_id)
74
+ .find_transcript(languages)
75
+ .fetch(preserve_formatting=preserve_formatting)
76
+ )
77
+
78
+ def list(
79
+ self,
80
+ video_id: str,
81
+ ) -> TranscriptList:
82
+ """
83
+ Retrieves the list of transcripts which are available for a given video. It
84
+ returns a `TranscriptList` object which is iterable and provides methods to
85
+ filter the list of transcripts for specific languages. While iterating over
86
+ the `TranscriptList` the individual transcripts are represented by
87
+ `Transcript` objects, which provide metadata and can either be fetched by
88
+ calling `transcript.fetch()` or translated by calling `transcript.translate(
89
+ 'en')`.
90
+
91
+ :param video_id: the ID or URL of the video you want to retrieve the transcript for.
92
+ """
93
+ return self._fetcher.fetch(video_id)
94
+
95
+ @classmethod
96
+ def list_transcripts(
97
+ cls,
98
+ video_id: str,
99
+ proxies: _LegacyProxies = None,
100
+ cookies: str | os.PathLike[str] | None = None,
101
+ ) -> TranscriptList:
102
+ """
103
+ DEPRECATED: use the `list` method instead!
104
+
105
+ The class method of youtube-transcript-api 0.6. ``proxies`` is a ``ProxyConfig`` or a
106
+ requests-style ``{"http": url, "https": url}`` mapping; ``cookies`` is ignored with a
107
+ warning, because cookie authentication is not supported.
108
+ """
109
+ warnings.warn(
110
+ "`list_transcripts` is deprecated and will be removed in a future version. "
111
+ "Use the `list` method instead!",
112
+ DeprecationWarning,
113
+ stacklevel=2,
114
+ )
115
+ if cookies:
116
+ warnings.warn(
117
+ "Cookie authentication is not supported; the cookies argument is ignored.",
118
+ UserWarning,
119
+ stacklevel=2,
120
+ )
121
+ return cls(proxy_config=_legacy_proxy_config(proxies)).list(video_id)
122
+
123
+ @classmethod
124
+ def get_transcripts( # noqa: PLR0917
125
+ cls,
126
+ video_ids: builtins.list[str],
127
+ languages: Iterable[str] = ("en",),
128
+ continue_after_error: bool = False,
129
+ proxies: _LegacyProxies = None,
130
+ cookies: str | os.PathLike[str] | None = None,
131
+ preserve_formatting: bool = False,
132
+ ) -> tuple[dict[str, _RawTranscript], builtins.list[str]]:
133
+ """
134
+ DEPRECATED: use the `fetch` method instead!
135
+
136
+ Retrieves the transcripts for a list of videos.
137
+
138
+ :return: a tuple containing a dictionary mapping video ids onto their corresponding
139
+ transcripts (lists of ``{"text", "start", "duration"}`` dictionaries), and a list of
140
+ video ids, which could not be retrieved
141
+ """
142
+ warnings.warn(
143
+ "`get_transcripts` is deprecated and will be removed in a future version. "
144
+ "Use the `fetch` method instead!",
145
+ DeprecationWarning,
146
+ stacklevel=2,
147
+ )
148
+ if not isinstance(video_ids, builtins.list):
149
+ raise AssertionError("`video_ids` must be a list of strings")
150
+
151
+ data: dict[str, _RawTranscript] = {}
152
+ unretrievable_videos: builtins.list[str] = []
153
+
154
+ for video_id in video_ids:
155
+ try:
156
+ data[video_id] = cls.get_transcript(
157
+ video_id, languages, proxies, cookies, preserve_formatting
158
+ )
159
+ except Exception:
160
+ if not continue_after_error:
161
+ raise
162
+
163
+ unretrievable_videos.append(video_id)
164
+
165
+ return data, unretrievable_videos
166
+
167
+ @classmethod
168
+ def get_transcript(
169
+ cls,
170
+ video_id: str,
171
+ languages: Iterable[str] = ("en",),
172
+ proxies: _LegacyProxies = None,
173
+ cookies: str | os.PathLike[str] | None = None,
174
+ preserve_formatting: bool = False,
175
+ ) -> _RawTranscript:
176
+ """
177
+ DEPRECATED: use the `fetch` method instead!
178
+
179
+ Retrieves the transcript for a single video as a list of ``{"text", "start",
180
+ "duration"}`` dictionaries.
181
+ """
182
+ warnings.warn(
183
+ "`get_transcript` is deprecated and will be removed in a future version. "
184
+ "Use the `fetch` method instead!",
185
+ DeprecationWarning,
186
+ stacklevel=2,
187
+ )
188
+ if not isinstance(video_id, str):
189
+ raise AssertionError("`video_id` must be a string")
190
+ return (
191
+ cls.list_transcripts(video_id, proxies, cookies)
192
+ .find_transcript(languages)
193
+ .fetch(preserve_formatting=preserve_formatting)
194
+ .to_raw_data()
195
+ )
196
+
197
+
198
+ def _legacy_proxy_config(proxies: _LegacyProxies) -> ProxyConfig | None:
199
+ if not proxies:
200
+ return None
201
+ if isinstance(proxies, ProxyConfig):
202
+ return proxies
203
+ return GenericProxyConfig(http_url=proxies.get("http"), https_url=proxies.get("https"))
@@ -0,0 +1,212 @@
1
+ """How utmax.compat reaches YouTube: utmax's transports and InnerTube client, with utmax errors
2
+ turned into the exceptions of youtube-transcript-api."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import re
7
+ from typing import Any
8
+
9
+ from utmax import errors
10
+ from utmax.adapters.http import RetryingTransport, UrllibTransport, redact
11
+ from utmax.adapters.innertube import InnerTubeClient
12
+ from utmax.compat._errors import (
13
+ AgeRestricted,
14
+ CouldNotRetrieveTranscript,
15
+ FailedToCreateConsentCookie,
16
+ InvalidVideoId,
17
+ IpBlocked,
18
+ PoTokenRequired,
19
+ RequestBlocked,
20
+ TranscriptsDisabled,
21
+ VideoUnavailable,
22
+ VideoUnplayable,
23
+ YouTubeDataUnparsable,
24
+ YouTubeRequestFailed,
25
+ )
26
+ from utmax.compat.proxies import InvalidProxyConfig, ProxyConfig
27
+ from utmax.core.ids import parse_video_id
28
+ from utmax.core.player import PlayerData
29
+ from utmax.transport import HttpRequest, HttpResponse, Transport
30
+
31
+ __all__ = [
32
+ "TIMEOUT",
33
+ "Connection",
34
+ "SessionTransport",
35
+ "compat_error",
36
+ "connection_for",
37
+ "make_transport",
38
+ "video_id_of",
39
+ ]
40
+
41
+ TIMEOUT = 30.0
42
+ """Seconds before a request is abandoned (also passed to an ``http_client``)."""
43
+
44
+ _SIMPLE_ERRORS: tuple[tuple[type[errors.UTMaxError], type[CouldNotRetrieveTranscript]], ...] = (
45
+ (errors.VideoUnavailable, VideoUnavailable),
46
+ (errors.AgeRestricted, AgeRestricted),
47
+ (errors.PoTokenRequired, PoTokenRequired),
48
+ (errors.FailedToCreateConsentCookie, FailedToCreateConsentCookie),
49
+ (errors.YouTubeDataUnparsable, YouTubeDataUnparsable),
50
+ (errors.TranscriptsDisabled, TranscriptsDisabled),
51
+ (errors.InvalidVideoId, InvalidVideoId),
52
+ )
53
+ _DECODED_HEADERS = frozenset({"content-encoding", "content-length", "transfer-encoding"})
54
+ _QUERY_RUN = re.compile(r"\?[^\s)'\"]+")
55
+ """A ``?`` and what follows it up to whitespace, a closing parenthesis or a quote."""
56
+
57
+
58
+ class SessionTransport:
59
+ """Sends utmax's requests through a ``requests.Session``-like ``http_client``.
60
+
61
+ The client needs ``request(method, url, headers=..., data=..., timeout=...)`` returning an
62
+ object with ``status_code``, ``headers`` and ``content`` (the decoded body); that is how
63
+ SOCKS proxies, custom certificates or shared cookies reach utmax without utmax depending on
64
+ requests.
65
+ """
66
+
67
+ def __init__(self, session: Any, *, timeout: float = TIMEOUT) -> None:
68
+ self._session = session
69
+ self._timeout = timeout
70
+
71
+ def send(self, request: HttpRequest) -> HttpResponse:
72
+ try:
73
+ response = self._session.request(
74
+ request.method,
75
+ request.url,
76
+ headers=dict(request.headers),
77
+ data=request.body,
78
+ timeout=self._timeout,
79
+ )
80
+ except Exception as error: # the session's own exception types are unknown here
81
+ reason = _without_query(str(error), request.url)
82
+ raise errors.NetworkError(
83
+ f"Could not complete {request.method} {redact(request.url)}: {reason}"
84
+ ) from error
85
+ headers = {
86
+ str(name): str(value)
87
+ for name, value in response.headers.items()
88
+ if str(name).lower() not in _DECODED_HEADERS
89
+ }
90
+ return HttpResponse(
91
+ status=int(response.status_code),
92
+ url=request.url,
93
+ headers=headers,
94
+ body=bytes(response.content),
95
+ )
96
+
97
+
98
+ def _without_query(text: str, url: str) -> str:
99
+ """``text`` without the query string of ``url`` and without any other ``?...`` run.
100
+
101
+ A session's own error text repeats the request (``Max retries exceeded with url:
102
+ /api/timedtext?...&sig=...``), and the query of a caption URL holds the client's IP address,
103
+ an expiry time and a signature, none of which belongs in a message (spec section 7).
104
+ """
105
+ query = url.partition("?")[2].partition("#")[0]
106
+ if query:
107
+ text = text.replace(f"?{query}", "").replace(query, "")
108
+ return _QUERY_RUN.sub("", text)
109
+
110
+
111
+ def make_transport(http_client: Any, proxy_config: ProxyConfig | None) -> Transport:
112
+ """The transport for an ``http_client`` (used as given) or for utmax's own HTTP stack.
113
+
114
+ Raises:
115
+ InvalidProxyConfig: utmax's own stack cannot use the proxy of ``proxy_config``.
116
+ """
117
+ if http_client is not None:
118
+ return SessionTransport(http_client)
119
+ proxy = None
120
+ if proxy_config is not None:
121
+ proxies = proxy_config.to_requests_dict()
122
+ proxy = proxies.get("https") or proxies.get("http")
123
+ if not proxy:
124
+ raise InvalidProxyConfig(
125
+ f"{type(proxy_config).__name__}.to_requests_dict() names no proxy URL."
126
+ )
127
+ try:
128
+ inner = UrllibTransport(proxy=proxy, timeout=TIMEOUT)
129
+ except errors.InvalidOption as error:
130
+ raise InvalidProxyConfig(
131
+ f"{error} Without an http_client only http:// proxies work; for https:// or "
132
+ "SOCKS proxies pass http_client=requests.Session() to YouTubeTranscriptApi."
133
+ ) from error
134
+ return RetryingTransport(inner)
135
+
136
+
137
+ class Connection:
138
+ """utmax's InnerTube client behind one ``YouTubeTranscriptApi`` and its transcripts."""
139
+
140
+ def __init__(self, transport: Transport, *, proxy_config: ProxyConfig | None = None) -> None:
141
+ retries = proxy_config.retries_when_blocked if proxy_config is not None else 0
142
+ self._innertube = InnerTubeClient(transport, block_retries=max(0, retries))
143
+ self.proxy_config = proxy_config
144
+
145
+ def player(self, video: str) -> tuple[str, PlayerData]:
146
+ """The video ID in ``video`` and its player response, which lists subtitle tracks.
147
+
148
+ Raises:
149
+ CouldNotRetrieveTranscript: the subclass matching what went wrong.
150
+ """
151
+ video_id = video_id_of(video)
152
+ try:
153
+ player = self._innertube.player(video_id, purpose="captions")
154
+ except errors.UTMaxError as error:
155
+ raise compat_error(error, video_id, self.proxy_config) from error
156
+ if not player.caption_tracks:
157
+ raise TranscriptsDisabled(video_id)
158
+ return video_id, player
159
+
160
+ def caption_text(self, url: str, video_id: str) -> str:
161
+ """The legacy XML of the caption track at ``url``.
162
+
163
+ Raises:
164
+ CouldNotRetrieveTranscript: the subclass matching what went wrong.
165
+ """
166
+ try:
167
+ response = self._innertube.fetch_captions(url, video_id=video_id, fmt=None)
168
+ except errors.UTMaxError as error:
169
+ raise compat_error(error, video_id) from error
170
+ return response.text
171
+
172
+
173
+ def connection_for(http_client: Any) -> Connection:
174
+ """The connection of a ``Transcript``: the one utmax made, or one around ``http_client``."""
175
+ if isinstance(http_client, Connection):
176
+ return http_client
177
+ return Connection(make_transport(http_client, None))
178
+
179
+
180
+ def video_id_of(video: str) -> str:
181
+ """The ID in ``video``, a bare video ID or any YouTube video URL.
182
+
183
+ Raises:
184
+ InvalidVideoId: ``video`` is a URL without a video ID.
185
+ VideoUnavailable: ``video`` cannot be a video ID (what YouTube would answer).
186
+ """
187
+ try:
188
+ return parse_video_id(video)
189
+ except errors.InvalidVideoId as error:
190
+ if video.strip().startswith(("http://", "https://")):
191
+ raise InvalidVideoId(video) from error
192
+ raise VideoUnavailable(video) from error
193
+
194
+
195
+ def compat_error(
196
+ error: errors.UTMaxError, video_id: str, proxy_config: ProxyConfig | None = None
197
+ ) -> CouldNotRetrieveTranscript:
198
+ """The youtube-transcript-api exception for a utmax error.
199
+
200
+ Blocks carry ``proxy_config`` (their message depends on it). Network failures and any other
201
+ error become ``YouTubeRequestFailed`` with the utmax message as the reason.
202
+ """
203
+ if isinstance(error, errors.IpBlocked):
204
+ return IpBlocked(video_id).with_proxy_config(proxy_config)
205
+ if isinstance(error, errors.RequestBlocked):
206
+ return RequestBlocked(video_id).with_proxy_config(proxy_config)
207
+ if isinstance(error, errors.VideoUnplayable):
208
+ return VideoUnplayable(video_id, error.reason or None, list(error.sub_reasons))
209
+ for source, target in _SIMPLE_ERRORS:
210
+ if isinstance(error, source):
211
+ return target(video_id)
212
+ return YouTubeRequestFailed(video_id, error)