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
|
|
|
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
|
2026-02-23 09:17:52 -06:00
|
|
|
from haunt_fm.services.skip_detector import get_sessions as get_skip_sessions
|
|
|
|
|
from haunt_fm.services.skip_detector import is_running as is_skip_detector_running
|
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
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
},
|
2026-02-23 09:17:52 -06:00
|
|
|
"skip_detector": {
|
|
|
|
|
"running": is_skip_detector_running(),
|
|
|
|
|
"active_sessions": len(get_skip_sessions()),
|
|
|
|
|
"sessions": [
|
|
|
|
|
{
|
|
|
|
|
"speaker_entity": entity,
|
|
|
|
|
"playlist_id": s.playlist_id,
|
|
|
|
|
"current_position": s.current_position,
|
|
|
|
|
"total_tracks": len(s.tracks),
|
|
|
|
|
"current_track": (
|
|
|
|
|
f"{s.tracks[s.current_position]['artist']} - {s.tracks[s.current_position]['title']}"
|
|
|
|
|
if s.current_position < len(s.tracks)
|
|
|
|
|
else None
|
|
|
|
|
),
|
|
|
|
|
"last_activity": s.last_activity_at.isoformat(),
|
|
|
|
|
}
|
|
|
|
|
for entity, s in get_skip_sessions().items()
|
|
|
|
|
],
|
|
|
|
|
},
|
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
|
|
|
},
|
|
|
|
|
"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),
|
|
|
|
|
},
|
|
|
|
|
}
|