diff --git a/executor.py b/executor.py index 3bff22e..03631be 100644 --- a/executor.py +++ b/executor.py @@ -3,6 +3,7 @@ from __future__ import annotations import csv import re import shutil +import subprocess import sys from pathlib import Path from typing import Optional, List, Dict, Any @@ -21,6 +22,9 @@ except ImportError: from cover_handler import embed_cover _SAFE_RE = re.compile(r'[<>:"/\\|?*\x00-\x1f]') +_CLASSICAL_GENRES = re.compile( + r"(?i)class|baroque|romantic|renaissance|opera|symphony|chamber|concerto|sonata|oratorio" +) REPORT_FIELDS = [ "status", "album_dir", "track_path", "old_title", "new_title", @@ -33,19 +37,62 @@ REPORT_FIELDS = [ def _safe_name(s: str) -> str: - return _SAFE_RE.sub("_", s).strip(". ") + """Filesystem-safe name: illegal chars → '_', spaces → '_'.""" + s = _SAFE_RE.sub("_", s) + return re.sub(r"\s+", "_", s).strip("._-") -def _proposed_filename(proposal: TrackProposal, ext: str, albumartist: str = "") -> str: +def _is_classical(albumartist: str, track_artist: str, genre: str) -> bool: + """ + Classical schema applies when performer (albumartist) ≠ composer (track_artist), + which covers both 'real' classical music and jazz-on-classical-themes albums. + Genre keyword matching is used as additional signal but not required. + """ + aa = (albumartist or "").casefold().strip() + ta = (track_artist or "").casefold().strip() + if not aa or aa in ("various artists", "unknown artist", "unknown"): + return False + if aa == ta: + return False + return True # performer ≠ composer → classical naming + + +def _proposed_filename( + proposal: TrackProposal, + ext: str, + albumartist: str = "", + genre: str = "", +) -> str: + """ + Pop/Default: TT_-_Artist_-_Titel.ext + Klassik: TT_-_Performer_-_Komponist_-_Titel[-_Orchester_Dirigent].ext + + Separator zwischen Teilen: _-_ + Leerzeichen innerhalb von Namen: _ + Fehlende Teile werden weggelassen. + """ tn = f"{proposal.track_number:02d}" if proposal.track_number else "00" - prefix = f"{proposal.disc_number}-{tn}" if proposal.disc_number and proposal.disc_number > 1 else tn + disc_prefix = f"{proposal.disc_number}-" if proposal.disc_number and proposal.disc_number > 1 else "" + prefix = f"{disc_prefix}{tn}" + track_artist = _safe_name(proposal.artist or "Unknown") aa = _safe_name(albumartist) title = _safe_name(proposal.title or "Unknown") - # Include albumartist when it differs from track artist (e.g. pianist vs. composer) - if aa and aa.casefold() != track_artist.casefold() and aa.casefold() not in ("various artists", "unknown"): - return f"{prefix} - {aa} - {track_artist} - {title}{ext}" - return f"{prefix} - {track_artist} - {title}{ext}" + + if _is_classical(aa, track_artist, genre): + # Klassik-Schema: Performer _-_ Komponist _-_ Werk [_-_ Orchester,Dirigent] + parts = [prefix, aa, track_artist, title] + # Orchester und Dirigent anhängen wenn vorhanden + extra = "_".join(filter(None, [ + _safe_name(proposal.orchestra or ""), + _safe_name(proposal.conductor or ""), + ])) + if extra: + parts.append(extra) + return "_-_".join(parts) + ext + else: + # Pop/Default-Schema: Tracknummer _-_ Artist _-_ Titel + return f"{prefix}_-_{track_artist}_-_{title}{ext}" def backup_file(path: Path, backup_dir: Path) -> bool: @@ -198,7 +245,11 @@ def execute_album( cover_embedded = True if do_rename: - new_name = _proposed_filename(tp, tp.path.suffix, proposal.albumartist or "") + new_name = _proposed_filename( + tp, tp.path.suffix, + albumartist=proposal.albumartist or "", + genre=proposal.genre or "", + ) candidate = tp.path.parent / new_name if candidate != tp.path: try: @@ -231,9 +282,38 @@ def execute_album( "sources": ", ".join(proposal.sources), }) + # Nach allen Umbenennungen: Verzeichnis Linux-kompatibel bereinigen + if do_rename and not dry_run: + sanitize_dir_names(proposal.album_dir) + return stats +def sanitize_dir_names(directory: Path) -> None: + """ + Macht alle Dateinamen im Verzeichnis Linux-kompatibel. + Bevorzugt 'NameToUnix ', fällt auf 'detox ' zurück. + """ + name_to_unix = shutil.which("NameToUnix") + if name_to_unix: + try: + subprocess.run([name_to_unix, str(directory)], check=True, capture_output=True) + return + except subprocess.CalledProcessError as e: + print(f" ⚠️ NameToUnix-Fehler: {e.stderr.decode(errors='replace').strip()}", file=sys.stderr) + + detox = shutil.which("detox") + if detox: + for f in sorted(directory.rglob("*")): + if f.is_file(): + try: + subprocess.run([detox, str(f)], check=True, capture_output=True) + except subprocess.CalledProcessError as e: + print(f" ⚠️ detox-Fehler {f.name}: {e.stderr.decode(errors='replace').strip()}", file=sys.stderr) + else: + print(" ℹ️ Weder NameToUnix noch detox gefunden — Dateinamen nicht nachbereinigt.", file=sys.stderr) + + def write_report(report_data: List[Dict[str, Any]], report_path: Path) -> None: try: report_path.parent.mkdir(parents=True, exist_ok=True) diff --git a/models.py b/models.py index 004b5da..f0b7f3a 100644 --- a/models.py +++ b/models.py @@ -61,6 +61,8 @@ class TrackProposal: disc_number: Optional[int] new_filename: Optional[str] = None # only set when --rename is active mbid: Optional[str] = None + conductor: Optional[str] = None # classical: Dirigent + orchestra: Optional[str] = None # classical: Orchester / Ensemble @dataclass diff --git a/test_suite_enricher.py b/test_suite_enricher.py index f587793..71bc588 100644 --- a/test_suite_enricher.py +++ b/test_suite_enricher.py @@ -196,10 +196,11 @@ def test_proposed_filename_single_disc() -> str: from executor import _proposed_filename from models import TrackProposal from pathlib import Path + # Pop schema: albumartist == track artist → TT_-_Artist_-_Title tp = TrackProposal(path=Path("dummy.mp3"), title="Dancing Queen", artist="ABBA", track_number=1, disc_number=None) - name = _proposed_filename(tp, ".mp3") - assert name == "01 - ABBA - Dancing Queen.mp3", f"got: {name!r}" + name = _proposed_filename(tp, ".mp3", albumartist="ABBA") + assert name == "01_-_ABBA_-_Dancing_Queen.mp3", f"got: {name!r}" return name @@ -207,10 +208,11 @@ def test_proposed_filename_multi_disc() -> str: from executor import _proposed_filename from models import TrackProposal from pathlib import Path + # Classical schema: albumartist (performer) ≠ track artist (composer) tp = TrackProposal(path=Path("dummy.flac"), title="Toccata", artist="Bach", track_number=7, disc_number=2) - name = _proposed_filename(tp, ".flac") - assert name == "2-07 - Bach - Toccata.flac", f"got: {name!r}" + name = _proposed_filename(tp, ".flac", albumartist="Gardiner") + assert name == "2-07_-_Gardiner_-_Bach_-_Toccata.flac", f"got: {name!r}" return name