Underscore filename schema, classical detection, NameToUnix post-processing

Pop schema:      TT_-_Artist_-_Title.ext
Classical schema: TT_-_Performer_-_Komponist_-_Werk[-_Orchester_Dirigent].ext
  triggered when albumartist ≠ track artist (pianist vs composer)

All spaces in names → underscores; separator _-_ between parts.
Missing parts (orchestra, conductor) are omitted.

models.py: added conductor/orchestra optional fields to TrackProposal.
executor.py: sanitize_dir_names() tries NameToUnix first, falls back to detox.
  Called after all renames in a directory are complete.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-04-28 22:46:43 +02:00
commit 5011cef4db
3 changed files with 96 additions and 12 deletions

View file

@ -3,6 +3,7 @@ from __future__ import annotations
import csv import csv
import re import re
import shutil import shutil
import subprocess
import sys import sys
from pathlib import Path from pathlib import Path
from typing import Optional, List, Dict, Any from typing import Optional, List, Dict, Any
@ -21,6 +22,9 @@ except ImportError:
from cover_handler import embed_cover from cover_handler import embed_cover
_SAFE_RE = re.compile(r'[<>:"/\\|?*\x00-\x1f]') _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 = [ REPORT_FIELDS = [
"status", "album_dir", "track_path", "status", "album_dir", "track_path",
"old_title", "new_title", "old_title", "new_title",
@ -33,19 +37,62 @@ REPORT_FIELDS = [
def _safe_name(s: str) -> str: 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" 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") track_artist = _safe_name(proposal.artist or "Unknown")
aa = _safe_name(albumartist) aa = _safe_name(albumartist)
title = _safe_name(proposal.title or "Unknown") 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"): if _is_classical(aa, track_artist, genre):
return f"{prefix} - {aa} - {track_artist} - {title}{ext}" # Klassik-Schema: Performer _-_ Komponist _-_ Werk [_-_ Orchester,Dirigent]
return f"{prefix} - {track_artist} - {title}{ext}" 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: def backup_file(path: Path, backup_dir: Path) -> bool:
@ -198,7 +245,11 @@ def execute_album(
cover_embedded = True cover_embedded = True
if do_rename: 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 candidate = tp.path.parent / new_name
if candidate != tp.path: if candidate != tp.path:
try: try:
@ -231,9 +282,38 @@ def execute_album(
"sources": ", ".join(proposal.sources), "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 return stats
def sanitize_dir_names(directory: Path) -> None:
"""
Macht alle Dateinamen im Verzeichnis Linux-kompatibel.
Bevorzugt 'NameToUnix <dir>', fällt auf 'detox <file>' 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: def write_report(report_data: List[Dict[str, Any]], report_path: Path) -> None:
try: try:
report_path.parent.mkdir(parents=True, exist_ok=True) report_path.parent.mkdir(parents=True, exist_ok=True)

View file

@ -61,6 +61,8 @@ class TrackProposal:
disc_number: Optional[int] disc_number: Optional[int]
new_filename: Optional[str] = None # only set when --rename is active new_filename: Optional[str] = None # only set when --rename is active
mbid: Optional[str] = None mbid: Optional[str] = None
conductor: Optional[str] = None # classical: Dirigent
orchestra: Optional[str] = None # classical: Orchester / Ensemble
@dataclass @dataclass

View file

@ -196,10 +196,11 @@ def test_proposed_filename_single_disc() -> str:
from executor import _proposed_filename from executor import _proposed_filename
from models import TrackProposal from models import TrackProposal
from pathlib import Path from pathlib import Path
# Pop schema: albumartist == track artist → TT_-_Artist_-_Title
tp = TrackProposal(path=Path("dummy.mp3"), title="Dancing Queen", tp = TrackProposal(path=Path("dummy.mp3"), title="Dancing Queen",
artist="ABBA", track_number=1, disc_number=None) artist="ABBA", track_number=1, disc_number=None)
name = _proposed_filename(tp, ".mp3") name = _proposed_filename(tp, ".mp3", albumartist="ABBA")
assert name == "01 - ABBA - Dancing Queen.mp3", f"got: {name!r}" assert name == "01_-_ABBA_-_Dancing_Queen.mp3", f"got: {name!r}"
return name return name
@ -207,10 +208,11 @@ def test_proposed_filename_multi_disc() -> str:
from executor import _proposed_filename from executor import _proposed_filename
from models import TrackProposal from models import TrackProposal
from pathlib import Path from pathlib import Path
# Classical schema: albumartist (performer) ≠ track artist (composer)
tp = TrackProposal(path=Path("dummy.flac"), title="Toccata", tp = TrackProposal(path=Path("dummy.flac"), title="Toccata",
artist="Bach", track_number=7, disc_number=2) artist="Bach", track_number=7, disc_number=2)
name = _proposed_filename(tp, ".flac") name = _proposed_filename(tp, ".flac", albumartist="Gardiner")
assert name == "2-07 - Bach - Toccata.flac", f"got: {name!r}" assert name == "2-07_-_Gardiner_-_Bach_-_Toccata.flac", f"got: {name!r}"
return name return name