ytm 0.2.0__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.
- ytm/__init__.py +1 -0
- ytm/api.py +28 -0
- ytm/auth.py +372 -0
- ytm/cache.py +184 -0
- ytm/cli.py +503 -0
- ytm/config.py +149 -0
- ytm/mpv/autoplay.lua +64 -0
- ytm/music.py +372 -0
- ytm/player.py +442 -0
- ytm/playlists_local.py +183 -0
- ytm/state.py +79 -0
- ytm/tui/__init__.py +1 -0
- ytm/tui/app.py +657 -0
- ytm/tui/app.tcss +118 -0
- ytm/tui/backend.py +364 -0
- ytm/tui/lyrics.py +29 -0
- ytm/tui/nowplaying.py +203 -0
- ytm/tui/playlists.py +111 -0
- ytm/tui/queue.py +58 -0
- ytm/tui/search.py +54 -0
- ytm/tui/widgets.py +22 -0
- ytm-0.2.0.dist-info/METADATA +208 -0
- ytm-0.2.0.dist-info/RECORD +27 -0
- ytm-0.2.0.dist-info/WHEEL +5 -0
- ytm-0.2.0.dist-info/entry_points.txt +2 -0
- ytm-0.2.0.dist-info/licenses/LICENSE +20 -0
- ytm-0.2.0.dist-info/top_level.txt +1 -0
ytm/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""YouTube Music CLI and daemon."""
|
ytm/api.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Compatibility shim: the catalogue layer now lives in :mod:`ytm.music`.
|
|
2
|
+
|
|
3
|
+
Kept only until the daemon and Textual TUI that import it are removed.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from ytm.music import * # noqa: F401,F403
|
|
7
|
+
from ytm.music import ( # noqa: F401
|
|
8
|
+
Playlist,
|
|
9
|
+
Track,
|
|
10
|
+
UPLOAD_VIDEO_TYPE,
|
|
11
|
+
_album_name,
|
|
12
|
+
_duration,
|
|
13
|
+
_join_artists,
|
|
14
|
+
_wrap_ytmusic_error,
|
|
15
|
+
add_playlist_items,
|
|
16
|
+
create_playlist,
|
|
17
|
+
delete_playlist,
|
|
18
|
+
edit_playlist,
|
|
19
|
+
get_lyrics,
|
|
20
|
+
get_playlist,
|
|
21
|
+
is_upload,
|
|
22
|
+
library_playlists,
|
|
23
|
+
remove_playlist_items,
|
|
24
|
+
search,
|
|
25
|
+
to_playlist,
|
|
26
|
+
to_track,
|
|
27
|
+
to_tracks,
|
|
28
|
+
)
|
ytm/auth.py
ADDED
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
"""Authentication module."""
|
|
2
|
+
import getpass
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import time
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import ytmusicapi
|
|
9
|
+
from yt_dlp.cookies import extract_cookies_from_browser
|
|
10
|
+
from ytmusicapi.auth.oauth.credentials import OAuthCredentials
|
|
11
|
+
from ytmusicapi.auth.oauth.exceptions import BadOAuthClient, UnauthorizedOAuthClient
|
|
12
|
+
from ytmusicapi.auth.oauth.token import OAuthToken
|
|
13
|
+
from ytmusicapi.exceptions import YTMusicError
|
|
14
|
+
|
|
15
|
+
AUTH_PATH = Path.home() / ".config" / "ytm" / "auth.json"
|
|
16
|
+
|
|
17
|
+
# Order in which --from-browser auto-detection tries local browser profiles.
|
|
18
|
+
_AUTODETECT_BROWSERS = ("chrome", "chromium", "edge", "brave", "vivaldi", "opera", "firefox")
|
|
19
|
+
|
|
20
|
+
_USER_AGENT = (
|
|
21
|
+
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) "
|
|
22
|
+
"Chrome/124.0.0.0 Safari/537.36"
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
_EXPIRED_HINT = (
|
|
26
|
+
"YouTube Music authentication is no longer valid (browser headers expire "
|
|
27
|
+
"when the session is revoked or the cookie ages out). Run 'ytm auth' to "
|
|
28
|
+
"paste fresh request headers."
|
|
29
|
+
)
|
|
30
|
+
_MISSING_HINT = "No YouTube Music credentials found at {path}. Run 'ytm auth' to set them up."
|
|
31
|
+
|
|
32
|
+
_OAUTH_EXPIRED_HINT = (
|
|
33
|
+
"YouTube Music OAuth authentication is no longer valid (the refresh token "
|
|
34
|
+
"was revoked or rejected). Run 'ytm auth --oauth' to re-authenticate."
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
_OAUTH_CLIENT_MISSING_HINT = (
|
|
38
|
+
"OAuth client credentials are missing (expected alongside {path}). "
|
|
39
|
+
"Run 'ytm auth --oauth' to set them up again."
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class _QuietLogger:
|
|
44
|
+
"""A yt-dlp logger that never prints anything (cookies must never be logged)."""
|
|
45
|
+
|
|
46
|
+
def debug(self, message):
|
|
47
|
+
pass
|
|
48
|
+
|
|
49
|
+
def info(self, message):
|
|
50
|
+
pass
|
|
51
|
+
|
|
52
|
+
def warning(self, message, only_once=False):
|
|
53
|
+
pass
|
|
54
|
+
|
|
55
|
+
def error(self, message):
|
|
56
|
+
pass
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class AuthError(Exception):
|
|
60
|
+
"""Base class for authentication problems."""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class AuthMissing(AuthError):
|
|
64
|
+
"""No credentials have been stored yet."""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class AuthExpired(AuthError):
|
|
68
|
+
"""Stored credentials are present but no longer accepted by YouTube Music."""
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def setup(path=AUTH_PATH):
|
|
72
|
+
"""Run the interactive browser-header setup and store credentials at path.
|
|
73
|
+
|
|
74
|
+
ytmusicapi 1.12.1's OAuth path needs a user-provisioned Google Cloud
|
|
75
|
+
client id and secret, so browser headers are the only credentials a human
|
|
76
|
+
can supply interactively. They are also the cookies stream resolution needs.
|
|
77
|
+
"""
|
|
78
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
79
|
+
print("Copy the request headers of an authenticated POST request from")
|
|
80
|
+
print("https://music.youtube.com (devtools > Network > filter '/browse' >")
|
|
81
|
+
print("right click the browse request > Copy > Copy request headers).")
|
|
82
|
+
try:
|
|
83
|
+
headers = ytmusicapi.setup()
|
|
84
|
+
except YTMusicError as exc:
|
|
85
|
+
raise AuthError(str(exc)) from exc
|
|
86
|
+
# os.open with mode 0600 so the file is never briefly world-readable.
|
|
87
|
+
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
88
|
+
with open(fd, "w", encoding="utf-8") as file:
|
|
89
|
+
file.write(headers)
|
|
90
|
+
os.chmod(path, 0o600)
|
|
91
|
+
return path
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _oauth_client_path(path):
|
|
95
|
+
"""Where the OAuth app's client_id/client_secret are stored, alongside path.
|
|
96
|
+
|
|
97
|
+
Kept separate from the token file because ytmusicapi rewrites the token
|
|
98
|
+
file on every refresh with only token fields (see RefreshingToken.store_token),
|
|
99
|
+
which would silently drop client_id/client_secret if they lived in the same file.
|
|
100
|
+
"""
|
|
101
|
+
return path.parent / "oauth_client.json"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _write_json_0600(path, data):
|
|
105
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
106
|
+
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
107
|
+
with open(fd, "w", encoding="utf-8") as file:
|
|
108
|
+
json.dump(data, file)
|
|
109
|
+
os.chmod(path, 0o600)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _resolve_oauth_client(client_id, client_secret):
|
|
113
|
+
"""Resolve client_id/client_secret: explicit args > env vars > interactive prompt."""
|
|
114
|
+
client_id = client_id or os.environ.get("YTM_OAUTH_CLIENT_ID")
|
|
115
|
+
client_secret = client_secret or os.environ.get("YTM_OAUTH_CLIENT_SECRET")
|
|
116
|
+
if not client_id:
|
|
117
|
+
client_id = input("Google Cloud OAuth client ID: ").strip()
|
|
118
|
+
if not client_secret:
|
|
119
|
+
client_secret = getpass.getpass("Google Cloud OAuth client secret: ").strip()
|
|
120
|
+
if not client_id or not client_secret:
|
|
121
|
+
raise AuthError(
|
|
122
|
+
"An OAuth client_id and client_secret are required. Create a 'TVs and "
|
|
123
|
+
"Limited Input devices' OAuth client in Google Cloud Console and pass "
|
|
124
|
+
"them via --client-id/--client-secret, YTM_OAUTH_CLIENT_ID/"
|
|
125
|
+
"YTM_OAUTH_CLIENT_SECRET, or the interactive prompt."
|
|
126
|
+
)
|
|
127
|
+
return client_id, client_secret
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _load_oauth_client(path):
|
|
131
|
+
"""Return the stored (client_id, client_secret) for the OAuth token at path."""
|
|
132
|
+
client_path = _oauth_client_path(path)
|
|
133
|
+
try:
|
|
134
|
+
with open(client_path, encoding="utf-8") as file:
|
|
135
|
+
data = json.load(file)
|
|
136
|
+
return data["client_id"], data["client_secret"]
|
|
137
|
+
except (OSError, ValueError, KeyError) as exc:
|
|
138
|
+
raise AuthMissing(_OAUTH_CLIENT_MISSING_HINT.format(path=path)) from exc
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def oauth_setup(
|
|
142
|
+
client_id=None,
|
|
143
|
+
client_secret=None,
|
|
144
|
+
path=AUTH_PATH,
|
|
145
|
+
credentials_factory=None,
|
|
146
|
+
sleep=time.sleep,
|
|
147
|
+
):
|
|
148
|
+
"""Run the OAuth device-code flow and store the resulting refreshable token at path.
|
|
149
|
+
|
|
150
|
+
Prints a verification URL and short user code for the user to enter on another
|
|
151
|
+
device, then polls token_from_code at the interval YouTube's response specifies
|
|
152
|
+
until authorised (or the device code expires). The client_id/client_secret are
|
|
153
|
+
persisted separately (see _oauth_client_path) since they are needed again for
|
|
154
|
+
every future token refresh.
|
|
155
|
+
"""
|
|
156
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
157
|
+
client_id, client_secret = _resolve_oauth_client(client_id, client_secret)
|
|
158
|
+
_write_json_0600(_oauth_client_path(path), {"client_id": client_id, "client_secret": client_secret})
|
|
159
|
+
|
|
160
|
+
make_credentials = credentials_factory or OAuthCredentials
|
|
161
|
+
credentials = make_credentials(client_id, client_secret)
|
|
162
|
+
try:
|
|
163
|
+
code = credentials.get_code()
|
|
164
|
+
except Exception as exc:
|
|
165
|
+
raise AuthError(f"Could not start the OAuth device flow: {exc}") from exc
|
|
166
|
+
|
|
167
|
+
print(f"Go to {code['verification_url']} and enter the code: {code['user_code']}")
|
|
168
|
+
print("Waiting for you to authorise this device...")
|
|
169
|
+
|
|
170
|
+
interval = code.get("interval", 5)
|
|
171
|
+
deadline = time.time() + code.get("expires_in", 1800)
|
|
172
|
+
raw = None
|
|
173
|
+
while True:
|
|
174
|
+
sleep(interval)
|
|
175
|
+
raw = credentials.token_from_code(code["device_code"])
|
|
176
|
+
if "access_token" in raw:
|
|
177
|
+
break
|
|
178
|
+
error = raw.get("error")
|
|
179
|
+
if error == "slow_down":
|
|
180
|
+
interval += 5
|
|
181
|
+
elif error != "authorization_pending":
|
|
182
|
+
raise AuthError(f"OAuth device authorisation failed: {raw}")
|
|
183
|
+
if time.time() > deadline:
|
|
184
|
+
raise AuthError(
|
|
185
|
+
"OAuth device code expired before authorisation completed; "
|
|
186
|
+
"run 'ytm auth --oauth' again."
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
token = {
|
|
190
|
+
"scope": raw["scope"],
|
|
191
|
+
"token_type": raw["token_type"],
|
|
192
|
+
"access_token": raw["access_token"],
|
|
193
|
+
"refresh_token": raw["refresh_token"],
|
|
194
|
+
"expires_in": raw["expires_in"],
|
|
195
|
+
"expires_at": int(time.time()) + raw["expires_in"],
|
|
196
|
+
}
|
|
197
|
+
_write_json_0600(path, token)
|
|
198
|
+
return path
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _cookie_header_from_jar(jar):
|
|
202
|
+
"""Build a Cookie header value from a http.cookiejar-style jar of youtube.com cookies.
|
|
203
|
+
|
|
204
|
+
Returns None if the jar has no usable logged-in YouTube session (no __Secure-3PAPISID).
|
|
205
|
+
"""
|
|
206
|
+
pairs = [(cookie.name, cookie.value) for cookie in jar if "youtube.com" in cookie.domain]
|
|
207
|
+
if not any(name == "__Secure-3PAPISID" for name, _ in pairs):
|
|
208
|
+
return None
|
|
209
|
+
return "; ".join(f"{name}={value}" for name, value in pairs)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _extract_browser_cookie_header(browser_name):
|
|
213
|
+
"""Return a Cookie header value extracted from browser_name's profile, or None."""
|
|
214
|
+
try:
|
|
215
|
+
jar = extract_cookies_from_browser(browser_name, logger=_QuietLogger())
|
|
216
|
+
except Exception:
|
|
217
|
+
return None
|
|
218
|
+
return _cookie_header_from_jar(jar)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def from_browser(browser=None, path=AUTH_PATH, client_factory=None):
|
|
222
|
+
"""Extract YouTube cookies from a local browser profile and store credentials at path.
|
|
223
|
+
|
|
224
|
+
If browser is None, tries each of _AUTODETECT_BROWSERS in turn and uses the first
|
|
225
|
+
that yields a logged-in YouTube cookie set. Validates the extracted credentials with
|
|
226
|
+
a live call before leaving the auth file in place; on failure the file is removed
|
|
227
|
+
and AuthError is raised so a dead auth file is never left behind silently.
|
|
228
|
+
"""
|
|
229
|
+
candidates = [browser] if browser else list(_AUTODETECT_BROWSERS)
|
|
230
|
+
cookie_header = None
|
|
231
|
+
for name in candidates:
|
|
232
|
+
cookie_header = _extract_browser_cookie_header(name)
|
|
233
|
+
if cookie_header:
|
|
234
|
+
break
|
|
235
|
+
if cookie_header is None:
|
|
236
|
+
raise AuthError(
|
|
237
|
+
"No logged-in YouTube session found in "
|
|
238
|
+
+ ", ".join(candidates)
|
|
239
|
+
+ ". Log in at https://music.youtube.com in one of these browsers first, "
|
|
240
|
+
"then run 'ytm auth --from-browser' again."
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
headers = {
|
|
244
|
+
"cookie": cookie_header,
|
|
245
|
+
"x-goog-authuser": "0",
|
|
246
|
+
"user-agent": _USER_AGENT,
|
|
247
|
+
# Placeholder so ytmusicapi recognises this as browser auth; it is
|
|
248
|
+
# regenerated from the cookie's SAPISID on every request.
|
|
249
|
+
"authorization": "SAPISIDHASH 0_0",
|
|
250
|
+
"origin": "https://music.youtube.com",
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
254
|
+
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
255
|
+
with open(fd, "w", encoding="utf-8") as file:
|
|
256
|
+
json.dump(headers, file)
|
|
257
|
+
os.chmod(path, 0o600)
|
|
258
|
+
|
|
259
|
+
make_client = client_factory or client
|
|
260
|
+
try:
|
|
261
|
+
make_client(path).search("test", limit=1)
|
|
262
|
+
except Exception as exc:
|
|
263
|
+
path.unlink(missing_ok=True)
|
|
264
|
+
raise AuthError(
|
|
265
|
+
"Extracted browser cookies were written but did not authenticate "
|
|
266
|
+
"successfully; no auth file was left behind. Make sure you are logged "
|
|
267
|
+
"in at https://music.youtube.com and try again. "
|
|
268
|
+
f"Underlying error: {exc}"
|
|
269
|
+
) from exc
|
|
270
|
+
return path
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def load_headers(path=AUTH_PATH):
|
|
274
|
+
"""Return the stored request headers.
|
|
275
|
+
|
|
276
|
+
Raises AuthMissing if no usable credentials have been stored.
|
|
277
|
+
"""
|
|
278
|
+
try:
|
|
279
|
+
with open(path, encoding="utf-8") as file:
|
|
280
|
+
return json.load(file)
|
|
281
|
+
except (OSError, ValueError) as exc:
|
|
282
|
+
raise AuthMissing(_MISSING_HINT.format(path=path)) from exc
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def load_cookies(path=AUTH_PATH):
|
|
286
|
+
"""Return the stored Cookie header value, for reuse by stream resolution.
|
|
287
|
+
|
|
288
|
+
OAuth auth files have no cookies (there is no browser session to extract
|
|
289
|
+
one from), so this returns None for them rather than raising -- stream
|
|
290
|
+
resolution falls back to cookie-less requests, see ytm/resolve.py.
|
|
291
|
+
"""
|
|
292
|
+
headers = load_headers(path)
|
|
293
|
+
if OAuthToken.is_oauth(headers):
|
|
294
|
+
return None
|
|
295
|
+
for key, value in headers.items():
|
|
296
|
+
if key.lower() == "cookie":
|
|
297
|
+
return value
|
|
298
|
+
raise AuthMissing(_MISSING_HINT.format(path=path))
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
COOKIES_PATH = AUTH_PATH.parent / "cookies.txt"
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def cookies_file(path=AUTH_PATH, cookies_path=COOKIES_PATH):
|
|
305
|
+
"""A Netscape-format cookie file for yt-dlp, derived from the stored auth.
|
|
306
|
+
|
|
307
|
+
yt-dlp (and therefore mpv's ytdl_hook) reads cookies from a file, while
|
|
308
|
+
ytmusicapi keeps them as one Cookie header in auth.json. This writes the
|
|
309
|
+
header out in the file format, refreshing it whenever auth.json is newer,
|
|
310
|
+
so re-authenticating is the only step the user ever takes. Returns None
|
|
311
|
+
when there are no cookies to write (OAuth auth, or not authenticated).
|
|
312
|
+
"""
|
|
313
|
+
try:
|
|
314
|
+
header = load_cookies(path)
|
|
315
|
+
except AuthError:
|
|
316
|
+
return None
|
|
317
|
+
if header is None:
|
|
318
|
+
return None
|
|
319
|
+
cookies_path = Path(cookies_path)
|
|
320
|
+
try:
|
|
321
|
+
fresh = cookies_path.stat().st_mtime >= Path(path).stat().st_mtime
|
|
322
|
+
except OSError:
|
|
323
|
+
fresh = False
|
|
324
|
+
if fresh:
|
|
325
|
+
return str(cookies_path)
|
|
326
|
+
lines = ["# Netscape HTTP Cookie File"]
|
|
327
|
+
for pair in header.split(";"):
|
|
328
|
+
name, _, value = pair.strip().partition("=")
|
|
329
|
+
if name:
|
|
330
|
+
lines.append(f".youtube.com\tTRUE\t/\tTRUE\t2147483647\t{name}\t{value}")
|
|
331
|
+
_write_text_0600(cookies_path, "\n".join(lines) + "\n")
|
|
332
|
+
return str(cookies_path)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _write_text_0600(path, text):
|
|
336
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
337
|
+
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
338
|
+
with open(fd, "w", encoding="utf-8") as file:
|
|
339
|
+
file.write(text)
|
|
340
|
+
os.chmod(path, 0o600)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def client(path=AUTH_PATH, credentials_factory=None):
|
|
344
|
+
"""Return an authenticated ytmusicapi client, for either auth kind stored at path."""
|
|
345
|
+
headers = load_headers(path)
|
|
346
|
+
if OAuthToken.is_oauth(headers):
|
|
347
|
+
return _oauth_client(path, credentials_factory)
|
|
348
|
+
try:
|
|
349
|
+
return ytmusicapi.YTMusic(headers)
|
|
350
|
+
except YTMusicError as exc:
|
|
351
|
+
raise AuthExpired(_EXPIRED_HINT) from exc
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _oauth_client(path, credentials_factory=None):
|
|
355
|
+
"""Build a YTMusic client from a stored OAuth token, eagerly refreshing if due."""
|
|
356
|
+
client_id, client_secret = _load_oauth_client(path)
|
|
357
|
+
make_credentials = credentials_factory or OAuthCredentials
|
|
358
|
+
credentials = make_credentials(client_id, client_secret)
|
|
359
|
+
try:
|
|
360
|
+
ytm = ytmusicapi.YTMusic(str(path), oauth_credentials=credentials)
|
|
361
|
+
# Touching access_token triggers RefreshingToken's auto-refresh (and
|
|
362
|
+
# persists it back to path) if the stored token is due to expire, so a
|
|
363
|
+
# revoked/invalid refresh token surfaces here rather than mid-request.
|
|
364
|
+
_ = ytm._token.access_token
|
|
365
|
+
except (YTMusicError, UnauthorizedOAuthClient, BadOAuthClient) as exc:
|
|
366
|
+
raise AuthExpired(_OAUTH_EXPIRED_HINT) from exc
|
|
367
|
+
return ytm
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def is_expiry(exc):
|
|
371
|
+
"""Whether a ytmusicapi error indicates credentials are no longer accepted."""
|
|
372
|
+
return any(code in str(exc) for code in ("HTTP 401", "HTTP 403"))
|
ytm/cache.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""Offline cache of downloaded track audio.
|
|
2
|
+
|
|
3
|
+
Downloads a ``videoId``'s audio via yt-dlp (reusing the cookie/options
|
|
4
|
+
approach from :mod:`ytm.resolve`) into a cache directory under
|
|
5
|
+
``~/.cache/ytm/tracks``, so a track can be replayed later without hitting
|
|
6
|
+
the network or resolving a (short-lived) stream URL at all.
|
|
7
|
+
|
|
8
|
+
Two correctness properties matter more than anything else here:
|
|
9
|
+
|
|
10
|
+
* A partial/interrupted download must never be mistaken for a complete
|
|
11
|
+
cache entry. Downloads land in a private temp directory and are only
|
|
12
|
+
moved into the cache directory -- via an atomic rename -- once yt-dlp has
|
|
13
|
+
finished successfully. If anything raises along the way, the temp
|
|
14
|
+
directory is discarded and the cache directory never sees a partial file.
|
|
15
|
+
* The cache has a size cap enforced by evicting the least-recently-*used*
|
|
16
|
+
entries (recency of playback, tracked via each file's mtime, which is
|
|
17
|
+
touched on every read), not least-recently-downloaded.
|
|
18
|
+
|
|
19
|
+
Unlike stream URLs, the files this module writes are the point -- they are
|
|
20
|
+
supposed to persist. What must never be persisted is a resolved stream URL
|
|
21
|
+
itself (see ytm.resolve); this module never touches those.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
import os
|
|
25
|
+
import shutil
|
|
26
|
+
import tempfile
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
import yt_dlp
|
|
30
|
+
|
|
31
|
+
from ytm import config as config_mod
|
|
32
|
+
|
|
33
|
+
#: default location for cached track audio
|
|
34
|
+
DEFAULT_CACHE_DIR = Path.home() / ".cache" / "ytm" / "tracks"
|
|
35
|
+
|
|
36
|
+
#: default total size cap for the cache, in bytes
|
|
37
|
+
DEFAULT_CAP_BYTES = 2 * 1024**3
|
|
38
|
+
|
|
39
|
+
_WATCH_URL = "https://www.youtube.com/watch?v={video_id}"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class CacheError(Exception):
|
|
43
|
+
"""A download completed without producing a usable file."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _resolve_cache_dir(cache_dir=None):
|
|
47
|
+
"""The cache directory to use, created if necessary."""
|
|
48
|
+
cache_dir = Path(cache_dir) if cache_dir is not None else DEFAULT_CACHE_DIR
|
|
49
|
+
cache_dir.mkdir(parents=True, exist_ok=True)
|
|
50
|
+
return cache_dir
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# -- lookup ------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def get_cached_path(video_id, cache_dir=None, touch=True):
|
|
57
|
+
"""The local path for `video_id` if fully cached, else None.
|
|
58
|
+
|
|
59
|
+
Marks the entry as just-used (for LRU purposes) unless `touch` is False.
|
|
60
|
+
"""
|
|
61
|
+
cache_dir = _resolve_cache_dir(cache_dir)
|
|
62
|
+
matches = sorted(cache_dir.glob(f"{video_id}.*"))
|
|
63
|
+
if not matches:
|
|
64
|
+
return None
|
|
65
|
+
path = matches[0]
|
|
66
|
+
if touch:
|
|
67
|
+
os.utime(path, None)
|
|
68
|
+
return path
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def is_cached(video_id, cache_dir=None):
|
|
72
|
+
"""Whether `video_id` has a complete cache entry."""
|
|
73
|
+
return get_cached_path(video_id, cache_dir=cache_dir, touch=False) is not None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def list_cached(cache_dir=None):
|
|
77
|
+
"""All complete cache entries as dicts with video_id, path, size, mtime."""
|
|
78
|
+
cache_dir = _resolve_cache_dir(cache_dir)
|
|
79
|
+
entries = []
|
|
80
|
+
for path in sorted(cache_dir.glob("*")):
|
|
81
|
+
if not path.is_file():
|
|
82
|
+
continue
|
|
83
|
+
stat = path.stat()
|
|
84
|
+
entries.append(
|
|
85
|
+
{
|
|
86
|
+
"video_id": path.stem,
|
|
87
|
+
"path": path,
|
|
88
|
+
"size": stat.st_size,
|
|
89
|
+
"mtime": stat.st_mtime,
|
|
90
|
+
}
|
|
91
|
+
)
|
|
92
|
+
return entries
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# -- mutation -----------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def remove(video_id, cache_dir=None):
|
|
99
|
+
"""Delete the cache entry for `video_id`, if any. Returns whether removed."""
|
|
100
|
+
cache_dir = _resolve_cache_dir(cache_dir)
|
|
101
|
+
removed = False
|
|
102
|
+
for path in cache_dir.glob(f"{video_id}.*"):
|
|
103
|
+
path.unlink()
|
|
104
|
+
removed = True
|
|
105
|
+
return removed
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def download(video_id, cache_dir=None, cap_bytes=DEFAULT_CAP_BYTES, ydl_class=yt_dlp.YoutubeDL):
|
|
109
|
+
"""Download `video_id`'s audio into the cache and return its local path.
|
|
110
|
+
|
|
111
|
+
Downloads to a private temporary directory first; the file is only
|
|
112
|
+
moved into the cache directory (via an atomic rename) after a
|
|
113
|
+
successful download, so an interrupted download never leaves behind
|
|
114
|
+
something that looks like a complete cache entry. Applies the LRU size
|
|
115
|
+
cap (if any) after the download lands.
|
|
116
|
+
"""
|
|
117
|
+
cache_dir = _resolve_cache_dir(cache_dir)
|
|
118
|
+
tmp_root = cache_dir / ".tmp"
|
|
119
|
+
tmp_root.mkdir(parents=True, exist_ok=True)
|
|
120
|
+
work_dir = Path(tempfile.mkdtemp(dir=tmp_root))
|
|
121
|
+
try:
|
|
122
|
+
outtmpl = str(work_dir / f"{video_id}.%(ext)s")
|
|
123
|
+
url = _WATCH_URL.format(video_id=video_id)
|
|
124
|
+
with ydl_class(_ydl_opts(outtmpl)) as ydl:
|
|
125
|
+
ydl.extract_info(url, download=True)
|
|
126
|
+
|
|
127
|
+
downloaded = [p for p in work_dir.glob(f"{video_id}.*") if p.is_file()]
|
|
128
|
+
if not downloaded:
|
|
129
|
+
raise CacheError(f"download of {video_id!r} produced no file")
|
|
130
|
+
src = downloaded[0]
|
|
131
|
+
dest = cache_dir / src.name
|
|
132
|
+
remove(video_id, cache_dir=cache_dir) # drop a stale entry with a different ext
|
|
133
|
+
os.replace(src, dest)
|
|
134
|
+
finally:
|
|
135
|
+
shutil.rmtree(work_dir, ignore_errors=True)
|
|
136
|
+
|
|
137
|
+
if cap_bytes is not None:
|
|
138
|
+
enforce_cap(cap_bytes, cache_dir=cache_dir)
|
|
139
|
+
return dest
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def enforce_cap(cap_bytes, cache_dir=None):
|
|
143
|
+
"""Evict least-recently-used entries until the cache is under `cap_bytes`.
|
|
144
|
+
|
|
145
|
+
Recency is each file's mtime, which callers touch on every use via
|
|
146
|
+
`get_cached_path`. Returns the list of evicted video_ids.
|
|
147
|
+
"""
|
|
148
|
+
cache_dir = _resolve_cache_dir(cache_dir)
|
|
149
|
+
entries = sorted(list_cached(cache_dir=cache_dir), key=lambda e: e["mtime"])
|
|
150
|
+
total = sum(e["size"] for e in entries)
|
|
151
|
+
evicted = []
|
|
152
|
+
for entry in entries:
|
|
153
|
+
if total <= cap_bytes:
|
|
154
|
+
break
|
|
155
|
+
try:
|
|
156
|
+
entry["path"].unlink()
|
|
157
|
+
except OSError:
|
|
158
|
+
continue
|
|
159
|
+
total -= entry["size"]
|
|
160
|
+
evicted.append(entry["video_id"])
|
|
161
|
+
return evicted
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
# -- playback integration -----------------------------------------------------
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _ydl_opts(outtmpl):
|
|
168
|
+
"""yt-dlp options for an anonymous audio download into `outtmpl`.
|
|
169
|
+
|
|
170
|
+
Anonymous on purpose: with account cookies YouTube serves URLs that need
|
|
171
|
+
an account-bound PO token, and the download then fails with 403 (the
|
|
172
|
+
same reason ``behaviour.authenticated_streams`` defaults to off).
|
|
173
|
+
"""
|
|
174
|
+
opts = {
|
|
175
|
+
"format": "bestaudio",
|
|
176
|
+
"quiet": True,
|
|
177
|
+
"no_warnings": True,
|
|
178
|
+
"noplaylist": True,
|
|
179
|
+
"outtmpl": outtmpl,
|
|
180
|
+
}
|
|
181
|
+
pot = config_mod.load()["pot"]
|
|
182
|
+
if pot["enabled"]:
|
|
183
|
+
opts["extractor_args"] = {"youtubepot-bgutilhttp": {"base_url": [pot["base_url"]]}}
|
|
184
|
+
return opts
|