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

113
src/haunt_fm/api/status.py Normal file
View File

@@ -0,0 +1,113 @@
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends
from sqlalchemy import func, select, text
from sqlalchemy.ext.asyncio import AsyncSession
from haunt_fm.db import get_session
from haunt_fm.models.track import (
ListenEvent,
Playlist,
TasteProfile,
Track,
TrackEmbedding,
)
from haunt_fm.config import settings
from haunt_fm.services.embedding import is_model_loaded
from haunt_fm.services.embedding_worker import is_running as is_worker_running
from haunt_fm.services.embedding_worker import last_processed as worker_last_processed
router = APIRouter(prefix="/api")
@router.get("/status")
async def status(session: AsyncSession = Depends(get_session)):
# DB connectivity
try:
await session.execute(text("SELECT 1"))
db_connected = True
except Exception:
db_connected = False
if not db_connected:
return {"healthy": False, "db_connected": False}
now = datetime.now(timezone.utc)
day_ago = now - timedelta(days=1)
# Listen events
total_events = (await session.execute(select(func.count(ListenEvent.id)))).scalar() or 0
events_24h = (
await session.execute(
select(func.count(ListenEvent.id)).where(ListenEvent.listened_at >= day_ago)
)
).scalar() or 0
latest_event = (
await session.execute(select(func.max(ListenEvent.listened_at)))
).scalar()
# Tracks
total_tracks = (await session.execute(select(func.count(Track.id)))).scalar() or 0
from_history = (
await session.execute(
select(func.count(func.distinct(ListenEvent.track_id)))
)
).scalar() or 0
from_discovery = total_tracks - from_history
# Embeddings
def _embedding_count(status_val: str):
return select(func.count(Track.id)).where(Track.embedding_status == status_val)
emb_done = (await session.execute(_embedding_count("done"))).scalar() or 0
emb_pending = (await session.execute(_embedding_count("pending"))).scalar() or 0
emb_failed = (await session.execute(_embedding_count("failed"))).scalar() or 0
emb_no_preview = (await session.execute(_embedding_count("no_preview"))).scalar() or 0
# Taste profile
taste = (await session.execute(select(TasteProfile).where(TasteProfile.name == "default"))).scalar()
# Playlists
total_playlists = (await session.execute(select(func.count(Playlist.id)))).scalar() or 0
last_playlist = (await session.execute(select(func.max(Playlist.created_at)))).scalar()
return {
"healthy": db_connected,
"db_connected": db_connected,
"clap_model_loaded": is_model_loaded(),
"pipeline": {
"listen_events": {
"total": total_events,
"last_24h": events_24h,
"latest": latest_event.isoformat() if latest_event else None,
},
"tracks": {
"total": total_tracks,
"from_history": from_history,
"from_discovery": from_discovery,
},
"embeddings": {
"done": emb_done,
"pending": emb_pending,
"failed": emb_failed,
"no_preview": emb_no_preview,
"worker_running": is_worker_running(),
"worker_last_processed": worker_last_processed().isoformat() if worker_last_processed() else None,
},
"taste_profile": {
"exists": taste is not None,
"track_count": taste.track_count if taste else 0,
"updated_at": taste.updated_at.isoformat() if taste else None,
},
"playlists": {
"total_generated": total_playlists,
"last_generated": last_playlist.isoformat() if last_playlist else None,
},
},
"dependencies": {
"lastfm_api": "configured" if settings.lastfm_api_key else "not_configured",
"itunes_api": "ok", # no auth needed
"ha_reachable": bool(settings.ha_token),
"music_assistant_reachable": bool(settings.ha_token),
},
}