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)
- In the Spotify developer dashboard (the existing app,
SPOTIFY_CLIENT_ID) add the redirect URIshttps://stanrunge.dev/api/spotify/callbackandhttps://dev.stanrunge.dev/api/spotify/callback. - 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. - That's it: the app itself polls every minute (
src/lib/server/music/poller.ts, started fromhooks.server.tsinit) 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.