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
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:
66
deploy/music-collector/README.md
Normal file
66
deploy/music-collector/README.md
Normal 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.
|
||||
17
deploy/music-collector/install.sh
Executable file
17
deploy/music-collector/install.sh
Executable 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
|
||||
15
deploy/music-collector/music-collector.service
Normal file
15
deploy/music-collector/music-collector.service
Normal 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
|
||||
10
deploy/music-collector/music-collector.timer
Normal file
10
deploy/music-collector/music-collector.timer
Normal 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
|
||||
Reference in New Issue
Block a user