A kind of initial commit

This commit is contained in:
heboba
2026-02-26 19:33:05 +00:00
commit 9ab125b1a6
37 changed files with 3053 additions and 0 deletions

View File

@@ -0,0 +1,251 @@
from __future__ import annotations
import unittest
from types import SimpleNamespace
from app.clients.spotify import SpotifyApiError
from app.services.recommendation import RecommendationEngine
from app.types import TrackCandidate
class DummyLastFm:
enabled = False
class RaisingLastFm:
enabled = True
async def track_similar(self, *, artist: str, track: str, limit: int = 20) -> list[dict]:
raise RuntimeError("Last.fm key invalid")
async def artist_similar(self, *, artist: str, limit: int = 15) -> list[dict]:
raise RuntimeError("Last.fm key invalid")
class StaticLastFm:
enabled = True
def __init__(
self,
*,
track_similar_results: dict[tuple[str, str], list[dict]] | None = None,
artist_similar_results: dict[str, list[dict]] | None = None,
) -> None:
self.track_similar_results = track_similar_results or {}
self.artist_similar_results = artist_similar_results or {}
async def track_similar(self, *, artist: str, track: str, limit: int = 20) -> list[dict]:
return list(self.track_similar_results.get((artist, track), []))
async def artist_similar(self, *, artist: str, limit: int = 15) -> list[dict]:
return list(self.artist_similar_results.get(artist, []))
class RecordingSpotifyStub:
def __init__(self) -> None:
self.recommendation_calls: list[tuple[list[str], list[str], str | None]] = []
self.top_tracks_calls: list[tuple[str, str]] = []
self.search_calls: list[tuple[str, str | None]] = []
self.raise_recommendations = False
self.raise_top_tracks = False
self.search_results_by_artist: dict[str, list[dict]] = {}
async def get_recommendations(
self,
access_token: str,
*,
seed_tracks: list[str],
seed_artists: list[str],
limit: int = 100,
market: str | None = None,
) -> list[dict]:
self.recommendation_calls.append((list(seed_tracks), list(seed_artists), market))
if self.raise_recommendations:
raise SpotifyApiError("recommendations disabled", 404, "")
return []
async def get_artist_top_tracks(self, access_token: str, artist_id: str, market: str) -> list[dict]:
self.top_tracks_calls.append((artist_id, market))
if self.raise_top_tracks:
raise SpotifyApiError("top tracks forbidden", 403, "")
return []
async def search_track(
self,
access_token: str,
*,
track_name: str,
artist_name: str | None = None,
market: str | None = None,
) -> list[dict]:
self.search_calls.append((track_name, artist_name))
if not artist_name:
return []
return list(self.search_results_by_artist.get(artist_name, []))
def make_engine(spotify_stub: RecordingSpotifyStub, lastfm=None) -> RecommendationEngine:
settings = SimpleNamespace(
recent_days_window=5,
spotify_default_market="US",
default_playlist_size=30,
min_new_ratio=0.8,
)
return RecommendationEngine(settings, spotify_stub, lastfm or DummyLastFm())
def fake_spotify_track(track_id: str, name: str, artist_id: str, artist_name: str, popularity: int = 50) -> dict:
return {
"id": track_id,
"uri": f"spotify:track:{track_id}",
"name": name,
"artists": [{"id": artist_id, "name": artist_name}],
"popularity": popularity,
}
class RecommendationEngineTests(unittest.IsolatedAsyncioTestCase):
async def test_collect_candidates_limits_recommendation_seeds_to_five(self) -> None:
spotify = RecordingSpotifyStub()
engine = make_engine(spotify)
seed = {
"seed_track_ids": [f"t{i}" for i in range(10)],
"seed_artists": [f"a{i}" for i in range(20)],
"seed_artist_names": [],
"recent_track_meta": {},
}
candidates = await engine._collect_candidates(access_token="token", seed=seed, market=None)
self.assertEqual(candidates, [])
self.assertEqual(len(spotify.recommendation_calls), 4)
self.assertTrue(
all((len(seed_tracks) + len(seed_artists)) <= 5 for seed_tracks, seed_artists, _ in spotify.recommendation_calls)
)
async def test_collect_candidates_uses_search_artist_fallback_when_other_sources_fail(self) -> None:
spotify = RecordingSpotifyStub()
spotify.raise_recommendations = True
spotify.raise_top_tracks = True
spotify.search_results_by_artist = {
"Artist One": [fake_spotify_track("c1", "Song 1", "ax1", "Artist One")],
"Artist Two": [fake_spotify_track("c2", "Song 2", "ax2", "Artist Two")],
}
engine = make_engine(spotify)
seed = {
"seed_track_ids": ["t1", "t2"],
"seed_artists": ["a1", "a2"],
"seed_artist_names": ["Artist One", "Artist Two"],
"recent_track_meta": {},
}
candidates = await engine._collect_candidates(access_token="token", seed=seed, market=None)
self.assertGreaterEqual(len(spotify.search_calls), 1)
self.assertEqual({c.id for c in candidates}, {"c1", "c2"})
self.assertTrue(all("spotify_search_artist" in c.source for c in candidates))
async def test_collect_candidates_tolerates_lastfm_errors(self) -> None:
spotify = RecordingSpotifyStub()
spotify.raise_recommendations = True
spotify.raise_top_tracks = True
spotify.search_results_by_artist = {
"Seed Artist": [fake_spotify_track("c1", "Song 1", "ax1", "Seed Artist")],
}
engine = make_engine(spotify, lastfm=RaisingLastFm())
seed = {
"seed_track_ids": ["t1"],
"seed_artists": ["a1"],
"seed_artist_names": ["Seed Artist"],
"recent_track_meta": {
"t1": {
"id": "t1",
"name": "Seed Track",
"artist_names": ["Seed Artist"],
}
},
}
candidates = await engine._collect_candidates(access_token="token", seed=seed, market=None)
self.assertEqual({c.id for c in candidates}, {"c1"})
self.assertTrue(any("spotify_search_artist" in c.source for c in candidates))
async def test_collect_candidates_uses_lastfm_artist_similar_search(self) -> None:
spotify = RecordingSpotifyStub()
spotify.raise_recommendations = True
spotify.raise_top_tracks = True
spotify.search_results_by_artist = {
"Similar Artist": [fake_spotify_track("lf1", "LF Song", "lfa1", "Similar Artist")],
}
lastfm = StaticLastFm(
artist_similar_results={
"Seed Artist": [{"name": "Similar Artist"}],
}
)
engine = make_engine(spotify, lastfm=lastfm)
seed = {
"seed_track_ids": [],
"seed_artists": ["a1"],
"seed_artist_names": ["Seed Artist"],
"recent_track_meta": {},
}
candidates = await engine._collect_candidates(access_token="token", seed=seed, market=None)
self.assertIn("lf1", {c.id for c in candidates})
self.assertTrue(any("lastfm_artist_similar" in c.source for c in candidates))
def test_normalize_spotify_market(self) -> None:
spotify = RecordingSpotifyStub()
engine = make_engine(spotify)
self.assertIsNone(engine._normalize_spotify_market("EU"))
self.assertIsNone(engine._normalize_spotify_market("global"))
self.assertEqual(engine._normalize_spotify_market("de"), "DE")
self.assertEqual(engine._normalize_spotify_market("US"), "US")
self.assertIsNone(engine._normalize_spotify_market("USA"))
self.assertIsNone(engine._normalize_spotify_market(""))
def test_rank_and_select_uses_liked_fallback_and_counts_as_reused(self) -> None:
spotify = RecordingSpotifyStub()
engine = make_engine(spotify)
candidates = [
TrackCandidate(
id="c1",
uri="spotify:track:c1",
name="Song 1",
artist_names=["Artist One"],
artist_ids=["a1"],
source="spotify_search_artist",
score=0.7,
),
TrackCandidate(
id="c2",
uri="spotify:track:c2",
name="Song 2",
artist_names=["Artist Two"],
artist_ids=["a2"],
source="spotify_search_artist",
score=0.6,
),
]
result = engine._rank_and_select(
candidates=candidates,
liked_ids={"c1", "c2"},
history_ids=set(),
target_size=2,
min_new_ratio=0.8,
)
self.assertEqual(len(result.tracks), 2)
self.assertEqual(result.new_count, 0)
self.assertEqual(result.reused_count, 2)
self.assertIsNotNone(result.notes)
self.assertIn("liked-track fallback", result.notes or "")
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,136 @@
from __future__ import annotations
import unittest
from types import SimpleNamespace
import httpx
from app.clients.spotify import SpotifyClient
class RecordingHttpClient:
def __init__(self, responses: list[httpx.Response]) -> None:
self._responses = list(responses)
self.calls: list[dict] = []
async def request(self, method: str, url: str, **kwargs):
self.calls.append(
{
"method": method,
"url": url,
"headers": kwargs.get("headers"),
"params": kwargs.get("params"),
"json": kwargs.get("json"),
}
)
if not self._responses:
raise AssertionError("No queued response for request")
return self._responses.pop(0)
def make_response(method: str, url: str, status_code: int, *, json_body=None, text_body: str | None = None) -> httpx.Response:
request = httpx.Request(method, url)
if json_body is not None:
return httpx.Response(status_code, request=request, json=json_body)
return httpx.Response(status_code, request=request, text=text_body or "")
def make_client(http_client: RecordingHttpClient) -> SpotifyClient:
settings = SimpleNamespace(
spotify_client_id="id",
spotify_client_secret="secret",
spotify_redirect_uri="https://example.test/callback",
)
return SpotifyClient(settings, http_client) # type: ignore[arg-type]
class SpotifyClientPlaylistTests(unittest.IsolatedAsyncioTestCase):
async def test_create_playlist_uses_me_endpoint(self) -> None:
http_client = RecordingHttpClient(
[
make_response(
"POST",
"https://api.spotify.com/v1/me/playlists",
201,
json_body={
"id": "pl1",
"name": "Test Playlist",
"external_urls": {"spotify": "https://open.spotify.com/playlist/pl1"},
},
)
]
)
client = make_client(http_client)
payload = await client.create_playlist(
"token",
user_id="user123",
name="Test Playlist",
description="desc",
public=False,
)
self.assertEqual(payload["id"], "pl1")
self.assertEqual(len(http_client.calls), 1)
call = http_client.calls[0]
self.assertEqual(call["method"], "POST")
self.assertEqual(call["url"], "https://api.spotify.com/v1/me/playlists")
self.assertEqual(call["json"], {"name": "Test Playlist", "description": "desc", "public": False})
self.assertEqual(call["headers"]["Authorization"], "Bearer token")
async def test_delete_playlist_calls_followers_delete(self) -> None:
http_client = RecordingHttpClient(
[
make_response(
"DELETE",
"https://api.spotify.com/v1/playlists/pl123/followers",
200,
text_body="",
)
]
)
client = make_client(http_client)
await client.delete_playlist("token", "pl123")
self.assertEqual(len(http_client.calls), 1)
call = http_client.calls[0]
self.assertEqual(call["method"], "DELETE")
self.assertEqual(call["url"], "https://api.spotify.com/v1/playlists/pl123/followers")
self.assertEqual(call["headers"]["Authorization"], "Bearer token")
self.assertIsNone(call["json"])
async def test_add_playlist_items_uses_items_endpoint_and_chunks(self) -> None:
uris = [f"spotify:track:{i:02d}" for i in range(101)]
http_client = RecordingHttpClient(
[
make_response(
"POST",
"https://api.spotify.com/v1/playlists/pl999/items",
201,
json_body={"snapshot_id": "snap1"},
),
make_response(
"POST",
"https://api.spotify.com/v1/playlists/pl999/items",
201,
json_body={"snapshot_id": "snap2"},
),
]
)
client = make_client(http_client)
await client.add_playlist_items("token", "pl999", uris)
self.assertEqual(len(http_client.calls), 2)
first, second = http_client.calls
self.assertEqual(first["method"], "POST")
self.assertEqual(first["url"], "https://api.spotify.com/v1/playlists/pl999/items")
self.assertEqual(second["url"], "https://api.spotify.com/v1/playlists/pl999/items")
self.assertEqual(len(first["json"]["uris"]), 100)
self.assertEqual(len(second["json"]["uris"]), 1)
self.assertEqual(first["headers"]["Authorization"], "Bearer token")
if __name__ == "__main__":
unittest.main()