Full music recommendation pipeline: listening history capture via webhook, Last.fm candidate discovery, iTunes preview download, CLAP audio embeddings (512-dim), pgvector cosine similarity recommendations, playlist generation with known/new track interleaving, and Music Assistant playback via HA. Includes: FastAPI app, SQLAlchemy models, Alembic migrations, Docker Compose with pgvector/pg17, status dashboard, and all API endpoints. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
58 lines
1.6 KiB
Python
58 lines
1.6 KiB
Python
import asyncio
|
|
import logging
|
|
|
|
import httpx
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
ITUNES_SEARCH_URL = "https://itunes.apple.com/search"
|
|
|
|
# Rate limit: ~20 req/min for iTunes
|
|
_last_request_time = 0.0
|
|
_min_interval = 3.0 # 3s between requests
|
|
|
|
|
|
async def _rate_limit():
|
|
global _last_request_time
|
|
now = asyncio.get_event_loop().time()
|
|
elapsed = now - _last_request_time
|
|
if elapsed < _min_interval:
|
|
await asyncio.sleep(_min_interval - elapsed)
|
|
_last_request_time = asyncio.get_event_loop().time()
|
|
|
|
|
|
async def search_track(artist: str, title: str) -> dict | None:
|
|
"""Search iTunes for a track and return preview info, or None if not found."""
|
|
await _rate_limit()
|
|
|
|
query = f"{artist} {title}"
|
|
async with httpx.AsyncClient(timeout=10) as client:
|
|
resp = await client.get(
|
|
ITUNES_SEARCH_URL,
|
|
params={
|
|
"term": query,
|
|
"media": "music",
|
|
"entity": "song",
|
|
"limit": 5,
|
|
},
|
|
)
|
|
resp.raise_for_status()
|
|
data = resp.json()
|
|
|
|
results = data.get("results", [])
|
|
if not results:
|
|
return None
|
|
|
|
# Find best match (simple: first result with a preview URL)
|
|
for r in results:
|
|
if r.get("previewUrl"):
|
|
return {
|
|
"track_id": r["trackId"],
|
|
"preview_url": r["previewUrl"],
|
|
"apple_music_id": str(r.get("trackId", "")),
|
|
"duration_ms": r.get("trackTimeMillis"),
|
|
"genre": r.get("primaryGenreName"),
|
|
}
|
|
|
|
return None
|