Initial haunt-fm implementation
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>
This commit is contained in:
57
src/haunt_fm/services/itunes_client.py
Normal file
57
src/haunt_fm/services/itunes_client.py
Normal file
@@ -0,0 +1,57 @@
|
||||
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
|
||||
Reference in New Issue
Block a user