- Strip the common /data/music/Library/ prefix from displayed keep/delete
paths (every track lives there, so it added width without adding
information); full path is still available via a hover title.
- Table now uses a colgroup with fixed column widths so headers stay
aligned with their content regardless of how long a given path is,
instead of drifting with the widest cell in the column.
- Add a per-row Delete button (self-contained form per cell) alongside
the existing checkbox + bulk "Delete selected" flow -- the checkboxes
now target an external form via the HTML `form` attribute instead of
wrapping the table in a form, since a form can't legally nest another
form for the per-row buttons.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- beets_service: filter params now use truthy checks instead of `is not
None`, since a real <select> left on "(any)" submits an empty string,
not an absent param -- the filter form silently matched zero rows for
any real submission. Also match grouping tokens within "; "-joined
multi-playlist values instead of requiring an exact string match.
- dashboard: credential status renders as compact dot indicators instead
of badge+text chips, so long scope names (telegram) stay on one line.
- credentials page: two-column grid layout instead of one long column.
- find-fuzzy-dupes.py: add --json/--only-paths flags matching
dedup-library.sh's convention, so it can plug into the same review
queue.
- dedup_review_service: add scan_fuzzy() and route confirm_and_apply()
to the correct underlying script (dedup-library.sh vs
find-fuzzy-dupes.py) per candidate's pass_name, since tag-based passes
miss duplicates whose tags differ even when the audio is identical
(e.g. a remix credited to different artists between two copies).
- dedup page: add a "Scan for audio duplicates" trigger alongside the
existing tag-based scan.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Full style rewrite (holographic/chrome dark theme) plus matching favicon,
authored externally and dropped in at /home/andrew/alembic-theme. Verified
every template's variable names, form field names, and action URLs match
the actual data each router passes before importing -- all matched exactly
with zero router/service changes needed. Validated all templates parse
(Jinja2 env.get_template over the full tree) and confirmed the live
container serves the new theme after rebuild+redeploy.
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).
- settings.py: pydantic-settings config for the two mount points
(MUSIC_DATA_DIR, ALEMBIC_CONFIG_DIR), OIDC client config, encryption key path
- db.py: SQLite engine with WAL mode, plain-SQL schema migration runner
(schema/*.sql, tracked by schema_version -- not the alembic migration tool)
- models.py: SQLAlchemy ORM models matching schema/0001_init.sql
- security/crypto.py: Fernet encrypt/decrypt for the secrets table
- security/oidc.py + routers/auth.py: Pocket ID OIDC login/callback/logout
- security/deps.py: require_auth dependency (redirect-to-login on no session)
- main.py: app factory, lifespan (init_db + beets WAL enable), session
middleware, minimal dashboard route
Verified boots end-to-end via TestClient: unauthenticated GET / redirects to
/auth/login, static files serve, and all 13 schema tables get created on
first run.