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:
2026-02-22 08:36:36 -06:00
parent 897d0fe1fb
commit 7ff69449d6
39 changed files with 2049 additions and 0 deletions

View File

@@ -0,0 +1,63 @@
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from haunt_fm.db import get_session
from haunt_fm.models.track import PlaylistTrack, Track
from haunt_fm.services.music_assistant import play_playlist_on_speaker
from haunt_fm.services.playlist_generator import generate_playlist
router = APIRouter(prefix="/api/playlists")
class GenerateRequest(BaseModel):
total_tracks: int = 20
known_pct: int = 30
name: str | None = None
speaker_entity: str | None = None
auto_play: bool = False
@router.post("/generate")
async def generate(req: GenerateRequest, session: AsyncSession = Depends(get_session)):
playlist = await generate_playlist(
session,
total_tracks=req.total_tracks,
known_pct=req.known_pct,
name=req.name,
)
# Load playlist tracks with track info
result = await session.execute(
select(PlaylistTrack, Track)
.join(Track, PlaylistTrack.track_id == Track.id)
.where(PlaylistTrack.playlist_id == playlist.id)
.order_by(PlaylistTrack.position)
)
rows = result.all()
track_list = [
{
"position": pt.position,
"artist": t.artist,
"title": t.title,
"album": t.album,
"is_known": pt.is_known,
"similarity_score": pt.similarity_score,
}
for pt, t in rows
]
# Auto-play if requested
if req.auto_play and req.speaker_entity:
await play_playlist_on_speaker(track_list, req.speaker_entity)
return {
"playlist_id": playlist.id,
"name": playlist.name,
"total_tracks": playlist.total_tracks,
"known_pct": playlist.known_pct,
"tracks": track_list,
"auto_played": req.auto_play and req.speaker_entity is not None,
}