Adds the approved design for a new optional `spotify` MusicProvider that streams real Spotify audio via a librespot-family sidecar behind one SpotifyAudioBackend interface (go-librespot on Linux/Docker, Rust librespot on Windows), with metadata via the Spotify Web API. Disabled by default, opt-in, Premium-only, ToS-risk warned. Includes verified research appendix. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
17 KiB
Spotify audio source (optional, hybrid librespot) — design spec
- Issue: #112 — Support for Spotify audio source
- Date: 2026-07-01
- Status: Approved (design) — pending implementation plan
- Chosen approach: Hybrid — real Spotify streaming via a librespot-family sidecar, with go-librespot on Linux/Docker and Rust librespot on Windows, behind one backend interface.
1. Summary
Add spotify as a new optional MusicProvider. Metadata (search / track / album / playlist) is read from the official Spotify Web API. Audio is the real Spotify stream, produced by a librespot-family sidecar and piped through ffmpeg into the bot's existing voice path.
The feature is disabled by default, opt-in, and requires the user's own Spotify Premium account. librespot is an unofficial, reverse-engineered client and using it violates Spotify's Terms of Service (account-ban risk). This is surfaced to the user as an explicit experimental warning, and no credentials are ever bundled.
If the feature is disabled, unauthenticated, or the sidecar binary is missing, the provider disables itself gracefully — exactly like YouTubeProvider when yt-dlp is absent (empty results, greyed-out in the UI, no crash).
2. Goals / Non-goals
Goals
- First-class
spotifysource: search, play, playlist/album import, lyrics best-effort, login status. - Real Spotify audio (Premium), not a YouTube match.
- Cross-platform: works on the project's three deployments — native Windows one-click, Linux systemd, Docker.
- Strictly optional and safe-by-default; zero impact when off.
- Mixed queues keep working (Spotify tracks interleaved with netease/qq/etc.).
Non-goals
- No free-tier audio (Premium is mandatory for librespot streaming).
- No bundled Spotify credentials or shared Developer app.
- No replacement of, or change to, existing sources' behavior.
- No CI e2e against live Spotify (requires Premium; manual only).
3. Backend selection
spotify.backend: "auto" | "go-librespot" | "librespot" (default auto).
| Platform | auto resolves to |
Why |
|---|---|---|
| Windows | librespot (Rust) |
go-librespot has no Windows binary and its FIFO capture is POSIX-only |
| Linux / Docker | go-librespot (fallback librespot) |
clean REST play-by-URI + prebuilt Linux binary |
| macOS | librespot (or go-librespot if built) |
no go-librespot macOS binary published |
The split is platform-determined, not a per-run user toggle (a user may still force one via config if they have the binary).
4. Architecture
4.1 The seam — SpotifyAudioBackend
New file src/music/spotify/backend.ts:
export interface SpotifyTrackEndedEvent { uri: string; reason: "ended" | "stopped" | "error"; }
export interface SpotifyAudioBackend {
start(): Promise<void>; // launch sidecar + resampling ffmpeg
stop(): void; // tear everything down
isReady(): boolean; // device online & streamable
playTrack(uri: string): Promise<void>; // begin ONE track (spotify:track:...)
pause(): Promise<void>;
resume(): Promise<void>;
seek(ms: number): Promise<void>;
getPcmStream(): Readable; // 48kHz s16le stereo (post-ffmpeg)
getPosition(): number; // ms into current track
on(event: "trackEnded", cb: (e: SpotifyTrackEndedEvent) => void): void;
on(event: "metadata", cb: (m: SpotifyNowPlaying) => void): void;
on(event: "ready" | "error", cb: (arg?: unknown) => void): void;
}
Both implementations emit the same 48 kHz s16le stereo PCM and the same events, so everything above the seam (provider, controller, player, UI) is backend-agnostic.
4.2 GoLibrespotBackend (Linux/Docker) — src/music/spotify/go-librespot.ts
- Writes a
config.ymlwithserver: { enabled: true, address: 'localhost', port: <p> },audio_backend: 'pipe',audio_output_pipe: '<fifo>',audio_output_pipe_format: 's16le',bitrate: 320, credentials block. start():mkfifo <fifo>→ spawn the FIFO-reading ffmpeg first → then spawn go-librespot. (FIFO open ordering is mandatory: go-librespot opens the write end withO_WRONLY|O_NONBLOCKand errorsENXIOif no reader exists yet.) ffmpeg:-f s16le -ar 44100 -ac 2 -i <fifo> -f s16le -ar 48000 -ac 2 -. ffmpeg stdout =getPcmStream().- Control (REST):
POST /player/play {uri},POST /player/pause,POST /player/resume,POST /player/seek {position}.GET /statusfor position. - Events (WebSocket
/events):metadata→metadata;not_playing→trackEnded(track boundaries can NOT be read from the FIFO — it is gapless/continuous and never EOFs between tracks). - Metadata token: go-librespot exposes
POST /token(first-party session token) and a/web-api/<path>proxy toapi.spotify.com— so on this backend a separate Spotify Developer app is optional. - Recovery: on reader death go-librespot's write returns
EPIPEand closes the pipe output; backend restarts the ffmpeg reader + reactivates the device.
4.3 RustLibrespotBackend (Windows / cross-platform) — src/music/spotify/rust-librespot.ts
Rust librespot is a passive Spotify Connect receiver; the bot acts as the Connect controller via the Spotify Web API.
start(): spawnlibrespot --backend pipe --name "<deviceName>" --bitrate 320 --cache <dir> [--access-token <tok> | --enable-oauth]. With no--device, librespot writes raw PCM (s16le, 44100 Hz, stereo) to stdout (cross-platform; verified againstpipe.rs—None => Box::new(io::stdout())). Pipe stdout → ffmpeg-f s16le -ar 44100 -ac 2 -i pipe:0 -f s16le -ar 48000 -ac 2 -→getPcmStream(). Do not pass--passthrough(that emits raw Ogg, not PCM).playTrack(uri):GET /v1/me/player/devices→ find ourdevice_idbydeviceName→PUT /v1/me/player/play?device_id={id}body{uris:[uri]}.pause/resume/seek→PUT /v1/me/player/{pause,play,seek}.trackEnded: pollGET /v1/me/player(is_playing → false /itemchanged /progress_ms ≈ duration_ms) plus optional--oneventhook (read-only notifications). Add a watchdog + retry/backoff for device-visibility latency and command202/404flakiness.- Requires a user OAuth token with scopes
streaming user-read-playback-state user-modify-playback-state user-read-currently-playing(+playlist-read-private playlist-read-collaborativefor user playlists).
5. Shared subsystems
5.1 Metadata — src/music/spotify/webapi.ts
Thin axios client mapping Spotify catalog objects → the bot's Song / Playlist / Album:
GET /v1/search?type=track,album,playlist&q=…GET /v1/tracks/{id},GET /v1/albums/{id}/tracks,GET /v1/playlists/{id}/tracks
All confirmed still available with a normal token after Spotify's 2024-11-27 cut (that cut removed related-artists, recommendations, audio-features/analysis, featured/category playlists, and preview_url — none of which we use). Token source is pluggable: user Developer-app token (primary) or go-librespot /web-api proxy (go-librespot path). Handle 429 Retry-After (rolling 30 s window; dev-mode quota).
5.2 Auth — src/music/spotify/auth.ts
One web-UI Authorization-Code + PKCE login.
- Primary: the user registers their own Spotify Developer app (Client ID [+ Secret] + redirect URI) — reliable, ToS-cleaner. The resulting access token drives metadata + Web-API control; the refresh token is persisted (credential store); access tokens (~1 h) auto-refresh. The same token bootstraps librespot via
--access-token, after which librespot caches reusable credentials — one user-facing login. - Fallbacks: librespot
--enable-oauth(built-in client65b708073fc0480ea92a077233ca87bd, redirecthttp://127.0.0.1:8898/login) or go-librespot interactive login (http://127.0.0.1:36842/login?code=…) as a separate one-time step; go-librespot/web-apiproxy when no Developer app is provided. - Username/password is dead (removed by Spotify in 2024); do not implement it.
5.3 Provider — src/music/spotify/provider.ts
SpotifyProvider implements MusicProvider with platform: "spotify":
search,getSongDetail,getPlaylistSongs,getAlbumSongs,getLyrics(best-effort/empty),getRecommendPlaylists→ Web API.getAuthStatus()reports login state and backend/binary availability (drives greying-out in UI, like YouTube).getSongUrl(id)returns a sentinel ({ url: "spotify:track:<id>" }) — actual playback is via the backend/controller, not a URL.instance.tsrecognizes the sentinel and routes to theSpotifyController.- QR-code login methods are no-ops; Spotify uses the OAuth card instead.
6. Queue / player / instance integration
SpotifyController(src/music/spotify/controller.ts, one per bot): owns the chosen backend and the Web-API/auth clients; exposesplayTrack/pause/resume/seek/stopand forwardstrackEnded/metadata.AudioPlayer— new external-PCM mode: addplayPcmStream(readable, { onExternalEnd })that feeds the existingpcmBuffer→ 20 ms frame loop → encoder →framepath without spawning a url-ffmpeg. For Spotify,trackEndis driven by the backend'strackEndedevent (the librespot→ffmpeg pipeline is long-lived and does not exit per song).pause/resume/seekon a Spotify song are routed to the backend byinstance.ts(and gate frame emission locally for crisp UI state).instance.ts:getProviderFor("spotify"), a-scommand flag ingetProvider(flags). When a dequeuedsong.platform === "spotify": ensure the controller/backend is started + device active,playTrack(uri), attach the player to the backend PCM. OntrackEnded→ advance the queue. When a non-Spotify song is next, pause the sidecar (so it doesn't buffer ahead) and use the normalplayer.play(url)path. This preserves one-track-at-a-time on-demand playback and mixed-source queues.- Real-time pacing is guaranteed by the voice consumer: TS voice pulls 20 ms frames at real time → the player reads PCM at real time → ffmpeg's read of the sidecar stalls → sidecar backpressure pauses decode. (This is why go-librespot's pull model avoids the classic librespot "plays too fast / skips" bug; Rust librespot to stdout is likewise paced by our reads.)
7. Config, opt-in & safety
BotConfig.spotify (all default-off), added to getDefaultConfig() and sanitized in loadConfig():
spotify: {
enabled: false,
backend: "auto", // "auto" | "go-librespot" | "librespot"
clientId: "", // user's Developer app (optional on go-librespot path)
clientSecret: "", // optional — PKCE needs none; only for confidential/client-credentials flows
deviceName: "TSMusicBot",
bitrate: 320, // 96 | 160 | 320
}
Inert unless enabled and logged-in and a resolvable binary. First-run/settings shows the experimental + ToS + Premium + own-credentials warning. Never store or transmit shared secrets.
8. Web UI
- Add
spotifytoplatformandSourceunions (web/src/stores/player.ts,web/src/stores/sourceTabs.ts). SourceTabs.vue: add "Spotify" tab;SongCard.vue: green #1DB954 badge;variables.scss:--brand-spotifytokens.stores/player.ts: extendauthStatus/recommendPlaylists/dailySongs/userPlaylistsmaps + the auth/recommend fetch fan-out.- Settings: a Spotify login card distinct from the QR cards — "Connect Spotify" OAuth button, optional Client ID/Secret fields, backend/binary status indicator, and the risk disclaimer.
9. Binary / dependency resolution — src/music/spotify/binary.ts
Mirror findYtDlp(): resolve go-librespot / librespot(.exe) from bin/ then PATH; positive availability cached, negative retried (install-while-running).
- No prebuilt Rust librespot exists (source-only:
cargo install librespot, distro/scoop/choco, or a binary dropped inbin/). - go-librespot ships Linux-only assets (
go-librespot_linux_{x86_64,arm64,armv6,armv6_rpi}.tar.gz, ~6 MB, atgithub.com/devgianlu/go-librespot/releases). Docker build downloads the correct Linux asset; optional runtime auto-download (like yt-dlp). go-librespot is GPL-3.0 → sidecar = mere aggregation (does not infect the Node code); ship its license text + a source offer. - Unresolved binary → source disabled with an actionable "install X / see docs" message.
10. Testing
Vitest, mocking child processes and HTTP:
- Web-API response →
Song/Playlist/Albummapping. chooseBackend()per platform/config.- Auth: PKCE challenge, token refresh, credential persistence.
- Config load/sanitize (defaults, hand-edited/corrupt input).
SpotifyProvidermethods (mock webapi).- Player external-PCM path: feed a fake
Readable→ assertframeemission +trackEndon external end. - go-librespot
/events→trackEndedtranslation; Rust-backend Web-API poll →trackEnded. - e2e against live Spotify is manual & documented (Premium required), not CI.
11. Files touched
New: src/music/spotify/{provider,backend,go-librespot,rust-librespot,webapi,auth,controller,binary}.ts (+ .test.ts).
Edited (backend): src/music/provider.ts (union: Song/Playlist/Album/MusicProvider), src/index.ts, src/bot/manager.ts, src/bot/instance.ts (router + -s flag + spotify routing), src/audio/player.ts (external-PCM mode), src/data/config.ts, src/music/auth.ts (credential store union), src/web/api/auth.ts, src/web/api/music.ts (routers + search aggregation), src/web/server.ts, src/data/database.ts (platform).
Edited (frontend): web/src/stores/player.ts, web/src/stores/sourceTabs.ts, web/src/components/SourceTabs.vue, web/src/components/SongCard.vue, web/src/styles/variables.scss.
Docs: README.md (Spotify section + warnings + install notes).
12. Staged rollout
- Metadata + provider + config + UI plumbing (no audio):
spotifysource searchable/browsable; playback returns "not yet playable". Fully testable without a sidecar. - go-librespot backend (Linux/Docker): real playback on Linux; REST + FIFO + WebSocket.
- Rust librespot backend (Windows): stdout PCM + Web-API Connect control.
- Polish: recovery/watchdog, docs, binary auto-download, README.
13. Risks & open questions
- Rust librespot control is the top risk — Connect device visibility/latency, poll-based track-end. Mitigation: retry/backoff + watchdog; degrade to "skipped" on repeated failure.
- One-OAuth-token bootstraps librespot (
--access-tokenfrom the user's own app client) is plausible but unverified — may fall back to two one-time logins. Verify with a spike in stage 3. - Windows go-librespot is impossible (FIFO) → Windows always uses Rust librespot.
- Token scope from librespot's built-in client may not include
user-modify-playback-state; if so, a user Developer app is required for the Rust/Windows control path. - Large surface area → staged rollout above; each stage independently shippable.
Appendix A — Verified technical facts (with sources)
go-librespot API (devgianlu/go-librespot, v0.7.x):
- REST:
POST /player/{play,pause,resume,playpause,stop,next,prev,seek,volume,add_to_queue},GET /status,GET /({playback_ready}),POST /token,GET|POST /web-api/<path>.POST /player/playbody{uri, skip_to_uri?, paused?}. Server enabled only whenserver.enabled: true. - WebSocket
/eventsenvelopes{type, data}; types includemetadata, will_play, playing, paused, not_playing, stopped, seek, volume, active, inactive. Track-end =not_playing. - Pipe backend: continuous raw PCM, no header, 44100 Hz stereo, format
s16le|s32le|f32le; FIFO opened once; POSIX-only. Backpressure real (blocks when reader stalls). - Sources:
github.com/devgianlu/go-librespot(README,api-spec.yml,daemon/api_server.go,output/driver-pipe.go).
Rust librespot (librespot-org/librespot, v0.8.0, MIT):
--backend pipewith no--device→ raw PCM to stdout; default S16 / 44100 / stereo;--formatfor higher bit depth;--passthrough= raw Ogg (do not use). Passive Connect receiver — no play-by-URI; control via Web API Connect.--onevent= read-only hook. No prebuilt binaries.- Sources:
github.com/librespot-org/librespotwiki (Audio-Backends, Options),librespot_playback/audio_backend/pipe.rs,docs/authentication.md.
Spotify auth & Web API:
- Username/password removed (2024); use OAuth (Auth-Code+PKCE) or Zeroconf. Premium required for librespot audio.
- Client-Credentials token authorizes
/v1/search+ public track/album/playlist GETs; 2024-11-27 cut did not touch these. Token endpointPOST https://accounts.spotify.com/api/token(grant_type=client_credentials, Basic base64(id:secret),expires_in=3600, no refresh). 429 on a rolling 30 s window. - Sources:
developer.spotify.com(Web API docs; 2024-11-27 blog),librespotdocs/authentication.md.