2026-02-18 00:00:44 +01:00
|
|
|
|
"""Tests für den CD-Ripper."""
|
|
|
|
|
|
|
|
|
|
|
|
from pathlib import Path
|
2026-02-18 06:18:12 +01:00
|
|
|
|
from unittest.mock import MagicMock, patch
|
2026-02-18 00:00:44 +01:00
|
|
|
|
|
|
|
|
|
|
from musiksammlung.config import AudioFormat
|
2026-02-18 07:24:16 +01:00
|
|
|
|
from musiksammlung.models import Album, Disc, Track, TrackInfo
|
2026-02-18 00:00:44 +01:00
|
|
|
|
from musiksammlung.ripper import (
|
|
|
|
|
|
RipperConfig,
|
|
|
|
|
|
_clean_input,
|
|
|
|
|
|
_extract_tracks,
|
|
|
|
|
|
_parse_cddb_lines,
|
2026-02-18 09:42:03 +01:00
|
|
|
|
_parse_grab_tracks,
|
2026-02-18 00:00:44 +01:00
|
|
|
|
_rename_files,
|
|
|
|
|
|
_sanitize_name,
|
2026-02-18 06:13:10 +01:00
|
|
|
|
interactive_rip,
|
2026-02-18 00:00:44 +01:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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<Track>"Name') == "TestTrackName"
|
|
|
|
|
|
assert _sanitize_name("Test|Track?Name*") == "TestTrackName"
|
2026-02-18 23:25:26 +01:00
|
|
|
|
assert _sanitize_name("It's_a_Test") == "Its_a_Test"
|
2026-02-18 00:00:44 +01:00
|
|
|
|
|
2026-02-19 14:05:59 +01:00
|
|
|
|
def test_remove_brackets_and_punctuation(self) -> None:
|
|
|
|
|
|
assert _sanitize_name("Best of (1990)") == "Best_of_1990"
|
|
|
|
|
|
assert _sanitize_name("Hello, World!") == "Hello_World"
|
|
|
|
|
|
assert _sanitize_name("Vol. 2") == "Vol_2"
|
|
|
|
|
|
assert _sanitize_name("Salt & Pepper [Remix]") == "Salt_Pepper_Remix"
|
|
|
|
|
|
assert _sanitize_name("The Best of... (Deluxe Edition)") == "The_Best_of_Deluxe_Edition"
|
|
|
|
|
|
|
2026-02-18 00:00:44 +01:00
|
|
|
|
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."""
|
|
|
|
|
|
|
2026-02-18 22:45:38 +01:00
|
|
|
|
def test_parse_single_track_title_only(self) -> None:
|
|
|
|
|
|
"""Reguläres Album: Zeile ohne ' / ' → artist leer, gesamter Inhalt = Titel."""
|
|
|
|
|
|
lines = ["1: Für Elise"]
|
2026-02-18 00:00:44 +01:00
|
|
|
|
tracks = _parse_cddb_lines(lines)
|
|
|
|
|
|
assert len(tracks) == 1
|
|
|
|
|
|
assert tracks[0].track_number == 1
|
2026-02-18 22:45:38 +01:00
|
|
|
|
assert tracks[0].artist == ""
|
|
|
|
|
|
assert tracks[0].title == "Für Elise"
|
2026-02-18 00:00:44 +01:00
|
|
|
|
|
2026-02-18 22:45:38 +01:00
|
|
|
|
def test_parse_regular_multiple_tracks(self) -> None:
|
|
|
|
|
|
"""Mehrere reguläre Tracks werden korrekt geparst."""
|
2026-02-18 00:00:44 +01:00
|
|
|
|
lines = [
|
2026-02-18 22:45:38 +01:00
|
|
|
|
"1: First Title",
|
|
|
|
|
|
"2: Second Title",
|
|
|
|
|
|
"3: Third Title",
|
2026-02-18 00:00:44 +01:00
|
|
|
|
]
|
|
|
|
|
|
tracks = _parse_cddb_lines(lines)
|
|
|
|
|
|
assert len(tracks) == 3
|
|
|
|
|
|
assert tracks[2].track_number == 3
|
2026-02-18 22:45:38 +01:00
|
|
|
|
assert tracks[2].artist == ""
|
|
|
|
|
|
assert tracks[2].title == "Third Title"
|
2026-02-18 00:00:44 +01:00
|
|
|
|
|
2026-02-18 22:45:38 +01:00
|
|
|
|
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']
|
2026-02-18 00:00:44 +01:00
|
|
|
|
tracks = _parse_cddb_lines(lines)
|
2026-02-18 22:45:38 +01:00
|
|
|
|
assert tracks[0].artist == ""
|
|
|
|
|
|
assert tracks[0].title == 'Sonata "Tempest": I. Largo - Allegro'
|
2026-02-18 00:00:44 +01:00
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
2026-02-18 09:42:03 +01:00
|
|
|
|
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"
|
|
|
|
|
|
|
2026-02-18 00:00:44 +01:00
|
|
|
|
def test_empty_input(self) -> None:
|
|
|
|
|
|
assert _parse_cddb_lines([]) == []
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-02-18 09:42:03 +01:00
|
|
|
|
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([]) == []
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-02-18 00:00:44 +01:00
|
|
|
|
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()
|
2026-02-18 06:13:10 +01:00
|
|
|
|
|
2026-02-18 23:25:26 +01:00
|
|
|
|
def test_regular_track_without_artist(self, tmp_path: Path) -> None:
|
|
|
|
|
|
"""Reguläre Tracks (artist='') → kein '_-_Artist'-Suffix."""
|
|
|
|
|
|
(tmp_path / "track01.flac").touch()
|
|
|
|
|
|
tracks = [TrackInfo(1, "", "Allegro con brio")]
|
|
|
|
|
|
_rename_files(tmp_path, tracks, AudioFormat.FLAC)
|
|
|
|
|
|
assert (tmp_path / "01_-_Allegro_con_brio.flac").exists()
|
|
|
|
|
|
|
2026-02-18 06:13:10 +01:00
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
2026-02-19 00:21:42 +01:00
|
|
|
|
# interactive_rip – EAN-First Workflow
|
2026-02-18 06:13:10 +01:00
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
_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"),
|
|
|
|
|
|
],
|
|
|
|
|
|
)
|
|
|
|
|
|
],
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-02-19 00:21:42 +01:00
|
|
|
|
_MB_ALBUM_2DISC = Album(
|
|
|
|
|
|
artist="The Beatles",
|
|
|
|
|
|
album="White Album",
|
|
|
|
|
|
year=1968,
|
|
|
|
|
|
discs=[
|
|
|
|
|
|
Disc(disc_number=1, tracks=[
|
|
|
|
|
|
Track(track_number=1, title="Back in the U.S.S.R."),
|
|
|
|
|
|
]),
|
|
|
|
|
|
Disc(disc_number=2, tracks=[
|
|
|
|
|
|
Track(track_number=1, title="Birthday"),
|
|
|
|
|
|
]),
|
|
|
|
|
|
],
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-02-18 06:13:10 +01:00
|
|
|
|
_CDDB_TRACKS = [
|
|
|
|
|
|
TrackInfo(1, "The Beatles", "Come Together"),
|
|
|
|
|
|
TrackInfo(2, "The Beatles", "Something"),
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-02-19 00:21:42 +01:00
|
|
|
|
class TestInteractiveRipEanFirst:
|
|
|
|
|
|
"""Tests für EAN-First Workflow in interactive_rip.
|
2026-02-18 06:13:10 +01:00
|
|
|
|
|
2026-02-19 00:21:42 +01:00
|
|
|
|
Neuer Flow:
|
|
|
|
|
|
1. EAN abfragen
|
|
|
|
|
|
2. MB-Treffer → Auto-Rip (kein Albumname, kein CDDB-Confirm)
|
|
|
|
|
|
3. Kein Treffer → Fallback (Albumname, CDDB-Confirm wie bisher)
|
|
|
|
|
|
"""
|
2026-02-18 06:13:10 +01:00
|
|
|
|
|
2026-02-19 00:21:42 +01:00
|
|
|
|
# ── Auto-Rip (MusicBrainz-Treffer) ──
|
2026-02-18 06:13:10 +01:00
|
|
|
|
|
2026-02-19 00:21:42 +01:00
|
|
|
|
def test_mb_hit_auto_rip_single_disc(self, tmp_path: Path) -> None:
|
|
|
|
|
|
"""MB-Treffer → Auto-Rip: eine Disc, kein Albumname-Prompt, album.json aus MB."""
|
|
|
|
|
|
inputs = [
|
|
|
|
|
|
"0602557360561", # EAN → MB-Treffer
|
|
|
|
|
|
"", # "CD 1/1 einlegen und Enter drücken"
|
|
|
|
|
|
"n", # next album?
|
|
|
|
|
|
]
|
2026-02-18 06:13:10 +01:00
|
|
|
|
config = RipperConfig(output_dir=tmp_path)
|
2026-02-19 14:05:59 +01:00
|
|
|
|
sp = self._scanner_patches()
|
2026-02-18 06:13:10 +01:00
|
|
|
|
with (
|
2026-02-19 14:05:59 +01:00
|
|
|
|
sp[0], sp[1],
|
2026-02-18 06:13:10 +01:00
|
|
|
|
patch("musiksammlung.ripper.rip_disc", return_value=(tmp_path, None, _CDDB_TRACKS)),
|
2026-02-19 00:21:42 +01:00
|
|
|
|
patch("builtins.input", side_effect=iter(inputs)),
|
2026-02-19 14:05:59 +01:00
|
|
|
|
patch("musiksammlung.ripper.lookup_by_barcode", return_value=(_MB_ALBUM, "fake-mbid")),
|
|
|
|
|
|
patch("musiksammlung.ripper.download_caa_covers") as mock_caa,
|
2026-02-18 06:13:10 +01:00
|
|
|
|
):
|
|
|
|
|
|
interactive_rip(config)
|
|
|
|
|
|
|
2026-02-19 00:21:42 +01:00
|
|
|
|
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
|
2026-02-19 14:05:59 +01:00
|
|
|
|
mock_caa.assert_called_once_with("fake-mbid", tmp_path / "Abbey_Road")
|
2026-02-18 06:13:10 +01:00
|
|
|
|
|
2026-02-19 00:21:42 +01:00
|
|
|
|
def test_mb_hit_auto_rip_multi_disc(self, tmp_path: Path) -> None:
|
|
|
|
|
|
"""MB-Treffer mit 2 Discs → 2x Disc-Insert-Prompt, album.json aus MB."""
|
2026-02-18 06:13:10 +01:00
|
|
|
|
inputs = [
|
2026-02-19 00:21:42 +01:00
|
|
|
|
"1234567890123", # EAN → MB-Treffer (2 Discs)
|
|
|
|
|
|
"", # "CD 1/2 einlegen und Enter drücken"
|
|
|
|
|
|
"", # "CD 2/2 einlegen und Enter drücken"
|
|
|
|
|
|
"n", # next album?
|
2026-02-18 06:13:10 +01:00
|
|
|
|
]
|
|
|
|
|
|
config = RipperConfig(output_dir=tmp_path)
|
2026-02-19 14:05:59 +01:00
|
|
|
|
sp = self._scanner_patches()
|
2026-02-18 06:13:10 +01:00
|
|
|
|
with (
|
2026-02-19 14:05:59 +01:00
|
|
|
|
sp[0], sp[1],
|
2026-02-18 06:13:10 +01:00
|
|
|
|
patch("musiksammlung.ripper.rip_disc", return_value=(tmp_path, None, _CDDB_TRACKS)),
|
|
|
|
|
|
patch("builtins.input", side_effect=iter(inputs)),
|
2026-02-19 14:05:59 +01:00
|
|
|
|
patch("musiksammlung.ripper.lookup_by_barcode",
|
|
|
|
|
|
return_value=(_MB_ALBUM_2DISC, "fake-mbid-2")),
|
|
|
|
|
|
patch("musiksammlung.ripper.download_caa_covers"),
|
2026-02-18 06:13:10 +01:00
|
|
|
|
):
|
|
|
|
|
|
interactive_rip(config)
|
|
|
|
|
|
|
2026-02-19 00:21:42 +01:00
|
|
|
|
json_path = tmp_path / "White_Album" / "album.json"
|
|
|
|
|
|
assert json_path.exists()
|
|
|
|
|
|
import json
|
|
|
|
|
|
data = json.loads(json_path.read_text())
|
|
|
|
|
|
assert data["artist"] == "The Beatles"
|
|
|
|
|
|
assert data["album"] == "White Album"
|
|
|
|
|
|
assert data["year"] == 1968
|
|
|
|
|
|
assert len(data["discs"]) == 2
|
2026-02-18 06:13:10 +01:00
|
|
|
|
|
2026-02-19 00:21:42 +01:00
|
|
|
|
def test_mb_hit_triggers_lookup(self, tmp_path: Path) -> None:
|
|
|
|
|
|
"""EAN eingegeben → lookup_by_barcode wird aufgerufen."""
|
2026-02-18 06:13:10 +01:00
|
|
|
|
inputs = [
|
2026-02-19 00:21:42 +01:00
|
|
|
|
"0602557360561", # EAN
|
|
|
|
|
|
"", # disc insert
|
|
|
|
|
|
"n", # next album?
|
2026-02-18 06:13:10 +01:00
|
|
|
|
]
|
|
|
|
|
|
config = RipperConfig(output_dir=tmp_path)
|
2026-02-19 14:05:59 +01:00
|
|
|
|
sp = self._scanner_patches()
|
2026-02-18 06:13:10 +01:00
|
|
|
|
with (
|
2026-02-19 14:05:59 +01:00
|
|
|
|
sp[0], sp[1],
|
2026-02-18 06:13:10 +01:00
|
|
|
|
patch("musiksammlung.ripper.rip_disc", return_value=(tmp_path, None, _CDDB_TRACKS)),
|
|
|
|
|
|
patch("builtins.input", side_effect=iter(inputs)),
|
2026-02-19 14:05:59 +01:00
|
|
|
|
patch("musiksammlung.ripper.lookup_by_barcode",
|
|
|
|
|
|
return_value=(_MB_ALBUM, "fake-mbid")) as mock_lookup,
|
|
|
|
|
|
patch("musiksammlung.ripper.download_caa_covers"),
|
2026-02-18 06:13:10 +01:00
|
|
|
|
):
|
|
|
|
|
|
interactive_rip(config)
|
|
|
|
|
|
|
|
|
|
|
|
mock_lookup.assert_called_once_with("0602557360561")
|
|
|
|
|
|
|
2026-02-19 00:21:42 +01:00
|
|
|
|
def test_mb_hit_renames_with_cddb_tracks(self, tmp_path: Path) -> None:
|
|
|
|
|
|
"""Auto-Rip nutzt CDDB-Tracks zum Umbenennen der Dateien."""
|
2026-02-18 06:13:10 +01:00
|
|
|
|
inputs = [
|
2026-02-19 00:21:42 +01:00
|
|
|
|
"0602557360561",
|
|
|
|
|
|
"", # disc insert
|
|
|
|
|
|
"n",
|
2026-02-18 06:13:10 +01:00
|
|
|
|
]
|
|
|
|
|
|
config = RipperConfig(output_dir=tmp_path)
|
2026-02-19 00:21:42 +01:00
|
|
|
|
disc_dir = tmp_path / "Abbey_Road" / "CD1"
|
|
|
|
|
|
disc_dir.mkdir(parents=True)
|
|
|
|
|
|
(disc_dir / "track01.flac").touch()
|
|
|
|
|
|
(disc_dir / "track02.flac").touch()
|
|
|
|
|
|
|
2026-02-19 14:05:59 +01:00
|
|
|
|
sp = self._scanner_patches()
|
2026-02-18 06:13:10 +01:00
|
|
|
|
with (
|
2026-02-19 14:05:59 +01:00
|
|
|
|
sp[0], sp[1],
|
2026-02-19 00:21:42 +01:00
|
|
|
|
patch("musiksammlung.ripper.rip_disc", return_value=(disc_dir, None, _CDDB_TRACKS)),
|
2026-02-18 06:13:10 +01:00
|
|
|
|
patch("builtins.input", side_effect=iter(inputs)),
|
2026-02-19 14:05:59 +01:00
|
|
|
|
patch("musiksammlung.ripper.lookup_by_barcode", return_value=(_MB_ALBUM, "fake-mbid")),
|
|
|
|
|
|
patch("musiksammlung.ripper.download_caa_covers"),
|
2026-02-18 06:13:10 +01:00
|
|
|
|
):
|
|
|
|
|
|
interactive_rip(config)
|
|
|
|
|
|
|
2026-02-19 00:21:42 +01:00
|
|
|
|
# _rename_files wurde aufgerufen (tracks vorhanden)
|
|
|
|
|
|
# Dateien existieren schon vorher, rename findet in _rename_files statt
|
|
|
|
|
|
assert (tmp_path / "Abbey_Road" / "album.json").exists()
|
|
|
|
|
|
|
2026-02-19 14:05:59 +01:00
|
|
|
|
# ── Gemeinsame Scanner-Patches (alle interactive_rip-Tests) ──
|
|
|
|
|
|
#
|
|
|
|
|
|
# Ab EAN-Prompt startet interactive_rip immer einen ScannerServer und
|
|
|
|
|
|
# nutzt _input_or_scan. Beide werden in allen Tests gemockt.
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _scanner_patches():
|
|
|
|
|
|
"""Patches für ScannerServer und _input_or_scan (alle interactive_rip-Tests)."""
|
|
|
|
|
|
mock_scanner = MagicMock()
|
|
|
|
|
|
mock_scanner.url.return_value = "http://127.0.0.1:8765"
|
|
|
|
|
|
mock_scanner.get_photo.return_value = None
|
|
|
|
|
|
return [
|
|
|
|
|
|
patch("musiksammlung.ripper.ScannerServer", return_value=mock_scanner),
|
|
|
|
|
|
patch(
|
|
|
|
|
|
"musiksammlung.ripper._input_or_scan",
|
|
|
|
|
|
side_effect=lambda prompt, scanner: (input(prompt), None),
|
|
|
|
|
|
),
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _fallback_patches(inputs: list[str]):
|
|
|
|
|
|
"""Gemeinsame Patches für Fallback-Tests."""
|
|
|
|
|
|
patches = TestInteractiveRipEanFirst._scanner_patches()
|
|
|
|
|
|
patches.append(patch("builtins.input", side_effect=iter(inputs)))
|
|
|
|
|
|
return patches
|
2026-02-19 00:21:42 +01:00
|
|
|
|
|
|
|
|
|
|
def test_ean_empty_falls_back_to_album_prompt(self, tmp_path: Path) -> None:
|
|
|
|
|
|
"""Leere EAN → Fallback: Albumname wird abgefragt."""
|
|
|
|
|
|
inputs = [
|
|
|
|
|
|
"", # EAN: leer → überspringen
|
|
|
|
|
|
"Abbey Road", # album name (Fallback-Flow)
|
|
|
|
|
|
"1", # disc number
|
|
|
|
|
|
"j", # CDDB korrekt?
|
|
|
|
|
|
"n", # next CD?
|
|
|
|
|
|
"n", # next album?
|
|
|
|
|
|
]
|
|
|
|
|
|
config = RipperConfig(output_dir=tmp_path)
|
2026-02-19 14:05:59 +01:00
|
|
|
|
patches = self._fallback_patches(inputs)
|
2026-02-19 00:21:42 +01:00
|
|
|
|
with (
|
2026-02-19 14:05:59 +01:00
|
|
|
|
patches[0], patches[1], patches[2],
|
2026-02-19 00:21:42 +01:00
|
|
|
|
patch("musiksammlung.ripper.rip_disc", return_value=(tmp_path, None, _CDDB_TRACKS)),
|
|
|
|
|
|
patch("musiksammlung.ripper.lookup_by_barcode") as mock_lookup,
|
|
|
|
|
|
):
|
|
|
|
|
|
interactive_rip(config)
|
|
|
|
|
|
|
|
|
|
|
|
mock_lookup.assert_not_called()
|
2026-02-18 06:13:10 +01:00
|
|
|
|
json_path = tmp_path / "Abbey_Road" / "album.json"
|
|
|
|
|
|
assert json_path.exists()
|
|
|
|
|
|
|
2026-02-19 00:21:42 +01:00
|
|
|
|
def test_mb_miss_falls_back_to_album_prompt(self, tmp_path: Path) -> None:
|
|
|
|
|
|
"""MB-Fehler → Fallback: Albumname wird abgefragt, CDDB-Daten genutzt."""
|
2026-02-18 06:13:10 +01:00
|
|
|
|
inputs = [
|
2026-02-19 00:21:42 +01:00
|
|
|
|
"0000000000000", # EAN (kein Treffer)
|
|
|
|
|
|
"Abbey Road", # album name (Fallback-Flow)
|
|
|
|
|
|
"1", # disc number
|
|
|
|
|
|
"j", # CDDB korrekt?
|
|
|
|
|
|
"n", # next CD?
|
|
|
|
|
|
"n", # next album?
|
2026-02-18 06:13:10 +01:00
|
|
|
|
]
|
|
|
|
|
|
config = RipperConfig(output_dir=tmp_path)
|
2026-02-19 14:05:59 +01:00
|
|
|
|
patches = self._fallback_patches(inputs)
|
2026-02-18 06:13:10 +01:00
|
|
|
|
with (
|
2026-02-19 14:05:59 +01:00
|
|
|
|
patches[0], patches[1], patches[2],
|
2026-02-18 06:13:10 +01:00
|
|
|
|
patch("musiksammlung.ripper.rip_disc", return_value=(tmp_path, None, _CDDB_TRACKS)),
|
|
|
|
|
|
patch(
|
|
|
|
|
|
"musiksammlung.ripper.lookup_by_barcode",
|
|
|
|
|
|
side_effect=ValueError("Kein MusicBrainz-Eintrag"),
|
|
|
|
|
|
),
|
|
|
|
|
|
):
|
2026-02-19 14:05:59 +01:00
|
|
|
|
interactive_rip(config)
|
2026-02-18 06:13:10 +01:00
|
|
|
|
|
|
|
|
|
|
json_path = tmp_path / "Abbey_Road" / "album.json"
|
|
|
|
|
|
assert json_path.exists()
|
|
|
|
|
|
|
2026-02-19 00:21:42 +01:00
|
|
|
|
def test_fallback_cddb_confirm_rejected(self, tmp_path: Path) -> None:
|
|
|
|
|
|
"""Fallback: CDDB-Daten abgelehnt → keine album.json."""
|
2026-02-18 06:13:10 +01:00
|
|
|
|
inputs = [
|
2026-02-19 00:21:42 +01:00
|
|
|
|
"", # EAN: leer
|
|
|
|
|
|
"TestAlbum", # album name
|
|
|
|
|
|
"1", # disc number
|
|
|
|
|
|
"n", # CDDB korrekt? → nein
|
|
|
|
|
|
"n", # next CD?
|
|
|
|
|
|
"n", # next album?
|
2026-02-18 06:13:10 +01:00
|
|
|
|
]
|
|
|
|
|
|
config = RipperConfig(output_dir=tmp_path)
|
2026-02-19 14:05:59 +01:00
|
|
|
|
patches = self._fallback_patches(inputs)
|
2026-02-18 06:13:10 +01:00
|
|
|
|
with (
|
2026-02-19 14:05:59 +01:00
|
|
|
|
patches[0], patches[1], patches[2],
|
2026-02-18 06:13:10 +01:00
|
|
|
|
patch("musiksammlung.ripper.rip_disc", return_value=(tmp_path, None, _CDDB_TRACKS)),
|
2026-02-19 00:21:42 +01:00
|
|
|
|
patch("musiksammlung.ripper.lookup_by_barcode"),
|
2026-02-18 06:13:10 +01:00
|
|
|
|
):
|
|
|
|
|
|
interactive_rip(config)
|
|
|
|
|
|
|
2026-02-19 00:21:42 +01:00
|
|
|
|
# Keine album.json, da CDDB-Daten verworfen
|
|
|
|
|
|
json_path = tmp_path / "TestAlbum" / "album.json"
|
|
|
|
|
|
assert not json_path.exists()
|
2026-02-18 06:13:10 +01:00
|
|
|
|
|
2026-02-19 00:21:42 +01:00
|
|
|
|
def test_fallback_multiple_discs(self, tmp_path: Path) -> None:
|
|
|
|
|
|
"""Fallback: Mehrere CDs für ein Album."""
|
2026-02-18 06:13:10 +01:00
|
|
|
|
inputs = [
|
2026-02-19 00:21:42 +01:00
|
|
|
|
"", # EAN: leer
|
|
|
|
|
|
"TestAlbum", # album name
|
|
|
|
|
|
"1", # disc 1
|
|
|
|
|
|
"j", # CDDB korrekt?
|
|
|
|
|
|
"y", # next CD? → ja
|
|
|
|
|
|
"2", # disc 2
|
|
|
|
|
|
"j", # CDDB korrekt?
|
|
|
|
|
|
"n", # next CD?
|
|
|
|
|
|
"n", # next album?
|
2026-02-18 06:13:10 +01:00
|
|
|
|
]
|
|
|
|
|
|
config = RipperConfig(output_dir=tmp_path)
|
2026-02-19 14:05:59 +01:00
|
|
|
|
patches = self._fallback_patches(inputs)
|
2026-02-18 06:13:10 +01:00
|
|
|
|
with (
|
2026-02-19 14:05:59 +01:00
|
|
|
|
patches[0], patches[1], patches[2],
|
2026-02-18 06:13:10 +01:00
|
|
|
|
patch("musiksammlung.ripper.rip_disc", return_value=(tmp_path, None, _CDDB_TRACKS)),
|
2026-02-19 00:21:42 +01:00
|
|
|
|
patch("musiksammlung.ripper.lookup_by_barcode"),
|
2026-02-18 06:13:10 +01:00
|
|
|
|
):
|
|
|
|
|
|
interactive_rip(config)
|
|
|
|
|
|
|
2026-02-19 00:21:42 +01:00
|
|
|
|
json_path = tmp_path / "TestAlbum" / "album.json"
|
2026-02-18 23:34:35 +01:00
|
|
|
|
assert json_path.exists()
|
|
|
|
|
|
import json
|
|
|
|
|
|
data = json.loads(json_path.read_text())
|
2026-02-19 00:21:42 +01:00
|
|
|
|
assert len(data["discs"]) == 2
|