Files
haunt-fm/src/haunt_fm/api/status.py
Thomas Hallock af6159a297 Add automatic skip detection for playlist playback
Background poller monitors HA media_player state during playlist sessions.
When a track transition occurs and the previous track was played < 40% of
its duration, automatically records "skip" feedback. Also includes the
previously uncommitted delete_feedback endpoint.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 09:17:52 -06:00

135 lines
5.1 KiB
Python

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
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
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,
},
"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()
],
},
},
"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),
},
}