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/enrich.py
ADDED
|
@@ -0,0 +1,865 @@
|
|
|
1
|
+
"""Metadata enrichment: subscription or API web research → proper ID3 tags."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
import tempfile
|
|
11
|
+
import threading
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Literal
|
|
15
|
+
|
|
16
|
+
ANTHROPIC_MODEL = "claude-haiku-5-5"
|
|
17
|
+
OPENAI_MODEL = "gpt-6-luna"
|
|
18
|
+
MAX_SEARCHES = 4
|
|
19
|
+
MAX_CONTINUATIONS = 3
|
|
20
|
+
BACKEND_LABELS = {
|
|
21
|
+
"codex": "Codex (ChatGPT subscription)",
|
|
22
|
+
"claude": "Claude subscription",
|
|
23
|
+
"openai_api": "OpenAI API",
|
|
24
|
+
"anthropic_api": "Anthropic API",
|
|
25
|
+
"none": "no provider",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
OUTPUT_SCHEMA = {
|
|
29
|
+
"type": "object",
|
|
30
|
+
"properties": {
|
|
31
|
+
"kind": {"type": "string", "enum": ["original", "reupload", "derivative"]},
|
|
32
|
+
"based_on": {"type": ["string", "null"]},
|
|
33
|
+
"artist": {"type": "string"},
|
|
34
|
+
"title": {"type": "string"},
|
|
35
|
+
"album": {"type": ["string", "null"]},
|
|
36
|
+
"album_artist": {"type": ["string", "null"]},
|
|
37
|
+
"year": {"type": ["string", "null"]},
|
|
38
|
+
"genre": {"type": ["string", "null"]},
|
|
39
|
+
"confidence": {"type": "string", "enum": ["high", "medium", "low"]},
|
|
40
|
+
},
|
|
41
|
+
"required": [
|
|
42
|
+
"kind",
|
|
43
|
+
"based_on",
|
|
44
|
+
"artist",
|
|
45
|
+
"title",
|
|
46
|
+
"album",
|
|
47
|
+
"album_artist",
|
|
48
|
+
"year",
|
|
49
|
+
"genre",
|
|
50
|
+
"confidence",
|
|
51
|
+
],
|
|
52
|
+
"additionalProperties": False,
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
PROMPT = """\
|
|
56
|
+
You are a music metadata expert identifying a track uploaded to YouTube/SoundCloud.
|
|
57
|
+
|
|
58
|
+
Track info from the source site:
|
|
59
|
+
- title: {title}
|
|
60
|
+
- uploader: {uploader}
|
|
61
|
+
- duration: {duration} seconds
|
|
62
|
+
- source url: {url}
|
|
63
|
+
- page tags: {page_tags}
|
|
64
|
+
- page description (verbatim from the upload):
|
|
65
|
+
{description}
|
|
66
|
+
|
|
67
|
+
STEP 1 — Classify the upload. Before anything else, use web search to determine which \
|
|
68
|
+
of these this specific recording is:
|
|
69
|
+
- "original": the artist's own official recording (uploaded by them or their label)
|
|
70
|
+
- "reupload": someone else re-hosting an official recording unchanged
|
|
71
|
+
- "derivative": a cover, remix, edit, flip, bootleg, mashup, or sped/slowed version \
|
|
72
|
+
made BY the uploader
|
|
73
|
+
Evidence to weigh: research the uploader account itself (handles are often aliases of \
|
|
74
|
+
known artists — check the display name, music databases like MusicBrainz/RateYourMusic, \
|
|
75
|
+
artist pages); compare the duration against the official release; check whether the \
|
|
76
|
+
track appears in the uploader's own sets/albums; genre tags like remix/edit/flip/\
|
|
77
|
+
nightcore/dariacore.
|
|
78
|
+
|
|
79
|
+
STEP 2 — Attribute and find release info for THAT recording:
|
|
80
|
+
- "original" or "reupload" → artist is the original artist (canonical spelling, never \
|
|
81
|
+
the channel name); use the official release's album/year/genre.
|
|
82
|
+
- "derivative" → decide the artist by whether the derivative is its own released \
|
|
83
|
+
work. Publishing or re-hosting a track is NOT authorship:
|
|
84
|
+
- A reproduction, edit, flip, bootleg, mashup, sped-up/slowed version, or fan-made \
|
|
85
|
+
instrumental remake of an existing song → artist is the ORIGINAL recording artist of \
|
|
86
|
+
the underlying song (canonical spelling). The uploader only remade or re-hosted it; \
|
|
87
|
+
put their role in the title if catalogued that way (e.g. "Song (reprod. Uploader)") \
|
|
88
|
+
and in "based_on", never in the artist field. Example: "2hollis - all of the lights \
|
|
89
|
+
(reprod. by me)" uploaded by 3enialis → artist "2hollis", not "3enialis".
|
|
90
|
+
- A distinct work released under the uploader's OWN catalogued artist identity (an \
|
|
91
|
+
official remix or a cover credited to them on a real release) → artist is that \
|
|
92
|
+
uploader identity, with the source artist and song in "based_on".
|
|
93
|
+
Use the derivative's own release info if it was released, else album may be null.
|
|
94
|
+
If it's a single or unreleased track, album may be the single name or null.
|
|
95
|
+
|
|
96
|
+
STEP 3 — If the artist is still unclear (unreleased tracks, leaks, snippets, and \
|
|
97
|
+
"if you know you know" uploads often omit the artist entirely): dig into the context. \
|
|
98
|
+
Read the page description and tags above for clues (producer tags, "prod.", social \
|
|
99
|
+
handles, emoji codes, era names). Fetch the source page itself if useful. Search fan \
|
|
100
|
+
communities — Reddit, Genius, leak/tracker databases and wikis — for the title or \
|
|
101
|
+
distinctive phrases from the description; these uploads are usually well-documented by \
|
|
102
|
+
fans even when unlabeled. Use the community-consensus attribution (e.g. leaked-song \
|
|
103
|
+
databases' credited artist and era). Being the uploader or host is not evidence of \
|
|
104
|
+
authorship; only as a last resort, when attribution is genuinely unknowable, fall \
|
|
105
|
+
back to the uploader name and mark confidence "low".
|
|
106
|
+
|
|
107
|
+
STEP 4 — Clean the names. "title" and "album" are the song and release names only. \
|
|
108
|
+
Drop release-status, leak, and file-quality markers — e.g. "unreleased", "prev. \
|
|
109
|
+
unreleased", "leak", "leaked", "snippet", "OG", "OG file", "CDQ", "HQ", "LQ", "full \
|
|
110
|
+
version", "(unreleased 2019)" — along with any brackets or separators they leave \
|
|
111
|
+
empty. Keep credits that identify a distinct recording, such as "remix", "sped up", \
|
|
112
|
+
or "reprod.".
|
|
113
|
+
|
|
114
|
+
Respond with ONLY a JSON object, no other text:
|
|
115
|
+
{{"kind": "original"|"reupload"|"derivative", "based_on": str|null, "artist": str, \
|
|
116
|
+
"title": str, "album": str|null, "album_artist": str|null, "year": str|null, \
|
|
117
|
+
"genre": str|null, "confidence": "high"|"medium"|"low"}}
|
|
118
|
+
("based_on" = the original artist and song a derivative is based on, else null.
|
|
119
|
+
"confidence" = how sure you are of the ARTIST attribution specifically: "high" only if \
|
|
120
|
+
confirmed by an official source or strong community consensus; "low" if you found no \
|
|
121
|
+
corroboration and are guessing from weak signals like the uploader name.)"""
|
|
122
|
+
|
|
123
|
+
HINT_SUFFIX = """
|
|
124
|
+
|
|
125
|
+
USER HINT: the user provided this additional context — treat it as the most reliable \
|
|
126
|
+
signal and re-verify against it: {hint}"""
|
|
127
|
+
|
|
128
|
+
TrackKind = Literal["original", "reupload", "derivative"]
|
|
129
|
+
Confidence = Literal["high", "medium", "low"]
|
|
130
|
+
CliState = Literal["signed_in", "signed_out", "missing", "unknown"]
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _claude_env() -> dict[str, str]:
|
|
134
|
+
return {
|
|
135
|
+
key: value for key, value in os.environ.items() if not key.startswith("CLAUDE")
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _codex_env() -> dict[str, str]:
|
|
140
|
+
keep_codex = {"CODEX_HOME", "CODEX_ACCESS_TOKEN", "CODEX_API_KEY"}
|
|
141
|
+
return {
|
|
142
|
+
key: value
|
|
143
|
+
for key, value in os.environ.items()
|
|
144
|
+
if not key.startswith("CODEX_") or key in keep_codex
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _probe_claude() -> CliState:
|
|
149
|
+
if not shutil.which("claude"):
|
|
150
|
+
return "missing"
|
|
151
|
+
try:
|
|
152
|
+
proc = subprocess.run(
|
|
153
|
+
["claude", "auth", "status", "--json"],
|
|
154
|
+
capture_output=True,
|
|
155
|
+
text=True,
|
|
156
|
+
timeout=10,
|
|
157
|
+
cwd=tempfile.gettempdir(),
|
|
158
|
+
env=_claude_env(),
|
|
159
|
+
check=False,
|
|
160
|
+
)
|
|
161
|
+
except FileNotFoundError:
|
|
162
|
+
return "missing"
|
|
163
|
+
except (subprocess.TimeoutExpired, OSError):
|
|
164
|
+
return "unknown"
|
|
165
|
+
try:
|
|
166
|
+
logged_in = json.loads(proc.stdout).get("loggedIn")
|
|
167
|
+
except (json.JSONDecodeError, AttributeError, TypeError):
|
|
168
|
+
return "unknown"
|
|
169
|
+
return (
|
|
170
|
+
("signed_in" if logged_in else "signed_out")
|
|
171
|
+
if isinstance(logged_in, bool)
|
|
172
|
+
else "unknown"
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _probe_codex() -> CliState:
|
|
177
|
+
if not shutil.which("codex"):
|
|
178
|
+
return "missing"
|
|
179
|
+
try:
|
|
180
|
+
proc = subprocess.run(
|
|
181
|
+
["codex", "login", "status"],
|
|
182
|
+
capture_output=True,
|
|
183
|
+
text=True,
|
|
184
|
+
timeout=10,
|
|
185
|
+
cwd=tempfile.gettempdir(),
|
|
186
|
+
env=_codex_env(),
|
|
187
|
+
check=False,
|
|
188
|
+
)
|
|
189
|
+
except FileNotFoundError:
|
|
190
|
+
return "missing"
|
|
191
|
+
except (subprocess.TimeoutExpired, OSError):
|
|
192
|
+
return "unknown"
|
|
193
|
+
if proc.returncode == 0:
|
|
194
|
+
return "signed_in"
|
|
195
|
+
return (
|
|
196
|
+
"signed_out"
|
|
197
|
+
if "not logged in" in f"{proc.stdout} {proc.stderr}".lower()
|
|
198
|
+
else "unknown"
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
@dataclass
|
|
203
|
+
class TrackTags:
|
|
204
|
+
artist: str | None = None
|
|
205
|
+
title: str | None = None
|
|
206
|
+
album: str | None = None
|
|
207
|
+
album_artist: str | None = None
|
|
208
|
+
year: str | None = None
|
|
209
|
+
genre: str | None = None
|
|
210
|
+
kind: TrackKind | None = None
|
|
211
|
+
based_on: str | None = None
|
|
212
|
+
confidence: Confidence | None = None
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
class Enricher:
|
|
216
|
+
"""Metadata lookup through subscription CLIs or provider APIs.
|
|
217
|
+
|
|
218
|
+
One API key can be active at a time. A key entered in the app overrides
|
|
219
|
+
environment credentials and subscription CLIs. Otherwise, environment API
|
|
220
|
+
keys take priority, followed by Claude and Codex subscription auth.
|
|
221
|
+
"""
|
|
222
|
+
|
|
223
|
+
def __init__(self) -> None:
|
|
224
|
+
self._error_state = threading.local()
|
|
225
|
+
self._client_lock = threading.RLock()
|
|
226
|
+
self.enabled = True
|
|
227
|
+
self.last_error: str | None = None
|
|
228
|
+
self._api_key: str | None = None
|
|
229
|
+
self._api_provider: str | None = None
|
|
230
|
+
self._api_key_source: str | None = None
|
|
231
|
+
self._explicitly_disabled = False
|
|
232
|
+
self.cli_states: dict[str, CliState] = {"claude": "missing", "codex": "missing"}
|
|
233
|
+
self._anthropic_client = None
|
|
234
|
+
self._openai_client = None
|
|
235
|
+
self._select_default_backend()
|
|
236
|
+
|
|
237
|
+
def _select_default_backend(self) -> None:
|
|
238
|
+
self.enabled = True
|
|
239
|
+
self.last_error = None
|
|
240
|
+
if os.environ.get("OPENAI_API_KEY"):
|
|
241
|
+
self._set_api_key(
|
|
242
|
+
os.environ["OPENAI_API_KEY"], "openai_api", source="environment"
|
|
243
|
+
)
|
|
244
|
+
elif os.environ.get("ANTHROPIC_API_KEY"):
|
|
245
|
+
self._set_api_key(
|
|
246
|
+
os.environ["ANTHROPIC_API_KEY"],
|
|
247
|
+
"anthropic_api",
|
|
248
|
+
source="environment",
|
|
249
|
+
)
|
|
250
|
+
else:
|
|
251
|
+
self._select_subscription_backend()
|
|
252
|
+
|
|
253
|
+
def _select_subscription_backend(self) -> str:
|
|
254
|
+
selected, states = self._probe_subscription_backend()
|
|
255
|
+
with self._client_lock:
|
|
256
|
+
self.cli_states = states
|
|
257
|
+
self._backend = selected
|
|
258
|
+
self._enabled = selected != "none"
|
|
259
|
+
return selected
|
|
260
|
+
|
|
261
|
+
@staticmethod
|
|
262
|
+
def _probe_subscription_backend() -> tuple[str, dict[str, CliState]]:
|
|
263
|
+
claude = _probe_claude()
|
|
264
|
+
states: dict[str, CliState] = {"claude": claude, "codex": "missing"}
|
|
265
|
+
if claude == "signed_in":
|
|
266
|
+
selected = "claude"
|
|
267
|
+
else:
|
|
268
|
+
states["codex"] = _probe_codex()
|
|
269
|
+
selected = next(
|
|
270
|
+
(name for name in ("claude", "codex") if states[name] == "signed_in"),
|
|
271
|
+
next(
|
|
272
|
+
(name for name in ("claude", "codex") if states[name] == "unknown"),
|
|
273
|
+
"none",
|
|
274
|
+
),
|
|
275
|
+
)
|
|
276
|
+
return selected, states
|
|
277
|
+
|
|
278
|
+
def refresh_subscription(self) -> str | None:
|
|
279
|
+
"""Select a newly signed-in CLI while no provider is available.
|
|
280
|
+
|
|
281
|
+
Runs the status probes outside the lock. Only a confirmed sign-in
|
|
282
|
+
switches the backend, so a transient probe failure keeps rechecking.
|
|
283
|
+
"""
|
|
284
|
+
if not self.needs_subscription_recheck:
|
|
285
|
+
return None
|
|
286
|
+
selected, states = self._probe_subscription_backend()
|
|
287
|
+
with self._client_lock:
|
|
288
|
+
if not self.needs_subscription_recheck:
|
|
289
|
+
return None
|
|
290
|
+
self.cli_states = states
|
|
291
|
+
if states.get(selected) != "signed_in":
|
|
292
|
+
return None
|
|
293
|
+
self._backend = selected
|
|
294
|
+
self._enabled = True
|
|
295
|
+
return selected
|
|
296
|
+
|
|
297
|
+
@property
|
|
298
|
+
def needs_subscription_recheck(self) -> bool:
|
|
299
|
+
with self._client_lock:
|
|
300
|
+
return (
|
|
301
|
+
self._backend == "none"
|
|
302
|
+
and self._api_key is None
|
|
303
|
+
and not self._explicitly_disabled
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
def connection_snapshot(self) -> tuple[dict[str, CliState], str, bool]:
|
|
307
|
+
with self._client_lock:
|
|
308
|
+
return dict(self.cli_states), self._backend, self._api_key is not None
|
|
309
|
+
|
|
310
|
+
@property
|
|
311
|
+
def backend_label(self) -> str:
|
|
312
|
+
return BACKEND_LABELS[self.backend]
|
|
313
|
+
|
|
314
|
+
@property
|
|
315
|
+
def backend(self) -> str:
|
|
316
|
+
with self._client_lock:
|
|
317
|
+
return self._backend
|
|
318
|
+
|
|
319
|
+
@backend.setter
|
|
320
|
+
def backend(self, value: str) -> None:
|
|
321
|
+
with self._client_lock:
|
|
322
|
+
self._backend = value
|
|
323
|
+
|
|
324
|
+
@property
|
|
325
|
+
def enabled(self) -> bool:
|
|
326
|
+
with self._client_lock:
|
|
327
|
+
return self._enabled
|
|
328
|
+
|
|
329
|
+
@enabled.setter
|
|
330
|
+
def enabled(self, value: bool) -> None:
|
|
331
|
+
with self._client_lock:
|
|
332
|
+
self._enabled = value
|
|
333
|
+
|
|
334
|
+
@property
|
|
335
|
+
def last_error(self) -> str | None:
|
|
336
|
+
return getattr(self._error_state, "value", None)
|
|
337
|
+
|
|
338
|
+
@last_error.setter
|
|
339
|
+
def last_error(self, value: str | None) -> None:
|
|
340
|
+
self._error_state.value = value
|
|
341
|
+
|
|
342
|
+
@property
|
|
343
|
+
def has_api_key(self) -> bool:
|
|
344
|
+
with self._client_lock:
|
|
345
|
+
return self._api_key is not None
|
|
346
|
+
|
|
347
|
+
@property
|
|
348
|
+
def api_key_source(self) -> str | None:
|
|
349
|
+
with self._client_lock:
|
|
350
|
+
return self._api_key_source
|
|
351
|
+
|
|
352
|
+
def use_backend(self, backend: str) -> None:
|
|
353
|
+
with self._client_lock:
|
|
354
|
+
self._explicitly_disabled = False
|
|
355
|
+
self._backend = backend
|
|
356
|
+
self._enabled = True
|
|
357
|
+
self.last_error = None
|
|
358
|
+
|
|
359
|
+
def set_enrichment_enabled(self, enabled: bool) -> None:
|
|
360
|
+
with self._client_lock:
|
|
361
|
+
self._explicitly_disabled = not enabled
|
|
362
|
+
self._enabled = enabled
|
|
363
|
+
|
|
364
|
+
@staticmethod
|
|
365
|
+
def detect_api_provider(key: str) -> str:
|
|
366
|
+
key = key.strip()
|
|
367
|
+
if key.startswith("sk-ant-"):
|
|
368
|
+
return "anthropic_api"
|
|
369
|
+
if key.startswith("sk-"):
|
|
370
|
+
return "openai_api"
|
|
371
|
+
raise ValueError("key must start with sk- (OpenAI) or sk-ant- (Anthropic)")
|
|
372
|
+
|
|
373
|
+
def _set_api_key(self, key: str, provider: str, *, source: str) -> None:
|
|
374
|
+
with self._client_lock:
|
|
375
|
+
self._api_key = key.strip()
|
|
376
|
+
self._api_provider = provider
|
|
377
|
+
self._api_key_source = source
|
|
378
|
+
self._anthropic_client = None
|
|
379
|
+
self._openai_client = None
|
|
380
|
+
self.use_backend(provider)
|
|
381
|
+
|
|
382
|
+
def set_api_key(self, key: str) -> None:
|
|
383
|
+
"""Replace the active in-memory key and select its detected provider."""
|
|
384
|
+
provider = self.detect_api_provider(key)
|
|
385
|
+
self._set_api_key(key, provider, source="entered")
|
|
386
|
+
|
|
387
|
+
def clear_api_key(self) -> None:
|
|
388
|
+
with self._client_lock:
|
|
389
|
+
self._api_key = None
|
|
390
|
+
self._api_provider = None
|
|
391
|
+
self._api_key_source = None
|
|
392
|
+
self._anthropic_client = None
|
|
393
|
+
self._openai_client = None
|
|
394
|
+
self._select_default_backend()
|
|
395
|
+
|
|
396
|
+
def use_api_key(self) -> bool:
|
|
397
|
+
with self._client_lock:
|
|
398
|
+
provider = self._api_provider
|
|
399
|
+
if not provider:
|
|
400
|
+
self.last_error = "no API key configured — run /key"
|
|
401
|
+
return False
|
|
402
|
+
self.use_backend(provider)
|
|
403
|
+
return True
|
|
404
|
+
|
|
405
|
+
def _get_anthropic_client(self):
|
|
406
|
+
with self._client_lock:
|
|
407
|
+
if self._anthropic_client is None:
|
|
408
|
+
import anthropic
|
|
409
|
+
|
|
410
|
+
self._anthropic_client = anthropic.Anthropic(api_key=self._api_key)
|
|
411
|
+
return self._anthropic_client
|
|
412
|
+
|
|
413
|
+
def _get_openai_client(self):
|
|
414
|
+
with self._client_lock:
|
|
415
|
+
if self._openai_client is None:
|
|
416
|
+
from openai import OpenAI
|
|
417
|
+
|
|
418
|
+
self._openai_client = OpenAI(api_key=self._api_key)
|
|
419
|
+
return self._openai_client
|
|
420
|
+
|
|
421
|
+
def lookup(
|
|
422
|
+
self,
|
|
423
|
+
*,
|
|
424
|
+
title: str,
|
|
425
|
+
uploader: str | None,
|
|
426
|
+
duration: int | None,
|
|
427
|
+
url: str,
|
|
428
|
+
description: str | None = None,
|
|
429
|
+
tags: list[str] | None = None,
|
|
430
|
+
hint: str | None = None,
|
|
431
|
+
) -> TrackTags | None:
|
|
432
|
+
"""Research canonical track metadata using the selected backend."""
|
|
433
|
+
self.last_error = None
|
|
434
|
+
desc = (description or "").strip()
|
|
435
|
+
if len(desc) > 1500:
|
|
436
|
+
desc = desc[:1500] + "…"
|
|
437
|
+
prompt = PROMPT.format(
|
|
438
|
+
title=title,
|
|
439
|
+
uploader=uploader or "unknown",
|
|
440
|
+
duration=duration or "unknown",
|
|
441
|
+
url=url,
|
|
442
|
+
page_tags=", ".join(tags) if tags else "none",
|
|
443
|
+
description=desc or "(none)",
|
|
444
|
+
)
|
|
445
|
+
if hint:
|
|
446
|
+
prompt += HINT_SUFFIX.format(hint=hint)
|
|
447
|
+
try:
|
|
448
|
+
with self._client_lock:
|
|
449
|
+
backend = self._backend
|
|
450
|
+
api_client = (
|
|
451
|
+
self._get_openai_client()
|
|
452
|
+
if backend == "openai_api"
|
|
453
|
+
else (
|
|
454
|
+
self._get_anthropic_client()
|
|
455
|
+
if backend == "anthropic_api"
|
|
456
|
+
else None
|
|
457
|
+
)
|
|
458
|
+
)
|
|
459
|
+
except Exception as error: # noqa: BLE001
|
|
460
|
+
self.last_error = self._provider_error(error)
|
|
461
|
+
return None
|
|
462
|
+
if backend == "codex":
|
|
463
|
+
text = self._ask_codex(prompt)
|
|
464
|
+
elif backend == "claude":
|
|
465
|
+
text = self._ask_claude(prompt)
|
|
466
|
+
elif backend == "openai_api":
|
|
467
|
+
text = self._ask_openai_api(prompt, api_client)
|
|
468
|
+
elif backend == "anthropic_api":
|
|
469
|
+
text = self._ask_anthropic_api(prompt, api_client)
|
|
470
|
+
else:
|
|
471
|
+
self.last_error = "no enrichment provider — run /key or /login"
|
|
472
|
+
return None
|
|
473
|
+
if text is None:
|
|
474
|
+
return None
|
|
475
|
+
|
|
476
|
+
try:
|
|
477
|
+
return _parse_track_tags(text)
|
|
478
|
+
except ValueError as error:
|
|
479
|
+
self.last_error = str(error)
|
|
480
|
+
return None
|
|
481
|
+
|
|
482
|
+
def _ask_codex(self, prompt: str) -> str | None:
|
|
483
|
+
"""Run a read-only web lookup through Codex with ChatGPT subscription auth."""
|
|
484
|
+
env = _codex_env()
|
|
485
|
+
try:
|
|
486
|
+
with tempfile.TemporaryDirectory(prefix="transmute-codex-") as run_dir:
|
|
487
|
+
schema_path = Path(run_dir) / "track-tags.schema.json"
|
|
488
|
+
schema_path.write_text(json.dumps(OUTPUT_SCHEMA), encoding="utf-8")
|
|
489
|
+
proc = subprocess.run(
|
|
490
|
+
[
|
|
491
|
+
"codex",
|
|
492
|
+
"--search",
|
|
493
|
+
"--ask-for-approval",
|
|
494
|
+
"never",
|
|
495
|
+
"--disable",
|
|
496
|
+
"shell_tool",
|
|
497
|
+
"exec",
|
|
498
|
+
"--model",
|
|
499
|
+
OPENAI_MODEL,
|
|
500
|
+
"--ephemeral",
|
|
501
|
+
"--ignore-user-config",
|
|
502
|
+
"--ignore-rules",
|
|
503
|
+
"--sandbox",
|
|
504
|
+
"read-only",
|
|
505
|
+
"--skip-git-repo-check",
|
|
506
|
+
"--cd",
|
|
507
|
+
run_dir,
|
|
508
|
+
"--output-schema",
|
|
509
|
+
str(schema_path),
|
|
510
|
+
prompt,
|
|
511
|
+
],
|
|
512
|
+
capture_output=True,
|
|
513
|
+
text=True,
|
|
514
|
+
timeout=300,
|
|
515
|
+
cwd=run_dir,
|
|
516
|
+
env=env,
|
|
517
|
+
check=False,
|
|
518
|
+
)
|
|
519
|
+
except FileNotFoundError:
|
|
520
|
+
self._disable_backend("codex")
|
|
521
|
+
self.last_error = "codex CLI not found — install Codex or choose /enrich claude"
|
|
522
|
+
return None
|
|
523
|
+
except subprocess.TimeoutExpired:
|
|
524
|
+
self.last_error = "codex CLI timed out"
|
|
525
|
+
return None
|
|
526
|
+
|
|
527
|
+
if proc.returncode != 0:
|
|
528
|
+
err = self._last_error_line(proc.stderr or proc.stdout)
|
|
529
|
+
err_lower = err.lower()
|
|
530
|
+
if any(
|
|
531
|
+
marker in err_lower
|
|
532
|
+
for marker in ("log in", "logged in", "authent", "unauthorized")
|
|
533
|
+
):
|
|
534
|
+
self._disable_backend("codex")
|
|
535
|
+
self.last_error = (
|
|
536
|
+
"not logged in to Codex — run /login codex "
|
|
537
|
+
"and choose your ChatGPT account"
|
|
538
|
+
)
|
|
539
|
+
else:
|
|
540
|
+
self.last_error = err[:200] or "codex CLI failed"
|
|
541
|
+
return None
|
|
542
|
+
return proc.stdout.strip()
|
|
543
|
+
|
|
544
|
+
@staticmethod
|
|
545
|
+
def _last_error_line(output: str) -> str:
|
|
546
|
+
lines = [line.strip() for line in output.splitlines() if line.strip()]
|
|
547
|
+
markers = (
|
|
548
|
+
"error:",
|
|
549
|
+
"not logged in",
|
|
550
|
+
"unauthorized",
|
|
551
|
+
"authentication",
|
|
552
|
+
"rate limit",
|
|
553
|
+
"usage limit",
|
|
554
|
+
"session limit",
|
|
555
|
+
"quota",
|
|
556
|
+
)
|
|
557
|
+
for line in reversed(lines):
|
|
558
|
+
if any(marker in line.lower() for marker in markers):
|
|
559
|
+
return line
|
|
560
|
+
return lines[-1] if lines else ""
|
|
561
|
+
|
|
562
|
+
def _ask_claude(self, prompt: str) -> str | None:
|
|
563
|
+
"""Run the lookup through headless Claude Code (`claude -p`) on the user's subscription."""
|
|
564
|
+
# Isolate the run: a neutral cwd (so no project context is ingested) and no
|
|
565
|
+
# inherited Claude Code session vars (so a run from inside another agent
|
|
566
|
+
# doesn't nest into that session).
|
|
567
|
+
env = _claude_env()
|
|
568
|
+
try:
|
|
569
|
+
proc = subprocess.run(
|
|
570
|
+
[
|
|
571
|
+
"claude",
|
|
572
|
+
"-p",
|
|
573
|
+
"--output-format",
|
|
574
|
+
"json",
|
|
575
|
+
"--allowedTools",
|
|
576
|
+
"WebSearch",
|
|
577
|
+
"WebFetch",
|
|
578
|
+
"--model",
|
|
579
|
+
ANTHROPIC_MODEL,
|
|
580
|
+
],
|
|
581
|
+
input=prompt,
|
|
582
|
+
capture_output=True,
|
|
583
|
+
text=True,
|
|
584
|
+
timeout=300,
|
|
585
|
+
cwd=tempfile.gettempdir(),
|
|
586
|
+
env=env,
|
|
587
|
+
check=False,
|
|
588
|
+
)
|
|
589
|
+
except FileNotFoundError:
|
|
590
|
+
self._disable_backend("claude")
|
|
591
|
+
self.last_error = "claude CLI not found — install Claude or choose /enrich codex"
|
|
592
|
+
return None
|
|
593
|
+
except subprocess.TimeoutExpired:
|
|
594
|
+
self.last_error = "claude CLI timed out"
|
|
595
|
+
return None
|
|
596
|
+
|
|
597
|
+
data = None
|
|
598
|
+
if proc.stdout:
|
|
599
|
+
try:
|
|
600
|
+
data = json.loads(proc.stdout)
|
|
601
|
+
except json.JSONDecodeError:
|
|
602
|
+
pass
|
|
603
|
+
|
|
604
|
+
if proc.returncode != 0:
|
|
605
|
+
result = data.get("result") if isinstance(data, dict) else None
|
|
606
|
+
err = str(result or proc.stderr or proc.stdout).strip()
|
|
607
|
+
err_lower = err.lower()
|
|
608
|
+
if any(
|
|
609
|
+
marker in err_lower
|
|
610
|
+
for marker in ("log in", "logged in", "authent", "invalid api key")
|
|
611
|
+
):
|
|
612
|
+
self._disable_backend("claude")
|
|
613
|
+
self.last_error = (
|
|
614
|
+
"not logged in to Claude — run `claude`, type /login, "
|
|
615
|
+
"and choose your subscription"
|
|
616
|
+
)
|
|
617
|
+
else:
|
|
618
|
+
self.last_error = err[:200] or "claude CLI failed"
|
|
619
|
+
return None
|
|
620
|
+
|
|
621
|
+
if data is None:
|
|
622
|
+
self.last_error = "unparseable claude CLI output"
|
|
623
|
+
return None
|
|
624
|
+
if data.get("is_error"):
|
|
625
|
+
self.last_error = str(data.get("result", "claude CLI error"))[:200]
|
|
626
|
+
return None
|
|
627
|
+
return data.get("result", "")
|
|
628
|
+
|
|
629
|
+
def _ask_openai_api(self, prompt: str, client=None) -> str | None:
|
|
630
|
+
"""Run the lookup through the OpenAI Responses API."""
|
|
631
|
+
import openai
|
|
632
|
+
|
|
633
|
+
try:
|
|
634
|
+
client = client or self._get_openai_client()
|
|
635
|
+
response = client.responses.create(
|
|
636
|
+
model=OPENAI_MODEL,
|
|
637
|
+
input=prompt,
|
|
638
|
+
tools=[{"type": "web_search"}],
|
|
639
|
+
reasoning={"effort": "low"},
|
|
640
|
+
text={
|
|
641
|
+
"format": {
|
|
642
|
+
"type": "json_schema",
|
|
643
|
+
"name": "track_tags",
|
|
644
|
+
"strict": True,
|
|
645
|
+
"schema": OUTPUT_SCHEMA,
|
|
646
|
+
}
|
|
647
|
+
},
|
|
648
|
+
store=False,
|
|
649
|
+
)
|
|
650
|
+
except openai.AuthenticationError:
|
|
651
|
+
self._disable_backend("openai_api")
|
|
652
|
+
self.last_error = "invalid OpenAI API key — run /key to replace it"
|
|
653
|
+
return None
|
|
654
|
+
except Exception as e: # noqa: BLE001
|
|
655
|
+
self.last_error = self._provider_error(e)
|
|
656
|
+
return None
|
|
657
|
+
return response.output_text
|
|
658
|
+
|
|
659
|
+
def _ask_anthropic_api(self, prompt: str, client=None) -> str | None:
|
|
660
|
+
"""Run the lookup through the Anthropic SDK (API key / Console billing)."""
|
|
661
|
+
import anthropic
|
|
662
|
+
|
|
663
|
+
client = client or self._get_anthropic_client()
|
|
664
|
+
messages = [{"role": "user", "content": prompt}]
|
|
665
|
+
tools = [
|
|
666
|
+
{
|
|
667
|
+
"type": "web_search_20260209",
|
|
668
|
+
"name": "web_search",
|
|
669
|
+
"max_uses": MAX_SEARCHES,
|
|
670
|
+
},
|
|
671
|
+
{
|
|
672
|
+
"type": "web_fetch_20260209",
|
|
673
|
+
"name": "web_fetch",
|
|
674
|
+
"max_uses": MAX_SEARCHES,
|
|
675
|
+
},
|
|
676
|
+
]
|
|
677
|
+
|
|
678
|
+
try:
|
|
679
|
+
response = client.messages.create(
|
|
680
|
+
model=ANTHROPIC_MODEL,
|
|
681
|
+
max_tokens=4000,
|
|
682
|
+
thinking={"type": "adaptive"},
|
|
683
|
+
output_config={"effort": "low"},
|
|
684
|
+
tools=tools,
|
|
685
|
+
messages=messages,
|
|
686
|
+
)
|
|
687
|
+
# Server-side tools may pause the turn; re-send to continue.
|
|
688
|
+
for _ in range(MAX_CONTINUATIONS):
|
|
689
|
+
if response.stop_reason != "pause_turn":
|
|
690
|
+
break
|
|
691
|
+
messages = [
|
|
692
|
+
*messages,
|
|
693
|
+
{"role": "assistant", "content": response.content}
|
|
694
|
+
]
|
|
695
|
+
response = client.messages.create(
|
|
696
|
+
model=ANTHROPIC_MODEL,
|
|
697
|
+
max_tokens=4000,
|
|
698
|
+
thinking={"type": "adaptive"},
|
|
699
|
+
output_config={"effort": "low"},
|
|
700
|
+
tools=tools,
|
|
701
|
+
messages=messages,
|
|
702
|
+
)
|
|
703
|
+
if response.stop_reason == "pause_turn":
|
|
704
|
+
self.last_error = "Anthropic lookup exceeded its continuation limit"
|
|
705
|
+
return None
|
|
706
|
+
except anthropic.AuthenticationError:
|
|
707
|
+
self._disable_backend("anthropic_api")
|
|
708
|
+
self.last_error = "invalid Anthropic API key — run /key to replace it"
|
|
709
|
+
return None
|
|
710
|
+
except Exception as e: # noqa: BLE001
|
|
711
|
+
if "Could not resolve authentication" in str(e):
|
|
712
|
+
self._disable_backend("anthropic_api")
|
|
713
|
+
self.last_error = "no Anthropic API key found — run /key"
|
|
714
|
+
else:
|
|
715
|
+
self.last_error = self._provider_error(e)
|
|
716
|
+
return None
|
|
717
|
+
|
|
718
|
+
return "".join(b.text for b in response.content if b.type == "text")
|
|
719
|
+
|
|
720
|
+
def _disable_backend(self, backend: str) -> None:
|
|
721
|
+
with self._client_lock:
|
|
722
|
+
if self._backend == backend:
|
|
723
|
+
self._enabled = False
|
|
724
|
+
|
|
725
|
+
def _provider_error(self, error: Exception) -> str:
|
|
726
|
+
lines = [line.strip() for line in str(error).splitlines() if line.strip()]
|
|
727
|
+
message = lines[0] if lines else error.__class__.__name__
|
|
728
|
+
with self._client_lock:
|
|
729
|
+
key = self._api_key
|
|
730
|
+
if key:
|
|
731
|
+
message = message.replace(key, "[redacted]")
|
|
732
|
+
message = re.sub(r"\bsk-[A-Za-z0-9_.-]{8,}", "[redacted]", message)
|
|
733
|
+
return message[:200]
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
def _parse_track_tags(text: str) -> TrackTags:
|
|
737
|
+
data = _first_json_object(text)
|
|
738
|
+
required = set(OUTPUT_SCHEMA["required"])
|
|
739
|
+
missing = sorted(required - data.keys())
|
|
740
|
+
if missing:
|
|
741
|
+
raise ValueError(f"metadata reply missing fields: {', '.join(missing)}")
|
|
742
|
+
unexpected = sorted(data.keys() - required)
|
|
743
|
+
if unexpected:
|
|
744
|
+
raise ValueError(
|
|
745
|
+
f"metadata reply has unexpected fields: {', '.join(unexpected)}"
|
|
746
|
+
)
|
|
747
|
+
|
|
748
|
+
artist = _required_string(data, "artist")
|
|
749
|
+
title = _required_string(data, "title")
|
|
750
|
+
kind = data["kind"]
|
|
751
|
+
if kind not in ("original", "reupload", "derivative"):
|
|
752
|
+
raise ValueError("metadata reply has invalid kind")
|
|
753
|
+
confidence = data["confidence"]
|
|
754
|
+
if confidence not in ("high", "medium", "low"):
|
|
755
|
+
raise ValueError("metadata reply has invalid confidence")
|
|
756
|
+
|
|
757
|
+
year = data["year"]
|
|
758
|
+
if year is not None and type(year) not in (str, int):
|
|
759
|
+
raise ValueError("metadata reply has invalid year")
|
|
760
|
+
|
|
761
|
+
return TrackTags(
|
|
762
|
+
artist=artist,
|
|
763
|
+
title=title,
|
|
764
|
+
album=_optional_string(data, "album"),
|
|
765
|
+
album_artist=_optional_string(data, "album_artist"),
|
|
766
|
+
year=str(year) if year is not None else None,
|
|
767
|
+
genre=_optional_string(data, "genre"),
|
|
768
|
+
kind=kind,
|
|
769
|
+
based_on=_optional_string(data, "based_on"),
|
|
770
|
+
confidence=confidence,
|
|
771
|
+
)
|
|
772
|
+
|
|
773
|
+
|
|
774
|
+
def _first_json_object(text: str) -> dict:
|
|
775
|
+
decoder = json.JSONDecoder()
|
|
776
|
+
found_start = False
|
|
777
|
+
for match in re.finditer(r"\{", text):
|
|
778
|
+
found_start = True
|
|
779
|
+
try:
|
|
780
|
+
value, _ = decoder.raw_decode(text[match.start() :])
|
|
781
|
+
except json.JSONDecodeError:
|
|
782
|
+
continue
|
|
783
|
+
if isinstance(value, dict):
|
|
784
|
+
return value
|
|
785
|
+
if found_start:
|
|
786
|
+
raise ValueError("unparseable JSON in model reply")
|
|
787
|
+
raise ValueError("no JSON in model reply")
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
def _required_string(data: dict, field: str) -> str:
|
|
791
|
+
value = data[field]
|
|
792
|
+
if not isinstance(value, str) or not value.strip():
|
|
793
|
+
raise ValueError(f"metadata reply has invalid {field}")
|
|
794
|
+
return value.strip()
|
|
795
|
+
|
|
796
|
+
|
|
797
|
+
def _optional_string(data: dict, field: str) -> str | None:
|
|
798
|
+
value = data[field]
|
|
799
|
+
if value is not None and not isinstance(value, str):
|
|
800
|
+
raise ValueError(f"metadata reply has invalid {field}")
|
|
801
|
+
if value is None:
|
|
802
|
+
return None
|
|
803
|
+
return value.strip() or None
|
|
804
|
+
|
|
805
|
+
|
|
806
|
+
def _safe_filename(name: str) -> str:
|
|
807
|
+
return re.sub(r'[/\\:*?"<>|\x00]', "_", name).strip()
|
|
808
|
+
|
|
809
|
+
|
|
810
|
+
def _unique_path(target: Path) -> Path:
|
|
811
|
+
"""Return `target`, or the first `name (n).ext` variant that does not exist.
|
|
812
|
+
|
|
813
|
+
The rename target may already hold an unrelated earlier download; never
|
|
814
|
+
overwrite it, but do not fall back to yt-dlp's raw `uploader - title` name
|
|
815
|
+
either, which carries the uploader alias alongside the real artist.
|
|
816
|
+
"""
|
|
817
|
+
if not target.exists():
|
|
818
|
+
return target
|
|
819
|
+
stem, suffix = target.stem, target.suffix
|
|
820
|
+
counter = 1
|
|
821
|
+
while True:
|
|
822
|
+
candidate = target.with_name(f"{stem} ({counter}){suffix}")
|
|
823
|
+
if not candidate.exists():
|
|
824
|
+
return candidate
|
|
825
|
+
counter += 1
|
|
826
|
+
|
|
827
|
+
|
|
828
|
+
def apply_tags(path: Path, tags: TrackTags) -> Path:
|
|
829
|
+
"""Update owned ID3 fields and rename without replacing an existing file."""
|
|
830
|
+
from mutagen.easyid3 import EasyID3
|
|
831
|
+
from mutagen.id3 import ID3NoHeaderError
|
|
832
|
+
|
|
833
|
+
try:
|
|
834
|
+
audio = EasyID3(path)
|
|
835
|
+
except ID3NoHeaderError:
|
|
836
|
+
from mutagen.mp3 import MP3
|
|
837
|
+
|
|
838
|
+
mp3 = MP3(path)
|
|
839
|
+
mp3.add_tags()
|
|
840
|
+
mp3.save()
|
|
841
|
+
audio = EasyID3(path)
|
|
842
|
+
|
|
843
|
+
if tags.title:
|
|
844
|
+
audio["title"] = tags.title
|
|
845
|
+
if tags.artist:
|
|
846
|
+
audio["artist"] = tags.artist
|
|
847
|
+
if tags.album:
|
|
848
|
+
audio["album"] = tags.album
|
|
849
|
+
if tags.album_artist:
|
|
850
|
+
audio["albumartist"] = tags.album_artist
|
|
851
|
+
if tags.year:
|
|
852
|
+
audio["date"] = tags.year
|
|
853
|
+
if tags.genre:
|
|
854
|
+
audio["genre"] = tags.genre
|
|
855
|
+
audio.save()
|
|
856
|
+
|
|
857
|
+
if tags.artist and tags.title:
|
|
858
|
+
target = path.with_name(
|
|
859
|
+
_safe_filename(f"{tags.artist} - {tags.title}") + ".mp3"
|
|
860
|
+
)
|
|
861
|
+
if target != path:
|
|
862
|
+
new_path = _unique_path(target)
|
|
863
|
+
path.rename(new_path)
|
|
864
|
+
return new_path
|
|
865
|
+
return path
|