A rules-based, fork-and-run tool that treats Spotify Liked Songs as an inbox. After a configurable number of days, liked tracks are matched against your rules and moved into playlists you already have. It never creates playlists unless a rule explicitly allows it. There is no backend, and nothing runs on a schedule until you turn one on — you start every run yourself (locally, via the dashboard's Run now button, or via the Actions tab).
The core sorter, safety machinery (dry-run, journal, verify, reconcile, restore),
MusicBrainz/script/playlist-based language enrichment, and the Pages site (Home, Setup guide, Configure,
Dashboard) are all built and covered by tests. --apply is available and guarded (see Safety guarantees
below). Scheduling is deliberately not enabled yet — see "Going unattended" below.
- Spotify Premium (required to create a Developer app). It cannot be verified via the API, so this is documentation only.
- Python 3.12+
- A GitHub account (and the
ghCLI for the secrets step)
The Setup guide on the Pages site walks through this with copy buttons and "you should see" checks. In short:
- Fork this repo.
- In your fork's Actions tab, click "I understand my workflows, go ahead and enable them" — GitHub disables Actions on every new fork, so nothing below will run until you do this.
- Create a Spotify Developer app at https://developer.spotify.com/dashboard with the redirect URI exactly
http://127.0.0.1:8888/callback. Note the Client ID. No Client Secret is needed (PKCE). - Copy
.env.exampleto.envand fill inSPOTIFY_CLIENT_ID. python -m pip install -r requirements.txtpython -m src.spotify_client --setupopens the browser, captures the code on localhost:8888 and writesSPOTIFY_REFRESH_TOKENto.env. Nothing secret is printed.- Optional live read-only check:
LIKEDZERO_LIVE=1 python -m src.spotify_client --smoke(PowerShell:$env:LIKEDZERO_LIVE=1first). powershell scripts/set_secrets.ps1once, to store the two Actions secrets (SPOTIFY_CLIENT_ID,SPOTIFY_REFRESH_TOKEN). The script reads.envlocally and pipes the values toghwithout echoing them.- In your fork's Settings → Actions → General → Workflow permissions, select Read and write permissions (lets a run commit its own logs), then Settings → Pages, source Deploy from a branch, branch
main, folder/docs.
Build config.yaml with the form-based Configure page at docs/builder/ (on GitHub Pages:
https://<your-user>.github.io/LikedZero/builder/), or copy config.example.yaml by hand. Configure
validates with the same rules as the sorter, previews the YAML live, imports an existing file, works offline
and can be installed as an app; nothing leaves your browser. It can also commit config.yaml straight to your
fork via Save to GitHub (a fine-grained token you create and control, kept in the browser tab only), or you
can download/copy it and commit it yourself. Supported match keys:
artist_in, genre_contains, language_in, release_year_before, release_year_after, explicit, track_name_contains, album_name_contains, artist_in_playlist, any, artist_country_in, any_of
- Conditions within a rule are ANDed.
- The first matching rule wins; a too-young match blocks rather than falling through to a broader later rule.
- Rules can be disabled.
- Each rule may set its own
days_thresholdandtarget_position(toporbottomof the target playlist). artist_in_playlist: trueroutes a song to whichever of your own playlists already holds most of that artist's tracks — no genre or language needed. Pair it withtarget_playlist: auto(the only valid target for this key); tuneartist_in_playlist.min_tracks/min_dominance(defaults 3 / 0.9) andexclude_playlists(Vault_drxis always excluded, whether or not it's listed) at the top level ofconfig.yaml. Seedesign/proposals/artist_in_playlist.md.any: truematches unconditionally — the real catch-all. Put a rule using it last; everything above it is tried first, and it only catches what nothing else did.artist_country_in: [IN, US, ...]checks the primary artist's MusicBrainz/ISRC country (2-letter ISO codes; the same field already fetched forenrichment.english_default, no extra API calls). Likecountry_default, it's an exact field lookup but still a weak signal — informational, not something to rely on alone.unless:is an optional per-rule block, same match-key vocabulary asmatch, AND-combined among itself. If a rule'smatchpasses AND itsunlessalso fully passes, the rule is blocked — treated exactly like a non-match, so evaluation continues to the next rule (this is not the age-gate's hard stop). Cannot containartist_in_playlist(itsautotarget only resolves from a rule's top-levelmatch).any_of: [...]insidematchis "OR within one rule": a list of match-condition groups (same vocabulary, each internally ANDed); the whole key passes if any one group passes, then ANDs with the rest ofmatchas usual. A group cannot containartist_in_playlistor nest anotherany_of. Also usable insideunless. Seedesign/proposals/more-conditions.mdfor the full design writeup of these last three keys.inbox_since(a date) is optional for manual runs, but songs liked before it are never evaluated regardless of how a run is started — see "Going unattended" below.
Spotify no longer provides genres or a language field. Genre and language come from MusicBrainz, the title's
script, and language learned from your own playlists (language_playlists in config); expect them to be
best-effort. python -m src.backtest measures how far each source can be trusted.
python -m src.syncpreviews what would move (dry-run; writes nothing, logs tologs/).python -m src.analyzedraftsconfig.draft.yamlfrom your existing playlists (all rules disabled; review, then copy intoconfig.yaml).python -m src.enrich --reportmeasures genre/language coverage of your Liked Songs.python -m src.backtestsimulates your rules against playlists you already sorted by hand, for a precision/recall estimate before ever running live.- See your own data: commit
config.yaml, then either click Run now on your Dashboard's Overview, or in your fork on GitHub open Actions → Sync → Run workflow (leave "Dry run" ticked). Once it finishes it commitslogs/*.jsonback to your fork; your Pages site's Dashboard reads them straight from your fork in Repo mode. If you would rather not commit logs to a public repo, or want to see them immediately, use the Dashboard's Open local files source and drag yourlogs/folder in — nothing is uploaded, it never leaves your browser.
Open Configure → Basics → Run automatically, pick the time of day and days in your own timezone (your
browser's timezone is filled in), and Save to GitHub. Nothing else to set up: the Sync workflow wakes every
30 minutes and only does something at the time you chose, stored as schedule: in config.yaml.
- Dry run first. Scheduled runs only show what would move (look at the dashboard) until you also turn on
Really move songs on schedule (
schedule.apply: true). inbox_sinceis required for that switch (Configure and the tool both refuse otherwise). An unattended run never touches a song liked before that date, and each run is capped by--max-moves(default 50).- GitHub's limits: a scheduled run can start late, sometimes hours (a slot stays valid for 12 hours, so a late or dropped tick still catches up, and runs once; the dashboard Runs view shows the slot, when GitHub fired the run, and when it started and finished, in UTC and IST), and GitHub pauses scheduled workflows in a repo with no activity for 60 days. The workflow refreshes itself daily once your last own commit is 30+ days old, but GitHub does not document what counts as activity, so if scheduled runs ever stop, press Enable workflow in the Actions tab (saving your config also counts). Forks must enable Actions on first visit, as GitHub disables them by default.
The dashboard is read-only: it cannot change Spotify, and it is part of the Pages site (there is no separate local server to run for normal use). It has three data sources, and it always shows which one is active in a label above the source picker:
- Repo (the default once the site is published on
*.github.io) reads your fork'slogs/folder straight fromraw.githubusercontent.com. This needslogs/*.jsonto actually be committed, which the Sync workflow does for you (see "Try it" above). - Demo data is the fixture data bundled with the site, for visitors who have not forked yet. It is the default everywhere else (e.g. running the site locally).
- Open local files is a drag-and-drop/file-picker area that reads
logs/*.jsonfiles entirely in your browser via the File API — nothing is uploaded anywhere. Committed logs never carry a song's title, artist or Spotify URI unlesslogging.include_track_names: trueis set (default is off, so public forks show counts and decisions only); Open local files is the only source that can show you real titles, since the files never leave your computer.
The source picker remembers your last choice. Every view shows when its data was made; a yellow banner appears if it is more than 2 days old. Every view always renders its full content — there is no reduced Simple mode.
- Overview asks "is it healthy?" with five cards, each answering one question: Last run (did it go well, and were there any problems), Next run (when it runs next; shown only when your run history carries a schedule), Waiting in Liked Songs (how many songs are waiting right now), Moved this week (songs moved by real runs in the last 7 days) and Last real run (every song accounted for, or a song that may need restoring). One sentence above them says what happens next. After a real run the inbox file is the list as it was when that run started, so the Overview takes the "waiting now" number from the run's own result instead. A Run now button dispatches the Sync workflow directly from here (via a token you create and control).
- Inbox asks "what is waiting, and why?": every liked song with its decision. Search, filter and sort it, then click a title to open the Explain drawer: a plain sentence on what happened, the rule-by-rule trace, and where the language and genre came from. Titles and artists always wrap in full, never cut off, on a phone too. After a real run a note at the top says the list is the inbox as it was when that run started.
- Rules asks "what does each rule do?": one card per rule with how many songs real runs have moved through it. Open a card for its conditions and the last 5 songs it moved, each with a button to that run's log.
- Playlists asks "can every target be written to?": found, missing, not writable or ambiguous, its size and how many real runs have added songs to it. Open a card for the last 5 of those runs.
- Runs is the history. Each row has a one-line summary ("Moved 4 songs: 3 to Playlist A, 1 to Playlist B"). A run's page leads with what moved where and by which rule, then what was left behind and any problems; the technical part (safety check, restore command, journal of removals) sits in a collapsed section underneath. Where the logs hide titles it shows "Song 1 of 4 (title hidden)" and when each was liked. You can also compare two runs side by side.
The dashboard has no separate Safety, Backtest or Signals tabs. The reconcile/restore/journal detail lives in each
real run's page under Runs. python -m src.backtest (precision/recall per playlist, confusions, worst
misroutes, and per-source language precision in logs/signal-precision.json) and python -m src.enrich --report
(coverage) are still standalone command-line tools with unchanged measurements. For a single song, the Explain
drawer shows its language and genre and where they came from.
Colours are never the only clue; every badge has a word. Green (ok) means fine, blue (info) means neutral information such as a dry run or a song that is too young, amber (warning) means look at this, red means a problem.
Language tiers: Playlist (learned from your own language playlists) and Script (the writing system of the title) are trusted. Hint and Country default are guesses; they may drive a rule only when measured at 90% precision or better ("qualified").
Terms: Blocked song: a rule would have matched, but only through a language signal that is not qualified.
Withheld signal: the language was found but hidden from the rules for that reason. What-if: a preview
in which disabled rules count as enabled; it is not what a normal run does. Old library banner: shown only when no
inbox start date (inbox_since) is set, meaning every song already in Liked Songs is in scope.
- Dry-run by default;
--applyneeds an explicit selector (--newest,--only-uris, or--allow-unselectedfor unattended runs only, which itself requiresinbox_since). - A
--max-movescap aborts a plan that's too big — and still writes an honest log (aborted_too_many) so the dashboard says so plainly, rather than silently disappearing. - Journal before any removal; verify after every move; reconcile against the journal on each run; restore from
the journal (
--restore) if needed. - Only
spotify:track:URIs are touched. Only playlists you own or that are collaborative are written to. - Committed public logs never carry a song's title, artist or URI by default (a bare URI resolves to a real song in one request, so it's stripped too) — only counts and decisions.
python -m pip install -r requirements-dev.txt
python -m pytest -q
python -m pytest tests/e2e -q -m e2e # needs `playwright install`
Live tests are skipped unless LIKEDZERO_LIVE=1.
Open source — fork and run against your own Spotify Developer app and GitHub Actions secrets. No shared backend.