diff --git a/core/spotify_client.py b/core/spotify_client.py index 8d61f19..34ea1c1 100644 --- a/core/spotify_client.py +++ b/core/spotify_client.py @@ -3,6 +3,8 @@ from __future__ import annotations import re +import time + import requests @@ -314,3 +316,89 @@ def _track_to_dict(t: dict) -> dict: "album": t.get("album", {}).get("name", ""), "duration_sec": int(t.get("duration_ms", 0)) // 1000, } + + +def search_artist_discography(token: str, artist_name: str) -> list: + """Trova l'artista esatto (o il piu' popolare tra i match) e ritorna + tutti i suoi brani: top tracks + tracce di ogni album/single. + Deduplica per (name.lower().strip(), first_artist.lower().strip()). + + Raises: + ValueError: se nessun artista trovato per il nome dato. + """ + headers = {"Authorization": f"Bearer {token}"} + + # 1. Cerca artista + resp = requests.get( + "https://api.spotify.com/v1/search", + headers=headers, + params={"q": artist_name, "type": "artist", "limit": 5}, + timeout=15, + ) + resp.raise_for_status() + candidates = resp.json().get("artists", {}).get("items", []) + if not candidates: + raise ValueError(f"Artista '{artist_name}' non trovato") + + # Match esatto (case-insensitive) se possibile, altrimenti piu' popolare + query_lower = artist_name.lower().strip() + exact = [c for c in candidates if c.get("name", "").lower().strip() == query_lower] + if exact: + artist = exact[0] + else: + artist = max(candidates, key=lambda c: c.get("popularity", 0)) + artist_id = artist["id"] + + collected: list = [] + + # 2. Top tracks + r_top = requests.get( + f"https://api.spotify.com/v1/artists/{artist_id}/top-tracks", + headers=headers, + params={"market": "IT"}, + timeout=15, + ) + r_top.raise_for_status() + for t in r_top.json().get("tracks", []): + collected.append(_track_to_dict(t)) + + # 3. Albums (album + single) + r_alb = requests.get( + f"https://api.spotify.com/v1/artists/{artist_id}/albums", + headers=headers, + params={"include_groups": "album,single", "limit": 50, "market": "IT"}, + timeout=15, + ) + r_alb.raise_for_status() + albums = r_alb.json().get("items", []) + + # 4. Per ogni album, tracce (album/track object non ha "album" sub-field, iniettiamola) + for alb in albums: + alb_id = alb.get("id") + alb_name = alb.get("name", "") + if not alb_id: + continue + time.sleep(0.1) # rate limit interno + r_at = requests.get( + f"https://api.spotify.com/v1/albums/{alb_id}/tracks", + headers=headers, + params={"limit": 50}, + timeout=15, + ) + r_at.raise_for_status() + for t in r_at.json().get("items", []): + t = dict(t) + # Album tracks non hanno "album" nested; iniettiamo il nome + t.setdefault("album", {"name": alb_name}) + collected.append(_track_to_dict(t)) + + # 5. Dedupe + seen: set = set() + unique: list = [] + for t in collected: + key = (t["name"].lower().strip(), t["artists"].split(",")[0].lower().strip()) + if key in seen: + continue + seen.add(key) + unique.append(t) + return unique diff --git a/tests/test_spotify_client.py b/tests/test_spotify_client.py index f2e71fb..6b04d36 100644 --- a/tests/test_spotify_client.py +++ b/tests/test_spotify_client.py @@ -2,6 +2,8 @@ from __future__ import annotations +from unittest.mock import patch + import pytest import responses @@ -145,3 +147,187 @@ class TestSearchTracks: assert params["q"] == "q" assert params["type"] == "track" assert params["limit"] == "25" + + +class TestSearchArtistDiscography: + @responses.activate + def test_exact_name_match_beats_popular(self): + # 3 candidati: uno esatto (case-insensitive), altri popolari + responses.add( + responses.GET, + "https://api.spotify.com/v1/search", + json={"artists": {"items": [ + {"id": "pop", "name": "Solomun Tribute", "popularity": 90}, + {"id": "exact", "name": "SOLOMUN", "popularity": 60}, + {"id": "unrelated", "name": "Other", "popularity": 70}, + ]}}, + status=200, + ) + # Mock top-tracks vuoto per non allungare il test + responses.add( + responses.GET, + "https://api.spotify.com/v1/artists/exact/top-tracks", + json={"tracks": []}, + status=200, + ) + # Mock albums vuoto + responses.add( + responses.GET, + "https://api.spotify.com/v1/artists/exact/albums", + json={"items": []}, + status=200, + ) + result = spotify_client.search_artist_discography("t", "Solomun") + assert result == [] + # Verifica che sia stato chiamato l'artista "exact", non "pop" + top_tracks_calls = [c for c in responses.calls if "/top-tracks" in c.request.url] + assert len(top_tracks_calls) == 1 + assert "/exact/top-tracks" in top_tracks_calls[0].request.url + + @responses.activate + def test_falls_back_to_most_popular_if_no_exact_match(self): + responses.add( + responses.GET, + "https://api.spotify.com/v1/search", + json={"artists": {"items": [ + {"id": "a1", "name": "Solomun Fanpage", "popularity": 30}, + {"id": "a2", "name": "Solomun Live", "popularity": 80}, + ]}}, + status=200, + ) + responses.add( + responses.GET, + "https://api.spotify.com/v1/artists/a2/top-tracks", + json={"tracks": []}, + status=200, + ) + responses.add( + responses.GET, + "https://api.spotify.com/v1/artists/a2/albums", + json={"items": []}, + status=200, + ) + spotify_client.search_artist_discography("t", "solomun") + top_tracks_calls = [c for c in responses.calls if "/top-tracks" in c.request.url] + assert "/a2/top-tracks" in top_tracks_calls[0].request.url + + @responses.activate + def test_raises_when_no_artist_found(self): + responses.add( + responses.GET, + "https://api.spotify.com/v1/search", + json={"artists": {"items": []}}, + status=200, + ) + with pytest.raises(ValueError, match="Artista"): + spotify_client.search_artist_discography("t", "asdgjhkasdgj") + + @responses.activate + def test_combines_top_tracks_and_album_tracks(self): + # 1 artista esatto + responses.add( + responses.GET, + "https://api.spotify.com/v1/search", + json={"artists": {"items": [{"id": "artX", "name": "artX", "popularity": 50}]}}, + status=200, + ) + # 3 top-tracks + top_tracks_data = {"tracks": [ + { + "id": f"t{i}", "name": f"Top{i}", + "artists": [{"name": "artX"}], + "album": {"name": "AlbTop"}, + "duration_ms": 200000, + "external_urls": {"spotify": f"u{i}"}, + } for i in range(3) + ]} + responses.add( + responses.GET, + "https://api.spotify.com/v1/artists/artX/top-tracks", + json=top_tracks_data, + status=200, + ) + # 2 album + responses.add( + responses.GET, + "https://api.spotify.com/v1/artists/artX/albums", + json={"items": [ + {"id": "alb1", "name": "Album 1"}, + {"id": "alb2", "name": "Album 2"}, + ]}, + status=200, + ) + # album 1: 2 tracce + responses.add( + responses.GET, + "https://api.spotify.com/v1/albums/alb1/tracks", + json={"items": [ + { + "id": f"a1t{i}", "name": f"Alb1Track{i}", + "artists": [{"name": "artX"}], + "duration_ms": 180000, + "external_urls": {"spotify": f"a1u{i}"}, + } for i in range(2) + ]}, + status=200, + ) + # album 2: 1 traccia + responses.add( + responses.GET, + "https://api.spotify.com/v1/albums/alb2/tracks", + json={"items": [ + { + "id": "a2t0", "name": "Alb2Track0", + "artists": [{"name": "artX"}], + "duration_ms": 240000, + "external_urls": {"spotify": "a2u0"}, + } + ]}, + status=200, + ) + with patch("core.spotify_client.time.sleep"): # no wait + result = spotify_client.search_artist_discography("t", "artX") + # 3 top + 2 alb1 + 1 alb2 = 6 + assert len(result) == 6 + titles = {r["name"] for r in result} + assert "Top0" in titles + assert "Alb1Track0" in titles + assert "Alb2Track0" in titles + + @responses.activate + def test_dedupe_across_top_and_album(self): + responses.add( + responses.GET, + "https://api.spotify.com/v1/search", + json={"artists": {"items": [{"id": "aX", "name": "aX", "popularity": 50}]}}, + status=200, + ) + # Stesso track name+artist in top-tracks e in album (id diverso) + common = { + "name": "Same Song", + "artists": [{"name": "aX"}], + "album": {"name": "OG Album"}, + "duration_ms": 200000, + "external_urls": {"spotify": "u"}, + } + responses.add( + responses.GET, + "https://api.spotify.com/v1/artists/aX/top-tracks", + json={"tracks": [{**common, "id": "top-id"}]}, + status=200, + ) + responses.add( + responses.GET, + "https://api.spotify.com/v1/artists/aX/albums", + json={"items": [{"id": "alb", "name": "OG"}]}, + status=200, + ) + responses.add( + responses.GET, + "https://api.spotify.com/v1/albums/alb/tracks", + json={"items": [{**common, "id": "alb-id"}]}, + status=200, + ) + with patch("core.spotify_client.time.sleep"): + result = spotify_client.search_artist_discography("t", "aX") + assert len(result) == 1