Files
haunt-fm/src/haunt_fm/services/itunes_client.py
Thomas Hallock 7ff69449d6 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>
2026-02-22 08:36:36 -06:00

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