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