From b30aaa617d2d9a9ab3dac0d8d3d8df9118643cac Mon Sep 17 00:00:00 2001 From: dschlueter Date: Wed, 18 Feb 2026 06:13:10 +0100 Subject: [PATCH] Add MusicBrainz barcode lookup (scan --barcode and interactive rip) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New module musicbrainz.py: lookup_by_barcode() via EAN-13/UPC-12, two-step API (barcode search → release detail with recordings), respects 1 req/s rate limit with User-Agent header - cli.py: scan command gets --barcode option as highest-priority mode (no images needed); _scan_to_album() dispatches to MusicBrainz first - ripper.py: interactive_rip() prompts for optional EAN after album name; MusicBrainz data (incl. year) takes priority over CDDB for album.json; album_root.mkdir() added so JSON can be written even when MB changes dir - tests: test_musicbrainz.py (16 tests), test_ripper.py +6 barcode tests Co-Authored-By: Claude Opus 4.6 --- src/musiksammlung/cli.py | 24 +++- src/musiksammlung/musicbrainz.py | 110 ++++++++++++++++ src/musiksammlung/ripper.py | 33 ++++- tests/test_musicbrainz.py | 209 +++++++++++++++++++++++++++++++ tests/test_ripper.py | 183 +++++++++++++++++++++++++++ 5 files changed, 552 insertions(+), 7 deletions(-) create mode 100644 src/musiksammlung/musicbrainz.py create mode 100644 tests/test_musicbrainz.py diff --git a/src/musiksammlung/cli.py b/src/musiksammlung/cli.py index 6bae954..5dba9c4 100644 --- a/src/musiksammlung/cli.py +++ b/src/musiksammlung/cli.py @@ -13,6 +13,7 @@ from musiksammlung.config import AUDIO_EXTENSIONS, AudioFormat from musiksammlung.cover import copy_covers, find_cover 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.playlist import generate_playlist @@ -40,9 +41,13 @@ def _scan_to_album( backend: str, model: str, base_url: str, + barcode: str | None = None, ) -> Album: - """Gemeinsame Scan-Logik: Text-Datei, Vision-LLM oder OCR+LLM.""" - if from_text: + """Gemeinsame Scan-Logik: Barcode, Text-Datei, Vision-LLM oder OCR+LLM.""" + if barcode: + typer.echo(f"MusicBrainz-Suche nach Barcode {barcode}...") + return lookup_by_barcode(barcode) + elif from_text: text = from_text.read_text(encoding="utf-8") typer.echo(f"Text-Datei geladen ({len(text)} Zeichen). LLM-Parsing...") return parse_tracklist( @@ -112,6 +117,9 @@ def scan( output: Path = typer.Option( "album.json", "--output", "-o", help="Ausgabe-JSON-Datei" ), + barcode: str = typer.Option( + None, "--barcode", help="EAN-13- oder UPC-12-Barcode für MusicBrainz-Lookup" + ), from_text: Path = typer.Option( None, "--from-text", "-t", help="Text/Markdown-Datei mit Trackliste (z.B. von Perplexity)", @@ -129,20 +137,23 @@ def scan( "http://localhost:11434", "--url", help="LLM-API-URL" ), ) -> None: - """Bilder oder Text → Album-JSON erzeugen (zur Prüfung vor dem Anwenden). + """Bilder, Text oder Barcode → Album-JSON erzeugen (zur Prüfung vor dem Anwenden). - Drei Modi: + Vier Modi: + --barcode EAN/UPC → MusicBrainz-Lookup → JSON --from-text Textdatei (z.B. von Perplexity) → LLM → JSON --vision Bild → Vision-LLM → JSON (Standard) Bild → Tesseract-OCR → Text-LLM → JSON """ - if from_text: + if barcode: + pass # kein Bild nötig + elif from_text: if not from_text.exists(): typer.echo(f"Fehler: Datei nicht gefunden: {from_text}", err=True) raise typer.Exit(1) elif not images: typer.echo( - "Fehler: Bilder oder --from-text angeben.", err=True + "Fehler: Bilder, --barcode oder --from-text angeben.", err=True ) raise typer.Exit(1) else: @@ -154,6 +165,7 @@ def scan( album = _scan_to_album( images, from_text, vision, vision_model, languages, backend, model, base_url, + barcode=barcode, ) output.write_text(album.model_dump_json(indent=2), encoding="utf-8") diff --git a/src/musiksammlung/musicbrainz.py b/src/musiksammlung/musicbrainz.py new file mode 100644 index 0000000..cd8abd6 --- /dev/null +++ b/src/musiksammlung/musicbrainz.py @@ -0,0 +1,110 @@ +"""MusicBrainz-Lookup via EAN/Barcode.""" + +from __future__ import annotations + +import logging +import time + +import httpx + +from musiksammlung.models import Album, Disc, Track + +logger = logging.getLogger(__name__) + +_MB_BASE = "https://musicbrainz.org/ws/2" +_USER_AGENT = "musiksammlung/0.1 ( https://kitux.de/forgejo/dschlueter/Musiksammlung )" +_RATE_SLEEP = 1.1 # MusicBrainz erlaubt max. 1 Request/Sekunde + + +def _get(path: str, params: dict) -> dict: + """HTTP-GET gegen die MusicBrainz-API mit korrektem User-Agent.""" + response = httpx.get( + f"{_MB_BASE}{path}", + params=params, + headers={"User-Agent": _USER_AGENT}, + timeout=30.0, + ) + response.raise_for_status() + return response.json() + + +def lookup_by_barcode(ean: str) -> Album: + """Schlägt ein Album anhand des EAN-Barcodes in MusicBrainz nach. + + Führt zwei API-Requests durch: + 1. Barcode-Suche → MBID des ersten Treffers + 2. Release-Details mit Recordings → Trackliste + + Args: + ean: EAN-13- oder UPC-12-Barcode + + Returns: + Album mit vollständiger Trackliste + + Raises: + ValueError: Kein Eintrag für diesen Barcode gefunden + httpx.HTTPError: Netzwerk- oder API-Fehler + """ + # Schritt 1: Barcode-Suche + logger.info("MusicBrainz: Suche nach Barcode %s", ean) + data = _get("/release/", {"query": f"barcode:{ean}", "fmt": "json"}) + + releases = data.get("releases", []) + if not releases: + raise ValueError(f"Kein MusicBrainz-Eintrag für Barcode {ean!r} gefunden.") + + mbid = releases[0]["id"] + logger.info("MusicBrainz: Treffer MBID=%s, lade Details...", mbid) + time.sleep(_RATE_SLEEP) + + # Schritt 2: Trackliste laden + detail = _get(f"/release/{mbid}", {"inc": "recordings", "fmt": "json"}) + return _parse_release(detail) + + +def _parse_release(data: dict) -> Album: + """Wandelt eine MusicBrainz-Release-Antwort in ein Album-Modell um.""" + # Künstler + artist_credit = data.get("artist-credit", []) + artist = artist_credit[0]["artist"]["name"] if artist_credit else "" + + # Albumtitel + title = data.get("title", "") + + # Jahr aus "date" extrahieren ("YYYY", "YYYY-MM" oder "YYYY-MM-DD") + year: int | None = None + date_str = data.get("date", "") + if date_str and len(date_str) >= 4: + try: + year = int(date_str[:4]) + except ValueError: + pass + + # Medien → Discs + discs: list[Disc] = [] + for medium in data.get("media", []): + disc_number = medium.get("position", len(discs) + 1) + tracks: list[Track] = [] + for t in medium.get("tracks", []): + track_number = t.get("position", len(tracks) + 1) + track_title = t.get("title", "") + + # Track-Künstler nur setzen, wenn er vom Album-Künstler abweicht + t_credits = t.get("artist-credit", []) + track_artist: str | None = None + if t_credits: + t_artist = t_credits[0]["artist"]["name"] + if t_artist != artist: + track_artist = t_artist + + tracks.append(Track( + track_number=track_number, + title=track_title, + artist=track_artist, + )) + discs.append(Disc(disc_number=disc_number, tracks=tracks)) + + if not discs: + raise ValueError("MusicBrainz-Release enthält keine Medien/Tracks.") + + return Album(artist=artist, album=title, year=year, discs=discs) diff --git a/src/musiksammlung/ripper.py b/src/musiksammlung/ripper.py index 1443413..20a27eb 100644 --- a/src/musiksammlung/ripper.py +++ b/src/musiksammlung/ripper.py @@ -14,6 +14,7 @@ from musiksammlung.config import AudioFormat from musiksammlung.models import Album as AlbumModel from musiksammlung.models import Disc as DiscModel from musiksammlung.models import Track as TrackModel +from musiksammlung.musicbrainz import lookup_by_barcode logger = logging.getLogger(__name__) @@ -435,6 +436,26 @@ def interactive_rip(config: RipperConfig) -> None: if not album_name: album_name = f"Album{album_counter}" + # Optional: EAN/Barcode für MusicBrainz-Lookup + raw_ean = input("EAN/Barcode für MusicBrainz (Enter = überspringen): ") + ean = _clean_input(raw_ean) + mb_album: AlbumModel | None = None + if ean: + try: + print(f" MusicBrainz-Suche nach Barcode {ean} ...", flush=True) + mb_album = lookup_by_barcode(ean) + print( + f" ✓ {mb_album.artist} – {mb_album.album}" + f" ({mb_album.year or '?'}," + f" {sum(len(d.tracks) for d in mb_album.discs)} Tracks)", + flush=True, + ) + # Albumnamen aus MusicBrainz übernehmen, wenn nicht manuell gesetzt + if album_name == f"Album{album_counter}": + album_name = mb_album.album or album_name + except Exception as e: + print(f" MusicBrainz: kein Treffer — {e}", flush=True) + disc_counter = 1 all_discs: list[DiscModel] = [] @@ -498,10 +519,20 @@ def interactive_rip(config: RipperConfig) -> None: disc_counter += 1 - if all_discs: + if mb_album: + # MusicBrainz-Daten haben Priorität (inkl. Jahr, kuratierte Titel) + album_model = mb_album + album_root = config.output_dir / _sanitize_name(mb_album.album or album_name) + elif all_discs: artist = all_discs[0].tracks[0].artist or album_name album_model = AlbumModel(artist=artist, album=album_name, discs=all_discs) album_root = config.output_dir / _sanitize_name(album_name) + else: + album_root = config.output_dir / _sanitize_name(album_name) + album_model = None + + if album_model is not None: + album_root.mkdir(parents=True, exist_ok=True) json_path = album_root / "album.json" json_path.write_text( album_model.model_dump_json(indent=2), encoding="utf-8" diff --git a/tests/test_musicbrainz.py b/tests/test_musicbrainz.py new file mode 100644 index 0000000..41308dd --- /dev/null +++ b/tests/test_musicbrainz.py @@ -0,0 +1,209 @@ +"""Tests für den MusicBrainz-Barcode-Lookup.""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest + +from musiksammlung.musicbrainz import _parse_release, lookup_by_barcode + +# --------------------------------------------------------------------------- +# Hilfsfunktionen +# --------------------------------------------------------------------------- + + +def _mock_response(data: dict) -> MagicMock: + """Erstellt ein Mock-httpx.Response-Objekt.""" + r = MagicMock() + r.json.return_value = data + r.raise_for_status.return_value = None + return r + + +_BARCODE_RESPONSE = { + "releases": [{"id": "abc-123"}], +} + +_RELEASE_RESPONSE = { + "title": "Abbey Road", + "date": "1969-09-26", + "artist-credit": [{"artist": {"name": "The Beatles"}}], + "media": [ + { + "position": 1, + "tracks": [ + { + "position": 1, + "title": "Come Together", + "artist-credit": [{"artist": {"name": "The Beatles"}}], + }, + { + "position": 2, + "title": "Something", + "artist-credit": [{"artist": {"name": "The Beatles"}}], + }, + ], + } + ], +} + + +# --------------------------------------------------------------------------- +# _parse_release +# --------------------------------------------------------------------------- + + +class TestParseRelease: + def test_basic_fields(self) -> None: + album = _parse_release(_RELEASE_RESPONSE) + assert album.artist == "The Beatles" + assert album.album == "Abbey Road" + assert album.year == 1969 + + def test_tracks(self) -> None: + album = _parse_release(_RELEASE_RESPONSE) + assert len(album.discs) == 1 + disc = album.discs[0] + assert disc.disc_number == 1 + assert len(disc.tracks) == 2 + assert disc.tracks[0].title == "Come Together" + assert disc.tracks[1].title == "Something" + + def test_track_artist_same_as_album_artist_is_none(self) -> None: + album = _parse_release(_RELEASE_RESPONSE) + # Track-Künstler = Album-Künstler → track.artist muss None sein + assert album.discs[0].tracks[0].artist is None + + def test_track_artist_different_is_set(self) -> None: + data = { + "title": "Compilation", + "date": "2000", + "artist-credit": [{"artist": {"name": "Various Artists"}}], + "media": [ + { + "position": 1, + "tracks": [ + { + "position": 1, + "title": "Song A", + "artist-credit": [{"artist": {"name": "Artist A"}}], + } + ], + } + ], + } + album = _parse_release(data) + assert album.discs[0].tracks[0].artist == "Artist A" + + def test_year_from_full_date(self) -> None: + data = {**_RELEASE_RESPONSE, "date": "1969-09-26"} + album = _parse_release(data) + assert album.year == 1969 + + def test_year_from_year_only(self) -> None: + data = {**_RELEASE_RESPONSE, "date": "1969"} + album = _parse_release(data) + assert album.year == 1969 + + def test_year_none_when_missing(self) -> None: + data = {**_RELEASE_RESPONSE, "date": ""} + album = _parse_release(data) + assert album.year is None + + def test_year_none_when_invalid(self) -> None: + data = {**_RELEASE_RESPONSE, "date": "unbekannt"} + album = _parse_release(data) + assert album.year is None + + def test_multi_disc(self) -> None: + data = { + "title": "The Wall", + "date": "1979", + "artist-credit": [{"artist": {"name": "Pink Floyd"}}], + "media": [ + { + "position": 1, + "tracks": [{"position": 1, "title": "In the Flesh?", "artist-credit": []}], + }, + { + "position": 2, + "tracks": [{"position": 1, "title": "Hey You", "artist-credit": []}], + }, + ], + } + album = _parse_release(data) + assert len(album.discs) == 2 + assert album.discs[0].disc_number == 1 + assert album.discs[1].disc_number == 2 + + def test_raises_when_no_media(self) -> None: + data = {**_RELEASE_RESPONSE, "media": []} + with pytest.raises(ValueError, match="keine Medien"): + _parse_release(data) + + def test_no_artist_credit_gives_empty_string(self) -> None: + data = {**_RELEASE_RESPONSE, "artist-credit": []} + album = _parse_release(data) + assert album.artist == "" + + +# --------------------------------------------------------------------------- +# lookup_by_barcode +# --------------------------------------------------------------------------- + + +class TestLookupByBarcode: + def test_successful_lookup(self) -> None: + responses = [_mock_response(_BARCODE_RESPONSE), _mock_response(_RELEASE_RESPONSE)] + with ( + patch("musiksammlung.musicbrainz.httpx.get", side_effect=responses), + patch("musiksammlung.musicbrainz.time.sleep"), + ): + album = lookup_by_barcode("0602557360561") + + assert album.artist == "The Beatles" + assert album.album == "Abbey Road" + assert album.year == 1969 + + def test_raises_when_no_releases(self) -> None: + empty = _mock_response({"releases": []}) + with ( + patch("musiksammlung.musicbrainz.httpx.get", return_value=empty), + patch("musiksammlung.musicbrainz.time.sleep"), + pytest.raises(ValueError, match="Kein MusicBrainz-Eintrag"), + ): + lookup_by_barcode("0000000000000") + + def test_uses_first_release(self) -> None: + barcode_data = {"releases": [{"id": "first-id"}, {"id": "second-id"}]} + responses = [_mock_response(barcode_data), _mock_response(_RELEASE_RESPONSE)] + with ( + patch("musiksammlung.musicbrainz.httpx.get", side_effect=responses) as mock_get, + patch("musiksammlung.musicbrainz.time.sleep"), + ): + lookup_by_barcode("1234567890123") + + # Zweiter Request muss die MBID des ersten Treffers verwenden + second_call_url = mock_get.call_args_list[1][0][0] + assert "first-id" in second_call_url + + def test_rate_limit_sleep_is_called(self) -> None: + responses = [_mock_response(_BARCODE_RESPONSE), _mock_response(_RELEASE_RESPONSE)] + with ( + patch("musiksammlung.musicbrainz.httpx.get", side_effect=responses), + patch("musiksammlung.musicbrainz.time.sleep") as mock_sleep, + ): + lookup_by_barcode("0602557360561") + + mock_sleep.assert_called_once() + assert mock_sleep.call_args[0][0] >= 1.0 + + def test_http_error_propagates(self) -> None: + import httpx + + with ( + patch("musiksammlung.musicbrainz.httpx.get", side_effect=httpx.HTTPError("timeout")), + pytest.raises(httpx.HTTPError), + ): + lookup_by_barcode("0000000000000") diff --git a/tests/test_ripper.py b/tests/test_ripper.py index 9874255..dfd2b15 100644 --- a/tests/test_ripper.py +++ b/tests/test_ripper.py @@ -1,8 +1,10 @@ """Tests für den CD-Ripper.""" from pathlib import Path +from unittest.mock import MagicMock, call, patch from musiksammlung.config import AudioFormat +from musiksammlung.models import Album, Disc, Track from musiksammlung.ripper import ( RipperConfig, TrackInfo, @@ -11,6 +13,7 @@ from musiksammlung.ripper import ( _parse_cddb_lines, _rename_files, _sanitize_name, + interactive_rip, ) @@ -230,3 +233,183 @@ class TestRenameFiles: 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 + "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 + "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 + "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 + "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 + "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", + "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()