transmute-cli 0.2.3a0__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.
- transmute/__init__.py +1 -0
- transmute/__main__.py +4 -0
- transmute/app.py +1481 -0
- transmute/commands.py +321 -0
- transmute/config.py +21 -0
- transmute/downloader.py +190 -0
- transmute/enrich.py +865 -0
- transmute/history.py +858 -0
- transmute/iphone.py +247 -0
- transmute/keys.py +152 -0
- transmute/layout.py +125 -0
- transmute/main.py +38 -0
- transmute/settings.py +114 -0
- transmute/style.py +32 -0
- transmute/sync.py +532 -0
- transmute/widgets.py +86 -0
- transmute_cli-0.2.3a0.dist-info/METADATA +335 -0
- transmute_cli-0.2.3a0.dist-info/RECORD +21 -0
- transmute_cli-0.2.3a0.dist-info/WHEEL +4 -0
- transmute_cli-0.2.3a0.dist-info/entry_points.txt +2 -0
- transmute_cli-0.2.3a0.dist-info/licenses/LICENSE +674 -0
transmute/commands.py
ADDED
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
"""Slash commands. Methods named cmd_<name> are dispatched from /<name>."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import getpass
|
|
6
|
+
import os
|
|
7
|
+
import subprocess
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from .config import QUALITIES
|
|
11
|
+
|
|
12
|
+
# The command reference shown in the scrollable /help panel above the live
|
|
13
|
+
# prompt, rather than streamed through the transient message log.
|
|
14
|
+
HELP_ROWS = [
|
|
15
|
+
("/out [dir]", "show or set output dir; no arg opens a separate ~/ editor"),
|
|
16
|
+
("/quality [128|192|256|320]", "show or set MP3 bitrate (kbps)"),
|
|
17
|
+
("/enrich [codex|claude|api|on|off]", "choose or toggle metadata enrichment"),
|
|
18
|
+
("/key [clear]", "securely set or clear one API key"),
|
|
19
|
+
("/list", "show recent converted and failed tracks across sessions"),
|
|
20
|
+
("/retry", "requeue all retryable failed downloads"),
|
|
21
|
+
("/sync [spotify|music|retry]", "preview Spotify sync; a/A select tracks, Enter continues"),
|
|
22
|
+
("/login [codex|claude]", "log in to a subscription provider"),
|
|
23
|
+
("/logout [codex|claude]", "log out of a subscription provider"),
|
|
24
|
+
("/clear", "clear history and messages"),
|
|
25
|
+
("/help", "show commands while keeping URL input live; Esc closes help"),
|
|
26
|
+
("/quit", "exit (or ctrl-d / double ctrl-c)"),
|
|
27
|
+
("↑/↓", "select failed or low-confidence entries in History"),
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Commands:
|
|
32
|
+
def __init__(self, app) -> None:
|
|
33
|
+
self.app = app
|
|
34
|
+
|
|
35
|
+
def dispatch(self, text: str) -> None:
|
|
36
|
+
"""Handle a /command line. /quit and friends exit the app."""
|
|
37
|
+
name, _, arg = text.lstrip("/").partition(" ")
|
|
38
|
+
if name in ("quit", "exit", "q"):
|
|
39
|
+
self.app.app.exit()
|
|
40
|
+
return
|
|
41
|
+
handler = getattr(self, f"cmd_{name}", None)
|
|
42
|
+
if handler:
|
|
43
|
+
handler(arg.strip())
|
|
44
|
+
else:
|
|
45
|
+
self.app.msg("class:err", f"unknown command: /{name} (/help for commands)")
|
|
46
|
+
|
|
47
|
+
def cmd_help(self, _arg: str) -> None:
|
|
48
|
+
self.app.open_help()
|
|
49
|
+
|
|
50
|
+
def cmd_sync(self, arg: str) -> None:
|
|
51
|
+
if arg == "music":
|
|
52
|
+
self.app.msg("class:dim", "Import the output folder into macOS Music, then use Finder > iPhone > Music to sync. iPhone Music export is unsupported.")
|
|
53
|
+
elif arg == "retry":
|
|
54
|
+
self.app.retry_sync()
|
|
55
|
+
elif arg in ("", "spotify"):
|
|
56
|
+
self.app.start_sync_preview()
|
|
57
|
+
else:
|
|
58
|
+
self.app.msg("class:err", "sync must be spotify, music, or retry")
|
|
59
|
+
|
|
60
|
+
def _set_out_dir(self, text: str) -> None:
|
|
61
|
+
path = Path(text).expanduser()
|
|
62
|
+
if not path.is_absolute():
|
|
63
|
+
path = Path.home() / text # bare names are home-relative
|
|
64
|
+
try:
|
|
65
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
66
|
+
except OSError as e:
|
|
67
|
+
self.app.msg("class:err", f"can't use {path}: {e}")
|
|
68
|
+
return
|
|
69
|
+
self.app.set_out_dir(path)
|
|
70
|
+
self.app.msg("class:dim", f"output directory: {path}")
|
|
71
|
+
|
|
72
|
+
def cmd_out(self, arg: str) -> None:
|
|
73
|
+
"""`/out <dir>` sets the directory in one shot (resolve, auto-create,
|
|
74
|
+
set). `/out` with no arg opens the interactive picker: the prompt is
|
|
75
|
+
rooted at ~/ (uneditable), completes real folder names as you type, and
|
|
76
|
+
offers to create a missing folder. URL input remains live; Enter applies
|
|
77
|
+
the folder and Esc cancels."""
|
|
78
|
+
if arg:
|
|
79
|
+
self._set_out_dir(arg)
|
|
80
|
+
return
|
|
81
|
+
settings = self.app.settings_snapshot()
|
|
82
|
+
self.app.msg("class:dim", f"output directory: {settings.out_dir}")
|
|
83
|
+
self.app.open_dir_picker()
|
|
84
|
+
|
|
85
|
+
def cmd_quality(self, arg: str) -> None:
|
|
86
|
+
if arg:
|
|
87
|
+
if arg not in QUALITIES:
|
|
88
|
+
self.app.msg(
|
|
89
|
+
"class:err", f"quality must be one of {', '.join(QUALITIES)}"
|
|
90
|
+
)
|
|
91
|
+
return
|
|
92
|
+
self.app.set_quality(arg)
|
|
93
|
+
quality = self.app.settings_snapshot().quality
|
|
94
|
+
self.app.msg("class:dim", f"quality: {quality}k")
|
|
95
|
+
|
|
96
|
+
def cmd_enrich(self, arg: str) -> None:
|
|
97
|
+
if arg == "off":
|
|
98
|
+
self.app.enricher.set_enrichment_enabled(False)
|
|
99
|
+
elif arg == "on":
|
|
100
|
+
self.app.enricher.set_enrichment_enabled(True)
|
|
101
|
+
elif arg == "api":
|
|
102
|
+
if not self.app.enricher.use_api_key():
|
|
103
|
+
self.app.msg("class:err", self.app.enricher.last_error)
|
|
104
|
+
return
|
|
105
|
+
elif arg in ("codex", "claude"):
|
|
106
|
+
self.app.enricher.use_backend(arg)
|
|
107
|
+
elif arg:
|
|
108
|
+
self.app.msg(
|
|
109
|
+
"class:err",
|
|
110
|
+
"enrichment must be codex, claude, api, on, or off",
|
|
111
|
+
)
|
|
112
|
+
return
|
|
113
|
+
state = "on" if self.app.enricher.enabled else "off"
|
|
114
|
+
self.app.msg(
|
|
115
|
+
"class:dim",
|
|
116
|
+
f"metadata enrichment: {state} · {self.app.enricher.backend_label}",
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
def _apply_key(self, key: str) -> None:
|
|
120
|
+
try:
|
|
121
|
+
self.app.enricher.set_api_key(key)
|
|
122
|
+
except ValueError as e:
|
|
123
|
+
self.app.msg("class:err", str(e))
|
|
124
|
+
return
|
|
125
|
+
self.app.msg(
|
|
126
|
+
"class:ok",
|
|
127
|
+
f"{self.app.enricher.backend_label} key active — API enrichment prioritized",
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
def cmd_key(self, arg: str) -> None:
|
|
131
|
+
if arg == "clear":
|
|
132
|
+
self.app.enricher.clear_api_key()
|
|
133
|
+
source = (
|
|
134
|
+
" from environment"
|
|
135
|
+
if self.app.enricher.api_key_source == "environment"
|
|
136
|
+
else ""
|
|
137
|
+
)
|
|
138
|
+
self.app.msg(
|
|
139
|
+
"class:dim",
|
|
140
|
+
f"entered API key cleared · "
|
|
141
|
+
f"{self.app.enricher.backend_label}{source}",
|
|
142
|
+
)
|
|
143
|
+
return
|
|
144
|
+
if arg:
|
|
145
|
+
self.app.msg(
|
|
146
|
+
"class:err",
|
|
147
|
+
"for security, run /key with no argument and use the masked prompt",
|
|
148
|
+
)
|
|
149
|
+
return
|
|
150
|
+
|
|
151
|
+
from prompt_toolkit.application import run_in_terminal
|
|
152
|
+
|
|
153
|
+
app = self.app
|
|
154
|
+
|
|
155
|
+
async def task():
|
|
156
|
+
try:
|
|
157
|
+
key = await run_in_terminal(
|
|
158
|
+
lambda: getpass.getpass(
|
|
159
|
+
"API key (OpenAI sk-… or Anthropic sk-ant-…): "
|
|
160
|
+
)
|
|
161
|
+
)
|
|
162
|
+
except (EOFError, KeyboardInterrupt):
|
|
163
|
+
key = ""
|
|
164
|
+
if key.strip():
|
|
165
|
+
self._apply_key(key)
|
|
166
|
+
else:
|
|
167
|
+
app.msg("class:dim", "API key entry cancelled")
|
|
168
|
+
|
|
169
|
+
app.app.create_background_task(task())
|
|
170
|
+
|
|
171
|
+
def cmd_login(self, arg: str) -> None:
|
|
172
|
+
from prompt_toolkit.application import run_in_terminal
|
|
173
|
+
|
|
174
|
+
app = self.app
|
|
175
|
+
provider = arg or "claude"
|
|
176
|
+
if provider not in ("codex", "claude"):
|
|
177
|
+
app.msg("class:err", "login provider must be codex or claude")
|
|
178
|
+
return
|
|
179
|
+
label = "Codex / ChatGPT" if provider == "codex" else "Claude"
|
|
180
|
+
command = (
|
|
181
|
+
["codex", "login"]
|
|
182
|
+
if provider == "codex"
|
|
183
|
+
else ["claude", "auth", "login", "--claudeai"]
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
async def task():
|
|
187
|
+
def blocking() -> int:
|
|
188
|
+
print(f"Launching {label} login (browser will open)…\n")
|
|
189
|
+
try:
|
|
190
|
+
return subprocess.run(
|
|
191
|
+
command,
|
|
192
|
+
timeout=300,
|
|
193
|
+
check=False,
|
|
194
|
+
).returncode
|
|
195
|
+
except FileNotFoundError:
|
|
196
|
+
return -1
|
|
197
|
+
except subprocess.TimeoutExpired:
|
|
198
|
+
return -2
|
|
199
|
+
|
|
200
|
+
rc = await run_in_terminal(blocking)
|
|
201
|
+
if rc == 0:
|
|
202
|
+
if app.enricher.has_api_key:
|
|
203
|
+
app.msg(
|
|
204
|
+
"class:ok",
|
|
205
|
+
f"logged in to {label} · "
|
|
206
|
+
f"{app.enricher.backend_label} remains prioritized",
|
|
207
|
+
)
|
|
208
|
+
else:
|
|
209
|
+
app.enricher.use_backend(provider)
|
|
210
|
+
app.msg(
|
|
211
|
+
"class:ok",
|
|
212
|
+
f"logged in to {app.enricher.backend_label} "
|
|
213
|
+
"— enrichment enabled",
|
|
214
|
+
)
|
|
215
|
+
elif rc == -1:
|
|
216
|
+
app.msg(
|
|
217
|
+
"class:err",
|
|
218
|
+
f"{provider} CLI not found — install it first",
|
|
219
|
+
)
|
|
220
|
+
elif rc == -2:
|
|
221
|
+
app.msg("class:err", f"{provider} login timed out")
|
|
222
|
+
else:
|
|
223
|
+
app.msg("class:err", "login failed or was cancelled")
|
|
224
|
+
|
|
225
|
+
app.app.create_background_task(task())
|
|
226
|
+
|
|
227
|
+
def cmd_logout(self, arg: str) -> None:
|
|
228
|
+
app = self.app
|
|
229
|
+
provider = arg or "claude"
|
|
230
|
+
if provider not in ("codex", "claude"):
|
|
231
|
+
app.msg("class:err", "logout provider must be codex or claude")
|
|
232
|
+
return
|
|
233
|
+
command = (
|
|
234
|
+
["codex", "logout"] if provider == "codex" else ["claude", "auth", "logout"]
|
|
235
|
+
)
|
|
236
|
+
try:
|
|
237
|
+
proc = subprocess.run(
|
|
238
|
+
command,
|
|
239
|
+
capture_output=True,
|
|
240
|
+
text=True,
|
|
241
|
+
timeout=30,
|
|
242
|
+
check=False,
|
|
243
|
+
)
|
|
244
|
+
except FileNotFoundError:
|
|
245
|
+
app.msg("class:err", f"{provider} CLI not found — nothing to log out of")
|
|
246
|
+
return
|
|
247
|
+
except subprocess.TimeoutExpired:
|
|
248
|
+
app.msg("class:err", f"{provider} logout timed out")
|
|
249
|
+
return
|
|
250
|
+
if proc.returncode == 0:
|
|
251
|
+
if app.enricher.backend == provider:
|
|
252
|
+
app.enricher.enabled = False
|
|
253
|
+
label = "Codex / ChatGPT" if provider == "codex" else "Claude"
|
|
254
|
+
app.msg(
|
|
255
|
+
"class:dim",
|
|
256
|
+
f"logged out of {label}",
|
|
257
|
+
)
|
|
258
|
+
else:
|
|
259
|
+
app.msg(
|
|
260
|
+
"class:err",
|
|
261
|
+
(proc.stderr or proc.stdout).strip()[:120] or "logout failed",
|
|
262
|
+
)
|
|
263
|
+
if provider == "claude" and os.environ.get("ANTHROPIC_API_KEY"):
|
|
264
|
+
app.msg(
|
|
265
|
+
"class:warn",
|
|
266
|
+
"⚠ ANTHROPIC_API_KEY is still set in your shell — "
|
|
267
|
+
"enrichment can still bill the API via that key",
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
def cmd_list(self, _arg: str) -> None:
|
|
271
|
+
app = self.app
|
|
272
|
+
with app.lock:
|
|
273
|
+
completed, failed = list(app.completed), list(app.failed)
|
|
274
|
+
if not completed and not failed:
|
|
275
|
+
app.msg("class:dim", "nothing converted yet")
|
|
276
|
+
return
|
|
277
|
+
for job in completed:
|
|
278
|
+
app.msg("class:ok", f"✔ {job.title or job.url}")
|
|
279
|
+
for job in failed:
|
|
280
|
+
app.msg("class:err", f"✘ {job.url}")
|
|
281
|
+
|
|
282
|
+
def cmd_retry(self, _arg: str) -> None:
|
|
283
|
+
app = self.app
|
|
284
|
+
with app.activity_lock:
|
|
285
|
+
with app.lock:
|
|
286
|
+
failed = [job for job in app.failed if job.retryable]
|
|
287
|
+
if not failed:
|
|
288
|
+
app.msg("class:dim", "no failed downloads to retry")
|
|
289
|
+
return
|
|
290
|
+
|
|
291
|
+
# Persist the queued transition before removing the actionable
|
|
292
|
+
# failures from the current view.
|
|
293
|
+
app._submit_jobs(failed)
|
|
294
|
+
retry_ids = {job.history_id for job in failed}
|
|
295
|
+
with app.lock:
|
|
296
|
+
app.failed = [
|
|
297
|
+
job for job in app.failed if job.history_id not in retry_ids
|
|
298
|
+
]
|
|
299
|
+
app.history = [
|
|
300
|
+
entry
|
|
301
|
+
for entry in app.history
|
|
302
|
+
if not (
|
|
303
|
+
entry.kind == "err"
|
|
304
|
+
and entry.job is not None
|
|
305
|
+
and entry.job.history_id in retry_ids
|
|
306
|
+
)
|
|
307
|
+
]
|
|
308
|
+
app.sel = None
|
|
309
|
+
|
|
310
|
+
def cmd_clear(self, _arg: str) -> None:
|
|
311
|
+
app = self.app
|
|
312
|
+
with app.activity_lock:
|
|
313
|
+
with app.lock:
|
|
314
|
+
app.messages.clear()
|
|
315
|
+
app._persist("clear")
|
|
316
|
+
with app.lock:
|
|
317
|
+
app.history.clear()
|
|
318
|
+
app.completed.clear()
|
|
319
|
+
app.failed.clear()
|
|
320
|
+
app.sel = None
|
|
321
|
+
app.refresh()
|
transmute/config.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Settings and shared constants."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
STATE_DIR = Path.home() / ".transmute"
|
|
9
|
+
HISTORY_FILE = STATE_DIR / "history"
|
|
10
|
+
ACTIVITY_FILE = STATE_DIR / "activity.sqlite3"
|
|
11
|
+
SETTINGS_FILE = STATE_DIR / "settings.json"
|
|
12
|
+
SETTINGS_VERSION = 1
|
|
13
|
+
QUALITIES = ("128", "192", "256", "320")
|
|
14
|
+
MAX_WORKERS = 4
|
|
15
|
+
SIGNIN_RECHECK_SECONDS = 15
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class Settings:
|
|
20
|
+
out_dir: Path = field(default_factory=lambda: Path.home() / "Downloads")
|
|
21
|
+
quality: str = "320"
|
transmute/downloader.py
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""Local yt-dlp download pipeline."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Literal
|
|
10
|
+
from urllib.parse import urlparse
|
|
11
|
+
from uuid import uuid4
|
|
12
|
+
|
|
13
|
+
from .config import Settings
|
|
14
|
+
|
|
15
|
+
URL_RE = re.compile(r"https?://(?:(?!https?://)\S)+")
|
|
16
|
+
JobStatus = Literal["queued", "downloading", "converting", "done", "error"]
|
|
17
|
+
ProgressCallback = Callable[["Job", float | None], None]
|
|
18
|
+
ANSI_RE = re.compile(r"\x1b\[[0-9;]*m")
|
|
19
|
+
|
|
20
|
+
# (substring of the lowercased yt-dlp message, short human message, retryable)
|
|
21
|
+
# Non-retryable means retrying the same URL cannot succeed without the user
|
|
22
|
+
# changing something first.
|
|
23
|
+
_ERROR_PATTERNS: list[tuple[str, str, bool]] = [
|
|
24
|
+
("unsupported url", "", False), # message built from the domain, see below
|
|
25
|
+
("is not a valid url", "not a valid link", False),
|
|
26
|
+
("private video", "video is private", False),
|
|
27
|
+
("video unavailable", "video unavailable", False),
|
|
28
|
+
("has been removed", "video was removed", False),
|
|
29
|
+
("no longer available", "video is no longer available", False),
|
|
30
|
+
("sign in to confirm", "requires login — needs browser cookies", False),
|
|
31
|
+
("login required", "requires login — needs browser cookies", False),
|
|
32
|
+
("not available in your country", "not available in your region", False),
|
|
33
|
+
("ffprobe and ffmpeg not found", "ffmpeg missing — brew install ffmpeg", False),
|
|
34
|
+
("timed out", "network error — timed out", True),
|
|
35
|
+
("temporary failure in name resolution", "network error — DNS failed", True),
|
|
36
|
+
("getaddrinfo failed", "network error — DNS failed", True),
|
|
37
|
+
("connection", "network error — connection failed", True),
|
|
38
|
+
("http error 403", "download refused (HTTP 403) — update transmute if it persists", True),
|
|
39
|
+
("http error 5", "server error — try again later", True),
|
|
40
|
+
("unable to download", "network error — download failed", True),
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
# Hosts we can actually download from. Subdomains (www., m., music., on., …)
|
|
44
|
+
# are matched too, so e.g. music.youtube.com and on.soundcloud.com are allowed.
|
|
45
|
+
#
|
|
46
|
+
# TO ADD A NEW MEDIA SOURCE: append its bare registrable domain here (e.g.
|
|
47
|
+
# "bandcamp.com", "vimeo.com"). That is the single choke point — is_supported_url
|
|
48
|
+
# and the input gate in App._accept both read from this tuple, so no other code
|
|
49
|
+
# needs to change to widen what the app accepts. Only add a host once yt-dlp can
|
|
50
|
+
# actually extract audio from it, otherwise links pass the gate and fail later at
|
|
51
|
+
# download time.
|
|
52
|
+
SUPPORTED_HOSTS = ("youtube.com", "youtu.be", "soundcloud.com")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def is_supported_url(url: str) -> bool:
|
|
56
|
+
"""True only for links to a supported media source (see SUPPORTED_HOSTS).
|
|
57
|
+
|
|
58
|
+
We gate input up front rather than letting anything ``http(s)://`` through
|
|
59
|
+
for two reasons: the download pipeline is built around yt-dlp audio
|
|
60
|
+
extraction, which only works for known media hosts, and rejecting an
|
|
61
|
+
unsupported paste immediately gives the user a clear message instead of a
|
|
62
|
+
cryptic failure minutes later. Matching is anchored to each host's registrable
|
|
63
|
+
domain (``host == h`` or ``host.endswith("." + h)``) so subdomains like
|
|
64
|
+
``music.youtube.com`` are accepted while look-alikes like
|
|
65
|
+
``youtube.com.evil.com`` are not.
|
|
66
|
+
|
|
67
|
+
To support a new format/source, add its host to SUPPORTED_HOSTS above; this
|
|
68
|
+
function and the App._accept input gate pick it up automatically.
|
|
69
|
+
"""
|
|
70
|
+
host = (urlparse(url).hostname or "").lower()
|
|
71
|
+
return any(host == h or host.endswith("." + h) for h in SUPPORTED_HOSTS)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass
|
|
75
|
+
class Job:
|
|
76
|
+
url: str
|
|
77
|
+
status: JobStatus = "queued"
|
|
78
|
+
title: str | None = None
|
|
79
|
+
uploader: str | None = None
|
|
80
|
+
duration: int | None = None
|
|
81
|
+
description: str | None = None
|
|
82
|
+
tags: list[str] | None = None
|
|
83
|
+
path: Path | None = None
|
|
84
|
+
error: str | None = None # short human-readable summary
|
|
85
|
+
error_detail: str | None = None # full untruncated message from yt-dlp
|
|
86
|
+
retryable: bool = True
|
|
87
|
+
history_id: str = field(default_factory=lambda: uuid4().hex, compare=False)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class _SilentLogger:
|
|
91
|
+
"""Swallow yt-dlp's log output; even with quiet=True it prints errors to
|
|
92
|
+
stderr, which corrupts the full-screen TUI. Failures reach us as raised
|
|
93
|
+
exceptions instead."""
|
|
94
|
+
|
|
95
|
+
def debug(self, msg: str) -> None:
|
|
96
|
+
pass
|
|
97
|
+
|
|
98
|
+
def warning(self, msg: str) -> None:
|
|
99
|
+
pass
|
|
100
|
+
|
|
101
|
+
def error(self, msg: str) -> None:
|
|
102
|
+
pass
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def classify_error(e: Exception, url: str) -> tuple[str, str, bool]:
|
|
106
|
+
"""Turn a raw yt-dlp exception into (summary, detail, retryable).
|
|
107
|
+
|
|
108
|
+
yt-dlp embeds ANSI color codes in its exception messages when it detects
|
|
109
|
+
a tty, so strip those before anything else.
|
|
110
|
+
"""
|
|
111
|
+
detail = ANSI_RE.sub("", str(e)).strip()
|
|
112
|
+
low = detail.lower()
|
|
113
|
+
for needle, summary, retryable in _ERROR_PATTERNS:
|
|
114
|
+
if needle in low:
|
|
115
|
+
if needle == "unsupported url":
|
|
116
|
+
domain = urlparse(url).netloc or url
|
|
117
|
+
summary = f"{domain} isn't a supported site"
|
|
118
|
+
return summary, detail, retryable
|
|
119
|
+
first = detail.split("\n")[0].removeprefix("ERROR: ").strip()[:120]
|
|
120
|
+
return first or "unknown error", detail, True
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def extract_urls(text: str) -> list[str]:
|
|
124
|
+
"""Pull URLs out of free text, including ones pasted back-to-back."""
|
|
125
|
+
return URL_RE.findall(text)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def download_job(
|
|
129
|
+
job: Job,
|
|
130
|
+
settings: Settings,
|
|
131
|
+
on_progress: ProgressCallback | None = None,
|
|
132
|
+
) -> Job:
|
|
133
|
+
"""Download one URL to MP3 and normalize third-party failures onto `job`."""
|
|
134
|
+
import yt_dlp
|
|
135
|
+
|
|
136
|
+
def hook(d: dict) -> None:
|
|
137
|
+
info = d.get("info_dict") or {}
|
|
138
|
+
if info.get("title"):
|
|
139
|
+
job.title = info["title"]
|
|
140
|
+
status = d.get("status")
|
|
141
|
+
if status == "downloading":
|
|
142
|
+
job.status = "downloading"
|
|
143
|
+
total = d.get("total_bytes") or d.get("total_bytes_estimate")
|
|
144
|
+
if on_progress and total:
|
|
145
|
+
on_progress(job, d.get("downloaded_bytes", 0) / total)
|
|
146
|
+
elif status == "finished":
|
|
147
|
+
job.status = "converting"
|
|
148
|
+
if on_progress:
|
|
149
|
+
on_progress(job, None)
|
|
150
|
+
|
|
151
|
+
opts = {
|
|
152
|
+
"format": "bestaudio/best",
|
|
153
|
+
"outtmpl": str(settings.out_dir / "%(uploader)s - %(title)s.%(ext)s"),
|
|
154
|
+
"noplaylist": True,
|
|
155
|
+
"color": "never", # keep ANSI codes out of exception messages
|
|
156
|
+
"logger": _SilentLogger(),
|
|
157
|
+
"quiet": True,
|
|
158
|
+
"no_warnings": True,
|
|
159
|
+
"noprogress": True,
|
|
160
|
+
"writethumbnail": True,
|
|
161
|
+
"progress_hooks": [hook],
|
|
162
|
+
"postprocessors": [
|
|
163
|
+
{
|
|
164
|
+
"key": "FFmpegExtractAudio",
|
|
165
|
+
"preferredcodec": "mp3",
|
|
166
|
+
"preferredquality": settings.quality,
|
|
167
|
+
},
|
|
168
|
+
{"key": "FFmpegMetadata"},
|
|
169
|
+
{"key": "EmbedThumbnail"},
|
|
170
|
+
],
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
try:
|
|
174
|
+
settings.out_dir.mkdir(parents=True, exist_ok=True)
|
|
175
|
+
with yt_dlp.YoutubeDL(opts) as ydl:
|
|
176
|
+
info = ydl.extract_info(job.url, download=True)
|
|
177
|
+
job.title = info.get("title") or job.title
|
|
178
|
+
job.uploader = info.get("uploader")
|
|
179
|
+
job.duration = info.get("duration")
|
|
180
|
+
job.description = info.get("description")
|
|
181
|
+
job.tags = info.get("tags")
|
|
182
|
+
downloads = info.get("requested_downloads") or []
|
|
183
|
+
if downloads and downloads[0].get("filepath"):
|
|
184
|
+
job.path = Path(downloads[0]["filepath"])
|
|
185
|
+
job.status = "done"
|
|
186
|
+
except Exception as e: # noqa: BLE001
|
|
187
|
+
job.status = "error"
|
|
188
|
+
job.error, job.error_detail, job.retryable = classify_error(e, job.url)
|
|
189
|
+
|
|
190
|
+
return job
|