diff --git a/src/musiksammlung/musicbrainz.py b/src/musiksammlung/musicbrainz.py index cd8abd6..c3c9242 100644 --- a/src/musiksammlung/musicbrainz.py +++ b/src/musiksammlung/musicbrainz.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging +import random import time import httpx @@ -28,29 +29,43 @@ def _get(path: str, params: dict) -> dict: return response.json() -def lookup_by_barcode(ean: str) -> Album: +def lookup_by_barcode(ean: str, retries: int = 3) -> 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 + Bei fehlendem Treffer wird die Suche bis zu `retries`-mal mit + zufälligem Warteabstand (2–6 s) wiederholt, da MusicBrainz manchmal + beim ersten Versuch keinen Treffer liefert. + Args: - ean: EAN-13- oder UPC-12-Barcode + ean: EAN-13- oder UPC-12-Barcode + retries: Anzahl Wiederholungsversuche bei leerem Ergebnis (Standard: 3) Returns: Album mit vollständiger Trackliste Raises: - ValueError: Kein Eintrag für diesen Barcode gefunden + ValueError: Kein Eintrag für diesen Barcode gefunden (nach allen Versuchen) 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"}) + for attempt in range(retries + 1): + # Schritt 1: Barcode-Suche + logger.info("MusicBrainz: Suche nach Barcode %s (Versuch %d/%d)", + ean, attempt + 1, retries + 1) + data = _get("/release/", {"query": f"barcode:{ean}", "fmt": "json"}) + releases = data.get("releases", []) - releases = data.get("releases", []) - if not releases: + if releases: + break + + if attempt < retries: + wait = random.uniform(2.0, 6.0) + logger.info("Kein Treffer — warte %.1f s vor erneutem Versuch...", wait) + time.sleep(wait) + else: raise ValueError(f"Kein MusicBrainz-Eintrag für Barcode {ean!r} gefunden.") mbid = releases[0]["id"] diff --git a/tests/test_musicbrainz.py b/tests/test_musicbrainz.py index 41308dd..c16ae21 100644 --- a/tests/test_musicbrainz.py +++ b/tests/test_musicbrainz.py @@ -171,9 +171,42 @@ class TestLookupByBarcode: with ( patch("musiksammlung.musicbrainz.httpx.get", return_value=empty), patch("musiksammlung.musicbrainz.time.sleep"), + patch("musiksammlung.musicbrainz.random.uniform", return_value=0.0), pytest.raises(ValueError, match="Kein MusicBrainz-Eintrag"), ): - lookup_by_barcode("0000000000000") + lookup_by_barcode("0000000000000", retries=0) + + def test_retries_on_empty_result(self) -> None: + """Bei leerem Ergebnis wird bis zu retries-mal wiederholt.""" + empty = _mock_response({"releases": []}) + hit = _mock_response(_BARCODE_RESPONSE) + # Erste zwei Versuche leer, dritter Versuch Treffer + responses = [empty, empty, hit, _mock_response(_RELEASE_RESPONSE)] + with ( + patch("musiksammlung.musicbrainz.httpx.get", side_effect=responses) as mock_get, + patch("musiksammlung.musicbrainz.time.sleep"), + patch("musiksammlung.musicbrainz.random.uniform", return_value=0.0), + ): + album = lookup_by_barcode("0602557360561", retries=3) + + assert album.artist == "The Beatles" + # 3 Barcode-Requests + 1 Release-Request + assert mock_get.call_count == 4 + + def test_retry_sleep_is_called_between_attempts(self) -> None: + """Zwischen den Versuchen wird time.sleep aufgerufen.""" + empty = _mock_response({"releases": []}) + responses = [empty, _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, + patch("musiksammlung.musicbrainz.random.uniform", return_value=2.5), + ): + lookup_by_barcode("0602557360561", retries=2) + + # Einmal Retry-Sleep (2.5 s) + einmal Rate-Limit-Sleep (≥1.1 s) + assert mock_sleep.call_count == 2 + assert mock_sleep.call_args_list[0][0][0] == 2.5 def test_uses_first_release(self) -> None: barcode_data = {"releases": [{"id": "first-id"}, {"id": "second-id"}]}