feat: auto-rename album directory after in-place apply
After all file operations (rename, tag, cover, playlist), apply now renames the album root directory to match album.json metadata: - input_dir = CD1/CD2 etc.: parent directory is renamed automatically e.g. Kärntner_Doppelsextett/ → Du_Berührst_Mi_20_Jahre_Kärntner_Doppelsextett/ - input_dir = album root: a hint with the mv command is printed instead (avoids renaming an actively used path) - Existing directory with target name: warning, no rename Also: _sanitize_filename() in organizer.py made public (sanitize_filename), used consistently across organizer, playlist and cli modules. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
c0e4d2aa85
commit
7554cade50
5 changed files with 148 additions and 20 deletions
|
|
@ -4,6 +4,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
@ -16,7 +17,12 @@ from musiksammlung.llm_parser import parse_tracklist
|
||||||
from musiksammlung.models import Album
|
from musiksammlung.models import Album
|
||||||
from musiksammlung.musicbrainz import lookup_by_barcode
|
from musiksammlung.musicbrainz import lookup_by_barcode
|
||||||
from musiksammlung.ocr import ocr_images
|
from musiksammlung.ocr import ocr_images
|
||||||
from musiksammlung.organizer import apply_mapping, build_mapping, check_disc_counts
|
from musiksammlung.organizer import (
|
||||||
|
apply_mapping,
|
||||||
|
build_mapping,
|
||||||
|
check_disc_counts,
|
||||||
|
sanitize_filename,
|
||||||
|
)
|
||||||
from musiksammlung.playlist import generate_playlist
|
from musiksammlung.playlist import generate_playlist
|
||||||
from musiksammlung.ripper import RipperConfig, interactive_rip
|
from musiksammlung.ripper import RipperConfig, interactive_rip
|
||||||
from musiksammlung.tagger import embed_album_cover, tag_album
|
from musiksammlung.tagger import embed_album_cover, tag_album
|
||||||
|
|
@ -77,6 +83,48 @@ def _scan_to_album(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_CD_SUBDIR = re.compile(r"^CD\d+$", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
def _rename_album_dir_inplace(input_dir: Path, album: Album) -> None:
|
||||||
|
"""Benennt das Album-Wurzelverzeichnis nach den Metadaten um.
|
||||||
|
|
||||||
|
Muss als allerletzter Schritt aufgerufen werden, nachdem Tags,
|
||||||
|
Cover und Playlist bereits gesetzt wurden.
|
||||||
|
|
||||||
|
Logik:
|
||||||
|
- input_dir heißt 'CD1', 'CD2' etc. → Album-Wurzel = input_dir.parent,
|
||||||
|
Umbenennung erfolgt automatisch.
|
||||||
|
- Sonst (input_dir ist selbst das Album-Wurzelverzeichnis) → nur Hinweis
|
||||||
|
ausgeben, da ein Umbenennen des aktiven Verzeichnisses vermieden wird.
|
||||||
|
"""
|
||||||
|
abs_input = input_dir.resolve()
|
||||||
|
desired = sanitize_filename(album.folder_name)
|
||||||
|
|
||||||
|
if _CD_SUBDIR.match(abs_input.name):
|
||||||
|
# CD-Unterverzeichnis übergeben → Elternverzeichnis umbenennen
|
||||||
|
album_root = abs_input.parent
|
||||||
|
if album_root.name == desired:
|
||||||
|
return # bereits korrekt
|
||||||
|
new_path = album_root.parent / desired
|
||||||
|
if new_path.exists():
|
||||||
|
typer.echo(
|
||||||
|
f"Warnung: Zielverzeichnis '{desired}' existiert bereits — "
|
||||||
|
"Verzeichnis nicht umbenannt.",
|
||||||
|
err=True,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
album_root.rename(new_path)
|
||||||
|
typer.echo(f"Verzeichnis umbenannt: {album_root.name} → {desired}")
|
||||||
|
else:
|
||||||
|
# Album-Wurzelverzeichnis direkt übergeben → nur Hinweis
|
||||||
|
if abs_input.name != desired:
|
||||||
|
typer.echo(
|
||||||
|
f"Tipp: Verzeichnis manuell umbenennen:\n"
|
||||||
|
f" mv '{abs_input.name}' '{desired}'"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _check_disc_counts_or_exit(album: Album, input_dir: Path, album_json: Path) -> None:
|
def _check_disc_counts_or_exit(album: Album, input_dir: Path, album_json: Path) -> None:
|
||||||
"""Prüft Disc-Zählungen und beendet das Programm bei Abweichungen."""
|
"""Prüft Disc-Zählungen und beendet das Programm bei Abweichungen."""
|
||||||
checks = check_disc_counts(album, input_dir)
|
checks = check_disc_counts(album, input_dir)
|
||||||
|
|
@ -251,6 +299,9 @@ def apply(
|
||||||
embed_album_cover(album, album_dir, cover)
|
embed_album_cover(album, album_dir, cover)
|
||||||
generate_playlist(album, album_dir)
|
generate_playlist(album, album_dir)
|
||||||
|
|
||||||
|
if in_place:
|
||||||
|
_rename_album_dir_inplace(input_dir, album)
|
||||||
|
|
||||||
typer.echo(f"Fertig! Album liegt in: {album_dir}")
|
typer.echo(f"Fertig! Album liegt in: {album_dir}")
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@ class DiscCheck:
|
||||||
return max(0, self.json_track_count - self.audio_file_count)
|
return max(0, self.json_track_count - self.audio_file_count)
|
||||||
|
|
||||||
|
|
||||||
def _sanitize_filename(name: str) -> str:
|
def sanitize_filename(name: str) -> str:
|
||||||
"""Ersetzt Sonderzeichen und Leerzeichen durch Unterstriche.
|
"""Ersetzt Sonderzeichen und Leerzeichen durch Unterstriche.
|
||||||
|
|
||||||
Buchstaben (inkl. Umlaute), Ziffern und bestehende Unterstriche bleiben erhalten.
|
Buchstaben (inkl. Umlaute), Ziffern und bestehende Unterstriche bleiben erhalten.
|
||||||
|
|
@ -104,8 +104,8 @@ def build_mapping(
|
||||||
album_dir = input_dir
|
album_dir = input_dir
|
||||||
else:
|
else:
|
||||||
assert output_root is not None, "output_root erforderlich wenn nicht in_place"
|
assert output_root is not None, "output_root erforderlich wenn nicht in_place"
|
||||||
artist_dir = _sanitize_filename(album.artist)
|
artist_dir = sanitize_filename(album.artist)
|
||||||
album_dir = output_root / artist_dir / _sanitize_filename(album.folder_name)
|
album_dir = output_root / artist_dir / sanitize_filename(album.folder_name)
|
||||||
|
|
||||||
mapping: dict[Path, Path] = {}
|
mapping: dict[Path, Path] = {}
|
||||||
multi_disc = len(album.discs) > 1
|
multi_disc = len(album.discs) > 1
|
||||||
|
|
@ -130,8 +130,8 @@ def build_mapping(
|
||||||
)
|
)
|
||||||
|
|
||||||
for audio_file, track in zip(audio_files, disc.tracks):
|
for audio_file, track in zip(audio_files, disc.tracks):
|
||||||
safe_title = _sanitize_filename(track.title)
|
safe_title = sanitize_filename(track.title)
|
||||||
safe_artist = _sanitize_filename(track.artist or album.artist)
|
safe_artist = sanitize_filename(track.artist or album.artist)
|
||||||
new_name = f"{track.track_number:02d}_-_{safe_title}_-_{safe_artist}{audio_file.suffix}"
|
new_name = f"{track.track_number:02d}_-_{safe_title}_-_{safe_artist}{audio_file.suffix}"
|
||||||
mapping[audio_file] = target_dir / new_name
|
mapping[audio_file] = target_dir / new_name
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from musiksammlung.models import Album
|
from musiksammlung.models import Album
|
||||||
from musiksammlung.organizer import _sanitize_filename
|
from musiksammlung.organizer import sanitize_filename
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -20,7 +20,7 @@ def generate_playlist(album: Album, album_dir: Path) -> Path:
|
||||||
Returns:
|
Returns:
|
||||||
Pfad zur erzeugten Playlist-Datei.
|
Pfad zur erzeugten Playlist-Datei.
|
||||||
"""
|
"""
|
||||||
playlist_name = _sanitize_filename(album.album) + ".m3u"
|
playlist_name = sanitize_filename(album.album) + ".m3u"
|
||||||
playlist_path = album_dir / playlist_name
|
playlist_path = album_dir / playlist_name
|
||||||
multi_disc = len(album.discs) > 1
|
multi_disc = len(album.discs) > 1
|
||||||
|
|
||||||
|
|
@ -33,7 +33,7 @@ def generate_playlist(album: Album, album_dir: Path) -> Path:
|
||||||
disc_prefix = ""
|
disc_prefix = ""
|
||||||
|
|
||||||
for track in disc.tracks:
|
for track in disc.tracks:
|
||||||
safe_title = _sanitize_filename(track.title)
|
safe_title = sanitize_filename(track.title)
|
||||||
# Audiodatei im Zielverzeichnis finden
|
# Audiodatei im Zielverzeichnis finden
|
||||||
pattern = f"{track.track_number:02d}_-_{safe_title}*"
|
pattern = f"{track.track_number:02d}_-_{safe_title}*"
|
||||||
if multi_disc:
|
if multi_disc:
|
||||||
|
|
|
||||||
|
|
@ -210,3 +210,80 @@ class TestScanCommand:
|
||||||
def test_scan_no_args_fails(self) -> None:
|
def test_scan_no_args_fails(self) -> None:
|
||||||
result = runner.invoke(app, ["scan"])
|
result = runner.invoke(app, ["scan"])
|
||||||
assert result.exit_code == 1
|
assert result.exit_code == 1
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _rename_album_dir_inplace (via apply --in-place)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestRenameAlbumDir:
|
||||||
|
"""Tests für automatisches Umbenennen des Album-Verzeichnisses."""
|
||||||
|
|
||||||
|
def test_cd_subdir_renames_parent(self, tmp_path: Path) -> None:
|
||||||
|
"""input_dir = CD1 → Elternverzeichnis wird umbenannt."""
|
||||||
|
album_root = tmp_path / "Falscher_Name"
|
||||||
|
cd1 = album_root / "CD1"
|
||||||
|
cd1.mkdir(parents=True)
|
||||||
|
|
||||||
|
_make_album_json(album_root / "album.json", n_tracks=1)
|
||||||
|
_make_flac(cd1 / "track01.flac")
|
||||||
|
|
||||||
|
result = runner.invoke(app, [
|
||||||
|
"apply", str(cd1), str(album_root / "album.json"), "--in-place",
|
||||||
|
])
|
||||||
|
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
expected = tmp_path / "TestAlbum_2024"
|
||||||
|
assert expected.exists(), f"Verzeichnis nicht umbenannt; output: {result.output}"
|
||||||
|
assert not album_root.exists()
|
||||||
|
|
||||||
|
def test_album_root_as_input_prints_hint(self, tmp_path: Path) -> None:
|
||||||
|
"""input_dir = AlbumRoot → kein auto-rename, stattdessen Hinweis."""
|
||||||
|
album = _make_album_json(tmp_path / "album.json", n_tracks=2)
|
||||||
|
_make_disc_files(tmp_path, album)
|
||||||
|
|
||||||
|
result = runner.invoke(app, [
|
||||||
|
"apply", str(tmp_path), str(tmp_path / "album.json"), "--in-place",
|
||||||
|
])
|
||||||
|
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
# Verzeichnis nicht umbenannt
|
||||||
|
assert tmp_path.exists()
|
||||||
|
# Aber Hinweis ausgegeben
|
||||||
|
assert "Tipp" in result.output or "mv" in result.output
|
||||||
|
|
||||||
|
def test_already_correct_name_no_rename(self, tmp_path: Path) -> None:
|
||||||
|
"""CD1 als input_dir, Elternverzeichnis heißt bereits korrekt → keine Ausgabe."""
|
||||||
|
album_root = tmp_path / "TestAlbum_2024"
|
||||||
|
cd1 = album_root / "CD1"
|
||||||
|
cd1.mkdir(parents=True)
|
||||||
|
|
||||||
|
_make_album_json(album_root / "album.json", n_tracks=1)
|
||||||
|
_make_flac(cd1 / "track01.flac")
|
||||||
|
|
||||||
|
result = runner.invoke(app, [
|
||||||
|
"apply", str(cd1), str(album_root / "album.json"), "--in-place",
|
||||||
|
])
|
||||||
|
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
assert album_root.exists()
|
||||||
|
assert "umbenannt" not in result.output
|
||||||
|
|
||||||
|
def test_target_exists_prints_warning(self, tmp_path: Path) -> None:
|
||||||
|
"""Zielverzeichnis existiert bereits → Warnung, kein Umbenennen."""
|
||||||
|
album_root = tmp_path / "Falscher_Name"
|
||||||
|
cd1 = album_root / "CD1"
|
||||||
|
cd1.mkdir(parents=True)
|
||||||
|
# Ziel existiert bereits
|
||||||
|
(tmp_path / "TestAlbum_2024").mkdir()
|
||||||
|
|
||||||
|
_make_album_json(album_root / "album.json", n_tracks=1)
|
||||||
|
_make_flac(cd1 / "track01.flac")
|
||||||
|
|
||||||
|
result = runner.invoke(app, [
|
||||||
|
"apply", str(cd1), str(album_root / "album.json"), "--in-place",
|
||||||
|
])
|
||||||
|
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
assert album_root.exists() # nicht umbenannt
|
||||||
|
|
|
||||||
|
|
@ -4,36 +4,36 @@ from pathlib import Path
|
||||||
|
|
||||||
from musiksammlung.models import Album, Disc, Track
|
from musiksammlung.models import Album, Disc, Track
|
||||||
from musiksammlung.organizer import (
|
from musiksammlung.organizer import (
|
||||||
_sanitize_filename,
|
|
||||||
build_mapping,
|
build_mapping,
|
||||||
check_disc_counts,
|
check_disc_counts,
|
||||||
discover_audio_files,
|
discover_audio_files,
|
||||||
|
sanitize_filename,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestSanitizeFilename:
|
class TestSanitizeFilename:
|
||||||
"""Tests für _sanitize_filename."""
|
"""Tests für sanitize_filename."""
|
||||||
|
|
||||||
def test_replaces_spaces(self) -> None:
|
def test_replaces_spaces(self) -> None:
|
||||||
assert _sanitize_filename("Hello World") == "Hello_World"
|
assert sanitize_filename("Hello World") == "Hello_World"
|
||||||
|
|
||||||
def test_replaces_punctuation(self) -> None:
|
def test_replaces_punctuation(self) -> None:
|
||||||
assert _sanitize_filename("Song (Live)") == "Song_Live"
|
assert sanitize_filename("Song (Live)") == "Song_Live"
|
||||||
assert _sanitize_filename("Artist: Name") == "Artist_Name"
|
assert sanitize_filename("Artist: Name") == "Artist_Name"
|
||||||
|
|
||||||
def test_collapses_multiple_underscores(self) -> None:
|
def test_collapses_multiple_underscores(self) -> None:
|
||||||
assert _sanitize_filename("A B") == "A_B"
|
assert sanitize_filename("A B") == "A_B"
|
||||||
assert _sanitize_filename("A--B") == "A_B"
|
assert sanitize_filename("A--B") == "A_B"
|
||||||
|
|
||||||
def test_strips_leading_trailing_underscores(self) -> None:
|
def test_strips_leading_trailing_underscores(self) -> None:
|
||||||
assert _sanitize_filename("_Test_") == "Test"
|
assert sanitize_filename("_Test_") == "Test"
|
||||||
|
|
||||||
def test_keeps_umlauts(self) -> None:
|
def test_keeps_umlauts(self) -> None:
|
||||||
assert _sanitize_filename("Für Elise") == "Für_Elise"
|
assert sanitize_filename("Für Elise") == "Für_Elise"
|
||||||
assert _sanitize_filename("Über den Wolken") == "Über_den_Wolken"
|
assert sanitize_filename("Über den Wolken") == "Über_den_Wolken"
|
||||||
|
|
||||||
def test_keeps_digits(self) -> None:
|
def test_keeps_digits(self) -> None:
|
||||||
assert _sanitize_filename("Track 01") == "Track_01"
|
assert sanitize_filename("Track 01") == "Track_01"
|
||||||
|
|
||||||
|
|
||||||
class TestDiscoverAudioFiles:
|
class TestDiscoverAudioFiles:
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue