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:
@@ -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}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"> — {{ listen.artist }}</span>
|
<span class="listen-artist"> — {{ listen.artist }}</span>
|
||||||
<div class="listen-meta">{{ listen.speaker }} · {{ listen.listened_at | timeago }}</div>
|
<div class="listen-meta">{{ listen.speaker }} · {{ 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">▲</button>
|
||||||
|
<button class="fb-btn fb-down" onclick="submitFeedback(this, {{ listen.track_id }}, 'down')" title="Thumbs down">▼</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 · {{ profile.track_count }} tracks
|
{{ profile.event_count }} events · {{ profile.track_count }} tracks
|
||||||
· last: {{ profile.last_listen | timeago }}
|
· 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 }}')">×</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' %}▲{% elif fb.signal == 'down' %}▼{% else %}▸{% endif %}</span>
|
<span class="signal-icon {{ fb.signal }}">{% if fb.signal == 'up' %}▲{% elif fb.signal == 'down' %}▼{% else %}▸{% endif %}</span>
|
||||||
<span class="feedback-track">{{ fb.title }} — {{ fb.artist }}</span>
|
<span class="feedback-track">{{ fb.title }} — {{ 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 }} · {{ fb.created_at | timeago }}</div>
|
<div class="feedback-meta">{{ fb.vibe_text }} · {{ fb.created_at | timeago }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
<button class="retract-btn" onclick="retractFeedback(this, {{ fb.id }})" title="Retract feedback">×</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 }} — {{ track.artist }}</div>
|
<div class="influence-track">{{ track.title }} — {{ 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">▲</button>
|
||||||
|
<button class="fb-btn fb-down" onclick="submitFeedback(this, {{ track.track_id }}, 'down')" title="Thumbs down">▼</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) · {{ taste.updated_at | timeago }}
|
Built ({{ taste.track_count }} tracks) · {{ 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 + '\')">×</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>
|
||||||
|
|||||||
Reference in New Issue
Block a user