Files
website/deploy/music-collector

music (self-hosted Last.fm)

/trackers/music (stats) and the "listening now" card on the homepage read the music schema in the personal database:

table / view contents
artists, albums Spotify ids when known (imports are matched by name), album art URLs (640 px + small)
tracks, track_artists match_key = normalised artist|title (src/lib/music/normalize.ts) for cross-source dedupe
plays one row per play: source spotify / lastfm / youtube / local, origin api / export / import
plays_deduped (view) plays minus Last.fm scrobbles that Spotify also has (what every stat reads)
now_playing row 1, refreshed every minute
spotify_auth row 1, refresh token + poller state. Not granted to personal_ro
import_state resumable backfill progress (lastfm:<user>)

Spotify (live)

  1. In the Spotify developer dashboard (the existing app, SPOTIFY_CLIENT_ID) add the redirect URIs https://stanrunge.dev/api/spotify/callback and https://dev.stanrunge.dev/api/spotify/callback.
  2. Logged in as the owner, open https://stanrunge.dev/api/spotify/connect once. Scopes: user-read-currently-playing user-read-recently-played user-read-playback-state user-top-read user-library-read playlist-read-private.
  3. That's it: the app itself polls every minute (src/lib/server/music/poller.ts, started from hooks.server.ts init) with its own role and Spotify credentials. Status (last poll, last error) is shown to the owner on /trackers/music.

Optional: poll from the host instead

Only if the poller should keep running independent of the app container. Needs its own role:

sudo docker exec -i postgres-db-1 psql -U postgres -d personal <<'SQL'
CREATE ROLE music_collector LOGIN CONNECTION LIMIT 3 PASSWORD '<pick one>';
GRANT CONNECT ON DATABASE personal TO music_collector;
GRANT USAGE ON SCHEMA music TO music_collector;
GRANT SELECT, INSERT, UPDATE ON music.artists, music.albums, music.tracks, music.track_artists,
  music.plays, music.now_playing, music.import_state TO music_collector;
GRANT SELECT, UPDATE ON music.spotify_auth TO music_collector;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA music TO music_collector;
SQL

~/.config/music-collector.env (mode 600): MUSIC_DB_URL=postgresql://music_collector:…@127.0.0.1:5440/personal, SPOTIFY_CLIENT_ID=…, SPOTIFY_CLIENT_SECRET=…. Then bash deploy/music-collector/install.sh --enable and set MUSIC_POLLER=off in prod.env. (The music migration grants the same when the role already exists.) Debug: journalctl --user -u music-collector -n 20.

Backfills

Both run from the deployed image with the app role, so no extra credentials:

tag=$(sudo sed -n 's/^IMAGE_TAG=//p' /opt/personal-website/.env)
# Last.fm (needs an API key from https://www.last.fm/api/account/create); resumable, re-run any time
sudo docker run --rm --network backend --env-file /etc/personal-website/prod.env \
  -e LASTFM_API_KEY=<key> git.stanrunge.dev/stan/website:$tag bun scripts/music-import-lastfm.ts --user stanrunge
# Spotify Extended Streaming History (unzipped export in ~/spotify-export)
sudo docker run --rm --network backend --env-file /etc/personal-website/prod.env \
  -v ~/spotify-export:/data:ro git.stanrunge.dev/stan/website:$tag bun scripts/music-import-history.ts /data

Dedupe: within one source a play within 60 s of the same match_key is skipped on insert; across sources plays_deduped hides a Last.fm scrobble when a Spotify play of the same match_key ended between 3 min before and track length + 3 min after the scrobble time (Last.fm records when a track started, Spotify when it ended). Listening time uses ms_played when known, else the track length.