Add interactive mutation UI to dashboard

Dashboard now supports all existing mutation APIs: feedback
(thumbs up/down, retract), profile CRUD, speaker mapping,
playlist generation, track discovery, taste rebuild, and
requeue failed embeddings. All controls use vanilla JS fetch
with toast notifications.

New endpoint: POST /api/admin/requeue-failed resets failed
embedding tracks back to pending.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-02 08:08:23 -06:00
parent 551b4c6ff9
commit 3e66f31df9
3 changed files with 527 additions and 24 deletions

View File

@@ -1,6 +1,6 @@
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy import func, select from sqlalchemy import func, select, update
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from haunt_fm.db import get_session from haunt_fm.db import get_session
@@ -62,3 +62,15 @@ async def build_profile(
"track_count": taste.track_count, "track_count": taste.track_count,
"updated_at": taste.updated_at.isoformat(), "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}

View File

@@ -87,6 +87,7 @@ async def status_page(
recent_rows = (await session.execute(listens_query)).all() recent_rows = (await session.execute(listens_query)).all()
recent_listens = [ recent_listens = [
{ {
"track_id": track.id,
"title": track.title, "title": track.title,
"artist": track.artist, "artist": track.artist,
"speaker": event.speaker_name or "Unknown", "speaker": event.speaker_name or "Unknown",
@@ -119,6 +120,7 @@ async def status_page(
profiles = [ profiles = [
{ {
"id": profile.id, "id": profile.id,
"raw_name": profile.name,
"name": profile.display_name or profile.name, "name": profile.display_name or profile.name,
"event_count": event_count, "event_count": event_count,
"track_count": track_count, "track_count": track_count,
@@ -193,6 +195,7 @@ async def status_page(
feedback_rows = (await session.execute(fb_recent_query)).all() feedback_rows = (await session.execute(fb_recent_query)).all()
recent_feedback = [ recent_feedback = [
{ {
"id": event.id,
"signal": event.signal, "signal": event.signal,
"signal_weight": event.signal_weight, "signal_weight": event.signal_weight,
"title": track.title, "title": track.title,
@@ -233,6 +236,7 @@ async def status_page(
for event, track in influence_rows: for event, track in influence_rows:
if track.id not in tracks_map: if track.id not in tracks_map:
tracks_map[track.id] = { tracks_map[track.id] = {
"track_id": track.id,
"title": track.title, "title": track.title,
"artist": track.artist, "artist": track.artist,
"vibes": [], "vibes": [],
@@ -268,6 +272,23 @@ async def status_page(
# Profile names for selector (use raw Profile.name, not display_name) # Profile names for selector (use raw Profile.name, not display_name)
all_profile_names = [profile.name for profile, *_ in profile_rows] 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") template = _jinja_env.get_template("status.html")
html = template.render( html = template.render(
data=data, data=data,
@@ -280,6 +301,7 @@ async def status_page(
vibe_influence=vibe_influence, vibe_influence=vibe_influence,
selected_profile=selected_profile.name if selected_profile else None, selected_profile=selected_profile.name if selected_profile else None,
all_profile_names=all_profile_names, all_profile_names=all_profile_names,
speaker_entities=speaker_entities,
now=datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC"), now=datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC"),
) )
return HTMLResponse(html) return HTMLResponse(html)

View File

@@ -27,6 +27,11 @@
background: #161b22; border: 1px solid #30363d; border-radius: 0.5rem; background: #161b22; border: 1px solid #30363d; border-radius: 0.5rem;
padding: 1.25rem; margin-bottom: 1rem; 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 { .section h2 {
color: #8b949e; font-size: 0.75rem; text-transform: uppercase; color: #8b949e; font-size: 0.75rem; text-transform: uppercase;
letter-spacing: 0.08em; margin-bottom: 0.75rem; letter-spacing: 0.08em; margin-bottom: 0.75rem;
@@ -52,8 +57,12 @@
.dot.gray { background: #484f58; } .dot.gray { background: #484f58; }
/* Listen items */ /* 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-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-title { color: #c9d1d9; font-weight: 500; font-size: 0.9rem; }
.listen-artist { color: #8b949e; font-size: 0.9rem; } .listen-artist { color: #8b949e; font-size: 0.9rem; }
.listen-meta { color: #484f58; font-size: 0.8rem; margin-top: 0.15rem; } .listen-meta { color: #484f58; font-size: 0.8rem; margin-top: 0.15rem; }
@@ -72,6 +81,7 @@
/* Profile cards */ /* Profile cards */
.profile-card { padding: 0.6rem 0; border-bottom: 1px solid #21262d; } .profile-card { padding: 0.6rem 0; border-bottom: 1px solid #21262d; }
.profile-card:last-child { border-bottom: none; } .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-name { color: #58a6ff; font-weight: 600; font-size: 0.95rem; }
.profile-stats { color: #8b949e; font-size: 0.85rem; margin-top: 0.15rem; } .profile-stats { color: #8b949e; font-size: 0.85rem; margin-top: 0.15rem; }
.profile-speakers { color: #484f58; font-size: 0.8rem; margin-top: 0.1rem; } .profile-speakers { color: #484f58; font-size: 0.8rem; margin-top: 0.1rem; }
@@ -120,8 +130,12 @@
.signal-icon.up { color: #3fb950; } .signal-icon.up { color: #3fb950; }
.signal-icon.down { color: #f85149; } .signal-icon.down { color: #f85149; }
.signal-icon.skip { color: #d29922; } .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-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-track { color: #c9d1d9; font-weight: 500; font-size: 0.9rem; }
.feedback-meta { color: #484f58; font-size: 0.8rem; margin-top: 0.15rem; } .feedback-meta { color: #484f58; font-size: 0.8rem; margin-top: 0.15rem; }
.vibe-pill { .vibe-pill {
@@ -131,10 +145,125 @@
.vibe-pill.up { background: rgba(63, 185, 80, 0.15); border: 1px solid #3fb950; color: #3fb950; } .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.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; } .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-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-track { color: #c9d1d9; font-weight: 500; font-size: 0.9rem; margin-bottom: 0.3rem; }
.influence-pills { display: flex; flex-wrap: wrap; } .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%; }
}
</style> </style>
</head> </head>
<body> <body>
@@ -160,10 +289,16 @@
{% if recent_listens %} {% if recent_listens %}
{% for listen in recent_listens %} {% for listen in recent_listens %}
<div class="listen-item"> <div class="listen-item">
<div class="listen-content">
<span class="listen-title">{{ listen.title }}</span> <span class="listen-title">{{ listen.title }}</span>
<span class="listen-artist"> &mdash; {{ listen.artist }}</span> <span class="listen-artist"> &mdash; {{ listen.artist }}</span>
<div class="listen-meta">{{ listen.speaker }} &middot; {{ listen.listened_at | timeago }}</div> <div class="listen-meta">{{ listen.speaker }} &middot; {{ listen.listened_at | timeago }}</div>
</div> </div>
<div class="feedback-btns">
<button class="fb-btn fb-up" onclick="submitFeedback(this, {{ listen.track_id }}, 'up')" title="Thumbs up">&#9650;</button>
<button class="fb-btn fb-down" onclick="submitFeedback(this, {{ listen.track_id }}, 'down')" title="Thumbs down">&#9660;</button>
</div>
</div>
{% endfor %} {% endfor %}
{% else %} {% else %}
<div class="empty">No listens recorded yet</div> <div class="empty">No listens recorded yet</div>
@@ -172,11 +307,33 @@
<!-- Profiles --> <!-- Profiles -->
<div class="section"> <div class="section">
<div class="section-header">
<h2>Profiles</h2> <h2>Profiles</h2>
<a class="section-action" onclick="toggleForm('create-profile-form')">+ New Profile</a>
</div>
<div id="create-profile-form" class="collapsible-form">
<div class="form-row">
<span class="form-label">Name</span>
<input id="new-profile-name" class="input-field" placeholder="lowercase-slug" />
</div>
<div class="form-row">
<span class="form-label">Display</span>
<input id="new-profile-display" class="input-field" placeholder="Display Name (optional)" />
</div>
<div class="form-row" style="justify-content: flex-end;">
<button class="action-btn" onclick="createProfile()">Create</button>
</div>
</div>
{% if profiles %} {% if profiles %}
{% for profile in profiles %} {% for profile in profiles %}
<div class="profile-card"> <div class="profile-card" id="profile-card-{{ profile.raw_name }}">
<div class="profile-name">{{ profile.name }}</div> <div class="profile-name-row">
<span class="profile-name">{{ profile.name }}</span>
{% if profile.raw_name != 'default' %}
<a class="section-action" onclick="deleteProfile('{{ profile.raw_name }}')" style="color: #f85149; font-size: 0.7rem;">delete</a>
{% endif %}
<a class="section-action" onclick="toggleForm('speakers-{{ profile.raw_name }}')" style="font-size: 0.7rem;">edit speakers</a>
</div>
<div class="profile-stats"> <div class="profile-stats">
{{ profile.event_count }} events &middot; {{ profile.track_count }} tracks {{ profile.event_count }} events &middot; {{ profile.track_count }} tracks
&middot; last: {{ profile.last_listen | timeago }} &middot; last: {{ profile.last_listen | timeago }}
@@ -184,6 +341,25 @@
{% if profile.speakers %} {% if profile.speakers %}
<div class="profile-speakers">speakers: {{ profile.speakers | join(', ') }}</div> <div class="profile-speakers">speakers: {{ profile.speakers | join(', ') }}</div>
{% endif %} {% endif %}
<div id="speakers-{{ profile.raw_name }}" class="collapsible-form">
<div class="form-row">
<span class="form-label">Speakers</span>
<select id="speaker-select-{{ profile.raw_name }}" class="input-field">
<option value="">Add speaker...</option>
{% for label, entity in speaker_entities %}
<option value="{{ entity }}">{{ label }}</option>
{% endfor %}
</select>
</div>
<div class="speaker-tags" id="speaker-tags-{{ profile.raw_name }}">
{% for spk in profile.speakers %}
<span class="speaker-tag" data-speaker="{{ spk }}">{{ spk }}<span class="remove-tag" onclick="removeSpeakerTag('{{ profile.raw_name }}', '{{ spk }}')">&times;</span></span>
{% endfor %}
</div>
<div class="form-row" style="justify-content: flex-end; margin-top: 0.5rem;">
<button class="action-btn action-btn-sm" onclick="updateSpeakers('{{ profile.raw_name }}')">Save Speakers</button>
</div>
</div>
</div> </div>
{% endfor %} {% endfor %}
{% else %} {% else %}
@@ -207,12 +383,15 @@
<span>{{ feedback_summary.tracks }} tracks</span> <span>{{ feedback_summary.tracks }} tracks</span>
</div> </div>
{% for fb in recent_feedback %} {% for fb in recent_feedback %}
<div class="feedback-item"> <div class="feedback-item" id="feedback-{{ fb.id }}">
<div class="feedback-content">
<span class="signal-icon {{ fb.signal }}">{% if fb.signal == 'up' %}&#9650;{% elif fb.signal == 'down' %}&#9660;{% else %}&#9656;{% endif %}</span> <span class="signal-icon {{ fb.signal }}">{% if fb.signal == 'up' %}&#9650;{% elif fb.signal == 'down' %}&#9660;{% else %}&#9656;{% endif %}</span>
<span class="feedback-track">{{ fb.title }} &mdash; {{ fb.artist }}</span> <span class="feedback-track">{{ fb.title }} &mdash; {{ fb.artist }}</span>
{% if fb.profile_name and not selected_profile %}<span class="profile-badge">{{ fb.profile_name }}</span>{% endif %} {% if fb.profile_name and not selected_profile %}<span class="profile-badge">{{ fb.profile_name }}</span>{% endif %}
<div class="feedback-meta">{{ fb.vibe_text }} &middot; {{ fb.created_at | timeago }}</div> <div class="feedback-meta">{{ fb.vibe_text }} &middot; {{ fb.created_at | timeago }}</div>
</div> </div>
<button class="retract-btn" onclick="retractFeedback(this, {{ fb.id }})" title="Retract feedback">&times;</button>
</div>
{% endfor %} {% endfor %}
</div> </div>
@@ -222,6 +401,7 @@
{% if vibe_influence %} {% if vibe_influence %}
{% for track in vibe_influence %} {% for track in vibe_influence %}
<div class="influence-row"> <div class="influence-row">
<div class="influence-content">
<div class="influence-track">{{ track.title }} &mdash; {{ track.artist }}</div> <div class="influence-track">{{ track.title }} &mdash; {{ track.artist }}</div>
<div class="influence-pills"> <div class="influence-pills">
{% for vibe in track.vibes %} {% for vibe in track.vibes %}
@@ -229,6 +409,11 @@
{% endfor %} {% endfor %}
</div> </div>
</div> </div>
<div class="feedback-btns">
<button class="fb-btn fb-up" onclick="submitFeedback(this, {{ track.track_id }}, 'up')" title="Thumbs up">&#9650;</button>
<button class="fb-btn fb-down" onclick="submitFeedback(this, {{ track.track_id }}, 'down')" title="Thumbs down">&#9660;</button>
</div>
</div>
{% endfor %} {% endfor %}
{% else %} {% else %}
<div class="empty">Submit feedback on vibe playlists to see how tracks are rated across different moods</div> <div class="empty">Submit feedback on vibe playlists to see how tracks are rated across different moods</div>
@@ -236,6 +421,25 @@
</div> </div>
{% endif %} {% endif %}
<!-- Actions -->
<div class="section">
<h2>Actions</h2>
<div class="form-row">
<span class="form-label">Discover</span>
<input id="discover-limit" type="number" class="input-field input-sm" value="50" min="1" max="500" style="width: 70px;" />
<button class="action-btn" onclick="triggerDiscovery()">Discover Tracks</button>
</div>
<div class="form-row">
<span class="form-label">Rebuild Taste</span>
<select id="rebuild-profile" class="input-field" style="width: auto; flex: 1;">
{% for pname in all_profile_names %}
<option value="{{ pname }}" {{ 'selected' if (selected_profile == pname) or (not selected_profile and pname == 'default') else '' }}>{{ pname }}</option>
{% endfor %}
</select>
<button class="action-btn" onclick="rebuildTaste()">Rebuild</button>
</div>
</div>
<!-- Pipeline + Tracks grid --> <!-- Pipeline + Tracks grid -->
<div class="grid"> <div class="grid">
<div class="section"> <div class="section">
@@ -291,7 +495,12 @@
</div> </div>
<div class="row"> <div class="row">
<span class="label">Failed</span> <span class="label">Failed</span>
<span class="value">{{ emb.failed }}</span> <span class="value" style="display: flex; align-items: center; gap: 0.5rem;">
{{ emb.failed }}
{% if emb.failed > 0 %}
<button class="action-btn action-btn-sm" onclick="requeueFailed()">Retry</button>
{% endif %}
</span>
</div> </div>
<div class="row"> <div class="row">
<span class="label">No preview</span> <span class="label">No preview</span>
@@ -300,7 +509,56 @@
</div> </div>
<div class="section"> <div class="section">
<div class="section-header">
<h2>Playlists</h2> <h2>Playlists</h2>
<a class="section-action" onclick="toggleForm('generate-playlist-form')">+ Generate</a>
</div>
<div id="generate-playlist-form" class="collapsible-form">
<div class="form-row">
<span class="form-label">Vibe</span>
<input id="pl-vibe" class="input-field" placeholder="e.g. chill ambient lo-fi" />
</div>
<div class="form-row">
<span class="form-label">Tracks</span>
<input id="pl-tracks" type="number" class="input-field input-sm" value="20" min="1" max="100" style="width: 70px;" />
</div>
<div class="form-row">
<span class="form-label">Known %</span>
<input id="pl-known" type="range" class="input-range" min="0" max="100" value="30" oninput="document.getElementById('pl-known-val').textContent=this.value+'%'" />
<span id="pl-known-val" style="color: #8b949e; font-size: 0.8rem; min-width: 32px;">30%</span>
</div>
<div class="form-row">
<span class="form-label">Alpha</span>
<input id="pl-alpha" type="range" class="input-range" min="0" max="100" value="50" oninput="document.getElementById('pl-alpha-val').textContent=(this.value/100).toFixed(2)" />
<span id="pl-alpha-val" style="color: #8b949e; font-size: 0.8rem; min-width: 32px;">0.50</span>
</div>
<div class="form-row">
<span class="form-label">Speaker</span>
<select id="pl-speaker" class="input-field">
<option value="">None (no auto-play)</option>
{% for label, entity in speaker_entities %}
<option value="{{ entity }}">{{ label }}</option>
{% endfor %}
</select>
</div>
<div class="form-row">
<span class="form-label">Profile</span>
<select id="pl-profile" class="input-field">
{% for pname in all_profile_names %}
<option value="{{ pname }}" {{ 'selected' if (selected_profile == pname) or (not selected_profile and pname == 'default') else '' }}>{{ pname }}</option>
{% endfor %}
</select>
</div>
<div class="form-row">
<span class="form-label"></span>
<label class="checkbox-label">
<input type="checkbox" id="pl-autoplay" checked /> Auto-play
</label>
</div>
<div class="form-row" style="justify-content: flex-end;">
<button class="action-btn" onclick="generatePlaylist()">Generate Playlist</button>
</div>
</div>
<div class="row"> <div class="row">
<span class="label">Generated</span> <span class="label">Generated</span>
<span class="value">{{ data.pipeline.playlists.total_generated }}</span> <span class="value">{{ data.pipeline.playlists.total_generated }}</span>
@@ -329,20 +587,22 @@
{% set taste = taste_profiles.get(profile.id) %} {% set taste = taste_profiles.get(profile.id) %}
<div class="row"> <div class="row">
<span class="label"><span class="dot {{ 'green' if taste else 'gray' }}"></span>{{ profile.name }}</span> <span class="label"><span class="dot {{ 'green' if taste else 'gray' }}"></span>{{ profile.name }}</span>
<span class="value"> <span class="value" style="display: flex; align-items: center; gap: 0.5rem;">
{% if taste %} {% if taste %}
Built ({{ taste.track_count }} tracks) &middot; {{ taste.updated_at | timeago }} Built ({{ taste.track_count }} tracks) &middot; {{ taste.updated_at | timeago }}
{% else %} {% else %}
Not built Not built
{% endif %} {% endif %}
<button class="action-btn action-btn-sm" onclick="rebuildTaste('{{ profile.raw_name }}')">Rebuild</button>
</span> </span>
</div> </div>
{% endfor %} {% endfor %}
{% else %} {% else %}
<div class="row"> <div class="row">
<span class="label"><span class="dot {{ 'green' if data.pipeline.taste_profile.exists else 'gray' }}"></span>Default</span> <span class="label"><span class="dot {{ 'green' if data.pipeline.taste_profile.exists else 'gray' }}"></span>Default</span>
<span class="value"> <span class="value" style="display: flex; align-items: center; gap: 0.5rem;">
{{ 'Built (' ~ data.pipeline.taste_profile.track_count ~ ' tracks)' if data.pipeline.taste_profile.exists else 'Not built' }} {{ 'Built (' ~ data.pipeline.taste_profile.track_count ~ ' tracks)' if data.pipeline.taste_profile.exists else 'Not built' }}
<button class="action-btn action-btn-sm" onclick="rebuildTaste('default')">Rebuild</button>
</span> </span>
</div> </div>
{% endif %} {% endif %}
@@ -362,5 +622,214 @@
</div> </div>
<p class="timestamp">Updated {{ now }}</p> <p class="timestamp">Updated {{ now }}</p>
<div id="toast-container"></div>
<script>
const SELECTED_PROFILE = {{ selected_profile | tojson }};
const CURRENT_PROFILE = SELECTED_PROFILE || 'default';
async function apiCall(method, url, body) {
try {
const opts = { method, headers: {} };
if (body !== undefined) {
opts.headers['Content-Type'] = 'application/json';
opts.body = JSON.stringify(body);
}
const resp = await fetch(url, opts);
const data = await resp.json();
if (!resp.ok) {
return { ok: false, data, error: data.detail || data.error || JSON.stringify(data) };
}
return { ok: true, data };
} catch (e) {
return { ok: false, data: null, error: e.message };
}
}
function showToast(message, type) {
const container = document.getElementById('toast-container');
const toast = document.createElement('div');
toast.className = 'toast ' + (type || 'success');
toast.textContent = message;
container.appendChild(toast);
setTimeout(() => {
toast.style.animation = 'toast-out 0.2s ease-in forwards';
setTimeout(() => toast.remove(), 200);
}, 4000);
}
function toggleForm(id) {
const el = document.getElementById(id);
if (el) el.classList.toggle('open');
}
function setLoading(btn, loading) {
btn.disabled = loading;
if (loading) btn.dataset.origText = btn.textContent;
btn.textContent = loading ? '...' : (btn.dataset.origText || btn.textContent);
}
// --- Feedback ---
async function submitFeedback(btn, trackId, signal) {
btn.disabled = true;
const res = await apiCall('POST', '/api/profiles/' + CURRENT_PROFILE + '/feedback', {
track_id: trackId, signal: signal, vibe: 'general'
});
if (res.ok) {
btn.classList.add('sent');
showToast('Feedback recorded: ' + signal, 'success');
} else {
btn.disabled = false;
showToast('Feedback failed: ' + res.error, 'error');
}
}
async function retractFeedback(btn, eventId) {
btn.disabled = true;
const res = await apiCall('DELETE', '/api/feedback/' + eventId);
if (res.ok) {
const row = document.getElementById('feedback-' + eventId);
if (row) row.style.opacity = '0.3';
showToast('Feedback retracted', 'success');
} else {
btn.disabled = false;
showToast('Retract failed: ' + res.error, 'error');
}
}
// --- Profiles ---
async function createProfile() {
const name = document.getElementById('new-profile-name').value.trim();
const display = document.getElementById('new-profile-display').value.trim();
if (!name) { showToast('Profile name required', 'error'); return; }
const body = { name };
if (display) body.display_name = display;
const res = await apiCall('POST', '/api/profiles', body);
if (res.ok) {
showToast('Profile "' + name + '" created', 'success');
setTimeout(() => location.reload(), 1000);
} else {
showToast('Create failed: ' + res.error, 'error');
}
}
async function deleteProfile(name) {
if (!confirm('Delete profile "' + name + '"? This cannot be undone.')) return;
const res = await apiCall('DELETE', '/api/profiles/' + name);
if (res.ok) {
showToast('Profile "' + name + '" deleted', 'success');
const card = document.getElementById('profile-card-' + name);
if (card) card.style.opacity = '0.3';
setTimeout(() => location.reload(), 1000);
} else {
showToast('Delete failed: ' + res.error, 'error');
}
}
// --- Speaker mapping ---
// Initialize speaker select dropdowns to add tags on change
document.querySelectorAll('[id^="speaker-select-"]').forEach(sel => {
sel.addEventListener('change', function() {
if (!this.value) return;
const profileName = this.id.replace('speaker-select-', '');
const tagsEl = document.getElementById('speaker-tags-' + profileName);
// Check if already added
if (tagsEl.querySelector('[data-speaker="' + this.value + '"]')) {
this.value = '';
return;
}
const tag = document.createElement('span');
tag.className = 'speaker-tag';
tag.dataset.speaker = this.value;
tag.innerHTML = this.value + '<span class="remove-tag" onclick="removeSpeakerTag(\'' + profileName + '\', \'' + this.value + '\')">&times;</span>';
tagsEl.appendChild(tag);
this.value = '';
});
});
function removeSpeakerTag(profileName, speaker) {
const tagsEl = document.getElementById('speaker-tags-' + profileName);
const tag = tagsEl.querySelector('[data-speaker="' + speaker + '"]');
if (tag) tag.remove();
}
async function updateSpeakers(name) {
const tagsEl = document.getElementById('speaker-tags-' + name);
const speakers = Array.from(tagsEl.querySelectorAll('.speaker-tag')).map(t => t.dataset.speaker);
const res = await apiCall('PUT', '/api/profiles/' + name + '/speakers', { speakers });
if (res.ok) {
showToast('Speakers updated for "' + name + '"', 'success');
} else {
showToast('Update failed: ' + res.error, 'error');
}
}
// --- Admin actions ---
async function triggerDiscovery() {
const limit = parseInt(document.getElementById('discover-limit').value) || 50;
const btn = event.target;
setLoading(btn, true);
const res = await apiCall('POST', '/api/admin/discover', { limit });
setLoading(btn, false);
if (res.ok) {
showToast('Discovered ' + res.data.candidates_discovered + ' tracks from ' + res.data.tracks_expanded + ' seeds', 'success');
} else {
showToast('Discovery failed: ' + res.error, 'error');
}
}
async function rebuildTaste(profileName) {
const name = profileName || document.getElementById('rebuild-profile').value;
const btn = event.target;
setLoading(btn, true);
const res = await apiCall('POST', '/api/admin/build-taste-profile?profile=' + encodeURIComponent(name));
setLoading(btn, false);
if (res.ok && res.data.ok) {
showToast('Taste profile rebuilt for "' + name + '" (' + res.data.track_count + ' tracks)', 'success');
} else {
showToast('Rebuild failed: ' + (res.data?.error || res.error), 'error');
}
}
async function requeueFailed() {
const btn = event.target;
setLoading(btn, true);
const res = await apiCall('POST', '/api/admin/requeue-failed');
setLoading(btn, false);
if (res.ok) {
showToast('Requeued ' + res.data.requeued + ' failed tracks', 'success');
} else {
showToast('Requeue failed: ' + res.error, 'error');
}
}
// --- Playlist generation ---
async function generatePlaylist() {
const vibe = document.getElementById('pl-vibe').value.trim();
const total_tracks = parseInt(document.getElementById('pl-tracks').value) || 20;
const known_pct = parseInt(document.getElementById('pl-known').value);
const alpha = parseInt(document.getElementById('pl-alpha').value) / 100;
const speaker = document.getElementById('pl-speaker').value;
const profile = document.getElementById('pl-profile').value;
const auto_play = document.getElementById('pl-autoplay').checked;
const body = { total_tracks, known_pct, profile };
if (vibe) { body.vibe = vibe; body.alpha = alpha; }
if (speaker) { body.speaker_entity = speaker; body.auto_play = auto_play; }
const btn = event.target;
setLoading(btn, true);
const res = await apiCall('POST', '/api/playlists/generate', body);
setLoading(btn, false);
if (res.ok) {
let msg = 'Playlist "' + res.data.name + '" created (' + res.data.total_tracks + ' tracks)';
if (res.data.auto_played) msg += ' - playing now!';
showToast(msg, 'success');
} else {
showToast('Generate failed: ' + res.error, 'error');
}
}
</script>
</body> </body>
</html> </html>