a spotify watchdog and database for likes ❤️ and track history ⏳.
spike watches your spotify account, records every like and play as an event in
a local sqlite database, keeps monthly playlists (YYYY-MM) in sync with your
liked songs, and can rebuild its entire database from the spotify api at any
time. the database is the journal; spotify is the source of truth.
create an app for the spotify Web API on your spotify developer dashboard
App Name: my spike app
App Description: watchdog for likes ❤️ and track history ⏳
Redirect URI: http://127.0.0.1:8888/callback
copy .env.example to .env and add your client credentials.
PORT=8888
CLIENT_ID="YOUR_SPOTIFY_CLIENT_ID"
CLIENT_SECRET="YOUR_SPOTIFY_CLIENT_SECRET"
LIKES_INTERVAL_S=60
HISTORY_INTERVAL_S=60docker compose up -d --buildthe db directory (sqlite database, artwork, auth tokens) is bind-mounted
and persists across rebuilds. deploying an update is
git pull && docker compose up -d --build.
executed with bun; the code itself uses standard node APIs.
bun install
bun index.js # or: bun --watch index.js for developmenta systemd unit for running without docker is in deploy/spike.service.
log in once at http://127.0.0.1:8888/login. tokens are stored in
db/auth.json and refreshed automatically.
the running daemon is the single executor; the spike cli is a thin http
client (SPIKE_URL to target a remote instance, default http://127.0.0.1:8888).
bun cli.js sync-likes # rebuild/refresh all likes from the spotify api
bun cli.js reconcile --dry-run # per-month drift report, no changes
bun cli.js reconcile --since 2026-04 # add missing likes to monthly playlists
bun cli.js reconcile --month 2026-05 # single month
bun cli.js reconcile --prune # also remove non-liked extras (default: keep)
bun cli.js verify # consistency + integrity checks, exit 2 on drift
bun cli.js hydrate # backfill track metadata + album artwork
bun cli.js stats # totals, likes per month, top artists
bun cli.js events --month 2026-06 --kind savedremote example from another machine on the LAN:
SPIKE_URL=http://nuc:8888 bun cli.js verifyor plain http: curl -X POST http://nuc:8888/ops/sync-likes.
a nightly consistency check via cron:
15 4 * * * curl -sf -X POST http://127.0.0.1:8888/ops/verify | grep -q '"ok":true' || echo "spike drift" | mail -s spike you@example.commonthly playlists are kept in sync by comparing desired state (liked
tracks grouped by the calendar month of added_at, Europe/Berlin time)
against actual state (the real playlist contents) and adding whatever is
missing. catching up on missed months, healing drift, and live operation are
all the same code path. reconcile is additive-only: tracks you removed
from a playlist by hand, or unliked later, are reported but never touched
(--prune opts into exact matching per run).
bulk saves are excluded. pre-2019, saving an album added every track to
liked songs; bulk library imports do the same. those events share identical
added_at timestamps - a human can't individually like 5 songs in one
second - so same-second clusters of ≥ 5 (BULK_THRESHOLD) are kept in the
event log but skipped by reconcile, reported in the bulk column.
--include-bulk opts them back in.
every observation is an event row with a deterministic id derived from
its natural key (provider|kind|timestamp|track), so re-running any sync or
rebuilding the database from scratch never creates duplicates:
saved- a like, timestamped with spotify'sadded_atlisten- a play from recently-played, timestamped withplayed_atplaylist-added- a track landing in a monthly playlist (also backfilled from spotify's ownadded_atwhen playlists are scanned)
every event also stores a local timestamp + timezone alongside the
verbatim utc triggered_at. spotify reports only utc, so local time is
derived from the home zone (SPIKE_TZ, default Europe/Berlin) at record
time - sqlite can't do IANA/DST math, so it's precomputed like month.
bun cli.js stats # includes a listens-by-hour histogramsync-likes refetches the entire library; deterministic ids make it
idempotent. delete db/spike.db and everything except play history is fully
reconstructed from the api. limitation: spotify only exposes the last ~50
recently-played tracks, so listen history is live-capture only - keep the
daemon running.
the one exception: your full lifetime play history can be rebuilt from spotify's gdpr export. request the extended streaming history under account privacy settings (arrives by email within ~30 days), unzip it somewhere the daemon can read, and run
bun cli.js import-history --path /path/to/my_spotify_dataplays shorter than 30s are skipped (--min-ms to change), entries without a
track uri (podcasts, or the basic non-extended export) are ignored, and a
±2 minute fuzzy window prevents duplicates where the export overlaps
live-captured plays. re-importing is a no-op. run hydrate afterwards to
fill in metadata and artwork for historical tracks.
the daemon polls likes and recently-played every *_INTERVAL_S seconds,
records events, and triggers a debounced reconcile of the affected month.
watcher cursors persist in the database, so likes during downtime are picked
up on the next poll or sync-likes.
any newly discovered song - via a like, a listen, or a playlist add - is
hydrated automatically: the same debounced action fetches full track
metadata and downloads the album cover, so /browse shows the art and the
journey push carries it without a manual hydrate. the hydrate command
remains for backfilling an existing database.
spike owns the journey.music module (journey/ - manifest + schemas for
music.track, music.listen, music.library_event). register it once per
server with JOURNEY_SETUP_TOKEN=<admin token> bun cli.js journey-register;
re-run after schema changes (it upserts, and the server refuses upgrades that
would orphan a schema version still used by stored items). pushes before
registration come back as retryable unknown_schema rejections and drain on
the next sync.
Journey is opt-in. Run journey-sync manually when JOURNEY_URL,
JOURNEY_TOKEN, and JOURNEY_CLIENT_ID are configured (a journey client with
write:music.* scope), or set JOURNEY_ENABLED=true to let the daemon push
automatically after watcher activity. Without JOURNEY_ENABLED=true, spike
continues to hydrate artwork and run locally without contacting Journey.
journey-sync pushes everything to a journey server as
provider-agnostic items over its normal sync api:
- tracks become
music.trackitems (deterministic ids from the provider uri), with album artwork uploaded as content-addressed blobs and attached with roleartwork listenevents becomemusic.listenitems,savedandplaylist-addedevents becomemusic.library_eventitems - reusing the event ids, which are already journey-style deterministic ulids, and carrying the event's timezone in the envelopetzfield (canonical utctsfor ordering, explicittzfor meaning) so the archive can answer time-of-day queries
immutable events push once (rowid cursor); mutable tracks are dirty-tracked
and re-push when their metadata or artwork is filled in later, so a track
never lands on the archive permanently art-less. the daemon hydrates before
pushing, so tracks normally arrive with artwork on the first push. the whole
thing is idempotent (--full re-pushes everything; the server absorbs
duplicates). With JOURNEY_ENABLED=true, any new listen, like, or playlist add
triggers a debounced hydrate-then-push ~10s later, and failures retry on the next
change since neither the cursor nor the dirty marks advance on error. spike stays
the source of its own working state; journey is the archive.
hydrate downloads each album's cover (largest size) once into a
content-addressed store (db/artwork/sha256/<aa>/<bb>/<hash>, the same
layout as the journey blob store, no extensions - content type lives in the
database) and links tracks to it - ready to be pushed as blobs to a future
journey server.
GET /browse- open in a browser: a Library cover grid of all tracks, and a History view — a day-grouped listen timeline (search, newest first)GET /healthz- livenessGET /stats- totals, likes per month, top artists, last sync timesGET /events?month=&kind=&limit=- event logGET /tracks?q=&limit=&offset=- track details incl. saved date + play countGET /artwork/<sha256>- album cover from the content-addressed storePOST /ops/sync-likes | /ops/reconcile | /ops/verify- operations (query params:dry-run,prune,month,since,strict,deep)POST /ops/hydrate+GET /ops/jobs/:id- long-running hydrationGET /login,GET /callback- spotify oauth
everything lives in db:
spike.db- sqlite database (tracks, events, playlist cache, sync state)artwork/- content-addressed album coversauth.json- spotify oauth tokensliked.json,playlists.json,history.csv- legacy files from the pre-sqlite era, kept untouched; not read by the current code
sqlite driver: bun:sqlite under bun, better-sqlite3 under node ≥ 18 - the
single runtime-specific module is src/db/driver.js.