Dashboard detail, library filtering/pagination, simplified playlist times,

job descriptions

Dashboard: format breakdown (FLAC/MP3/etc. counts), approximate library
size (bitrate*length/8, same approximation `beet stats` itself uses --
verified to match its "100.5 GiB" output exactly against the real
library), and a credential auth-state summary per scope (configured y/n
plus, for Bandcamp, cookie expiry parsed locally from the stored cookie
jar -- deliberately not a live network probe like pipeline-status.sh's
Qobuz check, since this renders on every dashboard load).

Library: was one unfiltered page dumping all 4072 tracks. Added search
(artist/title/album), playlist and format filter dropdowns, and real
SQL-level pagination (LIMIT/OFFSET, not fetch-everything-then-slice).
beets_service gained count_items()/distinct_formats() to support this.

Playlists: cron_expr is still the stored/scheduled representation, but the
UI now shows and edits a plain daily time picker instead of raw cron
syntax -- every playlist schedule today is a simple daily HH:MM anyway.
playlist_service.cron_to_time()/time_to_cron() convert at the router
boundary; verified round-trip against all real playlist cron values.

Jobs: each maintenance job now shows a short one-line description of what
it actually does (MAINTENANCE_JOB_DESCRIPTIONS), and "next run" is
formatted consistently with playlists' time style (HH:MM, with a day
qualifier for non-today runs -- maintenance jobs can be weekly/monthly,
unlike playlists' plain daily schedule).

Verified end-to-end against the real production data (4072 tracks, 100.5
GiB, real credentials): stats/format-breakdown/search/pagination all
correct, all 7 credential scopes report configured with Bandcamp's real
cookie expiry (325 days), and a full authenticated page-render sweep
(dashboard, library with every filter combination, playlist detail,
jobs) all returned 200 with no template errors.
This commit is contained in:
andrew
2026-07-08 15:39:22 -06:00
parent ee21251f3e
commit c87ddc2649
13 changed files with 355 additions and 34 deletions
+12 -1
View File
@@ -5,12 +5,21 @@ 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
from app.services import beets_service, credential_service, playlist_service
router = APIRouter(tags=["dashboard"])
templates = Jinja2Templates(directory="app/templates")
def _human_bytes(n: int) -> str:
size = float(n)
for unit in ("B", "KB", "MB", "GB", "TB"):
if size < 1024 or unit == "TB":
return f"{size:.1f} {unit}"
size /= 1024
return f"{size:.1f} TB"
@router.get("/")
async def dashboard(request: Request, user: dict = Depends(require_auth), db=Depends(get_db)):
stats = beets_service.stats()
@@ -24,8 +33,10 @@ async def dashboard(request: Request, user: dict = Depends(require_auth), db=Dep
{
"user": user,
"stats": stats,
"total_size_human": _human_bytes(stats.get("total_bytes", 0)),
"playlist_count": len(playlists),
"active_playlist_count": len([p for p in playlists if p.active]),
"recent_runs": recent_runs,
"auth_states": credential_service.auth_states(db),
},
)
+16 -1
View File
@@ -12,6 +12,19 @@ router = APIRouter(prefix="/jobs", tags=["jobs"])
templates = Jinja2Templates(directory="app/templates")
def _format_next_run(dt) -> str:
"""Same HH:MM (24h, zero-padded) convention the playlists' simplified
time field uses, with a short day qualifier when it's not today --
maintenance jobs can be weekly/monthly, so the date can't just be
dropped the way it is for playlists' plain daily time."""
if dt is None:
return "-"
now = dt.__class__.now(dt.tzinfo)
if dt.date() == now.date():
return dt.strftime("%H:%M")
return dt.strftime("%a %H:%M")
def _job_list_context(db):
scheduler = scheduler_service.get_scheduler()
registered = {j.id: j for j in (scheduler.get_jobs() if scheduler else [])}
@@ -21,10 +34,12 @@ def _job_list_context(db):
maintenance_jobs = []
for job_key in scheduler_service.MAINTENANCE_JOBS:
job = registered.get(job_key)
next_run = getattr(job, "next_run_time", None) if job else None
maintenance_jobs.append(
{
"job_key": job_key,
"next_run": getattr(job, "next_run_time", None) if job else None,
"description": scheduler_service.MAINTENANCE_JOB_DESCRIPTIONS.get(job_key, ""),
"next_run": _format_next_run(next_run),
"enabled": enabled_by_key.get(job_key, True),
}
)
+31 -3
View File
@@ -10,11 +10,39 @@ router = APIRouter(prefix="/library", tags=["library"])
templates = Jinja2Templates(directory="app/templates")
PAGE_SIZE = 50
@router.get("")
async def library_index(request: Request, grouping: str | None = None, user: dict = Depends(require_auth)):
items = beets_service.query_items(grouping=grouping)
async def library_index(
request: Request,
grouping: str | None = None,
format: str | None = None,
q: str | None = None,
page: int = 1,
user: dict = Depends(require_auth),
):
page = max(1, page)
offset = (page - 1) * PAGE_SIZE
items = beets_service.query_items(grouping=grouping, search=q, format=format, limit=PAGE_SIZE, offset=offset)
total = beets_service.count_items(grouping=grouping, search=q, format=format)
total_pages = max(1, (total + PAGE_SIZE - 1) // PAGE_SIZE)
stats = beets_service.stats()
return templates.TemplateResponse(
request, "library/index.html", {"items": items, "grouping": grouping}
request,
"library/index.html",
{
"items": items,
"grouping": grouping or "",
"format": format or "",
"q": q or "",
"page": page,
"total_pages": total_pages,
"total": total,
"all_formats": beets_service.distinct_formats(),
"all_groupings": stats["groupings"],
},
)
+18 -6
View File
@@ -13,20 +13,27 @@ 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})
sync_times = {p.id: playlist_service.cron_to_time(p.cron_expr) for p in playlists}
return templates.TemplateResponse(
request, "playlists/index.html", {"playlists": playlists, "sync_times": sync_times}
)
@router.post("")
async def create_playlist(
name: str = Form(...),
spotify_url: str = Form(...),
cron_expr: str = Form(""),
sync_time: 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
db,
name=name,
spotify_url=spotify_url,
cron_expr=playlist_service.time_to_cron(sync_time),
no_m3u=no_m3u,
)
return RedirectResponse(url="/playlists", status_code=303)
@@ -49,7 +56,12 @@ async def playlist_detail(
return templates.TemplateResponse(
request,
"playlists/detail.html",
{"playlist": playlist, "status": status, "error": error},
{
"playlist": playlist,
"sync_time": playlist_service.cron_to_time(playlist.cron_expr),
"status": status,
"error": error,
},
)
@@ -57,7 +69,7 @@ async def playlist_detail(
async def update_playlist(
playlist_id: int,
active: bool = Form(False),
cron_expr: str = Form(""),
sync_time: str = Form(""),
no_m3u: bool = Form(False),
notes: str = Form(""),
user: dict = Depends(require_auth),
@@ -67,7 +79,7 @@ async def update_playlist(
db,
playlist_id,
active=active,
cron_expr=cron_expr or None,
cron_expr=playlist_service.time_to_cron(sync_time),
no_m3u=no_m3u,
notes=notes or None,
)
+98 -11
View File
@@ -34,22 +34,74 @@ def _row_to_dict(row: sqlite3.Row) -> dict:
return d
def query_items(grouping: str | None = None) -> list[dict]:
"""All items, optionally filtered to one playlist's grouping tag."""
def query_items(
grouping: str | None = None,
search: str | None = None,
format: str | None = None,
limit: int | None = None,
offset: int = 0,
) -> list[dict]:
"""Items, optionally filtered by playlist grouping, a free-text search
over artist/title/album, and/or exact format match. limit/offset give
SQL-level pagination -- the library has thousands of tracks, so this
must not be a fetch-everything-then-slice-in-Python operation."""
if not db_exists():
return []
cols = ", ".join(_ITEM_COLUMNS)
where = []
params: list = []
if grouping is not None:
where.append("grouping = ?")
params.append(grouping)
if format is not None:
where.append("format = ?")
params.append(format)
if search:
where.append("(artist LIKE ? OR title LIKE ? OR albumartist LIKE ?)")
like = f"%{search}%"
params.extend([like, like, like])
where_sql = f"WHERE {' AND '.join(where)}" if where else ""
limit_sql = ""
if limit is not None:
limit_sql = "LIMIT ? OFFSET ?"
params.extend([limit, offset])
conn = _connect()
try:
if grouping is not None:
cur = conn.execute(f"SELECT {cols} FROM items WHERE grouping = ?", (grouping,))
else:
cur = conn.execute(f"SELECT {cols} FROM items")
cur = conn.execute(
f"SELECT {cols} FROM items {where_sql} ORDER BY artist, album, track {limit_sql}",
params,
)
return [_row_to_dict(row) for row in cur.fetchall()]
finally:
conn.close()
def count_items(grouping: str | None = None, search: str | None = None, format: str | None = None) -> int:
"""Matching row count for query_items()'s filters -- for pagination."""
if not db_exists():
return 0
where = []
params: list = []
if grouping is not None:
where.append("grouping = ?")
params.append(grouping)
if format is not None:
where.append("format = ?")
params.append(format)
if search:
where.append("(artist LIKE ? OR title LIKE ? OR albumartist LIKE ?)")
like = f"%{search}%"
params.extend([like, like, like])
where_sql = f"WHERE {' AND '.join(where)}" if where else ""
conn = _connect()
try:
return conn.execute(f"SELECT COUNT(*) FROM items {where_sql}", params).fetchone()[0]
finally:
conn.close()
def get_item(item_id: int) -> dict | None:
if not db_exists():
return None
@@ -63,20 +115,55 @@ def get_item(item_id: int) -> dict | None:
def stats() -> dict:
"""Cheap summary for the dashboard and for migration-verification
(compare against `beet stats` during Stage 0 cutover)."""
"""Summary for the dashboard: total tracks, format breakdown, storage
estimate (bitrate*length/8, same approximation `beet stats` itself
uses -- not a filesystem stat() pass over every file), and groupings."""
if not db_exists():
return {"db_exists": False, "total_tracks": 0, "groupings": []}
return {
"db_exists": False,
"total_tracks": 0,
"groupings": [],
"formats": [],
"total_bytes": 0,
}
conn = _connect()
try:
total = conn.execute("SELECT COUNT(*) FROM items").fetchone()[0]
rows = conn.execute(
grouping_rows = conn.execute(
"SELECT DISTINCT grouping FROM items WHERE grouping IS NOT NULL AND grouping != ''"
).fetchall()
format_rows = conn.execute(
"SELECT format, COUNT(*) as n FROM items "
"WHERE format IS NOT NULL AND format != '' "
"GROUP BY format ORDER BY n DESC"
).fetchall()
total_bytes = conn.execute(
"SELECT SUM(length * bitrate) / 8 FROM items WHERE length IS NOT NULL AND bitrate IS NOT NULL"
).fetchone()[0] or 0
return {
"db_exists": True,
"total_tracks": total,
"groupings": sorted(r[0] for r in rows),
"groupings": sorted(r[0] for r in grouping_rows),
"formats": [{"format": r["format"], "count": r["n"]} for r in format_rows],
"total_bytes": int(total_bytes),
}
finally:
conn.close()
def distinct_formats() -> list[str]:
"""For the library page's format filter dropdown."""
if not db_exists():
return []
conn = _connect()
try:
rows = conn.execute(
"SELECT DISTINCT format FROM items WHERE format IS NOT NULL AND format != '' ORDER BY format"
).fetchall()
return [r[0] for r in rows]
finally:
conn.close()
+36
View File
@@ -182,3 +182,39 @@ def render_scope(db: Session, scope: str) -> None:
else:
raise ValueError(f"unknown credential scope: {scope}")
def _bandcamp_cookie_expiry(cookies_txt: str) -> float | None:
"""Parse the Netscape-format cookie jar for the 'identity' cookie's
expiry (Unix timestamp) -- same field pipeline-status.sh already reads
via `awk -F'\\t' '$6=="identity" {print $5}'`. Purely local string
parsing, no network call, safe to run on every dashboard load."""
for line in cookies_txt.splitlines():
fields = line.split("\t")
if len(fields) >= 6 and fields[5] == "identity":
try:
return float(fields[4])
except ValueError:
return None
return None
def auth_states(db: Session) -> list[dict]:
"""Per-scope credential health for the dashboard: whether it's
configured at all, plus (bandcamp only) cookie expiry -- computed
locally from the stored cookie jar, not a live network probe (unlike
pipeline-status.sh's Qobuz check, which hits Qobuz's API on every run;
deliberately not replicated here since the dashboard renders on every
page load)."""
states = []
for scope in SCOPE_FIELDS:
values = get_scope(db, scope)
configured = bool(values)
entry = {"scope": scope, "configured": configured}
if scope == "bandcamp" and values.get("cookies_txt"):
expiry = _bandcamp_cookie_expiry(values["cookies_txt"])
if expiry is not None:
entry["expires_at"] = expiry
entry["days_left"] = int((expiry - time.time()) / 86400)
states.append(entry)
return states
+33
View File
@@ -8,6 +8,39 @@ from sqlalchemy.orm import Session
from app.models import Playlist
from app.settings import settings
def cron_to_time(cron_expr: str | None) -> str:
"""'30 1 * * *' -> '01:30'. Every playlist schedule today is a simple
daily HH:MM (no day-of-week/day-of-month complexity -- that's only used
by a couple of maintenance jobs), so the UI can offer a plain time
picker instead of raw cron syntax. Returns '' for anything else
(unscheduled, or a cron expression more complex than daily-at-HH:MM)."""
if not cron_expr:
return ""
parts = cron_expr.split()
if len(parts) == 5 and parts[2] == "*" and parts[3] == "*" and parts[4] == "*":
try:
hour, minute = int(parts[1]), int(parts[0])
if 0 <= hour < 24 and 0 <= minute < 60:
return f"{hour:02d}:{minute:02d}"
except ValueError:
pass
return ""
def time_to_cron(time_str: str | None) -> str | None:
"""'01:30' -> '30 1 * * *'. Empty/invalid input means unscheduled."""
if not time_str:
return None
try:
hour_str, minute_str = time_str.split(":")
hour, minute = int(hour_str), int(minute_str)
except ValueError:
return None
if not (0 <= hour < 24 and 0 <= minute < 60):
return None
return f"{minute} {hour} * * *"
# The 17-entry array this project is migrating off of (was hardcoded in
# /opt/sldl/configs/regen.sh). Used once by seed_legacy() during Stage 1/2
# of the migration; the DB is the source of truth from then on. cron_expr
+25
View File
@@ -153,6 +153,31 @@ MAINTENANCE_JOBS: dict[str, tuple[dict, callable]] = {
),
}
# One short line per job for the /jobs page -- what it actually does, not
# just its key. Kept separate from MAINTENANCE_JOBS so the schedule/function
# mapping above stays easy to scan.
MAINTENANCE_JOB_DESCRIPTIONS: dict[str, str] = {
"maintenance:sync_bandcamp": "Pull new Bandcamp purchases and import them.",
"maintenance:normalize_casing": "Fix artist-name casing to match your canonical list.",
"maintenance:dedup": "Scan for duplicate tracks (dry-run only; confirm deletes in Dedup).",
"maintenance:gen_djmix_playlist": "Rebuild the DJ-mix M3U from configured albums.",
"maintenance:gen_vgm_playlist": "Rebuild the video-game-soundtrack M3U.",
"maintenance:navidrome_scan": "Trigger a Navidrome library rescan (share-health gated).",
"maintenance:export_laptop_playlists": "Export dj-* Navidrome playlists for the laptop.",
"maintenance:enrich_buy_url": "Look up buy links for tracks without one yet.",
"maintenance:build_fingerprint_index": "Refresh the acoustic fingerprint index.",
"maintenance:pipeline_status_report": "Write the daily health snapshot / Telegram digest.",
"maintenance:log_rotation": "Delete job logs older than 30 days.",
"maintenance:strip_mb_tags": "Strip stale MusicBrainz tags that fragment Navidrome albums.",
"maintenance:strip_watermark_art": "Remove Soulseek-uploader watermark cover art.",
"maintenance:scrub_watermark_text": "Remove Soulseek-uploader watermark text frames.",
"maintenance:clean_sldl_index": "Prune stale entries from each playlist's download index.",
"maintenance:clear_bad_genres": "Blank malformed/junk GENRE tags before Spotify refill.",
"maintenance:spotify_genre": "Refill GENRE tags from Spotify per-artist data.",
"maintenance:beets_update_sync": "Sync the beets DB to on-disk tag changes.",
"maintenance:upgrade_mp3_to_flac": "Look for FLAC replacements for MP3 tracks (monthly).",
}
# Module-level singleton so other services (playlist_service, future
# routers) can reach the live scheduler without main.py threading it through
+30
View File
@@ -24,8 +24,38 @@
<div class="stat-value">{{ stats.groupings | length }}</div>
<div class="stat-label">Distinct groupings</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ total_size_human }}</div>
<div class="stat-label">Approx. library size</div>
</div>
</div>
<h2>File formats</h2>
{% if stats.formats %}
<ul class="chip-list">
{% for f in stats.formats %}
<li>{{ f.format }} <span class="muted">&times; {{ f.count }}</span></li>
{% endfor %}
</ul>
{% else %}
<p class="muted">No format data yet.</p>
{% endif %}
<h2>Credential status</h2>
<ul class="chip-list">
{% for a in auth_states %}
<li>
{% if a.configured %}<span class="badge badge-success">set</span>{% else %}<span class="badge badge-muted">unset</span>{% endif %}
{{ a.scope }}
{% if a.days_left is defined %}
{% if a.days_left < 14 %}<span class="badge badge-warning">{{ a.days_left }}d left</span>
{% else %}<span class="muted">({{ a.days_left }}d left)</span>{% endif %}
{% endif %}
</li>
{% endfor %}
</ul>
<p><a href="/settings/credentials">Manage credentials &rarr;</a></p>
<h2>Recent job runs</h2>
<div class="table-wrap scroll">
<table>
+5 -2
View File
@@ -15,9 +15,12 @@
<tbody>
{% for job in maintenance_jobs %}
<tr>
<td class="mono">{{ job.job_key }}</td>
<td>
<div class="mono">{{ job.job_key }}</div>
{% if job.description %}<div class="muted" style="font-size:0.8em; margin-top:0.15rem;" title="{{ job.description }}">{{ job.description }}</div>{% endif %}
</td>
<td>{% if job.enabled %}<span class="badge badge-success">enabled</span>{% else %}<span class="badge badge-muted">disabled</span>{% endif %}</td>
<td class="muted">{{ job.next_run or "—" }}</td>
<td class="muted mono">{{ job.next_run }}</td>
<td class="actions-cell">
<form method="post" action="/jobs/{{ job.job_key }}/run" class="inline">
<button type="submit" class="btn btn-sm">Run now</button>
+43 -3
View File
@@ -3,11 +3,38 @@
{% block content %}
<div class="page-header">
<div>
<h1>Library{% if grouping %} <span class="muted">— {{ grouping }}</span>{% endif %}</h1>
<p class="subtitle muted">Tracks currently imported into beets.</p>
<h1>Library</h1>
<p class="subtitle muted">{{ total }} track{{ "s" if total != 1 else "" }}{% if grouping or format or q %} matching the filters below{% endif %}.</p>
</div>
</div>
<form method="get" action="/library" class="panel" style="display:flex; gap:0.75rem; flex-wrap:wrap; align-items:flex-end; max-width:none;">
<div class="field">
<label for="q">Search</label>
<input type="text" id="q" name="q" value="{{ q }}" placeholder="artist, title, album...">
</div>
<div class="field">
<label for="grouping">Playlist</label>
<select id="grouping" name="grouping">
<option value="">(any)</option>
{% for g in all_groupings %}
<option value="{{ g }}" {{ "selected" if g == grouping }}>{{ g }}</option>
{% endfor %}
</select>
</div>
<div class="field">
<label for="format">Format</label>
<select id="format" name="format">
<option value="">(any)</option>
{% for f in all_formats %}
<option value="{{ f }}" {{ "selected" if f == format }}>{{ f }}</option>
{% endfor %}
</select>
</div>
<button type="submit" class="btn btn-primary">Filter</button>
{% if grouping or format or q %}<a class="btn btn-ghost" href="/library">Clear</a>{% endif %}
</form>
<div class="table-wrap scroll">
<table>
<thead>
@@ -26,11 +53,24 @@
<tr class="empty-row"><td colspan="5">
<div class="empty-state">
<span class="sparkle" style="position:relative; display:inline-block; margin-bottom:0.5rem;"></span>
<p class="muted" style="margin:0;">No tracks found{% if grouping %} for grouping "{{ grouping }}"{% endif %}.</p>
<p class="muted" style="margin:0;">No tracks match{% if grouping or format or q %} these filters{% endif %}.</p>
</div>
</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% if total_pages > 1 %}
{% set qs = "&grouping=" + grouping + "&format=" + format + "&q=" + q %}
<div class="pagination" style="display:flex; gap:0.5rem; align-items:center; margin-top:1rem;">
{% if page > 1 %}
<a class="btn btn-sm" href="/library?page={{ page - 1 }}{{ qs }}">&larr; Prev</a>
{% endif %}
<span class="muted">Page {{ page }} of {{ total_pages }}</span>
{% if page < total_pages %}
<a class="btn btn-sm" href="/library?page={{ page + 1 }}{{ qs }}">Next &rarr;</a>
{% endif %}
</div>
{% endif %}
{% endblock %}
+3 -2
View File
@@ -19,8 +19,9 @@
<form method="post" action="/playlists/{{ playlist.id }}">
<label class="checkbox-field"><input type="checkbox" name="active" value="true" {{ "checked" if playlist.active }}> Active</label>
<div class="field">
<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 for="sync_time">Sync time (daily)</label>
<input type="time" id="sync_time" name="sync_time" value="{{ sync_time }}">
<span class="hint">Leave blank for unscheduled/manual-only.</span>
</div>
<label class="checkbox-field"><input type="checkbox" name="no_m3u" value="true" {{ "checked" if playlist.no_m3u }}> Skip M3U</label>
<div class="field wide">
+5 -5
View File
@@ -11,14 +11,14 @@
<div class="table-wrap scroll">
<table>
<thead>
<tr><th>Name</th><th>Active</th><th>Cron</th><th>No M3U</th><th></th></tr>
<tr><th>Name</th><th>Active</th><th>Sync time</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>{% if p.active %}<span class="badge badge-success">active</span>{% else %}<span class="badge badge-muted">paused</span>{% endif %}</td>
<td class="muted mono">{{ p.cron_expr or "unscheduled" }}</td>
<td class="muted mono">{{ sync_times.get(p.id) or "unscheduled" }}</td>
<td class="muted">{{ "yes" if p.no_m3u else "no" }}</td>
<td class="actions-cell">
<form method="post" action="/playlists/{{ p.id }}/delete" class="inline"
@@ -51,9 +51,9 @@
<input type="text" id="spotify_url" name="spotify_url" required placeholder="https://open.spotify.com/playlist/...">
</div>
<div class="field">
<label for="cron_expr">Cron schedule</label>
<input type="text" id="cron_expr" name="cron_expr" placeholder="0 1 * * *">
<span class="hint">5-field cron. Leave blank for unscheduled/manual-only.</span>
<label for="sync_time">Sync time (daily)</label>
<input type="time" id="sync_time" name="sync_time">
<span class="hint">Leave blank for unscheduled/manual-only.</span>
</div>
<label class="checkbox-field"><input type="checkbox" name="no_m3u" value="true"> Skip M3U generation (e.g. "liked songs")</label>
<button type="submit" class="btn btn-primary">Add playlist</button>