feat: add FastAPI webapp for Spotify/Last.FM library management#46
Open
jmlrt wants to merge 20 commits into
Open
feat: add FastAPI webapp for Spotify/Last.FM library management#46jmlrt wants to merge 20 commits into
jmlrt wants to merge 20 commits into
Conversation
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>
Contributor
There was a problem hiding this comment.
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.webFastAPI 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
webextra 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.
- 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>
- 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>
- 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>
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>
… 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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
spotfm.*library functions directly — CLI unchanged[web] api_keyinspotfm.toml; background jobs tracked in-memory with HTMX polling for statusDockerfile+docker-compose.ymlwith volume mounts for DB, config, and token cacheFeatures
GET /GET /playlistsGET /tracksGET /duplicatesGET /scrobblesGET /track-countstrack_counts_logCSV visualized as a tablePOST /jobs/update-playlistsPOST /jobs/discoverGET /jobs/{job_id}Tech stack
uvicorn --factory)SessionMiddleware— signed cookie,api_keyas secretasyncio.create_task+run_in_executor— background jobs with in-memory registryTest plan
uv run pytest tests/web/— 25 web tests (auth, routes, jobs) passuv run pytest— full suite (340 tests) passes, no regressionsuv run spfm-web— app starts; login page at http://localhost:8000docker compose up --build— container starts, volume mounts work🤖 Generated with Claude Code