diff --git a/src/haunt_fm/api/status_page.py b/src/haunt_fm/api/status_page.py index e1f1d5b..bff0e4f 100644 --- a/src/haunt_fm/api/status_page.py +++ b/src/haunt_fm/api/status_page.py @@ -1,10 +1,10 @@ from datetime import datetime, timezone from pathlib import Path -from fastapi import APIRouter, Depends, Request +from fastapi import APIRouter, Depends, Query, Request from fastapi.responses import HTMLResponse from jinja2 import Environment, FileSystemLoader -from sqlalchemy import func, select +from sqlalchemy import func, or_, select from sqlalchemy.ext.asyncio import AsyncSession from haunt_fm.api.status import status as get_status_data @@ -57,18 +57,34 @@ _jinja_env.filters["timeago"] = _timeago @router.get("/", response_class=HTMLResponse) -async def status_page(request: Request, session: AsyncSession = Depends(get_session)): +async def status_page( + request: Request, + profile: str | None = Query(default=None), + session: AsyncSession = Depends(get_session), +): data = await get_status_data(session) + # Resolve selected profile + selected_profile: Profile | None = None + if profile: + result = await session.execute(select(Profile).where(Profile.name == profile)) + selected_profile = result.scalar_one_or_none() + # Recent listens (last 10) with track info - recent_rows = ( - await session.execute( - select(ListenEvent, Track) - .join(Track, ListenEvent.track_id == Track.id) - .order_by(ListenEvent.listened_at.desc()) - .limit(10) - ) - ).all() + listens_query = ( + select(ListenEvent, Track) + .join(Track, ListenEvent.track_id == Track.id) + .order_by(ListenEvent.listened_at.desc()) + .limit(10) + ) + if selected_profile: + if selected_profile.name == "default": + listens_query = listens_query.where( + or_(ListenEvent.profile_id == selected_profile.id, ListenEvent.profile_id.is_(None)) + ) + else: + listens_query = listens_query.where(ListenEvent.profile_id == selected_profile.id) + recent_rows = (await session.execute(listens_query)).all() recent_listens = [ { "title": track.title, @@ -121,28 +137,39 @@ async def status_page(request: Request, session: AsyncSession = Depends(get_sess "updated_at": tp.updated_at, } + # Feedback profile filter + def _feedback_profile_filter(): + if not selected_profile: + return None + if selected_profile.name == "default": + return or_(FeedbackEvent.profile_name.is_(None), FeedbackEvent.profile_name == "default") + return FeedbackEvent.profile_name == selected_profile.name + + fb_filter = _feedback_profile_filter() + # Feedback summary stats - feedback_total = ( - await session.execute(select(func.count(FeedbackEvent.id))) - ).scalar() or 0 + fb_count_query = select(func.count(FeedbackEvent.id)) + if fb_filter is not None: + fb_count_query = fb_count_query.where(fb_filter) + feedback_total = (await session.execute(fb_count_query)).scalar() or 0 feedback_by_signal: dict[str, int] = {} if feedback_total > 0: - signal_rows = ( - await session.execute( - select(FeedbackEvent.signal, func.count(FeedbackEvent.id)) - .group_by(FeedbackEvent.signal) - ) - ).all() + signal_query = ( + select(FeedbackEvent.signal, func.count(FeedbackEvent.id)) + .group_by(FeedbackEvent.signal) + ) + if fb_filter is not None: + signal_query = signal_query.where(fb_filter) + signal_rows = (await session.execute(signal_query)).all() feedback_by_signal = {signal: count for signal, count in signal_rows} feedback_distinct_tracks = 0 if feedback_total > 0: - feedback_distinct_tracks = ( - await session.execute( - select(func.count(func.distinct(FeedbackEvent.track_id))) - ) - ).scalar() or 0 + distinct_query = select(func.count(func.distinct(FeedbackEvent.track_id))) + if fb_filter is not None: + distinct_query = distinct_query.where(fb_filter) + feedback_distinct_tracks = (await session.execute(distinct_query)).scalar() or 0 feedback_summary = { "total": feedback_total, @@ -155,20 +182,22 @@ async def status_page(request: Request, session: AsyncSession = Depends(get_sess # Recent feedback events (last 15) recent_feedback: list[dict] = [] if feedback_total > 0: - feedback_rows = ( - await session.execute( - select(FeedbackEvent, Track) - .join(Track, FeedbackEvent.track_id == Track.id) - .order_by(FeedbackEvent.created_at.desc()) - .limit(15) - ) - ).all() + fb_recent_query = ( + select(FeedbackEvent, Track) + .join(Track, FeedbackEvent.track_id == Track.id) + .order_by(FeedbackEvent.created_at.desc()) + .limit(15) + ) + if fb_filter is not None: + fb_recent_query = fb_recent_query.where(fb_filter) + feedback_rows = (await session.execute(fb_recent_query)).all() recent_feedback = [ { "signal": event.signal, "signal_weight": event.signal_weight, "title": track.title, "artist": track.artist, + "profile_name": event.profile_name, "vibe_text": event.vibe_text or "no vibe", "created_at": event.created_at, } @@ -178,25 +207,27 @@ async def status_page(request: Request, session: AsyncSession = Depends(get_sess # Vibe influence data — top 10 tracks by feedback count vibe_influence: list[dict] = [] if feedback_total > 0: - top_track_ids_result = ( - await session.execute( - select(FeedbackEvent.track_id, func.count(FeedbackEvent.id).label("cnt")) - .group_by(FeedbackEvent.track_id) - .order_by(func.count(FeedbackEvent.id).desc()) - .limit(10) - ) - ).all() + top_tracks_query = ( + select(FeedbackEvent.track_id, func.count(FeedbackEvent.id).label("cnt")) + .group_by(FeedbackEvent.track_id) + .order_by(func.count(FeedbackEvent.id).desc()) + .limit(10) + ) + if fb_filter is not None: + top_tracks_query = top_tracks_query.where(fb_filter) + top_track_ids_result = (await session.execute(top_tracks_query)).all() top_track_ids = [row[0] for row in top_track_ids_result] if top_track_ids: - influence_rows = ( - await session.execute( - select(FeedbackEvent, Track) - .join(Track, FeedbackEvent.track_id == Track.id) - .where(FeedbackEvent.track_id.in_(top_track_ids)) - .order_by(FeedbackEvent.created_at.desc()) - ) - ).all() + influence_query = ( + select(FeedbackEvent, Track) + .join(Track, FeedbackEvent.track_id == Track.id) + .where(FeedbackEvent.track_id.in_(top_track_ids)) + .order_by(FeedbackEvent.created_at.desc()) + ) + if fb_filter is not None: + influence_query = influence_query.where(fb_filter) + influence_rows = (await session.execute(influence_query)).all() tracks_map: dict[int, dict] = {} for event, track in influence_rows: @@ -234,6 +265,9 @@ async def status_page(request: Request, session: AsyncSession = Depends(get_sess for p in playlist_rows ] + # Profile names for selector (use raw Profile.name, not display_name) + all_profile_names = [profile.name for profile, *_ in profile_rows] + template = _jinja_env.get_template("status.html") html = template.render( data=data, @@ -244,6 +278,8 @@ async def status_page(request: Request, session: AsyncSession = Depends(get_sess feedback_summary=feedback_summary, recent_feedback=recent_feedback, vibe_influence=vibe_influence, + selected_profile=selected_profile.name if selected_profile else None, + all_profile_names=all_profile_names, now=datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC"), ) return HTMLResponse(html) diff --git a/src/haunt_fm/templates/status.html b/src/haunt_fm/templates/status.html index 6c770a5..e0dfa17 100644 --- a/src/haunt_fm/templates/status.html +++ b/src/haunt_fm/templates/status.html @@ -85,6 +85,25 @@ /* Dependencies row */ .deps-row { display: flex; flex-wrap: wrap; gap: 1rem; padding: 0.3rem 0; } + /* Profile selector */ + .profile-selector { + display: flex; flex-wrap: wrap; gap: 0.4rem; margin-bottom: 1.25rem; + } + .profile-selector a { + display: inline-block; padding: 0.25rem 0.7rem; border-radius: 1rem; + font-size: 0.8rem; font-weight: 500; text-decoration: none; + border: 1px solid #30363d; color: #8b949e; background: #161b22; + transition: all 0.15s; + } + .profile-selector a:hover { border-color: #58a6ff; color: #c9d1d9; } + .profile-selector a.active { background: #58a6ff; color: #0d1117; border-color: #58a6ff; } + + .profile-badge { + display: inline-block; font-size: 0.7rem; padding: 0.1rem 0.4rem; + border-radius: 0.75rem; background: #21262d; color: #8b949e; + margin-left: 0.3rem; vertical-align: middle; + } + /* Footer */ .timestamp { color: #484f58; font-size: 0.8rem; margin-top: 1rem; text-align: center; } @@ -126,6 +145,15 @@ + {% if all_profile_names | length > 1 %} +