# 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:`) | ## 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 once. Scopes: `user-read-currently-playing user-read-recently-played user-read-playback-state user-top-read`. 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: ```sh sudo docker exec -i postgres-db-1 psql -U postgres -d personal <<'SQL' CREATE ROLE music_collector LOGIN CONNECTION LIMIT 3 PASSWORD ''; 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: ```sh 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= 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.