Skip to content

About

CLI + local web UI that finds and removes your least-listened Spotify tracks. Dry-run by default, typed DELETE to apply, GDPR-accurate scoring

Topics

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

spotify-cleaner: find the songs you never play and safely clear them out

License: MIT CI Python 3.10-3.13 PRs welcome

Find the songs you barely listen to and clear them out of your Liked Songs and playlists. Works from a CLI or a small local web UI, with a hard dry-run default so nothing leaves your library by accident.

Anyone can run this: you create your own free Spotify app (one dashboard click), paste the two credentials into .env, and the tool talks only to your account. Your login token stays on your machine in a gitignored cache. Nobody else, including this project, ever sees it.

The web UI showing least-listened candidates in a sortable table

The local web UI: every candidate in a sortable, filterable table. Review, then remove behind a typed-DELETE guard.

Contents

The one thing you need to know first

Spotify's API does not expose your personal play counts. Your desktop app shows them. The API hides them. So "least listened" has to come from one of two sources, and you pick which one:

--source True play counts? Setup Best for
toptracks No (top-50 vs not) none a fast first pass right now
gdpr Yes (lifetime + dates) request your data export, wait a bit the real, accurate cleanup

The reading and removing machinery is identical for both. Only the score source differs. That is the Scorer strategy you select.

Highlights

  • 🔒 Local and private. Talks only to your account. Your token never leaves your machine.
  • 🧹 Two ways to measure "least listened". Zero-setup toptracks, or true lifetime counts from your gdpr export.
  • 🖥️ CLI and a local web UI. Same engine, same safety, pick whichever you prefer.
  • 🛟 Dry-run by default. A real delete needs --apply and typing DELETE.
  • ♻️ Idempotent and resumable. If a run is interrupted, re-run the exact command. Already-removed tracks are skipped.
  • 🧪 Pure logic, unit-tested. The planner and GDPR parser are tested without ever touching an account.

Install

cd spotify-cleaner
python -m venv .venv
.venv\Scripts\activate            # Windows;  source .venv/bin/activate on macOS/Linux
pip install -e .
cp .env.example .env              # then fill in your Spotify app credentials

Create a Spotify app at https://developer.spotify.com/dashboard, and add the redirect URI http://127.0.0.1:8888/callback exactly. Your developer account must be Premium (a 2026 dev-mode requirement).

Use

Everything is a dry run by default: it only prints candidates. Nothing is deleted unless you pass --apply and type DELETE at the prompt.

# Zero-setup first look: tracks that aren't in your all-time top tracks
spotify-cleaner --source toptracks

# The accurate version, once your export has arrived (see below)
spotify-cleaner --source gdpr --gdpr-dir ./streaming_history --min-plays 1 --stale-days 365

# Actually remove them (asks for confirmation):
spotify-cleaner --source gdpr --gdpr-dir ./streaming_history --min-plays 1 \
    --apply --unlike --remove-from-playlists

Every scan covers your whole library: Liked Songs and every playlist you own. Useful flags: --limit N (how many to print), --time-range short_term|medium_term|long_term.

Web app (a local UI)

Prefer clicking to typing? There's a small local web UI that does everything the CLI does: pick a source, scan, eyeball the candidates in a sortable table, select what to drop, and apply behind the same typed-DELETE guard. It can also take a GDPR export by drag-and-drop instead of --gdpr-dir.

It runs entirely on your machine, on the same origin as the OAuth redirect (127.0.0.1:8888). Your token never leaves localhost, the browser never sees your client secret, and there's no CORS to configure.

Scan form, connected
1. Pick a source and scan. Top Tracks needs zero setup. GDPR is the accurate one.
Live scan progress
2. Watch it work. Progress streams live over Server-Sent Events as it reads your library.
GDPR export drop zone
GDPR mode. Drop your export folder or zip (no --gdpr-dir needed) and set the play/staleness cutoffs.
Typed-DELETE confirmation dialog
3. The guard. Choose what to remove, type DELETE. Nothing happens until you do.

Build the UI once, then start the server:

cd frontend
npm install
npm run build          # bundles into src/spotify_cleaner/web/static/
cd ..
pip install -e ".[web]"
python -m spotify_cleaner.web   # opens http://127.0.0.1:8888

After pip install -e ".[web]" you can also just run spotify-cleaner-web.

For UI work, run npm run dev instead (Vite on :5173, proxying the API to :8888) so changes hot-reload without a rebuild.

The table shows each candidate's confidence (how much to trust the verdict for that source), lets you filter as you type (press / to focus the box), and exports to CSV for review in a spreadsheet. Album thumbnails come from Spotify's public oEmbed endpoint (no API key, fetched lazily and proxied so the browser sees no third-party request). The handful of tracks it has no thumbnail for fall back to a placeholder icon, and nothing else is affected.

Architecture

The CLI and the web UI are two front-ends over one shared core. Both authorize with Spotify, read your whole library, score it through a pluggable strategy, and remove tracks behind the same guards. The only thing that differs between toptracks and gdpr is the scorer. Everything downstream is identical.

flowchart TD
    subgraph FE["Front-ends"]
        CLI["CLI<br/>cli.main"]
        WEB["Local Web UI<br/>React + FastAPI"]
    end
    subgraph CORE["Shared core pipeline"]
        AUTH["Spotify OAuth<br/>auth.make_client / web.oauth"]
        LIB["Whole-library read<br/>library.build_library<br/>owned playlists only"]
        PLAN["Planner<br/>planner.plan<br/>least-listened first"]
        CLEAN["Guarded cleaner<br/>cleaner.apply<br/>dry-run by default"]
        BACKUP["Backup + audit<br/>backup.write_backup"]
    end
    subgraph SCORERS["Pluggable Scorer strategy"]
        BASE["Scorer Protocol<br/>name · mode · score"]
        TOP["TopTracksScorer<br/>mode = rank"]
        GDPR["GdprScorer<br/>mode = count"]
    end
    SPAPI["Spotify Web API"]
    SPOTIFY[("Spotify writes<br/>unlike + remove")]

    CLI --> AUTH
    WEB --> AUTH
    AUTH --> LIB
    LIB --> BASE
    BASE -.implemented by.-> TOP
    BASE -.implemented by.-> GDPR
    TOP --> PLAN
    GDPR --> PLAN
    LIB --> PLAN
    PLAN --> CLEAN
    CLEAN --> BACKUP
    CLEAN --> SPOTIFY
    AUTH <--> SPAPI
    LIB <--> SPAPI
    TOP <--> SPAPI
Loading
Stage Module What it does
Auth auth.make_client · web.oauth Spotify OAuth. The token is cached locally (.cache-spotify), one cache per --profile.
Read library.build_library Reads your whole library: Liked Songs + every playlist you own.
Score scoring/ (base, toptracks, gdpr) The pluggable Scorer strategy, the only piece that changes between sources.
Plan planner.plan Pure, fully-tested candidacy logic, least-listened first. Never touches the network.
Clean cleaner.apply Guarded, idempotent, batched removals. Dry-run unless --apply.
Backup backup.write_backup Records a manifest of everything it touches.

The Scorer strategy. A scorer is a tiny Protocol (scoring/base.py): a name, a mode (count or rank), and one method: score(tracks) returning {track_id: PlayStats}. Adding a new "least-listened" signal means writing one class and registering it in cli._build_scorer. The planner only ever sees PlayStats, so it never needs to know where the numbers came from. That is exactly why the two sources share the whole read → plan → clean pipeline.

Web request flow. The web UI is a same-origin SPA: the OAuth token is cached server-side and never reaches the browser. Long operations run as background jobs and stream progress over Server-Sent Events.

sequenceDiagram
    participant B as Browser (SPA)
    participant S as FastAPI (127.0.0.1:8888)
    participant J as Background job
    participant API as Spotify Web API

    B->>S: POST /api/scan { source, options }
    S->>J: start job (token stays server-side)
    S-->>B: { jobId }
    B->>S: GET /api/scan/{jobId}/events (SSE)
    J->>API: read library + score
    J-->>B: phase / progress / log … done
    B->>S: GET /api/scan/{jobId}/result
    Note over B,S: Review in the table, select rows
    B->>S: POST /api/apply (after typing DELETE)
    S->>API: unlike / remove (batched, idempotent)
Loading

Cleaning up for a friend

Your friends do not need their own "API key." A Client ID/Secret identifies the app, not the person. You make it once, and it's yours. A friend only ever logs into their own Spotify and clicks Agree, which lets your app see their library. Their token is the only per-person thing, and it stays on whichever machine they authorized from.

A new Spotify app runs in Development Mode: only people you add by their Spotify-account email under your app's User Management (in the dashboard) can authorize it, up to 25 users. "Me and my friends" fits inside that, so you never need Spotify's public-app approval. Add each friend's email there first.

Then you run it for them on your machine. Your friend installs nothing and needs no Spotify app of their own. Two flags make that safe:

# Authorize the friend once. --profile gives them their own token file so it
# never collides with yours; --no-browser prints a link instead of opening a
# browser, so they can approve on their own phone.
spotify-cleaner --source toptracks --profile alice --no-browser

The tool prints Go to the following URL: …. Text that link to your friend. They open it (already logged into Spotify on their phone), tap Agree, and land on a http://127.0.0.1:8888/callback?... page that won't load. That's expected: they copy that URL from the address bar and send it back to you, you paste it at the prompt, and their login is saved as .cache-spotify-alice. The link only carries your public Client ID, never your secret.

After that, every run for that friend just reuses their cached login:

spotify-cleaner --source gdpr --gdpr-dir ./alice_history --profile alice \
    --min-plays 1 --apply --unlike --remove-from-playlists

Show them the dry-run list first, get their OK, then add --apply. They can revoke your app any time at https://www.spotify.com/account/apps/.

Getting the GDPR export (the accurate source)

  1. https://www.spotify.com/account/privacy/ → tick only "Extended streaming history" (not "Account data", which is just the last year).
  2. Wait. Usually hours to a few days, though Spotify quotes up to 30.
  3. Unzip the email's download. Point --gdpr-dir at the folder of Streaming_History_Audio_*.json files.

It computes true lifetime play counts and last-played dates, so --min-plays finds "added once, never played" and --stale-days finds "used to love it, dead for years".

Safety

  • Dry run unless --apply. Even then it requires typing DELETE.
  • Only touches playlists you own.
  • --unlike and --remove-from-playlists are independent opt-ins.
  • Removed-from-playlist tracks are not deleted from Spotify, and unliked tracks can be re-liked. Still: review the printed list before confirming.
  • Every operation is idempotent. If a run is interrupted (network drop, rate limit), it prints how far it got. Just re-run the exact same command to finish. Already-removed tracks are skipped, so nothing is double-handled.

Develop

pip install -e ".[dev]"
pytest                            # logic + parser tests, no account needed

Layout: auth/library (Spotify I/O) · scoring/ (the pluggable sources) · planner (pure candidacy logic, fully tested) · cleaner (the guarded deletes) · cli (wiring) · web/ (FastAPI + the React SPA). See Architecture for how they fit together, and CONTRIBUTING.md before opening a PR.

License

MIT. See LICENSE. Not affiliated with or endorsed by Spotify.

About

CLI + local web UI that finds and removes your least-listened Spotify tracks. Dry-run by default, typed DELETE to apply, GDPR-accurate scoring

Topics

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages