music: self-hosted scrobbler (Spotify poller, Last.fm + streaming-history importers, /trackers/music, homepage listening card)
All checks were successful
Deploy production / deploy (push) Successful in 2m37s

- music schema: artists/albums/tracks (+album art), plays with source + dedupe, now_playing,
  spotify_auth, import_state; plays_deduped view hides Last.fm scrobbles Spotify also has
- in-app Spotify poller (60s, leased) started from hooks init; no new role or env needed
- /api/spotify/connect + callback (owner only), /trackers/music public stats
- scripts: music-poll, music-import-lastfm (resumable), music-import-history
- deploy/music-collector: optional host timer with its own role
This commit is contained in:
2026-10-24 15:56:50 +00:00
parent 45c124a365
commit b757269151
37 changed files with 22124 additions and 6 deletions

View File

@@ -0,0 +1,66 @@
# 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`.
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 '<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:
```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=<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.

View File

@@ -0,0 +1,17 @@
#!/usr/bin/env bash
# Install the optional host-side Spotify poller as a systemd user timer (run as stan on the
# server). Copies the units; enables the timer only with --enable (needs
# ~/.config/music-collector.env, see README.md). Normally not needed: the app polls itself.
set -euo pipefail
here="$(cd "$(dirname "$0")" && pwd)"
install -D -m 644 "$here/music-collector.service" "$HOME/.config/systemd/user/music-collector.service"
install -D -m 644 "$here/music-collector.timer" "$HOME/.config/systemd/user/music-collector.timer"
systemctl --user daemon-reload
if [ "${1:-}" = --enable ]; then
test -f "$HOME/.config/music-collector.env" || { echo 'Missing ~/.config/music-collector.env' >&2; exit 1; }
systemctl --user enable --now music-collector.timer
systemctl --user start music-collector.service || true
systemctl --user --no-pager status music-collector.service | head -5
else
echo 'Installed (not enabled). Enable with: bash deploy/music-collector/install.sh --enable'
fi

View File

@@ -0,0 +1,15 @@
[Unit]
Description=Spotify scrobbler poll (now playing + recently played -> music.*) for stanrunge.dev/trackers/music
Wants=network-online.target
After=network-online.target
ConditionPathExists=%h/.config/music-collector.env
[Service]
Type=oneshot
# A checkout of stan/website on main. Not the live dev tree.
WorkingDirectory=%h/dev/website-music
# MUSIC_DB_URL (music_collector role), SPOTIFY_CLIENT_ID, SPOTIFY_CLIENT_SECRET; mode 600.
EnvironmentFile=%h/.config/music-collector.env
ExecStart=/usr/bin/bun scripts/music-poll.ts
TimeoutStartSec=50
Nice=10

View File

@@ -0,0 +1,10 @@
[Unit]
Description=Spotify scrobbler poll every minute (optional; the app polls by itself)
[Timer]
OnBootSec=45s
OnUnitActiveSec=60s
AccuracySec=5s
[Install]
WantedBy=timers.target