Add playlists, credentials, and jobs pages; wire up dashboard

Vendored htmx.min.js (1.9.12) and alpine.min.js (3.14.1) into static/ --
self-hosted per the no-CDN/CSP requirement, included in base.html.

routers/playlists.py: list/add/remove playlists, a detail page showing
live per-playlist status via status_service (Spotify vs. beets vs. sldl
index vs. quarantine), edit form for active/cron_expr/no_m3u/notes, and a
"Run now" button wired to the jobs run-now endpoint.

routers/credentials.py: write-only credential forms per scope (a saved
secret is never sent back to the browser -- fields just show "(set)" and
leaving a field blank keeps its current value, matching
credential_service.set_credentials' partial-update semantics).

routers/jobs.py: maintenance job list with run-now/enable-disable, and a
recent-runs table that auto-refreshes via HTMX polling
(hx-trigger="every 5s") against a partial-only endpoint -- the one place
in the UI where avoiding a full-page reload actually matters, since job
status changes while the page is sitting open. Per-run log viewing.

Dashboard now shows real data (beets stats, playlist counts, recent job
runs) instead of the placeholder from Task 2.

Verified all 10 authenticated pages render successfully (200, no template
errors) via TestClient with require_auth overridden, including the
playlist detail page's graceful-degradation path when Spotify credentials
aren't configured yet (renders a "couldn't fetch live status" message
instead of a 500).
This commit is contained in:
andrew
2026-07-08 14:24:46 -06:00
parent 2075d6cf66
commit 5601de3d44
14 changed files with 421 additions and 8 deletions
+4 -1
View File
@@ -5,7 +5,7 @@ from fastapi.staticfiles import StaticFiles
from starlette.middleware.sessions import SessionMiddleware
from app.db import enable_beets_db_wal, init_db
from app.routers import auth, dashboard, dedup, genres, import_, library
from app.routers import auth, credentials, dashboard, dedup, genres, import_, jobs, library, playlists
from app.services import scheduler_service
from app.settings import settings
@@ -37,6 +37,9 @@ def create_app() -> FastAPI:
app.include_router(import_.router)
app.include_router(dedup.router)
app.include_router(genres.router)
app.include_router(playlists.router)
app.include_router(credentials.router)
app.include_router(jobs.router)
return app
+41
View File
@@ -0,0 +1,41 @@
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import RedirectResponse
from fastapi.templating import Jinja2Templates
from app.db import get_db
from app.security.deps import require_auth
from app.services import credential_service
router = APIRouter(prefix="/settings/credentials", tags=["credentials"])
templates = Jinja2Templates(directory="app/templates")
@router.get("")
async def credentials_index(request: Request, user: dict = Depends(require_auth), db=Depends(get_db)):
# Write-only by design: never send a previously-saved secret value back
# to the browser. Each scope just reports which fields are already set.
configured = {
scope: set(credential_service.get_scope(db, scope).keys())
for scope in credential_service.SCOPE_FIELDS
}
return templates.TemplateResponse(
request,
"credentials/index.html",
{"scope_fields": credential_service.SCOPE_FIELDS, "configured": configured},
)
@router.post("/{scope}")
async def save_credentials(
scope: str, request: Request, user: dict = Depends(require_auth), db=Depends(get_db)
):
if scope not in credential_service.SCOPE_FIELDS:
raise HTTPException(404, "unknown credential scope")
form = await request.form()
values = {
field: value
for field, value in form.items()
if field in credential_service.SCOPE_FIELDS[scope] and value != ""
}
credential_service.set_credentials(db, scope, values)
return RedirectResponse(url="/settings/credentials", status_code=303)
+17 -2
View File
@@ -1,16 +1,31 @@
from fastapi import APIRouter, Depends, Request
from fastapi.templating import Jinja2Templates
from sqlalchemy import select
from app.db import get_db
from app.models import JobRun
from app.security.deps import require_auth
from app.services import beets_service, playlist_service
router = APIRouter(tags=["dashboard"])
templates = Jinja2Templates(directory="app/templates")
@router.get("/")
async def dashboard(request: Request, user: dict = Depends(require_auth)):
async def dashboard(request: Request, user: dict = Depends(require_auth), db=Depends(get_db)):
stats = beets_service.stats()
playlists = playlist_service.list_all(db)
recent_runs = list(
db.execute(select(JobRun).order_by(JobRun.started_at.desc()).limit(10)).scalars()
)
return templates.TemplateResponse(
request,
"dashboard.html",
{"user": user},
{
"user": user,
"stats": stats,
"playlist_count": len(playlists),
"active_playlist_count": len([p for p in playlists if p.active]),
"recent_runs": recent_runs,
},
)
+82
View File
@@ -0,0 +1,82 @@
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import PlainTextResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy import select
from app.db import get_db
from app.models import JobRun, ScheduledJob
from app.security.deps import require_auth
from app.services import scheduler_service
router = APIRouter(prefix="/jobs", tags=["jobs"])
templates = Jinja2Templates(directory="app/templates")
def _job_list_context(db):
scheduler = scheduler_service.get_scheduler()
registered = {j.id: j for j in (scheduler.get_jobs() if scheduler else [])}
enabled_by_key = {
row.job_key: row.enabled for row in db.execute(select(ScheduledJob)).scalars()
}
maintenance_jobs = []
for job_key in scheduler_service.MAINTENANCE_JOBS:
job = registered.get(job_key)
maintenance_jobs.append(
{
"job_key": job_key,
"next_run": getattr(job, "next_run_time", None) if job else None,
"enabled": enabled_by_key.get(job_key, True),
}
)
recent_runs = list(
db.execute(select(JobRun).order_by(JobRun.started_at.desc()).limit(50)).scalars()
)
return {"maintenance_jobs": maintenance_jobs, "recent_runs": recent_runs}
@router.get("")
async def jobs_index(request: Request, user: dict = Depends(require_auth), db=Depends(get_db)):
return templates.TemplateResponse(request, "jobs/index.html", _job_list_context(db))
@router.get("/_runs_table")
async def runs_table_partial(request: Request, user: dict = Depends(require_auth), db=Depends(get_db)):
"""HTMX polling target: just the recent-runs table body, so the jobs
page can auto-refresh run status without a full page reload."""
return templates.TemplateResponse(request, "jobs/_runs_table.html", _job_list_context(db))
@router.post("/{job_key:path}/run")
async def run_now(job_key: str, user: dict = Depends(require_auth)):
scheduler = scheduler_service.get_scheduler()
if scheduler is None:
raise HTTPException(503, "scheduler not running")
try:
await scheduler_service.trigger_now(scheduler, job_key)
except ValueError as exc:
raise HTTPException(404, str(exc))
return RedirectResponse(url="/jobs", status_code=303)
@router.post("/{job_key:path}/toggle")
async def toggle_enabled(job_key: str, user: dict = Depends(require_auth), db=Depends(get_db)):
scheduler = scheduler_service.get_scheduler()
if scheduler is None:
raise HTTPException(503, "scheduler not running")
row = db.execute(select(ScheduledJob).where(ScheduledJob.job_key == job_key)).scalar_one_or_none()
currently_enabled = row.enabled if row else True
scheduler_service.set_maintenance_enabled(scheduler, job_key, not currently_enabled)
return RedirectResponse(url="/jobs", status_code=303)
@router.get("/runs/{run_id}/log", response_class=PlainTextResponse)
async def view_log(run_id: int, user: dict = Depends(require_auth), db=Depends(get_db)):
run = db.get(JobRun, run_id)
if run is None or not run.log_path:
raise HTTPException(404, "no such run/log")
from pathlib import Path
path = Path(run.log_path)
if not path.exists():
raise HTTPException(404, "log file no longer exists")
return path.read_text(errors="replace")
+80
View File
@@ -0,0 +1,80 @@
from fastapi import APIRouter, Depends, Form, HTTPException, Request
from fastapi.responses import RedirectResponse
from fastapi.templating import Jinja2Templates
from app.db import get_db
from app.security.deps import require_auth
from app.services import playlist_service, status_service
router = APIRouter(prefix="/playlists", tags=["playlists"])
templates = Jinja2Templates(directory="app/templates")
@router.get("")
async def playlists_index(request: Request, user: dict = Depends(require_auth), db=Depends(get_db)):
playlists = playlist_service.list_all(db)
return templates.TemplateResponse(request, "playlists/index.html", {"playlists": playlists})
@router.post("")
async def create_playlist(
name: str = Form(...),
spotify_url: str = Form(...),
cron_expr: str = Form(""),
no_m3u: bool = Form(False),
user: dict = Depends(require_auth),
db=Depends(get_db),
):
playlist_service.create(
db, name=name, spotify_url=spotify_url, cron_expr=cron_expr or None, no_m3u=no_m3u
)
return RedirectResponse(url="/playlists", status_code=303)
@router.get("/{playlist_id}")
async def playlist_detail(
request: Request, playlist_id: int, user: dict = Depends(require_auth), db=Depends(get_db)
):
playlist = playlist_service.get(db, playlist_id)
if playlist is None:
raise HTTPException(404, "no such playlist")
status = None
error = None
try:
status = await status_service.playlist_status(db, playlist.name, playlist.spotify_url)
except Exception as exc:
error = str(exc)
return templates.TemplateResponse(
request,
"playlists/detail.html",
{"playlist": playlist, "status": status, "error": error},
)
@router.post("/{playlist_id}")
async def update_playlist(
playlist_id: int,
active: bool = Form(False),
cron_expr: str = Form(""),
no_m3u: bool = Form(False),
notes: str = Form(""),
user: dict = Depends(require_auth),
db=Depends(get_db),
):
playlist_service.update(
db,
playlist_id,
active=active,
cron_expr=cron_expr or None,
no_m3u=no_m3u,
notes=notes or None,
)
return RedirectResponse(url=f"/playlists/{playlist_id}", status_code=303)
@router.post("/{playlist_id}/delete")
async def delete_playlist(playlist_id: int, user: dict = Depends(require_auth), db=Depends(get_db)):
playlist_service.delete(db, playlist_id)
return RedirectResponse(url="/playlists", status_code=303)
+5
View File
File diff suppressed because one or more lines are too long
+1
View File
File diff suppressed because one or more lines are too long
+2
View File
@@ -5,6 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}alembic{% endblock %}</title>
<link rel="stylesheet" href="/static/style.css">
<script src="/static/htmx.min.js"></script>
<script src="/static/alpine.min.js" defer></script>
</head>
<body>
<header class="topbar">
+29
View File
@@ -0,0 +1,29 @@
{% extends "base.html" %}
{% block title %}Credentials — alembic{% endblock %}
{% block content %}
<h1>Credentials</h1>
<p class="muted">
Write-only: saved values are encrypted at rest and never sent back to the
browser. Leave a field blank to keep its current value unchanged.
</p>
{% for scope, fields in scope_fields.items() %}
<h2>{{ scope }}</h2>
<form method="post" action="/settings/credentials/{{ scope }}">
{% for field in fields %}
<div>
<label for="{{ scope }}_{{ field }}">
{{ field }}
{% if field in configured.get(scope, []) %}<span class="muted">(set)</span>{% endif %}
</label>
{% if field in ("cookies_txt",) %}
<textarea id="{{ scope }}_{{ field }}" name="{{ field }}" placeholder="leave blank to keep current"></textarea>
{% else %}
<input type="password" id="{{ scope }}_{{ field }}" name="{{ field }}" placeholder="leave blank to keep current" autocomplete="off">
{% endif %}
</div>
{% endfor %}
<button type="submit">Save {{ scope }}</button>
</form>
{% endfor %}
{% endblock %}
+25 -5
View File
@@ -2,9 +2,29 @@
{% block title %}Dashboard — alembic{% endblock %}
{% block content %}
<h1>Dashboard</h1>
<p class="muted">
Logged in as {{ user.name or user.email }}. Playlist status, job history,
and pipeline health will render here once the corresponding services are
built (status_service, scheduler_service).
</p>
<p class="muted">Logged in as {{ user.name or user.email }}.</p>
<h2>At a glance</h2>
<ul>
<li>{{ stats.total_tracks }} tracks in the beets library{% if not stats.db_exists %} <span class="muted">(beets DB not found)</span>{% endif %}</li>
<li>{{ active_playlist_count }} / {{ playlist_count }} playlists active — <a href="/playlists">manage</a></li>
</ul>
<h2>Recent job runs</h2>
<table>
<thead><tr><th>Job</th><th>Status</th><th>Triggered by</th><th>Summary</th></tr></thead>
<tbody>
{% for run in recent_runs %}
<tr>
<td>{{ run.job_key }}</td>
<td>{{ run.status }}</td>
<td class="muted">{{ run.triggered_by }}</td>
<td>{{ run.summary or "" }}</td>
</tr>
{% else %}
<tr><td colspan="4" class="muted">No job runs yet.</td></tr>
{% endfor %}
</tbody>
</table>
<p><a href="/jobs">See all jobs &rarr;</a></p>
{% endblock %}
+11
View File
@@ -0,0 +1,11 @@
{% for run in recent_runs %}
<tr>
<td>{{ run.job_key }}</td>
<td>{{ run.status }}</td>
<td class="muted">{{ run.triggered_by }}</td>
<td>{{ run.summary or "" }}</td>
<td>{% if run.log_path %}<a href="/jobs/runs/{{ run.id }}/log" target="_blank">log</a>{% endif %}</td>
</tr>
{% else %}
<tr><td colspan="5" class="muted">No job runs yet.</td></tr>
{% endfor %}
+36
View File
@@ -0,0 +1,36 @@
{% extends "base.html" %}
{% block title %}Jobs — alembic{% endblock %}
{% block content %}
<h1>Jobs</h1>
<h2>Maintenance jobs</h2>
<table>
<thead><tr><th>Job</th><th>Status</th><th>Next run</th><th></th></tr></thead>
<tbody>
{% for job in maintenance_jobs %}
<tr>
<td>{{ job.job_key }}</td>
<td>{{ "enabled" if job.enabled else "disabled" }}</td>
<td class="muted">{{ job.next_run or "-" }}</td>
<td>
<form method="post" action="/jobs/{{ job.job_key }}/run" style="display:inline">
<button type="submit">Run now</button>
</form>
<form method="post" action="/jobs/{{ job.job_key }}/toggle" style="display:inline">
<button type="submit">{{ "disable" if job.enabled else "enable" }}</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<p class="muted">Playlist syncs are triggered from each playlist's own page.</p>
<h2>Recent runs</h2>
<table>
<thead><tr><th>Job</th><th>Status</th><th>Triggered by</th><th>Summary</th><th></th></tr></thead>
<tbody id="runs-tbody" hx-get="/jobs/_runs_table" hx-trigger="every 5s" hx-swap="innerHTML">
{% include "jobs/_runs_table.html" %}
</tbody>
</table>
{% endblock %}
+44
View File
@@ -0,0 +1,44 @@
{% extends "base.html" %}
{% block title %}{{ playlist.name }} — alembic{% endblock %}
{% block content %}
<p><a href="/playlists">&larr; back to playlists</a></p>
<h1>{{ playlist.name }}</h1>
<p class="muted"><a href="{{ playlist.spotify_url }}">{{ playlist.spotify_url }}</a></p>
<form method="post" action="/playlists/{{ playlist.id }}">
<label><input type="checkbox" name="active" value="true" {{ "checked" if playlist.active }}> active</label>
<label for="cron_expr">Cron schedule</label>
<input type="text" id="cron_expr" name="cron_expr" value="{{ playlist.cron_expr or '' }}" placeholder="0 1 * * *">
<label><input type="checkbox" name="no_m3u" value="true" {{ "checked" if playlist.no_m3u }}> skip M3U</label>
<label for="notes">Notes</label>
<textarea id="notes" name="notes">{{ playlist.notes or '' }}</textarea>
<button type="submit">Save</button>
</form>
<form method="post" action="/jobs/playlist:{{ playlist.name }}/run" style="display:inline">
<button type="submit">Run now</button>
</form>
<h2>Status</h2>
{% if error %}
<p class="muted">Couldn't fetch live status: {{ error }}</p>
{% elif status %}
<p>
{% for key, count in status.counts.items() %}
<strong>{{ count }}</strong> {{ key }} &nbsp;
{% endfor %}
</p>
<table>
<thead><tr><th>Artist</th><th>Title</th><th>Status</th></tr></thead>
<tbody>
{% for t in status.tracks %}
<tr>
<td>{{ t.artist }}</td>
<td>{{ t.title }}</td>
<td>{{ t.status }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% endblock %}
+44
View File
@@ -0,0 +1,44 @@
{% extends "base.html" %}
{% block title %}Playlists — alembic{% endblock %}
{% block content %}
<h1>Playlists</h1>
<table>
<thead>
<tr><th>Name</th><th>Active</th><th>Cron</th><th>No M3U</th><th></th></tr>
</thead>
<tbody>
{% for p in playlists %}
<tr>
<td><a href="/playlists/{{ p.id }}">{{ p.name }}</a></td>
<td>{{ "yes" if p.active else "no" }}</td>
<td class="muted">{{ p.cron_expr or "(unscheduled)" }}</td>
<td>{{ "yes" if p.no_m3u else "no" }}</td>
<td>
<form method="post" action="/playlists/{{ p.id }}/delete" style="display:inline"
onsubmit="return confirm('Remove {{ p.name }}? This deletes its rendered config but not any downloaded music.')">
<button type="submit">remove</button>
</form>
</td>
</tr>
{% else %}
<tr><td colspan="5" class="muted">No playlists yet.</td></tr>
{% endfor %}
</tbody>
</table>
<h2>Add a playlist</h2>
<form method="post" action="/playlists">
<label for="name">Name (used as the beets grouping tag)</label>
<input type="text" id="name" name="name" required pattern="[a-z0-9_-]+">
<label for="spotify_url">Spotify playlist URL</label>
<input type="text" id="spotify_url" name="spotify_url" required>
<label for="cron_expr">Cron schedule (5-field, blank = unscheduled/manual-only)</label>
<input type="text" id="cron_expr" name="cron_expr" placeholder="0 1 * * *">
<label><input type="checkbox" name="no_m3u" value="true"> skip M3U generation (e.g. "liked songs")</label>
<button type="submit">Add playlist</button>
</form>
{% endblock %}