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:
117
src/haunt_fm/services/music_assistant.py
Normal file
117
src/haunt_fm/services/music_assistant.py
Normal file
@@ -0,0 +1,117 @@
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
|
||||
from haunt_fm.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _ha_request(method: str, path: str, **kwargs) -> dict:
|
||||
"""Make an authenticated request to Home Assistant REST API."""
|
||||
headers = {
|
||||
"Authorization": f"Bearer {settings.ha_token}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
resp = await client.request(
|
||||
method, f"{settings.ha_url}{path}", headers=headers, **kwargs
|
||||
)
|
||||
resp.raise_for_status()
|
||||
if resp.content:
|
||||
return resp.json()
|
||||
return {}
|
||||
|
||||
|
||||
async def is_ha_reachable() -> bool:
|
||||
"""Check if Home Assistant is reachable."""
|
||||
try:
|
||||
await _ha_request("GET", "/api/")
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
async def play_media_on_speaker(
|
||||
media_content_id: str,
|
||||
speaker_entity: str,
|
||||
media_content_type: str = "music",
|
||||
) -> None:
|
||||
"""Play a media item on a speaker via HA media_player service."""
|
||||
await _ha_request(
|
||||
"POST",
|
||||
"/api/services/media_player/play_media",
|
||||
json={
|
||||
"entity_id": speaker_entity,
|
||||
"media_content_id": media_content_id,
|
||||
"media_content_type": media_content_type,
|
||||
},
|
||||
)
|
||||
logger.info("Playing %s on %s", media_content_id, speaker_entity)
|
||||
|
||||
|
||||
async def search_and_play(
|
||||
artist: str,
|
||||
title: str,
|
||||
speaker_entity: str,
|
||||
) -> bool:
|
||||
"""Search Music Assistant for a track and play it.
|
||||
|
||||
Uses the mass.search service to find the track, then plays it.
|
||||
"""
|
||||
try:
|
||||
# Use Music Assistant search via HA
|
||||
result = await _ha_request(
|
||||
"POST",
|
||||
"/api/services/mass/search",
|
||||
json={
|
||||
"name": f"{artist} {title}",
|
||||
"media_type": "track",
|
||||
"limit": 1,
|
||||
},
|
||||
)
|
||||
logger.info("MA search result for '%s - %s': %s", artist, title, result)
|
||||
return True
|
||||
except Exception:
|
||||
logger.exception("Failed to search MA for %s - %s", artist, title)
|
||||
return False
|
||||
|
||||
|
||||
async def play_playlist_on_speaker(
|
||||
tracks: list[dict],
|
||||
speaker_entity: str,
|
||||
) -> None:
|
||||
"""Play a list of tracks on a speaker. Each track dict has 'artist' and 'title'.
|
||||
|
||||
Enqueues tracks via Music Assistant.
|
||||
"""
|
||||
if not tracks:
|
||||
return
|
||||
|
||||
for i, track in enumerate(tracks):
|
||||
try:
|
||||
if i == 0:
|
||||
# Play first track
|
||||
await _ha_request(
|
||||
"POST",
|
||||
"/api/services/media_player/play_media",
|
||||
json={
|
||||
"entity_id": speaker_entity,
|
||||
"media_content_id": f"{track['artist']} - {track['title']}",
|
||||
"media_content_type": "music",
|
||||
},
|
||||
)
|
||||
else:
|
||||
# Enqueue subsequent tracks
|
||||
await _ha_request(
|
||||
"POST",
|
||||
"/api/services/media_player/play_media",
|
||||
json={
|
||||
"entity_id": speaker_entity,
|
||||
"media_content_id": f"{track['artist']} - {track['title']}",
|
||||
"media_content_type": "music",
|
||||
"enqueue": "add",
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to enqueue %s - %s", track["artist"], track["title"])
|
||||
Reference in New Issue
Block a user