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/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"
@@ -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