Commit Graph

6 Commits

Author SHA1 Message Date
andrew 8ecff44811 0.6.3: Parse Spotify's renamed /items response shape (new apps get item, not track)
Spotify's February 2026 changes did not just move /playlists/{id}/tracks to
/items: for apps on the new behavior the per-entry payload key was renamed
from "track" to "item" (tracks.tracks.track -> items.items.item). Extended
Quota Mode (grandfathered) apps keep the old key, which is why this never
reproduced locally. Every parser in alembic read only "track", so on a new
app each entry looked like a null/local track and was silently skipped: the
CSV came out header-only, sldl no-opped with exit 0, and the playlist page
showed an empty tracklist. Confirmed live on a new app against a playlist
the connected account owns.

All /items consumers (spotify_client.py, spotify-playlist-csv.py,
spotify-retag.py) now parse both key names, and the fields query filter is
gone: it selects by key name, so filtering on track(...) is itself what
returned empty pages on the renamed shape.

Also per the migration guide, new apps only receive playlist contents for
playlists the connected account owns or collaborates on; other playlists
return metadata with no items field at all (public is no longer enough).
That case now raises a pointed error (UI and CSV fetch) instead of reading
as an empty playlist, run-playlist.sh logs the fetched track count and warns
loudly when it is zero, and the README guidance is updated to match.

New tests pin get_playlist_tracks against both response shapes, the
metadata-only error, and pagination.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 09:50:57 -06:00
andrew 97672ffdcd 0.5.2: Run now for unscheduled playlists, Spotify /items migration, friendlier 403
- Run now (jobs.run_now): playlists without a daily sync time are never
  registered as scheduler jobs, so the old registered-job check 404'd. Fall
  back to running the playlist directly via pipeline_runner for any
  playlist:<name> key that isn't registered.
- Spotify removed GET /playlists/{id}/tracks in its February 2026 API changes
  in favor of /items (identical response shape). New apps are already 403'd on
  the old endpoint. Migrate both callers (app/services/spotify_client.py and
  pipeline/lib/spotify-retag.py) to /items. (The vendored sldl downloader uses
  its own bundled Spotify library and would need an upstream update when
  /tracks is fully removed.)
- Friendlier on-screen error for a Spotify 403 (check credentials + playlist
  must be public) and a README troubleshooting entry covering the 403, the
  public-playlist requirement, and the org-only extended-quota reality.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 10:12:42 -06:00
andrew b135f11557 Security hardening and first-run/portability improvements
Security (P0):
- Remove committed session-secret default; auto-generate and persist a
  random secret to the config volume when SESSION_SECRET is unset
  (prevents forgeable session cookies / auth bypass).
- Validate playlist names to a safe charset and render sldl configs via
  literal Python substitution instead of sed (closes a command-injection
  and path-traversal path through playlist names).
- shlex-quote credential values written to shell-sourced env files, and
  strip newlines from values patched into .conf files.
- Render playlist .conf files 0600; warn at startup if the master key is
  co-located with the config volume; document keeping it separate.

Portability:
- Configurable timezone via TZ (default UTC) instead of hardcoded Edmonton.
- Remove personal defaults (navidrome user "andrew", ephemeral.club URLs).
- Ship generic example seeds; move the cross-album dedup keep-list and the
  legacy playlist import to editable config files; drop the personal
  _upgrade.csv.
- Generic VPN reference in docker-compose.snippet.yml.

First-run experience:
- Redirect to /setup instead of 500 when OIDC is unconfigured; surface a
  missing master key inline; entrypoint exits with an actionable message
  when the config folder is not writable.
- Add unauthenticated /health (JSON) and /setup (checklist) diagnostics.

Docs:
- Write docs/ARCHITECTURE.md and docs/MIGRATION.md (previously referenced
  but missing); expand README with ownership, backups, advanced settings,
  and migration guidance.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 14:25:55 -06:00
andrew 59c369c9e2 Fix playlist status page, dedup delete-by-path bug, duplicate scan rows; add public IP dashboard card
Playlist detail page's status section (and the new two-column compare
view) was never rendering: the router awaited status_service.playlist_status(),
but that function is plain sync, so awaiting its dict return raised
"object dict can't be used in 'await' expression" on every load, silently
caught and shown as a generic fetch error. Removed the erroneous await.

find-fuzzy-dupes.py's --apply and --apply-pairs both deleted by a `path::`
regex query even though the beets id was already available in scope --
same mixed-path-storage issue (pre/post beets-2.11-upgrade items store
absolute vs. library-relative paths) dedup-library.sh already worked
around by deleting via id instead. This silently failed to match for most
confirmed fuzzy-audio deletions (verified: 19 of 20 in one run). Added a
delete_id column to dedup_candidates, threaded through the scan/apply
pipeline, and switched both delete call sites to `id:`.

Dedup scans also never checked whether a pair was already sitting in the
pending list, so every re-scan (including the daily schedule) added a new
row for the same unreviewed duplicate -- cleaned up 38 redundant rows
already in production and added a check so future scans skip a pair
that's already pending.

Dashboard gets a Public IP stat card (cached 10 min, fetched via ipify)
as a quick confidence check that outbound traffic is actually routed
through gluetun's VPN and not the home connection.
2026-07-09 11:13:58 -06:00
andrew c87ddc2649 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.
2026-07-08 15:39:22 -06:00
andrew 5601de3d44 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).
2026-07-08 14:24:46 -06:00