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
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
import re
|
||||||
|
import time
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
|
|
||||||
@@ -314,3 +316,89 @@ def _track_to_dict(t: dict) -> dict:
|
|||||||
"album": t.get("album", {}).get("name", ""),
|
"album": t.get("album", {}).get("name", ""),
|
||||||
"duration_sec": int(t.get("duration_ms", 0)) // 1000,
|
"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 __future__ import annotations
|
||||||
|
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import responses
|
import responses
|
||||||
|
|
||||||
@@ -145,3 +147,187 @@ class TestSearchTracks:
|
|||||||
assert params["q"] == "q"
|
assert params["q"] == "q"
|
||||||
assert params["type"] == "track"
|
assert params["type"] == "track"
|
||||||
assert params["limit"] == "25"
|
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