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 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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue