"""Tests für den CD-Ripper.""" from pathlib import Path from unittest.mock import MagicMock, patch from musiksammlung.config import AudioFormat from musiksammlung.models import Album, Disc, Track, TrackInfo from musiksammlung.ripper import ( RipperConfig, _clean_input, _extract_tracks, _parse_cddb_lines, _parse_grab_tracks, _rename_files, _sanitize_name, interactive_rip, ) class TestSanitizeName: """Tests für _sanitize_name.""" def test_replace_spaces(self) -> None: assert _sanitize_name("Hello World") == "Hello_World" assert _sanitize_name("Test Track Title") == "Test_Track_Title" def test_remove_invalid_chars(self) -> None: assert _sanitize_name("Test/Track:Name") == "TestTrackName" assert _sanitize_name('Test"Name') == "TestTrackName" assert _sanitize_name("Test|Track?Name*") == "TestTrackName" def test_keep_umlauts(self) -> None: assert _sanitize_name("Grüße aus Österreich") == "Grüße_aus_Österreich" assert _sanitize_name("Schöne Sänger") == "Schöne_Sänger" def test_strip_underscores(self) -> None: assert _sanitize_name("_Test_") == "Test" assert _sanitize_name(" _Test_ ") == "Test" class TestCleanInput: """Tests für _clean_input.""" def test_strips_whitespace(self) -> None: assert _clean_input(" hello ") == "hello" def test_strips_quotes(self) -> None: assert _clean_input('"Album Name"') == "Album Name" assert _clean_input("'Album Name'") == "Album Name" def test_removes_ansi_escape(self) -> None: # ESC-based sequences (e.g. \x1b[A from arrow keys) are removed assert _clean_input("\x1b[A word") == "word" assert _clean_input("\x1b[1;5D text") == "text" def test_empty_string(self) -> None: assert _clean_input("") == "" def test_plain_input_unchanged(self) -> None: assert _clean_input("Beethoven Sinfonien") == "Beethoven Sinfonien" class TestParseCddbLines: """Tests für _parse_cddb_lines.""" def test_parse_single_track_title_only(self) -> None: """Reguläres Album: Zeile ohne ' / ' → artist leer, gesamter Inhalt = Titel.""" lines = ["1: Für Elise"] tracks = _parse_cddb_lines(lines) assert len(tracks) == 1 assert tracks[0].track_number == 1 assert tracks[0].artist == "" assert tracks[0].title == "Für Elise" def test_parse_regular_multiple_tracks(self) -> None: """Mehrere reguläre Tracks werden korrekt geparst.""" lines = [ "1: First Title", "2: Second Title", "3: Third Title", ] tracks = _parse_cddb_lines(lines) assert len(tracks) == 3 assert tracks[2].track_number == 3 assert tracks[2].artist == "" assert tracks[2].title == "Third Title" def test_dash_in_title_not_split(self) -> None: """' - ' in klassischen Titeln wird NICHT als Künstler-Separator behandelt.""" lines = ['1: Sonata "Tempest": I. Largo - Allegro'] tracks = _parse_cddb_lines(lines) assert tracks[0].artist == "" assert tracks[0].title == 'Sonata "Tempest": I. Largo - Allegro' def test_ignores_non_matching_lines(self) -> None: lines = [ "Retrieving CDDB info ...", "1: Artist - Title", "Tagging track 1 of 1", ] tracks = _parse_cddb_lines(lines) assert len(tracks) == 1 def test_compilation_slash_separator(self) -> None: """Kompilations-Format: 'N: Artist / Title' wird korrekt geparst.""" lines = [ "1: Trini Lopez / This Land Is Your Land (live)", "2: The Foundations / In the Bad Bad Old Days", ] tracks = _parse_cddb_lines(lines) assert len(tracks) == 2 assert tracks[0].artist == "Trini Lopez" assert tracks[0].title == "This Land Is Your Land (live)" assert tracks[1].artist == "The Foundations" assert tracks[1].title == "In the Bad Bad Old Days" def test_empty_input(self) -> None: assert _parse_cddb_lines([]) == [] class TestParseGrabTracks: """Tests für _parse_grab_tracks.""" def test_artist_slash_title(self) -> None: data = [(1, "Trini Lopez / This Land Is Your Land (live)")] tracks = _parse_grab_tracks(data) assert len(tracks) == 1 assert tracks[0].track_number == 1 assert tracks[0].artist == "Trini Lopez" assert tracks[0].title == "This Land Is Your Land (live)" def test_title_only_no_slash(self) -> None: """Ohne Slash → leerer Künstler, Titel = gesamter String.""" data = [(3, "Beethoven 5. Sinfonie")] tracks = _parse_grab_tracks(data) assert tracks[0].artist == "" assert tracks[0].title == "Beethoven 5. Sinfonie" def test_multiple_tracks(self) -> None: data = [ (1, "KC and the Sunshine Band / Give It Up"), (2, "Sam & Dave / Can't You Find Another Way"), ] tracks = _parse_grab_tracks(data) assert len(tracks) == 2 assert tracks[1].artist == "Sam & Dave" assert tracks[1].title == "Can't You Find Another Way" def test_empty_input(self) -> None: assert _parse_grab_tracks([]) == [] class TestRipperConfig: """Tests für RipperConfig.""" def test_default_config(self) -> None: config = RipperConfig() assert config.device == "/dev/cdrom" assert config.audio_format == AudioFormat.FLAC assert config.quality == "high" assert config.parallel_jobs == 1 assert config.use_pipes is False assert config.use_cddb is True def test_custom_config(self) -> None: config = RipperConfig( device="/dev/sr0", audio_format=AudioFormat.MP3, quality="low", parallel_jobs=4, use_pipes=True, use_cddb=False, ) assert config.device == "/dev/sr0" assert config.audio_format == AudioFormat.MP3 assert config.quality == "low" assert config.parallel_jobs == 4 assert config.use_pipes is True assert config.use_cddb is False class TestAudioFormat: """Tests für AudioFormat-Methoden.""" def test_abcde_format(self) -> None: assert AudioFormat.FLAC.get_abcde_format() == "flac" assert AudioFormat.MP3.get_abcde_format() == "mp3" assert AudioFormat.OPUS.get_abcde_format() == "opus" assert AudioFormat.AAC.get_abcde_format() == "m4a" assert AudioFormat.WAV.get_abcde_format() == "wav" def test_extension(self) -> None: assert AudioFormat.FLAC.extension == ".flac" assert AudioFormat.MP3.extension == ".mp3" assert AudioFormat.OPUS.extension == ".opus" assert AudioFormat.AAC.extension == ".aac" assert AudioFormat.WAV.extension == ".wav" def test_encoder_options_flac(self) -> None: assert AudioFormat.FLAC.get_encoder_options("low") == "" assert AudioFormat.FLAC.get_encoder_options("medium") == "" assert AudioFormat.FLAC.get_encoder_options("high") == "-8" def test_encoder_options_mp3(self) -> None: assert AudioFormat.MP3.get_encoder_options("low") == "-V 7" assert AudioFormat.MP3.get_encoder_options("medium") == "-V 5" assert AudioFormat.MP3.get_encoder_options("high") == "-V 0" def test_encoder_options_opus(self) -> None: assert AudioFormat.OPUS.get_encoder_options("low") == "-b 96" assert AudioFormat.OPUS.get_encoder_options("medium") == "-b 128" assert AudioFormat.OPUS.get_encoder_options("high") == "-b 192" def test_encoder_options_aac(self) -> None: assert AudioFormat.AAC.get_encoder_options("low") == "-q:a 2" assert AudioFormat.AAC.get_encoder_options("medium") == "-q:a 3" assert AudioFormat.AAC.get_encoder_options("high") == "-q:a 4" def test_encoder_options_wav(self) -> None: assert AudioFormat.WAV.get_encoder_options("low") == "" assert AudioFormat.WAV.get_encoder_options("high") == "" class TestExtractTracks: """Tests für _extract_tracks.""" def test_moves_files_from_subdir(self, tmp_path: Path) -> None: subdir = tmp_path / "abcde.XXXX" subdir.mkdir() (subdir / "track01.flac").touch() (subdir / "track02.flac").touch() result = _extract_tracks(tmp_path, AudioFormat.FLAC) assert len(result) == 2 assert (tmp_path / "track01.flac").exists() assert (tmp_path / "track02.flac").exists() # Files no longer in subdir assert not (subdir / "track01.flac").exists() def test_leaves_already_flat_files(self, tmp_path: Path) -> None: (tmp_path / "track01.flac").touch() result = _extract_tracks(tmp_path, AudioFormat.FLAC) assert len(result) == 1 def test_ignores_wrong_format(self, tmp_path: Path) -> None: (tmp_path / "track01.mp3").touch() result = _extract_tracks(tmp_path, AudioFormat.FLAC) assert result == [] def test_empty_directory(self, tmp_path: Path) -> None: result = _extract_tracks(tmp_path, AudioFormat.FLAC) assert result == [] class TestRenameFiles: """Tests für _rename_files.""" def test_renames_with_cddb_data(self, tmp_path: Path) -> None: (tmp_path / "track01.flac").touch() (tmp_path / "track02.flac").touch() tracks = [ TrackInfo(1, "Karajan", "Allegro con brio"), TrackInfo(2, "Karajan", "Andante con moto"), ] _rename_files(tmp_path, tracks, AudioFormat.FLAC) assert (tmp_path / "01_-_Allegro_con_brio_-_Karajan.flac").exists() assert (tmp_path / "02_-_Andante_con_moto_-_Karajan.flac").exists() def test_fallback_without_cddb(self, tmp_path: Path) -> None: (tmp_path / "track01.flac").touch() _rename_files(tmp_path, [], AudioFormat.FLAC) # No CDDB data → fallback to "01.flac" assert (tmp_path / "01.flac").exists() def test_sanitizes_title_in_filename(self, tmp_path: Path) -> None: (tmp_path / "track01.flac").touch() # _sanitize_name: spaces → _, then chars like :/\ are removed (not replaced) tracks = [TrackInfo(1, "Art ist", "My Title")] _rename_files(tmp_path, tracks, AudioFormat.FLAC) assert (tmp_path / "01_-_My_Title_-_Art_ist.flac").exists() # --------------------------------------------------------------------------- # interactive_rip – EAN/Barcode-Integration # --------------------------------------------------------------------------- _MB_ALBUM = Album( artist="The Beatles", album="Abbey Road", year=1969, discs=[ Disc( disc_number=1, tracks=[ Track(track_number=1, title="Come Together"), Track(track_number=2, title="Something"), ], ) ], ) _CDDB_TRACKS = [ TrackInfo(1, "The Beatles", "Come Together"), TrackInfo(2, "The Beatles", "Something"), ] def _make_rip_disc_mock(tracks: list[TrackInfo] | None = None): """Erstellt ein Mock für rip_disc, das sofort zurückgibt.""" mock = MagicMock(return_value=(Path("/tmp/disc"), "Album1", tracks or [])) return mock class TestInteractiveRipBarcode: """Tests für EAN/Barcode-Abfrage in interactive_rip.""" def _run(self, tmp_path: Path, inputs: list[str], mb_album=None, mb_error=None): """Führt interactive_rip mit gemocktem I/O aus.""" config = RipperConfig(output_dir=tmp_path) input_iter = iter(inputs) with ( patch("musiksammlung.ripper.rip_disc", return_value=(tmp_path, None, _CDDB_TRACKS)), patch("builtins.input", side_effect=input_iter), patch( "musiksammlung.ripper.lookup_by_barcode", side_effect=mb_error if mb_error else (lambda _: mb_album) if mb_album else None, ) as mock_lookup, ): interactive_rip(config) return mock_lookup def test_ean_skipped_does_not_call_musicbrainz(self, tmp_path: Path) -> None: """Kein EAN → lookup_by_barcode wird nicht aufgerufen.""" inputs = [ "Abbey Road", # album name "", # EAN: leer → überspringen "1", # disc number "j", # CDDB korrekt? "n", # next CD? "n", # next album? ] config = RipperConfig(output_dir=tmp_path) with ( patch("musiksammlung.ripper.rip_disc", return_value=(tmp_path, None, _CDDB_TRACKS)), patch("builtins.input", side_effect=iter(inputs)), patch("musiksammlung.ripper.lookup_by_barcode") as mock_lookup, ): interactive_rip(config) mock_lookup.assert_not_called() def test_ean_triggers_musicbrainz_lookup(self, tmp_path: Path) -> None: """EAN eingegeben → lookup_by_barcode wird mit der EAN aufgerufen.""" inputs = [ "Abbey Road", # album name "0602557360561", # EAN "1", # disc number "j", # CDDB korrekt? "n", # next CD? "n", # next album? ] config = RipperConfig(output_dir=tmp_path) with ( patch("musiksammlung.ripper.rip_disc", return_value=(tmp_path, None, _CDDB_TRACKS)), patch("builtins.input", side_effect=iter(inputs)), patch("musiksammlung.ripper.lookup_by_barcode", return_value=_MB_ALBUM) as mock_lookup, ): interactive_rip(config) mock_lookup.assert_called_once_with("0602557360561") def test_musicbrainz_data_saved_to_json(self, tmp_path: Path) -> None: """MusicBrainz-Daten werden in album.json gespeichert.""" inputs = [ "", # album name: leer → Default "0602557360561", # EAN "1", # disc number "j", # CDDB korrekt? "n", # next CD? "n", # next album? ] config = RipperConfig(output_dir=tmp_path) with ( patch("musiksammlung.ripper.rip_disc", return_value=(tmp_path, None, _CDDB_TRACKS)), patch("builtins.input", side_effect=iter(inputs)), patch("musiksammlung.ripper.lookup_by_barcode", return_value=_MB_ALBUM), ): interactive_rip(config) json_path = tmp_path / "Abbey_Road" / "album.json" assert json_path.exists() import json data = json.loads(json_path.read_text()) assert data["artist"] == "The Beatles" assert data["album"] == "Abbey Road" assert data["year"] == 1969 def test_musicbrainz_failure_falls_back_to_cddb(self, tmp_path: Path) -> None: """MusicBrainz-Fehler → CDDB-Daten werden verwendet, kein Absturz.""" inputs = [ "Abbey Road", # album name "0000000000000", # EAN (kein Treffer) "1", # disc number "j", # CDDB korrekt? "n", # next CD? "n", # next album? ] config = RipperConfig(output_dir=tmp_path) with ( patch("musiksammlung.ripper.rip_disc", return_value=(tmp_path, None, _CDDB_TRACKS)), patch("builtins.input", side_effect=iter(inputs)), patch( "musiksammlung.ripper.lookup_by_barcode", side_effect=ValueError("Kein MusicBrainz-Eintrag"), ), ): interactive_rip(config) # darf nicht werfen # CDDB-basierte album.json wurde erstellt json_path = tmp_path / "Abbey_Road" / "album.json" assert json_path.exists() def test_album_name_taken_from_musicbrainz_when_default(self, tmp_path: Path) -> None: """Albumnamen wird von MusicBrainz übernommen wenn kein Name manuell eingegeben.""" inputs = [ "", # album name: leer → Default (Album1) "0602557360561", # EAN "1", # disc number "j", # CDDB korrekt? "n", "n", ] config = RipperConfig(output_dir=tmp_path) with ( patch("musiksammlung.ripper.rip_disc", return_value=(tmp_path, None, _CDDB_TRACKS)), patch("builtins.input", side_effect=iter(inputs)), patch("musiksammlung.ripper.lookup_by_barcode", return_value=_MB_ALBUM), ): interactive_rip(config) # Verzeichnis und JSON nach MusicBrainz-Namen benannt assert (tmp_path / "Abbey_Road" / "album.json").exists() def test_manual_album_name_kept_when_not_default(self, tmp_path: Path) -> None: """Manuell eingegebener Albumname wird NICHT von MusicBrainz überschrieben.""" inputs = [ "Mein Album", # manuell eingegebener Name "0602557360561", # EAN "1", # disc number "j", # CDDB korrekt? "n", "n", ] config = RipperConfig(output_dir=tmp_path) with ( patch("musiksammlung.ripper.rip_disc", return_value=(tmp_path, None, _CDDB_TRACKS)), patch("builtins.input", side_effect=iter(inputs)), patch("musiksammlung.ripper.lookup_by_barcode", return_value=_MB_ALBUM), ): interactive_rip(config) # JSON-Inhalt kommt von MusicBrainz (artist/year), aber das Verzeichnis-Layout # richtet sich nach mb_album.album (da MB-Daten Priorität haben) assert (tmp_path / "Abbey_Road" / "album.json").exists()