Skip to content

Commit f231eef

Browse files
madmax1301claude
andcommitted
feat(v0.3.2): recover_set_titles.py via YT-reverse-search
Companion zu cleanup_set_mismatches.py für den Fall dass die Container- Logs nicht mehr da sind (z.B. nach docker compose pull && up -d recreate). Geht den umgekehrten Weg: walked die Library, findet Files > 900s, parst artist+title aus dem Pfad (/music/<Artist>/<Album>/<Title>.ext), replayed die Legacy-Suche `ytsearch1: <artist> <title> official audio` mit yt-dlp (no download, nur metadata-extract), und nutzt den Top-Result-Title als neuen Namen für File + TITLE-Tag. Robustheit: - is_meaningful_change-Heuristik überspringt Files wo der YT-Titel essentiell der requested Track ist (z.B. "Lose Yourself" → "Lose Yourself (Soundtrack)") — schützt vor falschen Rename-Aktionen bei legitimen Extended-Mixes. - _SKIP_DIRS überspringt _falsch-matched/_sets/_quarantine wenn der User bereits eine Quarantäne-Strategie verfolgt. - --dry-run default, --apply schreibt tatsächlich. - --limit N für inkrementelle Verarbeitung wenn die Library groß ist. Trade-off: YT-Search-Results können sich seit Original-Download geändert haben. Für stabile Festival-Sets (Defqon.1, Q-dance, This Is X) mit Millionen Views ist das Top-Result aber praktisch deterministisch. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 59d74c6 commit f231eef

1 file changed

Lines changed: 361 additions & 0 deletions

File tree

Lines changed: 361 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,361 @@
1+
#!/usr/bin/env python3
2+
"""Recovery-Tool für Falsch-Matches wenn die Container-Logs nicht mehr da sind.
3+
4+
Companion zu ``cleanup_set_mismatches.py``: jenes Script braucht das Tonus-
5+
Log um die echten YT-Titel zu kennen. Wenn der Container recreated wurde
6+
(z.B. via ``docker compose pull && up -d``), ist der alte Log verloren.
7+
8+
Dieses Script geht den anderen Weg:
9+
10+
1. Walked die Navidrome-Library, findet Audio-Files > MAX_TRACK_DURATION_S
11+
(default 900s = 15min). Aus dem Pfad ``/music/<Artist>/<Album>/<Title>``
12+
wird die ursprüngliche Spotify-Anfrage abgeleitet.
13+
2. Replayed die Legacy-Suche: ``ytsearch1: <Artist> <Title> official audio``
14+
mit yt-dlp (NO download — nur metadata-extract). Top-Result-Title wird
15+
als der "echte" Set-Name angenommen.
16+
3. Plant Rename + Retag (TITLE + ALBUM tags) damit die Datei unter dem
17+
richtigen Namen in der Library erscheint.
18+
19+
Limitations:
20+
21+
- YT-Search-Results können sich seit dem Original-Download geändert haben.
22+
Für stabile Festival-Sets (Defqon.1, Q-dance) mit Millionen Views ist
23+
das Top-Result aber praktisch deterministisch — daher der Recovery-
24+
Ansatz hier robust genug.
25+
- Funktioniert nur für Files die der Tonus-Pfad-Konvention folgen
26+
(``/music/<Artist>/<Album>/<Title>.<ext>``). File-Drops außerhalb davon
27+
werden übersprungen.
28+
- Macht KEINEN echten Download. Nur metadata-extract via yt-dlp — schnell.
29+
30+
Default ``--dry-run`` — zeigt was passieren würde ohne irgendwas anzufassen.
31+
``--apply`` schreibt tatsächlich Tags + File-Rename.
32+
33+
Usage (auf NAS via docker exec)::
34+
35+
sudo docker exec tonus python3 /app/backend/scripts/recover_set_titles.py \\
36+
--library /music \\
37+
--dry-run
38+
39+
# Nach Review:
40+
sudo docker exec tonus python3 /app/backend/scripts/recover_set_titles.py \\
41+
--library /music \\
42+
--apply
43+
44+
Danach: Navidrome-Library-Scan triggern damit Subsonic-DB die neuen Titel
45+
sieht.
46+
"""
47+
48+
from __future__ import annotations
49+
50+
import argparse
51+
import re
52+
import sys
53+
from pathlib import Path
54+
from typing import Iterator, List, Optional, Tuple
55+
56+
try:
57+
from mutagen import File as MutagenFile
58+
except ImportError:
59+
print("ERROR: mutagen not installed. Run: pip install mutagen", file=sys.stderr)
60+
sys.exit(1)
61+
62+
try:
63+
import yt_dlp
64+
except ImportError:
65+
print("ERROR: yt-dlp not installed. Run: pip install yt-dlp", file=sys.stderr)
66+
sys.exit(1)
67+
68+
69+
# 15min Default. Wenn die Library auch legitime Extended-Mixes von 12-15min
70+
# hat, lieber höher setzen damit die nicht versehentlich getroffen werden.
71+
DEFAULT_MIN_DURATION_S = 900
72+
73+
_FORBIDDEN_FILENAME_CHARS = re.compile(r'[<>:"/\\|?*\x00-\x1f]')
74+
_AUDIO_EXTS = {".opus", ".mp3", ".m4a", ".ogg", ".flac", ".wav"}
75+
76+
# Folder unter dem Library-Root den wir überspringen — User kann hier
77+
# Falsch-Matches manuell quarantänen ohne dass das Script sie zweimal anpackt.
78+
_SKIP_DIRS = {"_falsch-matched", "_sets", "_quarantine"}
79+
80+
81+
# ──────────────────────────────────────────────────────────────────────
82+
# Library-Walker
83+
# ──────────────────────────────────────────────────────────────────────
84+
85+
86+
def iter_library_audio(library_root: Path) -> Iterator[Path]:
87+
"""Yield audio-files unter dem library-root, skipt Quarantäne-Folder."""
88+
for p in library_root.rglob("*"):
89+
if not p.is_file() or p.suffix.lower() not in _AUDIO_EXTS:
90+
continue
91+
# Skip quarantäne-ähnliche Folder
92+
if any(part.lower() in _SKIP_DIRS for part in p.relative_to(library_root).parts):
93+
continue
94+
yield p
95+
96+
97+
def parse_request_from_path(path: Path, library_root: Path) -> Optional[Tuple[str, str]]:
98+
"""Parse Tonus' default path convention.
99+
100+
Erwartete Struktur::
101+
102+
/music/<Artist>/<Album>/<Track>.<ext>
103+
/music/<Artist>/<Track>.<ext> (no album subdir — selten)
104+
105+
Returns ``(artist, title)`` aus den Pfad-Komponenten. None wenn die
106+
Struktur nicht passt (z.B. Track im Library-Root, oder verschachtelt
107+
tiefer als 2 Ebenen).
108+
"""
109+
try:
110+
rel = path.relative_to(library_root)
111+
except ValueError:
112+
return None
113+
parts = rel.parts
114+
# parts: [Artist, Album, Title.ext] oder [Artist, Title.ext]
115+
if len(parts) not in (2, 3):
116+
return None
117+
artist = parts[0].strip()
118+
title = path.stem.strip()
119+
if not artist or not title:
120+
return None
121+
return artist, title
122+
123+
124+
def get_audio_duration(path: Path) -> float:
125+
"""Read duration via mutagen. 0 bei Fehler."""
126+
try:
127+
mf = MutagenFile(str(path))
128+
return float(mf.info.length) if mf and getattr(mf, "info", None) else 0.0
129+
except Exception:
130+
return 0.0
131+
132+
133+
# ──────────────────────────────────────────────────────────────────────
134+
# YT-Search-Replay
135+
# ──────────────────────────────────────────────────────────────────────
136+
137+
138+
def yt_search_top_title(artist: str, title: str, ydl: "yt_dlp.YoutubeDL") -> Optional[str]:
139+
"""Replay den Legacy-Fallback-Query und return den top-result-Title.
140+
141+
Identische Query-Form wie ``youtube.py::search_and_download`` im
142+
Legacy-Fallback-Pfad (vor v0.3.1): ``<artist> <title> official audio``.
143+
"""
144+
query = f"{artist} {title} official audio"
145+
try:
146+
info = ydl.extract_info(f"ytsearch1:{query}", download=False)
147+
except Exception as e:
148+
print(f" WARN: ytsearch1 failed: {type(e).__name__}: {e}")
149+
return None
150+
if not info:
151+
return None
152+
entries = info.get("entries") or []
153+
if not entries:
154+
return None
155+
top = entries[0]
156+
if not top:
157+
return None
158+
return top.get("title")
159+
160+
161+
def sanitize_filename_segment(s: str) -> str:
162+
cleaned = _FORBIDDEN_FILENAME_CHARS.sub("_", s)
163+
return cleaned.strip(" .")[:200]
164+
165+
166+
def write_title_tags(audio_path: Path, new_title: str) -> bool:
167+
"""Updated TITLE-Tag. Album-Tag bleibt unverändert — User kann den
168+
Album-Folder später manuell aufräumen wenn er konsistent benannt sein
169+
soll.
170+
"""
171+
try:
172+
mf = MutagenFile(str(audio_path), easy=True)
173+
if mf is None:
174+
return False
175+
mf["title"] = [new_title]
176+
mf.save()
177+
return True
178+
except Exception as e:
179+
print(f" ERROR: cannot write title tag on {audio_path}: {type(e).__name__}: {e}")
180+
return False
181+
182+
183+
def is_meaningful_change(old_title: str, new_title: str) -> bool:
184+
"""Heuristik: skip wenn der YT-Titel essentiell der gleiche Track ist.
185+
186+
Beispiel: requested "Lose Yourself" → YT returnt "Lose Yourself (Soundtrack)".
187+
Das wäre kein Falsch-Match sondern ein echter Track mit Suffix. Wenn der
188+
requested-Title (case+space-normalized) vollständig im YT-Titel
189+
enthalten ist UND die Länge nicht dramatisch unterschiedlich ist
190+
→ skip, das war wahrscheinlich legit.
191+
"""
192+
norm = lambda s: re.sub(r"[^a-z0-9]+", "", s.lower())
193+
old_norm = norm(old_title)
194+
new_norm = norm(new_title)
195+
if old_norm in new_norm:
196+
# Length-Ratio check: wenn der neue Titel 2x so lang wäre wie
197+
# der alte oder mehr, ist es kein "nur Suffix" sondern wirklich
198+
# ein anderer Title (z.B. Festival-Set-Header).
199+
if len(new_norm) < 2 * len(old_norm):
200+
return False # not meaningful, probably legit extended mix
201+
return True
202+
203+
204+
# ──────────────────────────────────────────────────────────────────────
205+
# Main
206+
# ──────────────────────────────────────────────────────────────────────
207+
208+
209+
def main() -> int:
210+
parser = argparse.ArgumentParser(
211+
description="Recover real titles for falsch-matched long files via YT-reverse-search."
212+
)
213+
parser.add_argument(
214+
"--library",
215+
type=Path,
216+
required=True,
217+
help="Navidrome library root (z.B. /music im Container, /volume1/music auf Host)",
218+
)
219+
parser.add_argument(
220+
"--min-duration",
221+
type=int,
222+
default=DEFAULT_MIN_DURATION_S,
223+
help=f"Nur Files mit duration > N seconds anfassen (default: {DEFAULT_MIN_DURATION_S})",
224+
)
225+
parser.add_argument(
226+
"--apply",
227+
action="store_true",
228+
help="Tatsächlich umbenennen + retaggen (default: dry-run)",
229+
)
230+
parser.add_argument(
231+
"--limit",
232+
type=int,
233+
default=0,
234+
help="Nur die ersten N Kandidaten verarbeiten (für Tests). 0 = unlimitiert.",
235+
)
236+
args = parser.parse_args()
237+
238+
if not args.library.exists() or not args.library.is_dir():
239+
print(f"ERROR: library not found: {args.library}", file=sys.stderr)
240+
return 1
241+
242+
dry_run = not args.apply
243+
244+
# ── Step 1: Library walken + Kandidaten sammeln ──
245+
print(f"Scanning {args.library} for files > {args.min_duration}s …")
246+
candidates: List[Tuple[Path, str, str, float]] = [] # (path, artist, title, duration)
247+
for audio_path in iter_library_audio(args.library):
248+
duration = get_audio_duration(audio_path)
249+
if duration <= args.min_duration:
250+
continue
251+
parsed = parse_request_from_path(audio_path, args.library)
252+
if parsed is None:
253+
continue
254+
artist, title = parsed
255+
candidates.append((audio_path, artist, title, duration))
256+
257+
print(f" Found {len(candidates)} candidate file(s)")
258+
if not candidates:
259+
print("Nothing to do.")
260+
return 0
261+
262+
if args.limit and args.limit < len(candidates):
263+
print(f" Limiting to first {args.limit} for this run")
264+
candidates = candidates[: args.limit]
265+
266+
# ── Step 2: YT-Search-Replay pro Kandidat ──
267+
ydl_opts = {
268+
"quiet": True,
269+
"no_warnings": True,
270+
"skip_download": True,
271+
"noplaylist": True,
272+
"extract_flat": False,
273+
}
274+
275+
rename_plan: List[Tuple[Path, Path, str, str]] = [] # (old, new, old_title, new_title)
276+
skipped_no_match = 0
277+
skipped_not_meaningful = 0
278+
279+
print(f"\nReplaying YT-searches for {len(candidates)} candidate(s) …")
280+
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
281+
for audio_path, artist, title, duration in candidates:
282+
print(f" • {artist}{title} ({duration:.0f}s)")
283+
actual_title = yt_search_top_title(artist, title, ydl)
284+
if not actual_title:
285+
print(" (no YT result)")
286+
skipped_no_match += 1
287+
continue
288+
if not is_meaningful_change(title, actual_title):
289+
print(f" → {actual_title!r} (skipped: similar to requested)")
290+
skipped_not_meaningful += 1
291+
continue
292+
new_filename = (
293+
f"{sanitize_filename_segment(artist)} - "
294+
f"{sanitize_filename_segment(actual_title)}{audio_path.suffix}"
295+
)
296+
new_path = audio_path.parent / new_filename
297+
if new_path == audio_path:
298+
continue
299+
rename_plan.append((audio_path, new_path, title, actual_title))
300+
print(f" → {actual_title!r}")
301+
302+
print()
303+
print(f"Summary: {len(rename_plan)} rename(s) planned, "
304+
f"{skipped_no_match} skipped (no YT result), "
305+
f"{skipped_not_meaningful} skipped (not meaningful change)")
306+
307+
if not rename_plan:
308+
return 0
309+
310+
# ── Step 3: Apply oder Dry-Run-Ausgabe ──
311+
print()
312+
if dry_run:
313+
print("=== DRY RUN — no changes will be made ===")
314+
else:
315+
print("=== APPLYING CHANGES ===")
316+
print()
317+
318+
renamed_ok = 0
319+
failed = 0
320+
for old_path, new_path, old_title, new_title in rename_plan:
321+
rel_old = old_path.relative_to(args.library)
322+
rel_new = new_path.relative_to(args.library)
323+
size_mb = old_path.stat().st_size / (1024 * 1024)
324+
325+
print(f" {rel_old} ({size_mb:.1f} MB)")
326+
print(f" → {rel_new}")
327+
print(f" title-tag: {old_title!r}{new_title!r}")
328+
329+
if not dry_run:
330+
if not write_title_tags(old_path, new_title):
331+
print(" ❌ tag-write failed, skipping rename")
332+
failed += 1
333+
print()
334+
continue
335+
if new_path.exists():
336+
print(f" ❌ target exists, skipping rename: {new_path.name}")
337+
failed += 1
338+
print()
339+
continue
340+
try:
341+
old_path.rename(new_path)
342+
print(" ✓ renamed + retagged")
343+
renamed_ok += 1
344+
except OSError as e:
345+
print(f" ❌ rename failed: {e}")
346+
failed += 1
347+
print()
348+
349+
if dry_run:
350+
print(f"\n{len(rename_plan)} files would be renamed. Re-run with --apply to commit.")
351+
else:
352+
print(f"\n{renamed_ok} renamed")
353+
if failed:
354+
print(f"❌ {failed} failed")
355+
print("Trigger a Navidrome library scan to refresh Subsonic-DB.")
356+
357+
return 0 if failed == 0 else 2
358+
359+
360+
if __name__ == "__main__":
361+
sys.exit(main())

0 commit comments

Comments
 (0)