diff --git a/src/haunt_fm/api/admin.py b/src/haunt_fm/api/admin.py index 3e2fae2..bb65a75 100644 --- a/src/haunt_fm/api/admin.py +++ b/src/haunt_fm/api/admin.py @@ -1,6 +1,6 @@ from fastapi import APIRouter, Depends, Query from pydantic import BaseModel -from sqlalchemy import func, select +from sqlalchemy import func, select, update from sqlalchemy.ext.asyncio import AsyncSession from haunt_fm.db import get_session @@ -62,3 +62,15 @@ async def build_profile( "track_count": taste.track_count, "updated_at": taste.updated_at.isoformat(), } + + +@router.post("/requeue-failed") +async def requeue_failed(session: AsyncSession = Depends(get_session)): + """Reset failed embedding tracks back to pending so the worker retries them.""" + result = await session.execute( + update(Track) + .where(Track.embedding_status == "failed") + .values(embedding_status="pending", embedding_error=None) + ) + await session.commit() + return {"requeued": result.rowcount} diff --git a/src/haunt_fm/api/status_page.py b/src/haunt_fm/api/status_page.py index bff0e4f..3b67022 100644 --- a/src/haunt_fm/api/status_page.py +++ b/src/haunt_fm/api/status_page.py @@ -87,6 +87,7 @@ async def status_page( recent_rows = (await session.execute(listens_query)).all() recent_listens = [ { + "track_id": track.id, "title": track.title, "artist": track.artist, "speaker": event.speaker_name or "Unknown", @@ -119,6 +120,7 @@ async def status_page( profiles = [ { "id": profile.id, + "raw_name": profile.name, "name": profile.display_name or profile.name, "event_count": event_count, "track_count": track_count, @@ -193,6 +195,7 @@ async def status_page( feedback_rows = (await session.execute(fb_recent_query)).all() recent_feedback = [ { + "id": event.id, "signal": event.signal, "signal_weight": event.signal_weight, "title": track.title, @@ -233,6 +236,7 @@ async def status_page( for event, track in influence_rows: if track.id not in tracks_map: tracks_map[track.id] = { + "track_id": track.id, "title": track.title, "artist": track.artist, "vibes": [], @@ -268,6 +272,23 @@ async def status_page( # Profile names for selector (use raw Profile.name, not display_name) all_profile_names = [profile.name for profile, *_ in profile_rows] + # Speaker entities for dropdowns + speaker_entities = [ + ("Living Room", "media_player.living_room_speaker_2"), + ("Dining Room", "media_player.dining_room_speaker_2"), + ("Basement", "media_player.basement_mini_2"), + ("Kitchen", "media_player.kitchen_stereo_2"), + ("Study", "media_player.study_speaker_2"), + ("Butler's Pantry", "media_player.butlers_pantry_speaker_2"), + ("Master Bathroom", "media_player.master_bathroom_speaker_2"), + ("Kids Room", "media_player.kids_room_speaker_2"), + ("Guest Bedroom", "media_player.guest_bedroom_speaker_2_2"), + ("Garage", "media_player.garage_wifi_2"), + ("Whole House", "media_player.whole_house_2"), + ("Downstairs", "media_player.downstairs_2"), + ("Upstairs", "media_player.upstairs_2"), + ] + template = _jinja_env.get_template("status.html") html = template.render( data=data, @@ -280,6 +301,7 @@ async def status_page( vibe_influence=vibe_influence, selected_profile=selected_profile.name if selected_profile else None, all_profile_names=all_profile_names, + speaker_entities=speaker_entities, 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 e0dfa17..549e19e 100644 --- a/src/haunt_fm/templates/status.html +++ b/src/haunt_fm/templates/status.html @@ -27,6 +27,11 @@ background: #161b22; border: 1px solid #30363d; border-radius: 0.5rem; padding: 1.25rem; margin-bottom: 1rem; } + .section-header { + display: flex; justify-content: space-between; align-items: center; + margin-bottom: 0.75rem; + } + .section-header h2 { margin-bottom: 0; } .section h2 { color: #8b949e; font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 0.75rem; @@ -52,8 +57,12 @@ .dot.gray { background: #484f58; } /* Listen items */ - .listen-item { padding: 0.5rem 0; border-bottom: 1px solid #21262d; } + .listen-item { + display: flex; justify-content: space-between; align-items: center; + padding: 0.5rem 0; border-bottom: 1px solid #21262d; + } .listen-item:last-child { border-bottom: none; } + .listen-content { flex: 1; min-width: 0; } .listen-title { color: #c9d1d9; font-weight: 500; font-size: 0.9rem; } .listen-artist { color: #8b949e; font-size: 0.9rem; } .listen-meta { color: #484f58; font-size: 0.8rem; margin-top: 0.15rem; } @@ -72,6 +81,7 @@ /* Profile cards */ .profile-card { padding: 0.6rem 0; border-bottom: 1px solid #21262d; } .profile-card:last-child { border-bottom: none; } + .profile-name-row { display: flex; align-items: center; gap: 0.5rem; } .profile-name { color: #58a6ff; font-weight: 600; font-size: 0.95rem; } .profile-stats { color: #8b949e; font-size: 0.85rem; margin-top: 0.15rem; } .profile-speakers { color: #484f58; font-size: 0.8rem; margin-top: 0.1rem; } @@ -120,8 +130,12 @@ .signal-icon.up { color: #3fb950; } .signal-icon.down { color: #f85149; } .signal-icon.skip { color: #d29922; } - .feedback-item { padding: 0.5rem 0; border-bottom: 1px solid #21262d; } + .feedback-item { + display: flex; justify-content: space-between; align-items: flex-start; + padding: 0.5rem 0; border-bottom: 1px solid #21262d; + } .feedback-item:last-child { border-bottom: none; } + .feedback-content { flex: 1; min-width: 0; } .feedback-track { color: #c9d1d9; font-weight: 500; font-size: 0.9rem; } .feedback-meta { color: #484f58; font-size: 0.8rem; margin-top: 0.15rem; } .vibe-pill { @@ -131,10 +145,125 @@ .vibe-pill.up { background: rgba(63, 185, 80, 0.15); border: 1px solid #3fb950; color: #3fb950; } .vibe-pill.down { background: rgba(248, 81, 73, 0.15); border: 1px solid #f85149; color: #f85149; } .vibe-pill.skip { background: rgba(210, 153, 34, 0.15); border: 1px solid #d29922; color: #d29922; } - .influence-row { padding: 0.6rem 0; border-bottom: 1px solid #21262d; } + .influence-row { + display: flex; justify-content: space-between; align-items: flex-start; + padding: 0.6rem 0; border-bottom: 1px solid #21262d; + } .influence-row:last-child { border-bottom: none; } + .influence-content { flex: 1; min-width: 0; } .influence-track { color: #c9d1d9; font-weight: 500; font-size: 0.9rem; margin-bottom: 0.3rem; } .influence-pills { display: flex; flex-wrap: wrap; } + + /* Action buttons */ + .action-btn { + display: inline-flex; align-items: center; gap: 0.3rem; + padding: 0.4rem 0.8rem; border-radius: 0.375rem; border: 1px solid #238636; + background: rgba(35, 134, 54, 0.15); color: #3fb950; + font-size: 0.8rem; font-weight: 500; cursor: pointer; + transition: all 0.15s; + } + .action-btn:hover { background: #238636; color: #fff; } + .action-btn:disabled { opacity: 0.5; cursor: not-allowed; } + .action-btn-sm { + padding: 0.2rem 0.5rem; font-size: 0.75rem; + } + .action-btn.danger { + border-color: #da3633; background: rgba(218, 54, 51, 0.1); color: #f85149; + } + .action-btn.danger:hover { background: #da3633; color: #fff; } + + /* Inline feedback buttons */ + .feedback-btns { + display: flex; gap: 0.25rem; margin-left: 0.5rem; flex-shrink: 0; + } + .fb-btn { + width: 28px; height: 28px; border-radius: 0.25rem; border: 1px solid #30363d; + background: transparent; color: #484f58; cursor: pointer; + display: flex; align-items: center; justify-content: center; + font-size: 0.75rem; transition: all 0.15s; + } + .fb-btn:hover { border-color: #8b949e; color: #c9d1d9; } + .fb-btn.fb-up:hover, .fb-btn.fb-up.sent { border-color: #3fb950; color: #3fb950; background: rgba(63, 185, 80, 0.1); } + .fb-btn.fb-down:hover, .fb-btn.fb-down.sent { border-color: #f85149; color: #f85149; background: rgba(248, 81, 73, 0.1); } + .fb-btn:disabled { opacity: 0.4; cursor: not-allowed; } + + /* Retract button */ + .retract-btn { + width: 22px; height: 22px; border-radius: 0.25rem; border: 1px solid transparent; + background: transparent; color: #484f58; cursor: pointer; + display: flex; align-items: center; justify-content: center; + font-size: 0.7rem; transition: all 0.15s; flex-shrink: 0; margin-left: 0.5rem; + } + .retract-btn:hover { border-color: #f85149; color: #f85149; } + .retract-btn:disabled { opacity: 0.4; cursor: not-allowed; } + + /* Form elements */ + .input-field { + background: #0d1117; border: 1px solid #30363d; border-radius: 0.375rem; + color: #c9d1d9; padding: 0.4rem 0.6rem; font-size: 0.85rem; + font-family: inherit; width: 100%; + } + .input-field:focus { outline: none; border-color: #58a6ff; } + .input-sm { width: auto; min-width: 60px; } + .input-range { width: 100%; accent-color: #58a6ff; } + select.input-field { cursor: pointer; } + + .form-row { + display: flex; align-items: center; gap: 0.5rem; + margin-bottom: 0.5rem; + } + .form-label { color: #8b949e; font-size: 0.8rem; min-width: 80px; flex-shrink: 0; } + + /* Collapsible forms */ + .section-action { + color: #58a6ff; font-size: 0.75rem; cursor: pointer; text-decoration: none; + font-weight: 500; + } + .section-action:hover { text-decoration: underline; } + .collapsible-form { + display: none; padding: 0.75rem; margin-top: 0.5rem; + background: #0d1117; border: 1px solid #30363d; border-radius: 0.375rem; + } + .collapsible-form.open { display: block; } + + /* Toast notifications */ + #toast-container { + position: fixed; bottom: 1.5rem; right: 1.5rem; z-index: 1000; + display: flex; flex-direction: column; gap: 0.5rem; + } + .toast { + padding: 0.6rem 1rem; border-radius: 0.375rem; font-size: 0.85rem; + max-width: 320px; animation: toast-in 0.2s ease-out; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); + } + .toast.success { background: #238636; color: #fff; } + .toast.error { background: #da3633; color: #fff; } + @keyframes toast-in { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } } + @keyframes toast-out { from { opacity: 1; } to { opacity: 0; transform: translateY(8px); } } + + /* Checkbox label */ + .checkbox-label { + display: flex; align-items: center; gap: 0.4rem; + color: #8b949e; font-size: 0.85rem; cursor: pointer; + } + .checkbox-label input { accent-color: #58a6ff; } + + /* Speaker tag editing */ + .speaker-tags { display: flex; flex-wrap: wrap; gap: 0.25rem; margin-top: 0.25rem; } + .speaker-tag { + display: inline-flex; align-items: center; gap: 0.2rem; + padding: 0.1rem 0.4rem; border-radius: 0.75rem; font-size: 0.7rem; + background: #21262d; color: #8b949e; + } + .speaker-tag .remove-tag { + cursor: pointer; color: #484f58; font-size: 0.65rem; + } + .speaker-tag .remove-tag:hover { color: #f85149; } + + @media (max-width: 600px) { + .form-row { flex-wrap: wrap; } + .form-label { min-width: 100%; } + } @@ -160,9 +289,15 @@ {% if recent_listens %} {% for listen in recent_listens %}
- {{ listen.title }} - — {{ listen.artist }} -
{{ listen.speaker }} · {{ listen.listened_at | timeago }}
+
+ {{ listen.title }} + — {{ listen.artist }} +
{{ listen.speaker }} · {{ listen.listened_at | timeago }}
+
+
+ + +
{% endfor %} {% else %} @@ -172,11 +307,33 @@
-

Profiles

+
+

Profiles

+ + New Profile +
+
+
+ Name + +
+
+ Display + +
+
+ +
+
{% if profiles %} {% for profile in profiles %} -
-
{{ profile.name }}
+
+
+ {{ profile.name }} + {% if profile.raw_name != 'default' %} + delete + {% endif %} + edit speakers +
{{ profile.event_count }} events · {{ profile.track_count }} tracks · last: {{ profile.last_listen | timeago }} @@ -184,6 +341,25 @@ {% if profile.speakers %}
speakers: {{ profile.speakers | join(', ') }}
{% endif %} +
+
+ Speakers + +
+
+ {% for spk in profile.speakers %} + {{ spk }}× + {% endfor %} +
+
+ +
+
{% endfor %} {% else %} @@ -207,11 +383,14 @@ {{ feedback_summary.tracks }} tracks
{% for fb in recent_feedback %} - @@ -222,11 +401,17 @@ {% if vibe_influence %} {% for track in vibe_influence %}
-
{{ track.title }} — {{ track.artist }}
-
- {% for vibe in track.vibes %} - {% if vibe.signal == 'up' %}▲{% elif vibe.signal == 'down' %}▼{% else %}▸{% endif %} {{ vibe.vibe_text }} - {% endfor %} +
+
{{ track.title }} — {{ track.artist }}
+
+ {% for vibe in track.vibes %} + {% if vibe.signal == 'up' %}▲{% elif vibe.signal == 'down' %}▼{% else %}▸{% endif %} {{ vibe.vibe_text }} + {% endfor %} +
+
+
{% endfor %} @@ -236,6 +421,25 @@
{% endif %} + +
+

Actions

+
+ Discover + + +
+
+ Rebuild Taste + + +
+
+
@@ -291,7 +495,12 @@
Failed - {{ emb.failed }} + + {{ emb.failed }} + {% if emb.failed > 0 %} + + {% endif %} +
No preview @@ -300,7 +509,56 @@
-

Playlists

+
+

Playlists

+ + Generate +
+
+
+ Vibe + +
+
+ Tracks + +
+
+ Known % + + 30% +
+
+ Alpha + + 0.50 +
+
+ Speaker + +
+
+ Profile + +
+
+ + +
+
+ +
+
Generated {{ data.pipeline.playlists.total_generated }} @@ -329,20 +587,22 @@ {% set taste = taste_profiles.get(profile.id) %}
{{ profile.name }} - + {% if taste %} Built ({{ taste.track_count }} tracks) · {{ taste.updated_at | timeago }} {% else %} Not built {% endif %} +
{% endfor %} {% else %}
Default - + {{ 'Built (' ~ data.pipeline.taste_profile.track_count ~ ' tracks)' if data.pipeline.taste_profile.exists else 'Not built' }} +
{% endif %} @@ -362,5 +622,214 @@

Updated {{ now }}

+ +
+ +