diff --git a/core/beatport.py b/core/beatport.py index a4d5ff2..1d4b8e9 100644 --- a/core/beatport.py +++ b/core/beatport.py @@ -9,8 +9,11 @@ from __future__ import annotations import json import re +import time from dataclasses import dataclass +from curl_cffi import requests as _cffi_requests + @dataclass(frozen=True) class BeatportTrack: @@ -166,3 +169,65 @@ def _parse_tracks(data: dict) -> list: raise BeatportParseError(f"track[{i}] shape inattesa: {e}") from e out.append(track) return out + + +_IMPERSONATE = "chrome131" # aggiorna se CF rompe il fingerprint +_REQUEST_TIMEOUT = 15 +_MAX_ATTEMPTS = 3 +_BACKOFF_SEC = [1, 3] # attese fra tentativi +_CACHE_TTL_SEC = 15 * 60 + +# Cache in-memory: slug → (timestamp_epoch, list[BeatportTrack]) +_cache: dict = {} + + +def _url_for(slug: str) -> str: + gid, _ = GENRES[slug] + return f"https://www.beatport.com/genre/{slug}/{gid}/top-100" + + +def _do_get(url: str) -> str: + """GET con retry e backoff. Solleva BeatportUnreachableError su fallimento definitivo.""" + last_exc = None + for attempt in range(_MAX_ATTEMPTS): + try: + resp = _cffi_requests.get( + url, + impersonate=_IMPERSONATE, + timeout=_REQUEST_TIMEOUT, + ) + if resp.status_code >= 500 or resp.status_code == 403: + raise Exception(f"HTTP {resp.status_code}") + resp.raise_for_status() + return resp.text + except Exception as e: + last_exc = e + if attempt < _MAX_ATTEMPTS - 1: + time.sleep(_BACKOFF_SEC[attempt]) + raise BeatportUnreachableError(f"Beatport irraggiungibile dopo {_MAX_ATTEMPTS} tentativi: {last_exc}") + + +def fetch_top100(slug: str, force_refresh: bool = False) -> list: + """Fetches la Top 100 Beatport per il genere dato. + + Cache in-memory 15 min. `force_refresh=True` bypassa la cache. + + Raises: + ValueError: se slug non è in GENRES. + BeatportUnreachableError: rete/5xx dopo i retry. + BeatportParseError: HTML/JSON non conforme allo schema. + """ + if slug not in GENRES: + raise ValueError(f"slug genere non valido: {slug!r}") + + now = time.time() + if not force_refresh: + cached = _cache.get(slug) + if cached and (now - cached[0]) < _CACHE_TTL_SEC: + return cached[1] + + html = _do_get(_url_for(slug)) + data = _extract_next_data(html) + tracks = _parse_tracks(data) + _cache[slug] = (now, tracks) + return tracks diff --git a/tests/test_beatport.py b/tests/test_beatport.py index 4f3400c..4ece0cf 100644 --- a/tests/test_beatport.py +++ b/tests/test_beatport.py @@ -111,3 +111,80 @@ class TestParseTracks: def test_schema_missing_results_raises(self): with pytest.raises(beatport.BeatportParseError, match="results"): beatport._parse_tracks({"props": {"pageProps": {}}}) + + +from unittest.mock import patch, MagicMock + +from freezegun import freeze_time + + +def _mock_response(text: str, status_code: int = 200) -> MagicMock: + resp = MagicMock() + resp.text = text + resp.status_code = status_code + def _raise(): + if status_code >= 400: + raise Exception(f"HTTP {status_code}") + resp.raise_for_status = _raise + return resp + + +class TestFetchTop100: + @pytest.fixture + def fixture_html(self, fixtures_dir): + return (fixtures_dir / "beatport_melodic_top100.html").read_text() + + def test_success_returns_100_tracks(self, fixture_html): + beatport._cache.clear() + with patch("core.beatport._cffi_requests.get") as mock_get: + mock_get.return_value = _mock_response(fixture_html, 200) + tracks = beatport.fetch_top100("melodic-house-techno") + assert len(tracks) == 100 + + def test_invalid_slug_raises_value_error(self): + with pytest.raises(ValueError, match="slug"): + beatport.fetch_top100("not-a-real-genre") + + def test_5xx_retries_and_raises_unreachable(self): + beatport._cache.clear() + with patch("core.beatport._cffi_requests.get") as mock_get: + mock_get.return_value = _mock_response("", 503) + with patch("core.beatport.time.sleep"): # skip backoff + with pytest.raises(beatport.BeatportUnreachableError): + beatport.fetch_top100("melodic-house-techno") + assert mock_get.call_count == 3 # 1 + 2 retry + + def test_cache_hit_within_ttl(self, fixture_html): + beatport._cache.clear() + with patch("core.beatport._cffi_requests.get") as mock_get: + mock_get.return_value = _mock_response(fixture_html, 200) + beatport.fetch_top100("melodic-house-techno") + beatport.fetch_top100("melodic-house-techno") + assert mock_get.call_count == 1 + + def test_cache_expires_after_ttl(self, fixture_html): + beatport._cache.clear() + with patch("core.beatport._cffi_requests.get") as mock_get: + mock_get.return_value = _mock_response(fixture_html, 200) + with freeze_time("2026-01-01 10:00:00") as frozen: + beatport.fetch_top100("melodic-house-techno") + frozen.tick(delta=beatport._CACHE_TTL_SEC + 1) + beatport.fetch_top100("melodic-house-techno") + assert mock_get.call_count == 2 + + def test_force_refresh_bypasses_cache(self, fixture_html): + beatport._cache.clear() + with patch("core.beatport._cffi_requests.get") as mock_get: + mock_get.return_value = _mock_response(fixture_html, 200) + beatport.fetch_top100("melodic-house-techno") + beatport.fetch_top100("melodic-house-techno", force_refresh=True) + assert mock_get.call_count == 2 + + def test_uses_chrome_impersonation(self, fixture_html): + beatport._cache.clear() + with patch("core.beatport._cffi_requests.get") as mock_get: + mock_get.return_value = _mock_response(fixture_html, 200) + beatport.fetch_top100("melodic-house-techno") + call_kwargs = mock_get.call_args.kwargs + assert "impersonate" in call_kwargs + assert call_kwargs["impersonate"].startswith("chrome")