spotify: search_artist_discography con dedupe top-tracks+album
This commit is contained in:
1 parent
252d76b907
commit
2771fa8075
2 files changed
+274
No files matched your search
@@ -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
|
||||
@@ -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
|
||||
Reference in new issue
Block a user