Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
bed4b0c
feat: add FastAPI webapp for Spotify/Last.FM library management
jmlrt May 31, 2026
e81606d
fix: address PR feedback on webapp
jmlrt Jun 1, 2026
46d4261
fix: add input validation and security checks
jmlrt Jun 1, 2026
ac63ee2
fix: address 15 copilot review comments
jmlrt Jun 1, 2026
24efedc
refactor: simplify auth, jobs, and scrobbles route (KISS/YAGNI)
jmlrt Jun 1, 2026
b413fd4
fix: address new copilot review comments
jmlrt Jun 1, 2026
fbaf3dc
fix: address new copilot review comments (round 3)
jmlrt Jun 1, 2026
0b0e504
fix: add --system flag to uv pip install in Dockerfile
jmlrt Jun 1, 2026
14d711a
fix: copy LICENSE file into Docker image
jmlrt Jun 1, 2026
d5b63da
fix: copy README.md into Docker image
jmlrt Jun 1, 2026
34ec9a3
feat: improve webapp UX — pagination, sorting, compact layout, scrobb…
jmlrt Jun 1, 2026
3655064
feat(scrobbles): remove pagination to prevent Last.FM re-fetch on pag…
jmlrt Jun 1, 2026
ea30686
fix(scrobbles): actually remove pagination include from template
jmlrt Jun 1, 2026
d5fd480
feat(jobs): show live logs on job page with HTMX polling
jmlrt Jun 1, 2026
b6b2abd
fix: hide dupe-specific message on non-dupe job completion
jmlrt Jun 1, 2026
b317d33
fix: skip track re-sync for unchanged playlists and fix count sort ty…
jmlrt Jun 1, 2026
8bf2028
fix(docker): remove uv.lock from image — uv pip install does not use it
jmlrt Jun 1, 2026
18eb3cd
fix: address Copilot review — thread safety, snapshot flag reset, log…
jmlrt Jun 1, 2026
2f4fa29
feat: dedicated dupe pages with sorting, playlist columns, and track …
jmlrt Jun 1, 2026
a8ee484
refactor: separate business logic from presentation in lastfm and mis…
jmlrt Jun 3, 2026
9b86205
fix: clamp lastfm limit and finish dupes presentation split
jmlrt Aug 13, 2026
cf321f0
chore: dedupe project settings and CLAUDE.md against global config
jmlrt Aug 13, 2026
b8c4de8
style: apply ruff formatting and remove unused imports
jmlrt Aug 13, 2026
1af5688
fix: remove redundant if/else in fetch_recent_scrobbles mode assignment
jmlrt Aug 13, 2026
8035da6
fix: clamp Last.FM limit to 999 to account for pylast's internal +1
jmlrt Aug 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 5 additions & 22 deletions .claude/settings.json
Original file line number Diff line number Diff line change
@@ -1,31 +1,14 @@
{
"permissions": {
"allow": [
"Bash(git:*)",
"Bash(gh:*)",
"Bash(make:*)",
"Bash(pre-commit:*)",
"Bash(python3 -m py_compile:*)",
"Bash(ruff:*)",
"Bash(spfm:*)",
"Bash(time uv run:*)",
"Bash(uv:*)",
"Bash(rm -rf .pytest_cache:*)",
"Bash(rm -rf .ruff_cache:*)",
"Bash(rm -rf __pycache__:*)",
"Bash(rm -rf build:*)",
"Bash(rm -rf dist:*)",
"Bash(rm -rf *.egg-info:*)",
"Skill(create-pr:*)",
"Skill(review-pr)"
],
"deny": [
"Bash(git push --force:*)",
"Bash(git push -f:*)",
"Bash(git reset --hard:*)",
"Bash(rm -rf /*:*)",
"Bash(rm -rf ~:*)",
"Bash(rm -rf ./:*)"
"Skill(pull-request)"
]
},
"env": {
"PRE_COMMIT_HOME": ".cache/pre-commit",
"UV_CACHE_DIR": ".cache/uv"
}
}
2 changes: 2 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
# .pre-commit-config.yaml
default_language_version:
python: python3.14
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
Expand Down
37 changes: 6 additions & 31 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,11 @@ A Python library and CLI tool for Spotify and Last.FM API interaction. Focuses o
- `spotfm/sqlite.py` - SQLite singleton connection management
- `spotfm/utils.py` - Config, caching, string sanitization

**Layered Separation Rule (core vs. presentation):**
- **Core modules** (`spotfm/spotify/`, `spotfm/lastfm.py`): return structured data (dicts/lists), use `logging` for observability, **never call `print()`**
- **Presentation layer** (`spotfm/cli.py`, `spotfm/web/`): format and display data; **never own business logic**
- Shared orchestration logic used by both CLI and web belongs in the core module, not duplicated in each caller

## Code Style & Requirements

- **Python**: 3.14+ (PEP 758 bracketless exception syntax)
Expand Down Expand Up @@ -78,23 +83,7 @@ A Python library and CLI tool for Spotify and Last.FM API interaction. Focuses o
5. **Global DB Connection**: `spotfm/sqlite.py` uses module-level singleton with atexit cleanup
6. **Duplicate Detection**: Operates on SQLite only, no API calls (optimization)

## Development Practices

**Before committing:**
- Run `make test` (all tests pass)
- Run `make lint` (no ruff violations)
- When fixing a bug in one CLI command, proactively check ALL similar commands for the same issue

**Branch creation when on a feature branch:**
- Always check `git branch` before creating a new fix/feature branch
- If currently on a feature branch (e.g. `perf/threadpool-phase2`), do NOT stash and switch — the stash will conflict
- Instead: note changed files, `git checkout main && git checkout -b fix/new-branch`, re-apply edits manually with the Edit tool

**Commit strategy:**
- **ONLY commit files you changed** — use `git add <file1> <file2>` (not `git add .` or `git add -A`)
- **Do not commit unrelated changes** — keep commits focused and atomic
- **Example**: If you fix a bug in 3 files, commit exactly those 3 files, nothing more
- This prevents accidentally committing IDE config, lock files, or temporary analysis artifacts
## Test Execution & Markers

**Test execution:**
- `make test` - Full suite
Expand All @@ -107,20 +96,6 @@ A Python library and CLI tool for Spotify and Last.FM API interaction. Focuses o
- `@pytest.mark.integration` - Integration tests (use temp databases)
- `@pytest.mark.slow` - Long-running tests (run with `pytest -m slow`)

## Tool Priority Strategy

**Always use dedicated tools before requesting new bash permissions:**

1. **File reading** → Use `Read` tool (not `cat`)
2. **File searching** → Use `Grep` tool (not `grep`)
3. **File patterns** → Use `Glob` tool (not `find`)
4. **File writing** → Use `Write` tool (not `echo`/`cat`)
5. **File editing** → Use `Edit` tool (not `sed`)
6. **Git commands** → Use `Bash(git:*)` (already allowed)
7. **Build/test** → Use `make` targets (already allowed)

**Benefit**: Dedicated tools provide better UX, proper permissions handling, and reduce bash permission bloat.

## 📖 For More Information

- **Architecture & Design**: See [SPEC.md](SPEC.md) for comprehensive specification
Expand Down
8 changes: 8 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
FROM python:3.14-slim
WORKDIR /app
RUN pip install --no-cache-dir uv
COPY pyproject.toml LICENSE README.md ./
COPY spotfm/ spotfm/
RUN uv pip install --no-cache-dir --system ".[web]"
EXPOSE 8000
CMD ["uvicorn", "spotfm.web.app:create_app", "--factory", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]
9 changes: 9 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
services:
spotfm-web:
build: .
ports:
- "8000:8000"
volumes:
- ~/.spotfm:/root/.spotfm
- ~/.cache/spotfm:/root/.cache/spotfm
restart: unless-stopped
15 changes: 15 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,21 @@ dev = [
"ruff",
"twine",
]
web = [
"fastapi>=0.136.3",
"httpx>=0.28.1",
"itsdangerous>=2.2.0",
"jinja2>=3.1.6",
"python-multipart>=0.0.30",
"uvicorn[standard]>=0.48.0",
]

[project.urls]
homepage = "https://github.com/jmlrt/spotfm"

[project.scripts]
spfm = "spotfm.cli:main"
spfm-web = "spotfm.web.app:run"

[tool.ruff]
line-length = 120
Expand Down Expand Up @@ -66,6 +75,7 @@ skip-magic-trailing-comma = false
line-ending = "auto"

[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
python_files = ["test_*.py"]
python_classes = ["Test*"]
Expand Down Expand Up @@ -109,3 +119,8 @@ exclude_lines = [
"if __name__ == .__main__.:",
"if TYPE_CHECKING:",
]

[dependency-groups]
dev = [
"pytest-asyncio>=1.4.0",
]
3 changes: 3 additions & 0 deletions spotfm.example.toml
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
[web]
api_key = "" # any random string; required to access the webapp

[lastfm]
api_key = ""
api_secret = ""
Expand Down
98 changes: 56 additions & 42 deletions spotfm/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from logging.handlers import RotatingFileHandler

from spotfm import lastfm, utils
from spotfm.lastfm import read_lastfm_state, save_lastfm_state
from spotfm.lastfm import fetch_recent_scrobbles, read_lastfm_state
from spotfm.spotify import client as spotify_client
from spotfm.spotify import constants as spotify_constants
from spotfm.spotify import dupes as spotify_dupes
Expand All @@ -31,44 +31,45 @@ def _non_negative_int(value):
return ivalue


def recent_scrobbles(user, limit, scrobbles_minimum, period, period_minimum, interactive):
current_count = user.get_playcount()
scrobble_count_to_save = current_count # Track what state to save
def _format_track(track_dict):
"""Format a track dict as 'artist - title - period_scrobbles - total_scrobbles - url'."""
return f"{track_dict['artist']} - {track_dict['title']} - {track_dict['period_scrobbles']} - {track_dict['total_scrobbles']} - {track_dict['url']}"

state = read_lastfm_state()
if state is None:
# First run: initialize state with current count, fetch --limit scrobbles
print(f"Initializing scrobble tracking. Fetching up to {limit} recent scrobbles.")
else:
# Subsequent runs: fetch all new scrobbles since last run
last_scrobble_count = None
if isinstance(state, dict):
last_scrobble_count = state.get("last_scrobble_count")
if not isinstance(last_scrobble_count, int):
print("Invalid previous state. Re-initializing scrobble tracking.")
save_lastfm_state(current_count)
return
computed_limit = current_count - last_scrobble_count
if computed_limit <= 0:
print("No new scrobbles since last run.")
save_lastfm_state(current_count)
return
# Fetch all new scrobbles on subsequent runs
limit = computed_limit
print(f"Fetching {limit} new scrobbles (was {last_scrobble_count}, now {current_count}).")

# Get scrobbles generator
scrobbles_gen = user.get_recent_tracks_scrobbles(limit, scrobbles_minimum, period, period_minimum)
def recent_scrobbles(user, limit, scrobbles_minimum, period, period_minimum, interactive, config):
# Check if this is first run BEFORE fetching (once fetch_recent_scrobbles runs, state is saved)
is_first_run = read_lastfm_state() is None

if interactive:
# Collect all results first so exceptions/logs surface before editor opens
scrobbles = list(scrobbles_gen)
lines = sorted(set(scrobbles))
if not lines:
print("No results to open in editor.")
save_lastfm_state(scrobble_count_to_save)
return
# Fetch scrobbles with incremental state management (orchestration in lastfm module)
tracks, mode = fetch_recent_scrobbles(
user, config, limit=limit, scrobbles_minimum=scrobbles_minimum, period=period, period_minimum=period_minimum
)

# CLI-layer presentation: handle user feedback and interactive mode
if mode == "no_new":
print("No new scrobbles since last run.")
return
elif mode == "incremental":
state = read_lastfm_state()
last_count = state.get("last_scrobble_count") if isinstance(state, dict) else None
current_count = user.get_playcount()
if last_count:
print(f"Fetching {len(tracks)} new scrobbles (was {last_count}, now {current_count}).")
else:
print(f"Initializing scrobble tracking. Fetching up to {limit} recent scrobbles.")
else: # mode == "full"
# First run shows initialization message; subsequent runs show fetch message
if is_first_run:
print(f"Initializing scrobble tracking. Fetching up to {limit} recent scrobbles.")
else:
print(f"Fetching {len(tracks)} recent scrobbles.")

if not tracks:
print("No results found.")
return

if interactive:
lines = sorted({_format_track(s) for s in tracks})
editor = os.environ.get("VISUAL") or os.environ.get("EDITOR", "vim")
editor_args = shlex.split(editor)
with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False, encoding="utf-8", newline="\n") as f:
Expand All @@ -80,14 +81,12 @@ def recent_scrobbles(user, limit, scrobbles_minimum, period, period_minimum, int
os.unlink(tmp)
if result.returncode != 0:
print(f"Editor command {' '.join(editor_args)} exited with status {result.returncode}.")
print("Not advancing Last.FM state; please fix the editor issue and retry.")
print("Last.FM state was already advanced; please fix the editor issue.")
return
else:
# Stream output in non-interactive mode
for scrobble in scrobbles_gen:
print(scrobble)

save_lastfm_state(scrobble_count_to_save)
for track in tracks:
print(_format_track(track))


def count_tracks(playlists_pattern=None):
Expand Down Expand Up @@ -126,7 +125,15 @@ def lastfm_cli(args, config):
if period_minimum is None:
period_minimum = config.get("lastfm", {}).get("period_minimum")

recent_scrobbles(user, args.limit, scrobbles_minimum, args.period, period_minimum, args.interactive)
recent_scrobbles(
user,
args.limit,
scrobbles_minimum,
args.period,
period_minimum,
args.interactive,
config.get("lastfm", {}),
)


def spotify_cli(args, config):
Expand Down Expand Up @@ -184,7 +191,14 @@ def spotify_cli(args, config):
spotify_dupes.find_duplicate_names(excluded_playlist_ids=excluded, threshold=threshold)
case "find-relinked-tracks":
excluded = config["spotify"].get("excluded_playlists", [])
spotify_misc.find_relinked_tracks(client.client, excluded_playlist_ids=excluded, output_file=args.output)
relinked = spotify_misc.find_relinked_tracks(
client.client, excluded_playlist_ids=excluded, output_file=args.output
)
if not args.output:
for track in relinked:
print(
f"Relinked - {track['playlist_name']} - {track['original_track']} -> {track['replacement_track']} - {track['original_id']} - {track['replacement_id']}"
)
case "list-playlists-with-track-counts":
playlists = spotify_misc.list_playlists_with_track_counts()
total_entries = 0
Expand Down
70 changes: 68 additions & 2 deletions spotfm/lastfm.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@

LASTFM_BASE_URL = "https://www.last.fm"
PREDEFINED_PERIODS = [7, 30, 90, 180, 365]
# pylast's User.get_recent_tracks() internally requests limit+1 (to allow filtering out
# a "now playing" track), so we must stay one under Last.FM's hard cap of 1000.
LASTFM_MAX_LIMIT = 999
LASTFM_STATE_FILE = utils.WORK_DIR / "lastfm_state.json"


Expand Down Expand Up @@ -125,9 +128,15 @@ def get_recent_tracks_scrobbles(self, limit=10, scrobbles_minimum=0, period=90,
# Rate limiting: 0.2s between API calls (5 req/sec = Last.FM limit)
sleep(0.2)

# Yield results (same format as before - backward compatible)
# Yield structured track data for callers to format as needed
for track, period_scrobbles, total_scrobbles, url in tracks_data:
yield f"{track} - {period_scrobbles} - {total_scrobbles} - {url}"
yield {
"artist": track.artist,
"title": track.title,
"period_scrobbles": period_scrobbles,
"total_scrobbles": total_scrobbles,
"url": url,
}


def read_lastfm_state(state_file=None):
Expand Down Expand Up @@ -168,3 +177,60 @@ def save_lastfm_state(scrobble_count, state_file=None):
with contextlib.suppress(OSError):
tmp_path.unlink()
logging.error(f"Failed to save Last.FM state file at {path}: {e}")


def fetch_recent_scrobbles(user, config, *, limit=None, scrobbles_minimum=None, period=90, period_minimum=None):
"""Fetch recent scrobbles with incremental state management.

Handles the orchestration of reading saved state, computing effective limits,
and saving state after fetch. This ensures consistent behavior across CLI and web.

Args:
user: Last.FM User instance
config: Last.FM config dict with keys: limit, scrobbles_minimum, period_minimum
limit: Explicit limit override (if None, uses incremental mode with saved state)
scrobbles_minimum: Minimum total scrobbles filter (if None, uses config)
period: Period in days for scrobble counting (default 90)
period_minimum: Minimum scrobbles in period window (if None, uses config)

Returns:
tuple: (tracks: list[dict], mode: str) where mode is "incremental", "full", or "no_new"
"""
current_count = user.get_playcount()
scrobble_count_to_save = current_count

# Resolve config defaults
effective_limit = min(limit if limit is not None else config.get("limit", 50), LASTFM_MAX_LIMIT)
effective_scrobbles_minimum = (
scrobbles_minimum if scrobbles_minimum is not None else config.get("scrobbles_minimum", 4)
)
effective_period_minimum = period_minimum if period_minimum is not None else config.get("period_minimum")

# Check for saved state first. If it exists, use incremental mode (ignore explicit limit)
state = read_lastfm_state()
mode = "full"
if state is not None and isinstance(state, dict):
last_scrobble_count = state.get("last_scrobble_count")
if isinstance(last_scrobble_count, int):
# Saved state exists: always use incremental mode, compute limit from diff
mode = "incremental"
computed_limit = current_count - last_scrobble_count
if computed_limit <= 0:
save_lastfm_state(current_count)
return [], "no_new"
effective_limit = min(computed_limit, LASTFM_MAX_LIMIT)

# Fetch scrobbles
tracks = list(
user.get_recent_tracks_scrobbles(
limit=effective_limit,
scrobbles_minimum=effective_scrobbles_minimum,
period=period,
period_minimum=effective_period_minimum,
)
)

# Save state after successful fetch
save_lastfm_state(scrobble_count_to_save)

return tracks, mode
Loading
Loading