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:
parent
8bd48cf166
commit
5011cef4db
3 changed files with 96 additions and 12 deletions
96
executor.py
96
executor.py
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue