Musiksammlung/tests/test_ripper.py

477 lines
18 KiB
Python
Raw Normal View History

"""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<Track>"Name') == "TestTrackName"
assert _sanitize_name("Test|Track?Name*") == "TestTrackName"
assert _sanitize_name("It's_a_Test") == "Its_a_Test"
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()
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()
# ---------------------------------------------------------------------------
# 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()