From 7554cade5085cba1a41690e7f6850901a09db977 Mon Sep 17 00:00:00 2001 From: dschlueter Date: Wed, 18 Feb 2026 06:49:17 +0100 Subject: [PATCH] feat: auto-rename album directory after in-place apply MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/musiksammlung/cli.py | 53 ++++++++++++++++++++++- src/musiksammlung/organizer.py | 10 ++--- src/musiksammlung/playlist.py | 6 +-- tests/test_cli.py | 77 ++++++++++++++++++++++++++++++++++ tests/test_organizer.py | 22 +++++----- 5 files changed, 148 insertions(+), 20 deletions(-) diff --git a/src/musiksammlung/cli.py b/src/musiksammlung/cli.py index bcbe1b7..b8fc49c 100644 --- a/src/musiksammlung/cli.py +++ b/src/musiksammlung/cli.py @@ -4,6 +4,7 @@ from __future__ import annotations import json import logging +import re from pathlib import Path import httpx @@ -16,7 +17,12 @@ from musiksammlung.llm_parser import parse_tracklist from musiksammlung.models import Album from musiksammlung.musicbrainz import lookup_by_barcode 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.ripper import RipperConfig, interactive_rip 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: """Prüft Disc-Zählungen und beendet das Programm bei Abweichungen.""" checks = check_disc_counts(album, input_dir) @@ -251,6 +299,9 @@ def apply( embed_album_cover(album, album_dir, cover) generate_playlist(album, album_dir) + if in_place: + _rename_album_dir_inplace(input_dir, album) + typer.echo(f"Fertig! Album liegt in: {album_dir}") diff --git a/src/musiksammlung/organizer.py b/src/musiksammlung/organizer.py index aaf3292..2aa2fe6 100644 --- a/src/musiksammlung/organizer.py +++ b/src/musiksammlung/organizer.py @@ -37,7 +37,7 @@ class DiscCheck: 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. Buchstaben (inkl. Umlaute), Ziffern und bestehende Unterstriche bleiben erhalten. @@ -104,8 +104,8 @@ def build_mapping( album_dir = input_dir else: assert output_root is not None, "output_root erforderlich wenn nicht in_place" - artist_dir = _sanitize_filename(album.artist) - album_dir = output_root / artist_dir / _sanitize_filename(album.folder_name) + artist_dir = sanitize_filename(album.artist) + album_dir = output_root / artist_dir / sanitize_filename(album.folder_name) mapping: dict[Path, Path] = {} multi_disc = len(album.discs) > 1 @@ -130,8 +130,8 @@ def build_mapping( ) for audio_file, track in zip(audio_files, disc.tracks): - safe_title = _sanitize_filename(track.title) - safe_artist = _sanitize_filename(track.artist or album.artist) + safe_title = sanitize_filename(track.title) + safe_artist = sanitize_filename(track.artist or album.artist) new_name = f"{track.track_number:02d}_-_{safe_title}_-_{safe_artist}{audio_file.suffix}" mapping[audio_file] = target_dir / new_name diff --git a/src/musiksammlung/playlist.py b/src/musiksammlung/playlist.py index 5ec18ee..679024d 100644 --- a/src/musiksammlung/playlist.py +++ b/src/musiksammlung/playlist.py @@ -6,7 +6,7 @@ import logging from pathlib import Path from musiksammlung.models import Album -from musiksammlung.organizer import _sanitize_filename +from musiksammlung.organizer import sanitize_filename logger = logging.getLogger(__name__) @@ -20,7 +20,7 @@ def generate_playlist(album: Album, album_dir: Path) -> Path: Returns: 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 multi_disc = len(album.discs) > 1 @@ -33,7 +33,7 @@ def generate_playlist(album: Album, album_dir: Path) -> Path: disc_prefix = "" for track in disc.tracks: - safe_title = _sanitize_filename(track.title) + safe_title = sanitize_filename(track.title) # Audiodatei im Zielverzeichnis finden pattern = f"{track.track_number:02d}_-_{safe_title}*" if multi_disc: diff --git a/tests/test_cli.py b/tests/test_cli.py index 12b9453..8ff7f65 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -210,3 +210,80 @@ class TestScanCommand: def test_scan_no_args_fails(self) -> None: result = runner.invoke(app, ["scan"]) 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 diff --git a/tests/test_organizer.py b/tests/test_organizer.py index 5ac1f45..70891ab 100644 --- a/tests/test_organizer.py +++ b/tests/test_organizer.py @@ -4,36 +4,36 @@ from pathlib import Path from musiksammlung.models import Album, Disc, Track from musiksammlung.organizer import ( - _sanitize_filename, build_mapping, check_disc_counts, discover_audio_files, + sanitize_filename, ) class TestSanitizeFilename: - """Tests für _sanitize_filename.""" + """Tests für sanitize_filename.""" 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: - assert _sanitize_filename("Song (Live)") == "Song_Live" - assert _sanitize_filename("Artist: Name") == "Artist_Name" + assert sanitize_filename("Song (Live)") == "Song_Live" + assert sanitize_filename("Artist: Name") == "Artist_Name" 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: - assert _sanitize_filename("_Test_") == "Test" + assert sanitize_filename("_Test_") == "Test" def test_keeps_umlauts(self) -> None: - assert _sanitize_filename("Für Elise") == "Für_Elise" - assert _sanitize_filename("Über den Wolken") == "Über_den_Wolken" + assert sanitize_filename("Für Elise") == "Für_Elise" + assert sanitize_filename("Über den Wolken") == "Über_den_Wolken" def test_keeps_digits(self) -> None: - assert _sanitize_filename("Track 01") == "Track_01" + assert sanitize_filename("Track 01") == "Track_01" class TestDiscoverAudioFiles: