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