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:
113
src/haunt_fm/api/status.py
Normal file
113
src/haunt_fm/api/status.py
Normal 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),
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user