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 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 <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:
try:
report_path.parent.mkdir(parents=True, exist_ok=True)