Skip to content

feat: add FastAPI webapp for Spotify/Last.FM library management#46

Open
jmlrt wants to merge 20 commits into
mainfrom
feat/webapp
Open

feat: add FastAPI webapp for Spotify/Last.FM library management#46
jmlrt wants to merge 20 commits into
mainfrom
feat/webapp

Conversation

@jmlrt

@jmlrt jmlrt commented May 31, 2026

Copy link
Copy Markdown
Owner

Summary

  • Adds a server-rendered web interface (FastAPI + Jinja2 + HTMX + Pico CSS) that imports existing spotfm.* library functions directly — CLI unchanged
  • Session-based auth via [web] api_key in spotfm.toml; background jobs tracked in-memory with HTMX polling for status
  • Docker deployment via Dockerfile + docker-compose.yml with volume mounts for DB, config, and token cache

Features

Route Description
GET / Dashboard: track/playlist/artist/album counts
GET /playlists Playlists with track counts + per-playlist update buttons
GET /tracks Track list with filters: playlist, artist, album, year range
GET /duplicates Duplicate detection by ID (sync) or name/fuzzy (async job)
GET /scrobbles Recent Last.FM scrobbles
GET /track-counts track_counts_log CSV visualized as a table
POST /jobs/update-playlists Background job: update playlists from Spotify API
POST /jobs/discover Background job: discover new tracks
GET /jobs/{job_id} HTMX polling partial for job status

Tech stack

  • FastAPI 0.115+ — async routes, factory pattern (uvicorn --factory)
  • Jinja2 + HTMX 2.x — server-rendered, zero JS build step
  • Pico CSS 2.x — classless minimal styling (CDN)
  • Starlette SessionMiddleware — signed cookie, api_key as secret
  • asyncio.create_task + run_in_executor — background jobs with in-memory registry

Test plan

  • uv run pytest tests/web/ — 25 web tests (auth, routes, jobs) pass
  • uv run pytest — full suite (340 tests) passes, no regressions
  • uv run spfm-web — app starts; login page at http://localhost:8000
  • Manual: log in, navigate dashboard, playlists, track filters
  • Manual: trigger update-playlists or discover, observe HTMX job polling
  • docker compose up --build — container starts, volume mounts work

🤖 Generated with Claude Code

Adds a server-rendered web interface (FastAPI + Jinja2 + HTMX + Pico CSS)
that imports existing library functions directly rather than wrapping the CLI.
Features: dashboard stats, playlists, track filtering, duplicate detection,
background jobs with HTMX polling, Last.FM scrobbles, track counts CSV view,
and session-based auth via api_key in spotfm.toml. Includes Docker deployment
files and 25 web tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@jmlrt jmlrt marked this pull request as ready for review June 1, 2026 06:44
Copilot AI review requested due to automatic review settings June 1, 2026 06:44

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a new server-rendered FastAPI web application layer on top of the existing spotfm.* library to manage Spotify/Last.FM data via a browser (auth, dashboards, playlists/tracks views, duplicate detection, and background jobs), plus containerization for deployment.

Changes:

  • Introduces spotfm.web FastAPI app (routes, templates, static assets) with session-based API-key login and HTMX polling for job status.
  • Adds a simple in-memory background job registry/runner and new web-focused tests.
  • Adds web extra dependencies + Docker/Docker Compose scaffolding; adjusts SQLite connection settings for web usage.

Reviewed changes

Copilot reviewed 25 out of 29 changed files in this pull request and generated 15 comments.

Show a summary per file
File Description
uv.lock Adds locked dependency set for the new web extra and pytest-asyncio.
pyproject.toml Defines web extra deps, adds spfm-web entrypoint, enables pytest-asyncio mode, and adds a dependency group for pytest-asyncio.
Dockerfile Container image definition to run the new FastAPI app via Uvicorn.
docker-compose.yml Compose service for running the web app with mounted config/cache volumes.
spotfm.example.toml Documents [web] api_key configuration required for web access.
spotfm/sqlite.py Changes SQLite connection creation flags to support web execution model.
spotfm/web/app.py FastAPI app factory, session middleware, login/logout, dashboard, router wiring, and spfm-web runner.
spotfm/web/auth.py API key check and auth-gate redirect helper for protected routes.
spotfm/web/jobs.py In-memory job registry + async runner that executes sync work in an executor.
spotfm/web/routes/spotify.py Web routes for playlists, tracks filtering, duplicates, and Spotify jobs.
spotfm/web/routes/lastfm.py Web routes for recent scrobbles and track-count log rendering.
spotfm/web/routes/__init__.py Declares routes package.
spotfm/web/templates/base.html Shared layout + nav for the web UI.
spotfm/web/templates/index.html Dashboard counts + action buttons.
spotfm/web/templates/login.html Login page and API key form.
spotfm/web/templates/playlists.html Playlist list + job-trigger buttons.
spotfm/web/templates/tracks.html Track browsing/filter UI and results table.
spotfm/web/templates/duplicates.html Duplicate detection UI (exact + fuzzy job trigger).
spotfm/web/templates/job_status.html HTMX-polled job status partial.
spotfm/web/templates/scrobbles.html Recent scrobbles list.
spotfm/web/templates/track_counts.html Track-count CSV rendered as a table.
spotfm/web/static/app.css Minimal styling for layout grids and login box.
spotfm/web/__init__.py Declares web package.
tests/web/__init__.py Declares web tests package.
tests/web/conftest.py Web app/client fixtures, temp config, and job reset fixture.
tests/web/test_auth.py Unit tests for login/logout and auth protection behavior.
tests/web/test_jobs.py Unit tests for job registry and job runner (success/failure).
tests/web/test_routes_spotify.py Unit tests for Spotify-related web routes and job creation.
tests/web/test_routes_lastfm.py Unit tests for Last.FM and track-count routes.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread spotfm/web/routes/spotify.py Outdated
Comment thread spotfm/web/templates/tracks.html Outdated
Comment thread spotfm/web/auth.py Outdated
Comment thread spotfm/web/app.py
Comment thread spotfm/sqlite.py Outdated
Comment thread spotfm/web/routes/lastfm.py Outdated
Comment thread spotfm/web/templates/scrobbles.html
Comment thread tests/web/test_routes_spotify.py Outdated
Comment thread Dockerfile Outdated
Comment thread spotfm/web/templates/login.html Outdated
jmlrt added 3 commits June 1, 2026 08:54
- Rename lfm_client to lastfm_client for consistency
- Move track_counts route from lastfm.py to spotify.py (Spotify-related feature)
- Make scrobbles configurable via query params (limit, scrobbles_minimum, period, period_minimum) with defaults from config
- Move corresponding tests from test_routes_lastfm to test_routes_spotify
- Add test for scrobbles with custom query parameters
- Add open redirect protection in /login: validate next_url is a relative path
- Add input validation for scrobbles query params (limit, period, etc): catch ValueError and use defaults
- Add tests for open redirect prevention and invalid query param handling
Critical fixes:
- Fix tuple unpacking bug in find_tracks_by_criteria (returns dicts, not tuples)
- Add thread-safe locking to job registry (_jobs dict) to prevent race conditions
- Replace deprecated asyncio.get_event_loop() with asyncio.get_running_loop()
- Improve progress reporting with logging handler instead of sys.stderr mutation
- Add URL encoding for 'next' parameter to prevent open redirects and broken URLs
- Add missing LastFM config error handling
- Fix Dockerfile to use uv for reproducible builds

Improvements:
- Render error messages in scrobbles template
- Track filter presence in template context instead of relying on query_params
- Close coroutine in test to fix resource warnings
- Document single-worker requirement for SQLite safety

All 344 tests pass with improved security and thread safety.
- auth: drop SHA256 wrapping in check_api_key — hmac.compare_digest handles constant-time comparison directly
- jobs: scope lock to progress list only (was applied to pure async-context functions that don't need it); remove redundant double removeHandler
- scrobbles: use FastAPI native int query params instead of manual string parsing with silent fallback; invalid input now returns 422
- remove test_scrobbles_invalid_query_params_uses_defaults (was testing bad behavior)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 25 out of 29 changed files in this pull request and generated 8 comments.

Comment thread spotfm/web/app.py Outdated
Comment thread spotfm/web/jobs.py
Comment thread spotfm/web/routes/spotify.py Outdated
Comment thread spotfm/web/routes/spotify.py Outdated
Comment thread spotfm/web/routes/lastfm.py
Comment thread spotfm/sqlite.py Outdated
Comment thread spotfm/web/routes/spotify.py Outdated
Comment thread spotfm/web/jobs.py
- app.py: reject scheme-relative open redirects (//evil.com bypassed the existing check)
- jobs.py: treat PENDING as active in get_running_job to prevent duplicate job creation during startup race
- lastfm.py: treat empty string config values as missing (was only checking key presence)
- spotify.py: suppress stdout when calling find_duplicate_ids/names (CLI functions print CSV to stdout)
- spotify.py: catch OSError on track-counts CSV read instead of 500ing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 25 out of 29 changed files in this pull request and generated 6 comments.

Comment thread spotfm/web/auth.py Outdated
Comment thread spotfm/web/jobs.py Outdated
Comment thread spotfm/sqlite.py Outdated
Comment thread spotfm/web/app.py Outdated
Comment thread tests/web/test_routes_spotify.py Outdated
Comment thread spotfm/web/templates/scrobbles.html Outdated
jmlrt and others added 10 commits June 1, 2026 09:34
- auth.py: fix next param encoding — use safe="/" so ?/& are percent-encoded, preventing multi-param URLs from being truncated after login redirect
- app.py: replace sys.exit(1) with RuntimeError for missing api_key; remove redundant unquote() (Starlette already decodes query params)
- lastfm.py: pass limit/period to template context so UI reflects actual values
- scrobbles.html: render actual limit/period instead of hardcoded "Last 50 tracks · 90-day window"
- test_routes_spotify.py: remove dead run_job stub (test works via create_task mock, not run_job)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
uv requires either a venv or --system to install into the system Python.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
hatchling requires it to build the package wheel.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
hatchling also requires it (pyproject.toml references readme = "README.md").

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…les form

- Dashboard: count only in-playlist tracks/artists/albums (not orphans)
- Tracks page: show all in-playlist tracks by default; add Playlists column
  with clickable links; server-side column sorting (artist, name, album, year)
- Playlists page: names link to /tracks?playlist=<id>; column sorting
- Scrobbles: form-first flow with params (limit, period, thresholds);
  incremental mode from lastfm_state.json shown in form; artist/title
  split into separate columns; client-side column sorting (no re-fetch)
- Fuzzy dupes: show existing job on /duplicates page; display results
  table when done; fix progress capture (root logger level); allow re-run
- track-counts: fix CSV delimiter (;), proper header row
- Pagination: page numbers with ellipsis (±4 around current + first/last)
  on all table pages; 100 rows/page default
- Compact CSS: 13px base font, tighter table/form spacing, pagination nav
- table_sort.js: generic client-side sort for data-client-sort tables

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
…e navigation

Show all scrobble results on a single page so navigating does not
trigger another Last.FM API call. Client-side sort (table_sort.js)
handles ordering without any server round-trip.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
The previous commit described removing it but the file edit was not
persisted — the include was still present, causing a 500 error when
pagination context was not passed.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Direct navigation to /jobs/<id> now renders a full page (job_page.html)
with base.html chrome so htmx.js is loaded and polling fires every 2s.
HTMX requests (partial refreshes) still receive the bare job_status.html
partial. Applies to update-playlists and discover jobs.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
…pe error

- Add snapshot_unchanged flag to Playlist to short-circuit sync_to_db when
  snapshot_id matches, avoiding redundant track re-sync on unchanged playlists
- Fix TypeError when sorting playlists by count: use 0 fallback for int
  column instead of "" to prevent mixed-type comparison

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 30 out of 34 changed files in this pull request and generated 5 comments.

Comment thread spotfm/sqlite.py Outdated
Comment thread spotfm/spotify/playlist.py
Comment thread spotfm/web/jobs.py
Comment thread spotfm/web/routes/lastfm.py Outdated
Comment thread spotfm/web/app.py
uv sync uses uv.lock; uv pip install does not. Copying the lockfile gave
a false sense of reproducibility without actually pinning dependency versions.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
jmlrt and others added 3 commits June 1, 2026 13:21
… level race, config coercion

- sqlite.py: replace global singleton connection with threading.local() so each
  thread (event loop + executor workers) gets its own connection; removes
  check_same_thread=False and eliminates shared-connection races under run_in_executor
- playlist.py: reset snapshot_unchanged=False at start of update_from_api() so
  a reused Playlist instance with a changed snapshot doesn't skip track sync
- jobs.py: use a refcount (_active_jobs) to manage root logger level — only the
  last concurrent job to finish restores the original level, preventing INFO
  logs from being silenced mid-run for overlapping jobs
- lastfm.py: coerce period_minimum/scrobbles_minimum config values to int via
  _cfg_int() helper to prevent TypeError when config values are non-int strings

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
…count logging

- /duplicates/ids: dedicated page with sortable columns (playlists, artist, track)
  and pagination; playlists column shows which playlists each duplicate appears in
- GET /duplicates/names: new dedicated page showing fuzzy dupe job results with
  sortable columns (track1, artists1, playlists1, track2, artists2, playlists2, score)
  and pagination; defaults to score desc
- /duplicates: simplified hub page linking to both dedicated pages; job_status
  widget now shows "View results →" link instead of inline table when dupe-names done
- /jobs/update-playlists: chains log_track_counts() after update_playlists when
  no pattern filter (i.e. "Update all"), matching spfm spotify update-playlists --log-counts

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
…c modules

Extract incremental scrobble orchestration into lastfm.fetch_recent_scrobbles()
to eliminate duplication between CLI and web routes. When saved state exists,
always use incremental mode (ignoring explicit limit). Remove stdout printing
from core modules (misc.py) and use logging instead. Update web/CLI to handle
all presentation concerns. All 347 tests pass.

Implements Layered Separation pattern: core modules return structured data and
use logging; CLI/web handle formatting and display.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants