Compare commits

...
Author SHA1 Message Date
b9767a7471 Merge PR #121: show requester names in play history
Merges feature/play-history-requester (@Fa1nttt) into main.

The PR records the WebUI/TeamSpeak requester on queued songs and persists it
to play history (schema migration for requestedBy), rendering it as a badge in
SongCard (gray for 游客/guest).

Conflicts (frontend platform union) resolved to keep both 'spotify' (from #118)
and the new requestedBy/playedAt fields.

Integration fix: the Spotify playback branch in resolveAndPlay (added by #118,
which did not exist on the PR's base) also records play history — added
`requestedBy: song.requestedBy` there so Spotify tracks carry attribution too,
matching the non-Spotify path.

Verified on the merged tree: tsc --noEmit clean, full suite 1309/1309, web build clean.

Co-Authored-By: Fa1nttt <noreply@github.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 23:17:16 +08:00
saopig1andClaude Opus 4.8 47b29358ae docs(readme): add Spotify badge to the badge bar, marked 可选 like YouTube
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 23:08:16 +08:00
Fa1nttt cb66d77e9c feat: show requester names in play history 2026-07-04 22:32:21 +08:00
saopig1andClaude Opus 4.8 9f5e27a277 docs(readme): document Spotify source, search pagination, and full !lyrics
Reflect the two merged PRs in the README:
- Multi-source bullet + changelog: Spotify (#112, experimental/opt-in) and
  per-source search pagination "加载更多" (#115).
- !lyrics command now shows full lyrics chunked into multiple messages (#116).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 15:18:34 +08:00
saopig1 486c3a0a69 Merge PR #120: full !lyrics output (#116) + web search pagination (#115)
# Conflicts:
#	src/bot/instance.test.ts
2026-07-04 15:12:26 +08:00
saopig1 8359c56dc7 Merge PR #118: optional Spotify audio source (hybrid go-librespot/Rust librespot) (#112) 2026-07-04 15:08:50 +08:00
saopig1andClaude Opus 4.8 1a3ccd0e38 fix(spotify): device-scope Rust Connect control + ignore foreign-device poll state (multi-bot) [corner-case R4-4]
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 13:50:28 +08:00
saopig1andClaude Opus 4.8 ec0a027a1e fix(spotify): round seek position to integer ms + one-poll seek grace before near-end skip [corner-case R4-1,R4-6]
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 13:40:21 +08:00
saopig1andClaude Opus 4.8 19306002e3 fix(spotify): go-librespot recover on sidecar death + WS-reconnect status re-sync + stable-connection backoff [corner-case R4-2,R4-3,R4-5]
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 12:08:29 +08:00
saopig1andClaude Opus 4.8 8e89a078a7 fix(spotify): atomic OAuth token store write so a crash during rotating refresh can't corrupt/lose it [corner-case R3-5]
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 11:31:00 +08:00
saopig1andClaude Opus 4.8 4cd269d852 fix(player): don't run stall/EOF end-detection while paused [corner-case R3-4]
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 11:25:23 +08:00
saopig1andClaude Opus 4.8 49a47f5e2b fix(spotify): reconcile sidecar/player state on remove-current, skip-while-paused, and queue-exhaust [corner-case R3-2,R3-3,R3-6]
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 11:17:59 +08:00
saopig1andClaude Opus 4.8 952bd26d2d fix(spotify): confirm transient null-item over two polls before ending Rust track [corner-case R3-1]
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 11:08:28 +08:00
saopig1andClaude Opus 4.8 7e58e8a611 fix: bound album-tracks pagination + treat non-object config as corrupt (backup, not crash) [corner-case R2 minors]
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 12:44:33 +08:00
saopig1andClaude Opus 4.8 2e05d276bf fix(spotify): per-bot Connect device name to avoid multi-bot device collision/misroute [corner-case R2-5]
config.spotify is a single process-wide object shared by every BotInstance, so
config.spotify.deviceName was identical for all bots. On the Rust (librespot)
backend each bot spawned `librespot --name <deviceName>` with no per-bot
uniqueness, registering two Connect devices with the same name under the one
shared account. findDeviceByName()/waitForDevice() match by name, so bot A's
transfer()+play() could drive bot B's librespot (misroute) and a bot could
report ready on seeing the OTHER bot's same-named device (false readiness).

Fix: derive a per-bot-unique Connect identity from the shared base name.
- controller.ts: new exported pure helper perBotDeviceName(base, instanceId?)
  (`${base}-${instanceId}` when an id is given, else base). Add optional
  instanceId to SpotifyControllerOptions; buildBackend() computes the effective
  name once and passes the SAME value to both the Rust and go backends so
  --name, findDeviceByName, and waitForDevice all key on one identity.
- instance.ts: pass instanceId: this.id into buildController(); add instanceId
  to the spotifyControllerFactory param type (test seam).

The user-configured config.spotify.deviceName base is left untouched; the suffix
applies only to the backend/Connect identity. Behavior-preserving for callers
that pass no instanceId (base name used unchanged).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 12:32:02 +08:00
saopig1andClaude Opus 4.8 9a32f3c602 fix(spotify): refresh Web API search provider creds on Settings save (no restart) [corner-case R2-4]
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 12:25:45 +08:00
saopig1andClaude Opus 4.8 2413a9a3a1 fix(spotify): paginate playlist/album tracks (bounded) + null-filter search tracks [corner-case R2-3,R2-6,R2-7]
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 12:20:47 +08:00
saopig1andClaude Opus 4.8 03690952cb fix(config): atomic saveConfig + never overwrite a real config on transient/corrupt read [corner-case R2-1,R2-2]
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 12:03:01 +08:00
saopig1andClaude Opus 4.8 c8227faf31 fix(spotify): never skip a self-paused Rust track (gate near-end + null-state on !paused) [corner-case residual]
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 11:37:55 +08:00
saopig1andClaude Opus 4.8 beda8d626c docs(spotify): document per-bot port-collision limitation + correct misleading comment [corner-case]
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 11:24:06 +08:00
saopig1andClaude Opus 4.8 08fe350e02 fix(spotify): guard non-numeric 429 Retry-After (no immediate retry) [corner-case]
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 11:22:34 +08:00
saopig1andClaude Opus 4.8 6dc99d88e2 fix(spotify): don't skip paused Rust track; handle ffmpeg stdin EPIPE; guard sub-window end-detection [corner-case]
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 11:18:47 +08:00
saopig1andClaude Opus 4.8 11c0948330 docs(spotify): document known limitations (token CLI arg, gapless elapsed) [whole-branch d1,d2]
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 01:55:34 +08:00
saopig1andClaude Opus 4.8 0914cfeb2f test(spotify): guard 429 retry bound, disclaimer copy, deviceName blank-ignore, catalog mappers [whole-branch I5,m2,m3,m4]
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 01:54:02 +08:00
saopig1andClaude Opus 4.8 d796dd48ff fix(spotify): apply UI-entered Client ID to live SpotifyOAuth without restart [whole-branch I2]
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 01:49:33 +08:00
saopig1andClaude Opus 4.8 399cf0cf41 fix(spotify): PATH-aware binary presence detection (bin/ or PATH) [whole-branch I3,m1]
rustPresent/goPresent and getBackendInfo used existsSync(findX()), which for a
bare PATH command name resolves against cwd, not $PATH — so a scoop/choco/cargo/
apt install was invisible and Spotify was gated off. Add a sync PATH-aware
resolveExecutable() + isLibrespotPresent()/isGoLibrespotPresent() in binary.ts
and route controller.ts and web/server.ts through them.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 01:45:48 +08:00
saopig1andClaude Opus 4.8 b4c3cc0539 fix(spotify): tear down in-flight backend on stop + degrade-to-skip on persistent play failure [whole-branch I1,I4]
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 01:37:49 +08:00
saopig1andClaude Opus 4.8 dc763ec689 fix(spotify): resume re-attached PCM stream so mixed-queue Spotify tracks aren't silent [whole-branch C1]
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 01:27:55 +08:00
saopig1andClaude Opus 4.8 b9fe770ab1 feat(spotify): retry/backoff on Connect commands (device-latency/flakiness watchdog) [S4.6]
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 01:09:00 +08:00
saopig1andClaude Opus 4.8 657c198a37 docs(spotify): README Spotify source section — setup, binaries, warnings, license [S4.5]
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 01:02:52 +08:00
saopig1andClaude Opus 4.8 488734c0db feat(spotify): Connect-Spotify settings card (config + OAuth login + status) [S4.4]
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 00:57:30 +08:00
saopig1 6e0a0f7392 fix(spotify): collapse concurrent OAuth refresh + TTL/cap PKCE verifiers [S4.3] 2026-07-03 00:50:40 +08:00
saopig1andClaude Opus 4.8 6a72833c2a feat(spotify): report resolved backend + binaryAvailable on /status; share backend resolver [S4.2]
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 00:45:55 +08:00
saopig1 b672d76634 feat(spotify): expose spotify config on /api/bot/settings (secret masked) [S4.1] 2026-07-03 00:41:29 +08:00
saopig1 eb3523f374 docs(spotify): stage 4 plan — config UI, OAuth hardening, Connect watchdog, docs (#112) 2026-07-03 00:38:11 +08:00
saopig1 6bde51c991 chore(spotify): stage 3 verification pass 2026-07-03 00:17:20 +08:00
saopig1andClaude Opus 4.8 8998b9623f feat(spotify): web OAuth endpoints + thread single shared SpotifyOAuth to web + controllers (Stage 3, Task 6)
Add the /api/spotify {login,callback,status} router behind the SpotifyOAuthLike
seam (DI-tested with supertest, no network). Build ONE process-wide SpotifyOAuth
in index.ts (clientId/redirectUri from config; store via the already-exported
createFileOAuthTokenStore) and thread that same instance into BOTH createWebServer
AND BotManager -> BotInstance -> SpotifyController, so a web login authorizes
playback (C3.1). Reuses the existing file token store (no token-store.ts).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 00:09:12 +08:00
saopig1andClaude Opus 4.8 322335dfc5 feat(spotify): backend selection + Rust librespot wiring in SpotifyController
Add chooseBackend() honoring config.spotify.backend (go-librespot|librespot|
auto) against platform + binary availability (auto: linux+go binary -> go;
else librespot present -> Rust; else null). Controller now owns a shared
SpotifyOAuth + SpotifyConnectApi passed to the Rust backend; isAvailable() =
enabled && a backend is selectable; the Rust path additionally gates
ensureStarted() on oauth.isAuthorized(). go-librespot path unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 23:50:03 +08:00
saopig1andClaude Opus 4.8 efe47c5bbc fix(spotify): arm RustLibrespot track-end detection only after playTrack (no spurious startup advance)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 23:43:27 +08:00
saopig1andClaude Opus 4.8 35bdd2a168 feat(spotify): add RustLibrespotBackend (stdout-pipe -> ffmpeg, Connect-API control)
Implements the Stage-2 SpotifyAudioBackend over Rust librespot: spawns
librespot with --backend pipe (no --device => s16le/44100/2 on stdout, no
--passthrough), pipes stdout -> ffmpeg (44100->48000 s16le), waits for the
Connect device to register before emitting "ready", and controls playback
(transfer/play/pause/resume/seek) plus track-end/position/metadata via a
polled SpotifyConnectApi. child_process/connect/oauth/ffmpeg injected for
fully mocked, no-network unit tests (Windows-targeted; not e2e without Premium).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 23:34:58 +08:00
saopig1andClaude Opus 4.8 540bf8c032 feat(spotify): add SpotifyConnectApi Web API Connect control client
Wraps an injected axios instance with a live user Bearer token from
getToken(): getDevices/findDeviceByName, transfer/play/pause/resume/seek,
and getPlaybackState (null on 204). Read-only calls degrade gracefully;
mutating calls no-op when unauthorized. Fully unit-tested with a mocked
AxiosInstance (no network) — Windows-targeted, not e2e-testable (no Premium).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 23:22:54 +08:00
saopig1andClaude Opus 4.8 333f7606e4 feat(spotify): add SpotifyOAuth Authorization Code + PKCE control-token flow
Stage 3 Task 2. PKCE (S256) authorize URL, code exchange, and refresh with
rotated-refresh-token persistence + invalid_grant store-clear. axios/http and
token store injected for fully mocked, network-free unit tests.

Corrections C3.2 (require the operator's own client_id; no librespot public
client / :5588 default; buildAuthorizeUrl throws + isAuthorized false without
it) and C3.7 (delete the state->verifier map entry in a finally on every
terminal path) applied.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 23:13:05 +08:00
saopig1andClaude Opus 4.8 8c84090b63 feat(spotify): add Rust librespot binary resolver (Stage 3 Task 1)
Append isRustLibrespotSupported/pickLibrespotPath/findLibrespot/
checkLibrespotAvailable/resetLibrespotBinaryCache to binary.ts, mirroring
the go-librespot resolver. Supported on all platforms (pipe->stdout),
resolves librespot.exe on win32, caches only positive --version probes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 23:04:16 +08:00
saopig1andClaude Opus 4.8 3277736f5d docs(spotify): stage 3 plan — Rust librespot Windows backend (#112)
Drafted from a locked contract + research map, adversarially verified (3 critics).
REQUIRED CORRECTIONS: single shared OAuth threaded to web+controller; auth via the
user's own Developer app + web callback (drop ToS-gray librespot-client + :5588);
resolved-backend status; poll hasPlayed-gating + 204 track-end; Connect error
guarding; verifier-map cleanup. Cross-platform, gated, not e2e-testable (Premium).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 23:00:08 +08:00
saopig1andClaude Opus 4.8 8bd0aae7c3 fix(spotify): loopback-bind sidecar API, recover on sidecar death, per-bot go-librespot ports
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 22:24:51 +08:00
saopig1andClaude Opus 4.8 9c796ec05c fix(spotify): correct Spotify seek units (s→ms) + gate re-attach on player external state
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 22:00:56 +08:00
saopig1andClaude Opus 4.8 b5b3585e77 feat(spotify): orchestrate go-librespot backend from BotInstance (Stage 2 Task 7)
Construct one SpotifyController per bot (config.spotify + per-bot work/config
dirs under DATA_DIR, threaded via BotManager + index). resolveAndPlay now
routes spotify: sentinels through controller.ensureStarted/playTrack +
player.playPcmStream (falling back to the Stage-1 message when unavailable),
fences/pauses the sidecar on source transitions, advances via controller
"trackEnded", and delegates pause/resume/stop transport. Correction C4:
no re-attach on a spotify->spotify handoff (playPcmStream once across tracks,
no player.stop() — playPcmStream fences the prior ffmpeg internally); occupancy
auto-pause/resume + updateAutoPause + a new BotInstance.seek() (web seek route)
also delegate to the sidecar.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 21:44:39 +08:00
saopig1andClaude Opus 4.8 179e7c248a fix(spotify): tear down errored backend in SpotifyController (no leak/cross-talk)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 21:30:13 +08:00
saopig1andClaude Opus 4.8 3a9504500b feat(spotify): SpotifyController backend lifecycle, gating, and event re-emission
Per-bot orchestrator: isAvailable() gates on config.enabled + platform +
binary presence; ensureStarted() starts the backend once (idempotent, retries
on failure); playTrack/pause/resume/seek/stop delegate; getPcmStream() proxies
the backend PCM; re-emits backend trackEnded/metadata. backendFactory injected
for tests (fake backend, no real binary/network).

Correction C3: the controller does not re-emit a raw "error" event (Node's
EventEmitter throws on an unhandled "error"); it logs the backend error and
marks itself not-ready so the next ensureStarted() relaunches. getPcmStream()
returns the backend's single persistent stream (no per-attach PassThrough).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 21:22:12 +08:00
saopig1andClaude Opus 4.8 6debe23034 feat(audio): add external-PCM mode (playPcmStream) for Spotify sidecar
Adds AudioPlayer.playPcmStream(readable, {onExternalEnd}) that feeds a
long-lived external 48kHz/s16le/stereo Readable into the existing pcmBuffer +
20ms frame loop + Opus encoder without spawning a per-URL ffmpeg. Reuses the
same high/low-water backpressure (pausing/resuming the Readable), suppresses the
underrun trackEnd drain/stall branches while external (emitting a silence frame
to keep the 20ms timeline), tears down externalMode in stop() by DETACHING the
shared readable (remove our data/end/error listeners + pause, never destroy the
sidecar stream) and clearing onExternalEnd, and makes seek() a local no-op in
external mode. The url play() path and all exported pure functions are unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 21:14:15 +08:00
saopig1andClaude Opus 4.8 641da086e5 fix(spotify): GoLibrespotBackend start() cleanup on failure + unhandled-error guard
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 21:04:42 +08:00
saopig1andClaude Opus 4.8 9d55bea240 feat(spotify): GoLibrespotBackend sidecar (FIFO + ffmpeg PCM + REST/WS)
Implements SpotifyAudioBackend over a go-librespot sidecar: start() mkfifos
the pipe, spawns the FIFO->48k s16le ffmpeg reader BEFORE go-librespot, writes
config.yml, polls the REST /  until ready, then connects the WS event stream.
Maps not_playing/stopped -> trackEnded and metadata -> SpotifyNowPlaying;
play/pause/resume/seek delegate to the REST client. All child_process/fs/REST/WS
seams are injectable so the lifecycle is fully unit-tested without a real binary.

Correction C1: ffmpeg is resolved via getFfmpegCommand() (now exported from
src/audio/player.ts) so the bundled ffmpeg-static fallback is honored in Docker;
the ffmpeg command is overridable via deps.ffmpegCommand for tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 20:58:13 +08:00
saopig1andClaude Opus 4.8 f2b14b5f00 feat(spotify): add go-librespot REST client + WS event client (Stage 2)
GoLibrespotRestClient wraps axios (injectable via deps.http) for
/player/play|pause|resume|stop|seek, GET /status, GET / ping; ping/getStatus
swallow errors to false/null, mutating ops reject. GoLibrespotEventClient
(EventEmitter) parses {type,data} /events frames and re-emits type with data,
reconnects on close with capped backoff, stop() tears down. TDD with a mock
AxiosInstance and a fake WebSocket (no real binary/network).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 20:47:44 +08:00
saopig1andClaude Opus 4.8 470a62129a feat(spotify): add SpotifyAudioBackend interface + go-librespot config.yml renderer
- backend.ts: type-only SpotifyAudioBackend contract + track/metadata DTOs
- go-librespot-config.ts: renderConfigYml() hand-built config (pipe/s16le,
  server enabled, interactive OAuth), no yaml dependency
- tests assert exact keys/values and round-trip via a tiny structural parser

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 20:40:15 +08:00
saopig1andClaude Opus 4.8 32718f0118 feat(spotify): add go-librespot binary resolver + Linux support gate
Mirror youtube.ts findYtDlp/checkYtDlpAvailable (bin/ then PATH,
cache-positive-only availability, reset test hook) and add
isGoLibrespotSupported() Linux gate for the Stage 2 audio backend.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 20:33:42 +08:00
saopig1andClaude Opus 4.8 978e6ee7f2 docs(spotify): stage 2 plan — go-librespot audio backend (#112)
Drafted from a locked interface contract + integration map, then adversarially
verified (3 critics). Includes REQUIRED CORRECTIONS fixing the gapless-handoff
blocker (no stream re-attach on spotify->spotify), detach-not-destroy teardown,
ffmpeg-static resolution, occupancy/seek transport delegation, and unhandled-error
guarding. Linux/Docker-only + gated; not e2e-testable without Premium.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 20:30:45 +08:00
saopig1andClaude Opus 4.8 77d71fe418 docs(spotify): note intentional Spotify omission from unified /search/all (stage 1)
Spotify tracks are metadata-only until the librespot audio backend lands, so
they are surfaced only from the dedicated Spotify tab, not the default
all-sources search. Conscious decision from the whole-branch review (#112).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 00:32:08 +08:00
saopig1andClaude Opus 4.8 50bdaae26a feat(spotify): frontend plumbing (source tab, badge, auth status)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 00:22:45 +08:00
saopig1andClaude Opus 4.8 e1171dbcfe feat(spotify): expose provider through web music/auth routers
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 00:19:22 +08:00
saopig1andClaude Opus 4.8 9a9f68c446 feat(spotify): wire provider through manager/instance; skip playback sentinel
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 00:13:22 +08:00
saopig1andClaude Opus 4.8 848b3931b8 feat(spotify): config block (disabled by default) + sanitize
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 00:04:31 +08:00
saopig1 828fd21e29 feat(spotify): SpotifyProvider (search/browse; playback sentinel) 2026-07-01 23:59:41 +08:00
saopig1andClaude Opus 4.8 6c16e2d966 feat(spotify): Web API client + catalog mappers, add spotify platform
Adds src/music/spotify/webapi.ts (client-credentials token, catalog
mappers, 429 retry) + tests, and threads the new "spotify" platform id
through the type unions in provider.ts and database.ts. Also widens the
downstream QueuedSong.platform union (audio/queue.ts) and the
getProviderFor parameter (bot/instance.ts) so tsc --noEmit stays clean;
these two are the necessary call-site fixes for the new union member.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 23:52:22 +08:00
saopig1andClaude Opus 4.8 b352d71528 docs(spotify): stage 1 implementation plan (metadata + provider + wiring) (#112)
Bite-sized TDD plan for the first shippable increment: Spotify becomes a
searchable/browsable source (Web API), with playback cleanly reporting
"not playable yet". Adversarially verified against the codebase (3 critics)
and fixed: getProviderFor signature, pre-existing config.test.ts imports,
all three BotInstance sites, enabled-gate safety, 429 handling.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 23:04:35 +08:00
saopig1andClaude Opus 4.8 92bdfcb294 docs(spotify): design spec for optional hybrid librespot audio source (#112)
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>
2026-07-01 22:26:07 +08:00
58 changed files with 18709 additions and 111 deletions

No files matched your search

+132 -3
View File
@@ -18,6 +18,7 @@
<img src="https://img.shields.io/badge/酷狗音乐-支持-2ca2f9" />
<img src="https://img.shields.io/badge/BiliBili-支持-00a1d6?logo=bilibili&logoColor=white" />
<img src="https://img.shields.io/badge/YouTube-可选-FF0000?logo=youtube&logoColor=white" />
<img src="https://img.shields.io/badge/Spotify-可选-1DB954?logo=spotify&logoColor=white" />
<img src="https://img.shields.io/badge/TS3-支持-2580C3?logo=teamspeak&logoColor=white" />
<img src="https://img.shields.io/badge/TS6-支持-2580C3?logo=teamspeak&logoColor=white" />
</p>
@@ -30,7 +31,7 @@
- **本地音频上传播放** — 在搜索页拖拽或选择本地音频上传,上传后可直接播放 / 下一首播放 / 加入队列;管理员可在 设置 → 行为设置 开关此功能,播放结束或停止/清空/替换队列时会清理服务端接收的本地文件
- **专属链接(单机器人锁定)** — 通过 `/bot/<id>` 专属链接打开 WebUI 时锁定到单个机器人,刷新后保持,适合把某台机器人的控制页分享给特定用户
- **频道无人时自动暂停** — 机器人所在频道没有其他人时自动暂停播放,有人加入后自动恢复(**默认关闭**,可在设置中开启)
- **多平台音源** — 网易云音乐 + QQ 音乐 + 酷狗音乐 + 哔哩哔哩(默认内置),YouTube 可选启用(通过 yt-dlp),统一搜索,结果标注来源
- **多平台音源** — 网易云音乐 + QQ 音乐 + 酷狗音乐 + 哔哩哔哩(默认内置),YouTube 可选启用(通过 yt-dlp),**Spotify(实验性)** 可选启用(需 Premium + 自建开发者应用,默认关闭,详见 [Spotify 音源(实验性)](#spotify-音源实验性)),统一搜索(歌曲 / 歌单 / 专辑均支持翻页「加载更多」),结果标注来源
- **真实客户端协议 (TS3/TS6 双协议)** — 机器人在 TeamSpeak 中可见(非 ServerQuery 隐身模式),自动检测并适配 TS3 和 TS6 服务器,支持 TS6 HTTP Query API
- **YesPlayMusic 风格 WebUI** — 精美界面,支持深色/浅色主题切换
- **完整播放控制** — 播放/暂停/上一首/下一首/进度跳转/音量调节
@@ -344,7 +345,7 @@ sudo systemctl start tsmusicbot
| `!fm` | 私人 FM(网易云,自动续播) |
| `!fm -q` | QQ 音乐雷达 / 猜你喜欢 FM(自动续播) |
| `!fm -k` | 酷狗私人电台 / 个性化推荐 FM(自动续播) |
| `!lyrics` | 显示当前歌词 |
| `!lyrics` | 显示当前完整歌词(自动分多条消息发送,不再只显示开头几行) |
| `!now` | 当前播放信息 |
| `!vote` | 投票跳过当前歌曲 |
| `!move <频道名>` | 移动到指定频道 |
@@ -518,6 +519,131 @@ pip install -U yt-dlp
- 受 YouTube 风控/地域限制,部分视频可能无法播放
- `yt-dlp` 更新较频繁,如果播放失败,先尝试升级 `yt-dlp` 到最新版本
## Spotify 音源(实验性)
> **⚠️ 实验性功能,启用前请务必读完本节**
>
> - **需要 Spotify Premium 账号。** 免费账号无法通过 Spotify Connect 输出音频,无法使用本功能。
> - **使用你自己在 Spotify Developer Dashboard 注册的应用(Client ID)。** 本项目**不内置任何共享凭据**,也不会替你代管账号。
> - Spotify 官方并未开放第三方播放的公开授权,本功能处于 **Spotify 服务条款的灰色地带**,是否使用请自行评估,**风险自负**。
> - 该音源**默认关闭**(`spotify.enabled = false`),需要手动开启并完成授权。
> - 这**不是** YouTube 那样的「免登录回退音源」,而是**真正的 Spotify 音频**,必须有 Premium 才能出声;音频链路依赖第三方开源解码器(librespot / go-librespot),本项目仅在本地作为独立子进程调用,**播放效果不做保证**,也未在本仓库端到端测试。
### 工作原理(简述)
1. `librespot`(Rust)或 `go-librespot`(Linux)作为**独立子进程**登录 Spotify Connect 并解码音频,输出原始 **PCM**。
2. 本项目用内置 **ffmpeg** 把 PCM 重采样到 **48kHz**。
3. 重采样后的音频接入**现有的 Opus 编码 / 发送管线**(与其它音源共用),推送到 TeamSpeak。
4. 歌名、歌手、封面等**元数据来自 Spotify Web API**。
### 平台矩阵
后端由配置项 `spotify.backend` 决定,可选 `auto`(默认)/ `go-librespot` / `librespot`:
| 平台 | 默认后端(`auto`) | 说明 |
|------|------|------|
| Windows | `librespot`(Rust) | 不支持 go-librespot(FIFO 仅限 POSIX,且官方无 Windows 资产) |
| Linux / Docker | `go-librespot`(可回退 `librespot`) | `auto` 优先 go-librespot,未检测到时自动改用 librespot |
| macOS | `librespot`(Rust) | 与 Windows 同,仅支持 Rust 版 |
> `auto` 会按平台与二进制可用性自动选择:优先 `go-librespot`(若可用),否则 `librespot`。若显式指定 `go-librespot` 或 `librespot` 但对应二进制不存在,则该音源保持不可用。
### 获取二进制
程序会先在项目根目录的 `bin/` 中查找,找不到再回退到系统 `PATH`。`bin/` 已被 `.gitignore` 忽略,不影响代码更新。
**Rust librespot(librespot-org,全平台)** — 官方**没有预编译发布包**,需自行获取(任选其一):
```bash
# 方式 1:用 Cargo 编译安装(需要 Rust 工具链)
cargo install librespot
# 方式 2(Windows):scoop / choco
scoop install librespot # 或:choco install librespot
# 方式 3:把可执行文件放到项目 bin/ 目录
# Windows: bin/librespot.exe
# Linux/macOS: bin/librespot
```
或直接把 `librespot` 加入系统 `PATH`。
**go-librespot(仅 Linux)** — 官方仅提供 **Linux** 预编译资产:
```bash
# 从 Release 页下载对应架构的二进制:
# https://github.com/devgianlu/go-librespot/releases
# 放到项目 bin/ 目录(或加入系统 PATH):
# bin/go-librespot
```
> **Windows 不支持 go-librespot**:它依赖 POSIX FIFO(`mkfifo`),官方也只发布 Linux 资产。Windows / macOS 请使用 Rust `librespot`。
### 注册 Spotify 开发者应用 + 回调地址
1. 打开 [Spotify Developer Dashboard](https://developer.spotify.com/dashboard),新建一个应用,记下 **Client ID**。
2. 在应用设置里添加 **Redirect URI(回调地址)**,精确填写:
```
http://127.0.0.1:<webPort>/api/spotify/callback
```
其中 `<webPort>` 与本项目设置里的 Web 端口一致(默认 `3000`,即 `http://127.0.0.1:3000/api/spotify/callback`)。回调路径必须精确为 `/api/spotify/callback`。
3. 本项目使用 **Authorization Code + PKCE** 流程,**不需要 Client Secret**(配置里的 `clientSecret` 可留空)。
4. 授权时请求的权限范围(scope):
```
streaming user-read-playback-state user-modify-playback-state user-read-currently-playing playlist-read-private
```
### 启用步骤
1. 进入**设置页**的「连接 Spotify」卡片。
2. 填入 **Client ID**、选择**后端**(`auto` / `go-librespot` / `librespot`)、打开**开关**。
3. 点击**保存**。
4. 点击「**连接 Spotify**」,在弹出的 Spotify 页面完成 **OAuth 授权**。
> 仅当 **`enabled = true`** + **已完成 OAuth 授权** + **检测到可用的后端二进制** 三者同时满足时,Spotify 音源才可播放。任一条件不满足,该音源保持**不可用**(点播会被跳过,播放队列照常前进,不影响其它音源)。
对应的配置块(`data/config.json`):
```json
{
"spotify": {
"enabled": false,
"backend": "auto",
"clientId": "",
"clientSecret": "",
"deviceName": "TSMusicBot",
"bitrate": 320
}
}
```
OAuth 相关端点:`/api/spotify/login`、`/api/spotify/callback`、`/api/spotify/status`。
### 许可与来源
- **go-librespot** 采用 **GPL-3.0** 许可。本项目**仅将其作为独立子进程调用**(mere aggregation / 独立聚合),**不链接、不打包**其代码,因此**不影响本项目自身的 MIT 许可**。
- 源码与许可:<https://github.com/devgianlu/go-librespot>(GPL-3.0;如需其对应源码请前往该仓库获取 —— source offer)。
- **Rust librespot** 采用 **MIT** 许可:<https://github.com/librespot-org/librespot>。
### 故障排查
| 现象 | 处理 |
|------|------|
| 提示「未检测到 librespot / go-librespot」 | 检查项目 `bin/` 目录里是否放了可执行文件,或该命令是否在系统 `PATH` 中;Rust 版可用 `cargo install librespot` 安装 |
| 提示「未授权 / 需要连接 Spotify」 | 在设置页「连接 Spotify」卡片点击「连接 Spotify」完成 OAuth 授权 |
| Windows 上无法使用 go-librespot | 属预期行为(FIFO 仅限 POSIX,官方无 Windows 资产);请把 `backend` 设为 `librespot` 或 `auto` |
| 完全没有声音 | 确认账号为 **Spotify Premium**;免费账号无法通过 Spotify Connect 输出音频 |
### 已知限制
- 在多租户/共享主机上,librespot 通过命令行参数接收访问令牌,同机其他本地进程理论上可读取(令牌约 1 小时有效,需本地访问权限)。
- Spotify 连续播放(gapless spotify→spotify)时,网页进度条的"已播放时间"可能不准确(以后端上报的播放进度为准)。
- 运行多个启用 Spotify 的 bot 时,若两个 bot 的 id 端口哈希发生冲突(同一 % 1000 桶),第二个 go-librespot 边车会因端口占用而启动失败、该 bot 的 Spotify 不可用(后续将改为按需分配空闲端口)。
- **一个 Spotify Premium 账号只支持「一路」正在播放的音频流**。因此若要同时运行**多个**启用 Spotify 的 bot 并让它们各自独立播放,必须为**每个 bot 配置独立的 Spotify 账号**。在 Rust(librespot)后端上,本机制已把播放控制(暂停/继续/跳转)限定到 bot 自己的设备,并在读取播放状态时忽略其它设备的状态,以避免多 bot 之间互相抢占、来回抖动(cross-control/thrash);但受 Spotify 平台限制,**共用同一账号无法实现多路同时播放**(第二个 bot 开始播放会夺走该账号唯一的活跃会话)。go-librespot 后端为每个边车独立的本地 REST API,不受此账号级抢占影响。
## 配置文件
配置文件位于 **`data/config.json`**(与数据库、Cookie、日志同在持久化的 `data/` 目录,Docker 部署对应挂载卷),首次运行时自动生成,可手动编辑:
@@ -641,7 +767,7 @@ A:本项目内置 `/login` 限流(每 IP 每分钟 5 次),但生产部
### 最新版本
**功能增强:细粒度权限 / 本地收藏 / 本地音频上传 / 专属链接 / 自动暂停 / QQ 雷达 FM**
**功能增强:Spotify 音源(实验性)/ 搜索结果翻页 / 细粒度权限 / 本地收藏 / 本地音频上传 / 专属链接 / 自动暂停 / QQ 雷达 FM**
- **细粒度账号权限**(叠加在 admin / member 之上):管理员可为每个成员勾选 5 项能力(`player.control` / `player.queue` / `bot.manage` / `platform.auth` / `quality`)和按机器人授权白名单;所有变更路由由后端 `requirePermission` / `requireBotAccess` 中间件逐请求强制校验,未授权返回 403,未授权的机器人对成员不可见(列表过滤,无 403-vs-404 枚举泄漏)。已有成员经一次性迁移获得全部能力,新成员默认基础能力。
- **本地收藏歌单**:按用户存储的收藏(`favorite_playlists` 表 + `/api/favorites`),首页 / 搜索 / 歌单页一键收藏,跨设备同步。
@@ -649,9 +775,12 @@ A:本项目内置 `/login` 限流(每 IP 每分钟 5 次),但生产部
- **专属链接(单机器人锁定)**:`/bot/<id>` 打开时锁定到单台机器人,`?bot=<id>` 随刷新保持;与权限白名单组合,机器人下拉只显示"作用域 ∩ 可控"的机器人。
- **频道无人时自动暂停**:机器人所在频道清空时暂停、有人加入时恢复(区分用户手动暂停,不会误恢复);可在 设置 → 行为设置 开关(默认关闭)。占用检测在 `clientlist` 查询失败时按"未知"处理而非"无人",避免有人在听时被误暂停。
- **QQ 音乐雷达 / 私人 FM**:`!fm -q` 或 WebUI 启动 QQ 雷达推荐流(失败回退"猜你喜欢"),FM 自动续播现支持任意平台。
- **#112 Spotify 音源(实验性)**:新增 Spotify 作为可选音源,默认关闭、需 Premium + 自建开发者应用(PKCE,无需 Client Secret)。采用混合 librespot 后端(Linux/Docker 用 go-librespot,Windows 用 Rust librespot),元数据走 Spotify Web API,可与现有音源混排入队。为 ToS 灰色地带的实验特性,详见 [Spotify 音源(实验性)](#spotify-音源实验性)。
- **#115 搜索结果翻页**:WebUI 搜索现按来源、按分类(歌曲 / 歌单 / 专辑)提供「加载更多」,服务端新增 `offset` 分页,不再固定只返回首页 20 首 / 10 个歌单 / 10 张专辑。
**Bug 修复**
- **#116 `!lyrics` 只显示开头几行**:聊天命令曾把歌词截断为前 10 行,且长消息未分片会触及 TeamSpeak 单条约 1 KB 上限。现发送完整歌词,并按 UTF-8 字节安全地分割成多条消息(长回复通用分片,不再截断)。
- **#86 config.json 未在首次运行生成**:配置文件改放到持久化的 `data/config.json`(旧版写在项目根目录,不在 Docker 卷内,导致重启丢失、手动编辑不生效);升级时自动把根目录旧配置迁移到 `data/` 并保留你的设置。
- **#89 B站长音频约 16 分钟被暂停且无法继续**:ffmpeg 增加 `-reconnect_at_eof`(B站 CDN 会在 token/会话到期时提前关闭连接造成 EOF),并新增"远离结尾的卡死看门狗"——彻底卡死的流会自动推进到下一首而不是永久静音。
- **#84 音量曲线不顺滑**:0–100 改为连续单调曲线 `0.2x + 0.8x^8`(消除 80–99 的"死区"与 100 处的突跳,满响度仍保留在 100)。
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,520 @@
# Spotify Source — Stage 4 (Polish, Config UI, Docs) Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Complete the Spotify source's last mile — make it user-configurable and user-authorizable from the web UI, document it (with the required safety warnings), and land the deferred OAuth-robustness hardening.
**Architecture:** The Stage-3 OAuth endpoints (`/api/spotify/{login,callback,status}`) and the single shared `SpotifyOAuth` already exist but are unreachable from the UI. Stage 4 adds: (1) a `spotify` block to the `/api/bot/settings` config API (secret masked); (2) a resolved-backend indicator on `/status`; (3) OAuth refresh/verifier hardening; (4) the approved Settings "Connect Spotify" card (spec §8); (5) the README Spotify section (spec §11/§12). Deferred (documented, NOT built this stage): runtime binary auto-download, connect play-not-reflected diagnostics, extra watchdog.
**Tech Stack:** TypeScript (ESM, `.js` specifiers), Express, Vitest (root config also runs `web/src/**/*.test.ts` — stores/composables only; there is NO `.vue` component-test harness), Vue 3 + `<script setup>` + Pinia, `vue-tsc` (web build gate).
## Global Constraints
- **Safe-by-default (spec §2/§7):** Spotify stays inert unless `enabled` AND authorized AND a resolvable binary. Nothing in this stage may change behavior when `spotify.enabled === false`.
- **Never expose secrets (spec §2/§7):** `clientSecret` is the operator's own value. The config GET API MUST NOT return the raw `clientSecret` — return only a boolean `hasClientSecret`. POST overwrites `clientSecret` only when a non-empty string is supplied (blank/omitted = unchanged). No bundled credentials, no shared Developer app.
- **Permissions:** config writes gated by `requirePermission("bot.manage")`; reads by `requireNotGuest`; the OAuth login card is additionally shown only to `can('platform.auth')` / admins — mirror the existing Settings gating.
- **Validation mirrors `src/data/config.ts`:** `backend` ∈ `{"auto","go-librespot","librespot"}`; `bitrate` ∈ `{96,160,320}`; `deviceName` non-empty trimmed string (else keep prior); strings coerced/guarded. Reuse the exact value sets.
- **Language:** README additions are in Chinese (repo is Chinese-only), matching existing heading style (`## 功能特性` etc.).
- **Licensing (spec §9):** go-librespot is GPL-3.0 — the README must include its license note + a source offer; the bot ships NO binary (source-only Rust librespot; Linux-only go-librespot assets).
- **Honesty:** live audio / Connect control / real OAuth round-trip remain NOT verifiable here (need Premium + real binaries + a real account). "Done" = unit-tested (mocked process/HTTP/FS), `tsc --noEmit` clean, `cd web && npm run build` clean, full suite green, reviewed. Never claim audio works.
- **Branch:** all work on `feat/spotify-audio`. Do NOT create per-task branches.
- **Full suite command:** `npx vitest run --no-file-parallelism` (avoids the users.test.ts bcrypt LOAD flake). Typecheck: `npx tsc --noEmit`. Web build: `cd web && npm run build`.
---
### Task 1: `/api/bot/settings` reads & writes the `spotify` block (secret masked)
**Files:**
- Modify: `src/web/api/bot.ts` (GET `/settings` response + POST `/settings` destructure/validate/echo)
- Test: `src/web/api/bot.test.ts`
**Interfaces:**
- Consumes: `config.spotify: SpotifyConfig` (`{ enabled, backend, clientId, clientSecret, deviceName, bitrate }`), `saveConfig(configPath, config)`, `requirePermission("bot.manage")`, `requireNotGuest` — all already imported/used in this file.
- Produces (new response shape on GET+POST, both add a `spotify` key):
```ts
// masked view — NEVER includes clientSecret
spotify: {
enabled: boolean;
backend: "auto" | "go-librespot" | "librespot";
clientId: string;
deviceName: string;
bitrate: number;
hasClientSecret: boolean; // whether a non-empty secret is stored
}
```
- [ ] **Step 1.1: Write failing tests.** Add to `src/web/api/bot.test.ts` (mirror the existing settings tests — reuse their app/harness + auth stubs). Cover:
1. GET `/api/bot/settings` includes a `spotify` object with `enabled/backend/clientId/deviceName/bitrate/hasClientSecret` and does NOT include a `clientSecret` key. With a stored secret, `hasClientSecret === true`; with `clientSecret: ""`, `false`.
2. POST `/api/bot/settings` with `{ spotify: { enabled: true, backend: "librespot", clientId: "cid", deviceName: "Dev", bitrate: 160 } }` updates all those fields and echoes the masked view; `saveConfig` called.
3. POST with `{ spotify: { backend: "bogus" } }` leaves `backend` unchanged (invalid rejected, not 400 for the whole request — partial-merge semantics like the other fields). Same for `bitrate: 999`.
4. POST with `{ spotify: { clientSecret: "newsecret" } }` sets the secret (assert via `hasClientSecret === true` in the echo AND that `config.spotify.clientSecret === "newsecret"`). POST with `{ spotify: { clientSecret: "" } }` does NOT overwrite an existing secret.
5. POST `/api/bot/settings` spotify write requires `bot.manage` (a member without it → 403; reuse the existing 403 test pattern).
6. An existing settings POST that omits `spotify` still works and does not touch `config.spotify` (no regression).
- [ ] **Step 1.2: Run the tests — expect failure** (`npx vitest run src/web/api/bot.test.ts`): the `spotify` key is absent from responses.
- [ ] **Step 1.3: Extend GET `/settings`.** Add the masked spotify view to the response object (both the GET at ~line 36 and the POST echo at ~line 103 — extract a local helper to avoid duplication):
```ts
// near the top of createBotRouter, after other helpers:
const maskedSpotify = () => ({
enabled: config.spotify.enabled,
backend: config.spotify.backend,
clientId: config.spotify.clientId,
deviceName: config.spotify.deviceName,
bitrate: config.spotify.bitrate,
hasClientSecret: config.spotify.clientSecret.length > 0,
});
```
Add `spotify: maskedSpotify(),` to BOTH the GET response and the POST echo object.
- [ ] **Step 1.4: Handle `spotify` in POST `/settings`.** Add `spotify` to the destructure and a partial-merge block (mirror config.ts validation). Place before `saveConfig(...)`:
```ts
const VALID_BACKENDS = ["auto", "go-librespot", "librespot"] as const;
const VALID_BITRATES = [96, 160, 320];
const sp = req.body?.spotify;
if (sp && typeof sp === "object") {
const t = config.spotify;
if (typeof sp.enabled === "boolean") t.enabled = sp.enabled;
if (typeof sp.backend === "string" && (VALID_BACKENDS as readonly string[]).includes(sp.backend)) {
t.backend = sp.backend as SpotifyConfig["backend"];
}
if (typeof sp.clientId === "string") t.clientId = sp.clientId;
// Secret is write-only + set-on-non-empty so a blank field never wipes it.
if (typeof sp.clientSecret === "string" && sp.clientSecret.length > 0) {
t.clientSecret = sp.clientSecret;
}
if (typeof sp.deviceName === "string" && sp.deviceName.trim().length > 0) {
t.deviceName = sp.deviceName.trim();
}
if (typeof sp.bitrate === "number" && VALID_BITRATES.includes(sp.bitrate)) {
t.bitrate = sp.bitrate;
}
}
```
Add the type import if not present: `import type { SpotifyConfig } from "../../data/config.js";`.
- [ ] **Step 1.5: Run tests — expect pass** (`npx vitest run src/web/api/bot.test.ts`). Then `npx tsc --noEmit` clean.
- [ ] **Step 1.6: Commit.**
```bash
git add src/web/api/bot.ts src/web/api/bot.test.ts
git commit -m "feat(spotify): expose spotify config on /api/bot/settings (secret masked) [S4.1]"
```
---
### Task 2: `/api/spotify/status` reports the RESOLVED backend + binary availability (D11)
**Files:**
- Create: `src/music/spotify/backend-select.ts` (pure resolver) + `src/music/spotify/backend-select.test.ts`
- Modify: `src/music/spotify/controller.ts` (delegate `chooseBackend` to the resolver)
- Modify: `src/web/api/spotify.ts` (`/status` shape + `getBackendInfo` type)
- Modify: `src/web/server.ts` (`getBackendInfo` computes the resolved kind)
- Modify: `src/web/api/spotify.test.ts` (update the `/status` shape assertion)
**Interfaces:**
- Produces:
```ts
// backend-select.ts
export type SpotifyBackendKind = "go-librespot" | "librespot";
export function resolveSpotifyBackendKind(
backend: "auto" | "go-librespot" | "librespot",
goPresent: boolean,
rustPresent: boolean,
): SpotifyBackendKind | null;
// spotify.ts getBackendInfo now returns:
{ backend: string; deviceName: string; binaryAvailable: boolean }
// /status response now:
{ authorized: boolean; backend: string; deviceName: string; binaryAvailable: boolean }
```
- Note: `SpotifyBackendKind` currently lives in `controller.ts`. Move the canonical definition to `backend-select.ts` and re-export it from `controller.ts` (`export type { SpotifyBackendKind } from "./backend-select.js";`) so existing importers are unaffected.
- [ ] **Step 2.1: Write failing resolver tests** `src/music/spotify/backend-select.test.ts` — the same 8-case matrix S3.5 used, but against the pure function:
```ts
import { describe, it, expect } from "vitest";
import { resolveSpotifyBackendKind as pick } from "./backend-select.js";
describe("resolveSpotifyBackendKind", () => {
it("auto: go present -> go-librespot", () => expect(pick("auto", true, true)).toBe("go-librespot"));
it("auto: go absent, rust present -> librespot", () => expect(pick("auto", false, true)).toBe("librespot"));
it("auto: neither -> null", () => expect(pick("auto", false, false)).toBeNull());
it("go-librespot: present -> go-librespot", () => expect(pick("go-librespot", true, true)).toBe("go-librespot"));
it("go-librespot: absent -> null even if rust present", () => expect(pick("go-librespot", false, true)).toBeNull());
it("librespot: present -> librespot", () => expect(pick("librespot", true, true)).toBe("librespot"));
it("librespot: absent -> null even if go present", () => expect(pick("librespot", true, false)).toBeNull());
it("auto default fallthrough matches auto", () => expect(pick("auto", true, false)).toBe("go-librespot"));
});
```
- [ ] **Step 2.2: Run — expect failure** (module missing).
- [ ] **Step 2.3: Create the resolver** `src/music/spotify/backend-select.ts`:
```ts
/** Which concrete backend runs for a given config + host binary availability. */
export type SpotifyBackendKind = "go-librespot" | "librespot";
/**
* Pure backend selection shared by SpotifyController.chooseBackend() (per-bot)
* and the web /status endpoint (process-wide). Booleans in, no IO — the caller
* supplies platform+binary presence.
*/
export function resolveSpotifyBackendKind(
backend: "auto" | "go-librespot" | "librespot",
goPresent: boolean,
rustPresent: boolean,
): SpotifyBackendKind | null {
switch (backend) {
case "go-librespot":
return goPresent ? "go-librespot" : null;
case "librespot":
return rustPresent ? "librespot" : null;
case "auto":
default:
if (goPresent) return "go-librespot";
if (rustPresent) return "librespot";
return null;
}
}
```
- [ ] **Step 2.4: Run resolver tests — expect pass.**
- [ ] **Step 2.5: Delegate from the controller.** In `controller.ts`, import the type+resolver **with a local binding** (a bare `export … from` re-export does NOT create a local name, and `SpotifyBackendKind` is still referenced locally at `chooseBackend()`'s return type and `buildBackend(kind: SpotifyBackendKind)` → would fail TS2304). Use:
```ts
import { resolveSpotifyBackendKind, type SpotifyBackendKind } from "./backend-select.js";
export type { SpotifyBackendKind }; // keep the name exported for existing importers
// ...
chooseBackend(): SpotifyBackendKind | null {
return resolveSpotifyBackendKind(this.config.backend, this.goPresent(), this.rustPresent());
}
```
Remove the old inline `switch` body and the standalone `export type SpotifyBackendKind = "go-librespot" | "librespot";` line. Keep `goPresent()/rustPresent()` as-is. Run `npx vitest run src/music/spotify/controller.test.ts` — the S3.5 matrix + auth-gate specs MUST still pass unchanged.
- [ ] **Step 2.6: Update `/status`** in `src/web/api/spotify.ts` — extend the `getBackendInfo` type and the response:
```ts
getBackendInfo: () => { backend: string; deviceName: string; binaryAvailable: boolean };
// ...
router.get("/status", requireNotGuest, (_req, res) => {
const info = opts.getBackendInfo();
res.json({
authorized: oauth.isAuthorized(),
backend: info.backend,
deviceName: info.deviceName,
binaryAvailable: info.binaryAvailable,
});
});
```
- [ ] **Step 2.7: Compute the resolved kind in `server.ts`.** Replace the `getBackendInfo` closure (currently returns raw `config.spotify.backend`) with a resolver call using live probes. Add imports `import { resolveSpotifyBackendKind } from "../music/spotify/backend-select.js";` and `import { isGoLibrespotSupported, findGoLibrespot, isRustLibrespotSupported, findLibrespot } from "../music/spotify/binary.js";` and `import { existsSync } from "node:fs";` (verify existsSync isn't already imported):
```ts
getBackendInfo: () => {
const goPresent = isGoLibrespotSupported() && existsSync(findGoLibrespot());
const rustPresent = isRustLibrespotSupported() && existsSync(findLibrespot());
const resolved = resolveSpotifyBackendKind(options.config.spotify.backend, goPresent, rustPresent);
return {
backend: resolved ?? "none",
deviceName: options.config.spotify.deviceName,
binaryAvailable: resolved !== null,
};
},
```
- [ ] **Step 2.8: Update the `/status` test** in `src/web/api/spotify.test.ts` — the existing `toEqual({ authorized, backend, deviceName })` must become `toEqual({ authorized, backend, deviceName, binaryAvailable })`; extend the fake `getBackendInfo` in that test to return `binaryAvailable`. This is THIS task's contract change; update only the status test.
- [ ] **Step 2.9: Verify.** `npx vitest run src/music/spotify/backend-select.test.ts src/music/spotify/controller.test.ts src/web/api/spotify.test.ts` all pass; `npx tsc --noEmit` clean.
- [ ] **Step 2.10: Commit.**
```bash
git add src/music/spotify/backend-select.ts src/music/spotify/backend-select.test.ts src/music/spotify/controller.ts src/web/api/spotify.ts src/web/server.ts src/web/api/spotify.test.ts
git commit -m "feat(spotify): report resolved backend + binaryAvailable on /status; share backend resolver [S4.2]"
```
---
### Task 3: OAuth robustness — in-flight refresh cache + verifier TTL/cap (D9)
**Files:**
- Modify: `src/music/spotify/spotify-oauth.ts`
- Test: `src/music/spotify/spotify-oauth.test.ts`
**Interfaces:**
- Consumes: existing `SpotifyOAuthOptions.deps?: { http?: AxiosInstance }`. Extend deps with an optional clock for testability: `deps?: { http?: AxiosInstance; now?: () => number }`.
- Produces: no public API change. Internals: `refreshInFlight: Promise<string|null> | null`; `pendingVerifiers: Map<string, { verifier: string; expiresAt: number }>`.
- [ ] **Step 3.1: Write failing tests.** Add to `src/music/spotify/spotify-oauth.test.ts`:
1. **Concurrent refresh collapses to one POST.** Build with a fake `http` whose `post("/api/token")` returns a promise you resolve manually (a `Deferred`) or counts calls, and a MUTABLE fake store (`save()` persists, `load()` returns the last saved value) seeded with an expired token. Fire two `getAccessToken()` calls before the POST resolves; assert `http.post` called exactly ONCE and both awaited results equal the new access token.
2. **In-flight clears after settle.** Using the same mutable store + a mutable `now` (`let t=…; now=()=>t`), after test 1 resolves, advance `t` past the newly-saved `expiresAt` and call `getAccessToken()` again → a NEW POST fires (count → 2). (Requires `toTokens` on `this.now()` per Step 3.3.)
3. **Verifier TTL.** With injected mutable `now`, `buildAuthorizeUrl()` at t=0 (capture its `state`), advance `now` to TTL+1, then `handleCallback(code, state)` → returns false (expired) and the entry is gone.
4. **Verifier cap.** Call `buildAuthorizeUrl()` `VERIFIER_MAX + 1` times (capturing the FIRST `state`); assert **behaviorally** that `handleCallback(code, <first state>)` now returns false (evicted as oldest). Prefer this behavioral assertion over inspecting the private `pendingVerifiers` map (no `as any` cast). Use the injected `now` for all timing.
- [ ] **Step 3.2: Run — expect failure.**
- [ ] **Step 3.3: Add the clock + in-flight refresh.** In the constructor: `this.now = o.deps?.now ?? (() => Date.now());` (add `private now: () => number;`). Rewrite `getAccessToken()` + add the in-flight field:
```ts
private refreshInFlight: Promise<string | null> | null = null;
async getAccessToken(): Promise<string | null> {
if (!this.clientId) return null;
const tokens = this.store.load();
if (!tokens?.refreshToken) return null;
if (tokens.accessToken && this.now() < tokens.expiresAt) return tokens.accessToken;
// Collapse concurrent refreshes: rotation makes a second in-flight refresh
// use a refresh token the first one already invalidated.
if (this.refreshInFlight) return this.refreshInFlight;
this.refreshInFlight = this.refresh(tokens).finally(() => {
this.refreshInFlight = null;
});
return this.refreshInFlight;
}
```
Replace the `Date.now()` in `getAccessToken` (spotify-oauth.ts:188) with `this.now()`. **Also change `toTokens` (spotify-oauth.ts:~221) to compute `expiresAt` from `this.now()` instead of `Date.now()`** — this is REQUIRED for testability: if `toTokens` kept real `Date.now()` while `getAccessToken` used an injected fixed clock, a freshly-refreshed token's `expiresAt` would sit far in the injected past/future and the "subsequent call → new POST" test would be non-deterministic. With both on `this.now()`, the test drives a mutable `now` (e.g. `let t = 0; const now = () => t;`) and advances it past the new `expiresAt` to force the second refresh.
- [ ] **Step 3.4: Add verifier TTL + cap.** Change the map type and the two touch points:
```ts
private pendingVerifiers = new Map<string, { verifier: string; expiresAt: number }>();
private static readonly VERIFIER_TTL_MS = 10 * 60 * 1000;
private static readonly VERIFIER_MAX = 32;
private evictStaleVerifiers(): void {
const t = this.now();
for (const [state, e] of this.pendingVerifiers) {
if (e.expiresAt < t) this.pendingVerifiers.delete(state);
}
// Bound memory even if all are unexpired: drop oldest (insertion order).
while (this.pendingVerifiers.size >= SpotifyOAuth.VERIFIER_MAX) {
const oldest = this.pendingVerifiers.keys().next().value;
if (oldest === undefined) break;
this.pendingVerifiers.delete(oldest);
}
}
```
In `buildAuthorizeUrl()`: call `this.evictStaleVerifiers();` before the set, then `this.pendingVerifiers.set(state, { verifier, expiresAt: this.now() + SpotifyOAuth.VERIFIER_TTL_MS });`.
In `handleCallback()`: replace the `get`:
```ts
const entry = this.pendingVerifiers.get(state);
if (!entry || entry.expiresAt < this.now()) {
this.pendingVerifiers.delete(state);
return false;
}
const verifier = entry.verifier;
```
(Keep the existing `finally { this.pendingVerifiers.delete(state); }`.)
- [ ] **Step 3.5: Run tests — expect pass.** Then `npx vitest run src/music/spotify/spotify-oauth.test.ts` (all, incl. prior S3.2 tests) + `npx tsc --noEmit` clean.
- [ ] **Step 3.6: Commit.**
```bash
git add src/music/spotify/spotify-oauth.ts src/music/spotify/spotify-oauth.test.ts
git commit -m "fix(spotify): collapse concurrent OAuth refresh + TTL/cap PKCE verifiers [S4.3]"
```
---
### Task 4: Settings "Connect Spotify" card (spec §8)
**Files:**
- Create: `web/src/composables/useSpotifySettings.ts` (pure, testable logic) + `web/src/composables/useSpotifySettings.test.ts`
- Modify: `web/src/views/Settings.vue` (add the card + wiring)
**Interfaces (composable — the unit-tested surface):**
```ts
export interface SpotifyConfigForm {
enabled: boolean;
backend: "auto" | "go-librespot" | "librespot";
clientId: string;
clientSecret: string; // blank means "unchanged"
deviceName: string;
bitrate: number;
}
export interface SpotifyStatus { authorized: boolean; backend: string; deviceName: string; binaryAvailable: boolean; }
export const SPOTIFY_DISCLAIMER: string; // Chinese risk copy
export function buildSpotifyPayload(f: SpotifyConfigForm): { spotify: Record<string, unknown> }; // omits clientSecret when blank
export function parseSpotifyRedirect(search: string): "success" | "error" | null; // from ?spotify=...
export function statusSummary(s: SpotifyStatus | null, enabled: boolean): { label: string; tone: "ok" | "warn" | "off" };
```
- [ ] **Step 4.1: Write failing composable tests** `web/src/composables/useSpotifySettings.test.ts` (root vitest picks it up; import from `./useSpotifySettings.js` per the repo's ESM `.js` convention):
- `buildSpotifyPayload` includes `clientSecret` only when non-blank; always includes enabled/backend/clientId/deviceName/bitrate under a `spotify` key.
- `parseSpotifyRedirect("?spotify=success")==="success"`, `"?spotify=error"==="error"`, `"?x=1"===null`.
- `statusSummary(null, false)` → tone `"off"`; `statusSummary({authorized:false,binaryAvailable:false,...}, true)` → tone `"warn"`; `statusSummary({authorized:true,binaryAvailable:true,...}, true)` → tone `"ok"`.
- [ ] **Step 4.2: Run — expect failure** (module missing).
- [ ] **Step 4.3: Implement the composable** `web/src/composables/useSpotifySettings.ts`:
```ts
export interface SpotifyConfigForm { enabled: boolean; backend: "auto" | "go-librespot" | "librespot"; clientId: string; clientSecret: string; deviceName: string; bitrate: number; }
export interface SpotifyStatus { authorized: boolean; backend: string; deviceName: string; binaryAvailable: boolean; }
// 实验性 · 灰色地带 · 需要 Premium · 使用你自己的开发者应用凭据
export const SPOTIFY_DISCLAIMER =
"实验性功能:通过 librespot 播放 Spotify 需要 Spotify Premium 账号,并使用你自己注册的 Spotify 开发者应用凭据。" +
"该方式处于 Spotify 服务条款的灰色地带,风险自负;默认关闭,不会内置任何共享凭据。";
export function buildSpotifyPayload(f: SpotifyConfigForm): { spotify: Record<string, unknown> } {
const spotify: Record<string, unknown> = {
enabled: f.enabled,
backend: f.backend,
clientId: f.clientId,
deviceName: f.deviceName,
bitrate: f.bitrate,
};
if (f.clientSecret && f.clientSecret.length > 0) spotify.clientSecret = f.clientSecret;
return { spotify };
}
export function parseSpotifyRedirect(search: string): "success" | "error" | null {
const v = new URLSearchParams(search).get("spotify");
return v === "success" || v === "error" ? v : null;
}
export function statusSummary(s: SpotifyStatus | null, enabled: boolean): { label: string; tone: "ok" | "warn" | "off" } {
if (!enabled) return { label: "已关闭", tone: "off" };
if (!s) return { label: "未知", tone: "warn" };
if (!s.binaryAvailable) return { label: "未检测到 librespot 可执行文件", tone: "warn" };
if (!s.authorized) return { label: "未授权(点击“连接 Spotify”登录)", tone: "warn" };
return { label: `已就绪 · 后端 ${s.backend}`, tone: "ok" };
}
```
- [ ] **Step 4.4: Run composable tests — expect pass.**
- [ ] **Step 4.5: Add the card to `Settings.vue`.** Insert a new `.account-card`-style section in the settings surface, gated `v-if="can('platform.auth')"` (mirror the platform-login section). Follow the EXISTING patterns in this file:
- typed input + Save → mirror the idle-timeout input/saver (`Settings.vue` idle-timeout block + `saveIdleTimeout()` → `POST /api/bot/settings`);
- select group → mirror the quality button-group for `backend` and `bitrate`;
- toggle → mirror `localAudioEnabled` for `enabled`.
**Fields to render (all six):** the `enabled` toggle, `backend` group, `bitrate` group, a `clientId` text input, a `deviceName` text input, and a **Client Secret** field — a write-only password input (`type="password"`, `autocomplete="off"`), left BLANK on load with a "已设置 / 未设置" hint from `hasClientSecret`; a blank secret on Save means "unchanged" (never wipes). The Secret is §8-mandated — do not omit it.
Wiring (use `axios`, matching the file's other calls):
- **Load** on mount: `GET /api/bot/settings` → populate the form from `res.data.spotify` (leave `clientSecret` blank; show “已设置/未设置” from `hasClientSecret`); `GET /api/spotify/status` → status indicator via `statusSummary`.
- **Save**: `POST /api/bot/settings` with `buildSpotifyPayload(form)`; on success re-load status.
- **Connect**: `const { data } = await axios.get('/api/spotify/login'); window.location.href = data.url;` (guard errors → show message; a 403 means missing `platform.auth`).
- **Redirect handling**: on mount, `parseSpotifyRedirect(window.location.search)`; if `success`/`error`, show a toast/message and strip the param (e.g. `history.replaceState`). Reuse the file's existing notification/toast mechanism if present; otherwise a simple reactive message line.
- **Disclaimer**: render `SPOTIFY_DISCLAIMER` prominently in the card.
Keep the `<script setup>` logic thin — delegate payload/summary/redirect parsing to the composable (already tested). Do not add a `.vue` test (no harness).
- **Keep the two "spotify auth" concepts distinct:** the card's status comes ONLY from `/api/spotify/status` (playback OAuth `authorized`). Do NOT wire it to the player store's `authStatus.spotify`, which reflects `/api/auth/status?platform=spotify` (metadata Web-API `loggedIn`) — a different thing. Leave the player store untouched.
- **`vue-tsc` typing:** annotate the form as `reactive<SpotifyConfigForm>({...})` and the status as `ref<SpotifyStatus | null>(null)`; otherwise a button-group assignment (`form.backend = 'librespot'`) widens `backend` to `string` and the `null` init breaks the union passed into `buildSpotifyPayload`/`statusSummary` under `vue-tsc --noEmit`.
- **Hard order dependency:** `binaryAvailable` on `/api/spotify/status` exists only after Task 2 — execute this task AFTER Task 2.
- [ ] **Step 4.6: Build gate.** `cd web && npm run build` → `vue-tsc --noEmit` clean + vite build succeeds. Then from root `npx vitest run web/src/composables/useSpotifySettings.test.ts` green.
- [ ] **Step 4.7: Commit.**
```bash
git add web/src/composables/useSpotifySettings.ts web/src/composables/useSpotifySettings.test.ts web/src/views/Settings.vue
git commit -m "feat(spotify): Connect-Spotify settings card (config + OAuth login + status) [S4.4]"
```
**Notes:** NOT e2e-verifiable (needs a real account/binary). Verified = composable unit tests + `vue-tsc` typecheck + build. The card only exposes config + a login trigger; it never displays the stored `clientSecret` (GET returns `hasClientSecret`, not the value).
---
### Task 5: README Spotify section (Chinese) — spec §9/§11/§12
**Files:**
- Modify: `README.md` (add a `## Spotify 音源(实验性)` section; add "Spotify" to the feature bullet if appropriate)
**Content (required — write real prose, not placeholders):**
- [ ] **Step 5.1:** Add a top-level section `## Spotify 音源(实验性)` covering, in Chinese:
1. **醒目警告框:** 实验性;需要 **Spotify Premium**;使用**你自己注册的 Spotify 开发者应用**(不内置任何共享凭据);处于 Spotify 服务条款灰色地带,风险自负;**默认关闭**。
2. **工作原理(简述):** 通过 librespot(Rust)/ go-librespot(Linux)作为独立进程解码 → PCM → ffmpeg 重采样到 48k → 走现有 Opus 发送管线。元数据来自 Spotify Web API。
3. **平台矩阵:** Windows → `librespot`(Rust);Linux/Docker → `go-librespot`(可回退 `librespot`);`auto` 自动选择(表格)。
4. **获取二进制:** Rust librespot 无预编译包 → `cargo install librespot` 或 scoop/choco,或将可执行文件放入项目 `bin/`(`bin/librespot.exe` / `bin/librespot`)。go-librespot 仅提供 Linux 资产(`github.com/devgianlu/go-librespot/releases`),放入 `bin/go-librespot` 或加入 PATH。
5. **注册开发者应用 + 回调:** 在 Spotify Developer Dashboard 建应用,取 Client ID,回调地址填 `http://127.0.0.1:<webPort>/api/spotify/callback`(与设置里的 Web 端口一致);PKCE 不需要 Client Secret。
6. **启用步骤:** 设置页「连接 Spotify」卡片 → 填 Client ID、选后端、开启开关 → 保存 → 点「连接 Spotify」完成 OAuth 授权。
7. **许可与来源:** go-librespot 为 **GPL-3.0**,作为独立子进程调用(mere aggregation,不影响本项目许可);附上其源码地址与许可说明(source offer)。
8. **故障排查:** 「未检测到 librespot」→ 检查 `bin/` 或 PATH;「未授权」→ 完成 OAuth;Windows 不支持 go-librespot(FIFO 仅限 POSIX)。
- [ ] **Step 5.2:** Sanity-check the doc renders (headings consistent with existing `##` style; links valid). No code test.
- [ ] **Step 5.3: Commit.**
```bash
git add README.md
git commit -m "docs(spotify): README Spotify source section — setup, binaries, warnings, license [S4.5]"
```
---
### Task 6: Rust Connect command retry/backoff (recovery/watchdog — spec §4.3/§13)
**Rationale:** Spec §4.3 mandates "a watchdog + retry/backoff for device-visibility latency and command 202/404 flakiness"; §13 lists Rust Connect as the **top risk** (mitigation: retry/backoff + degrade-to-skipped). Stage 3 already landed the device-visibility watchdog (`waitForDevice()` bounded poll in `rust-librespot.ts`) and per-track device-absence → skip (`findDeviceByName`→null → `playTrack` false → BotInstance skips). The remaining, un-built piece is transient-failure retry/backoff on the Connect **mutating commands** — this task adds it while preserving C3.6 (never reject up the queue path; swallow on exhaustion).
**Files:**
- Modify: `src/music/spotify/connect-api.ts` (retry/backoff around the mutating PUTs; optional injected `sleep`/`logger`)
- Modify: `src/music/spotify/controller.ts` (pass `this.logger` into the default `SpotifyConnectApi`)
- Test: `src/music/spotify/connect-api.test.ts`
**Interfaces:**
- Consumes: existing `constructor(getToken: () => Promise<string|null>, deps?: { http?: AxiosInstance })`. **Extend deps** to `{ http?: AxiosInstance; sleep?: (ms: number) => Promise<void>; logger?: import("pino").Logger }` (all optional → source-compatible).
- No public method signature change: `transfer/play/pause/resume/seek` stay `Promise<void>` and still swallow on final failure.
- [ ] **Step 6.1: Write failing tests** in `src/music/spotify/connect-api.test.ts` (reuse the existing `getToken`/`http` injection pattern; inject a no-op `sleep: async () => {}` so no real timers run):
1. `play()` retries a transient 404 then succeeds: `http.put` rejects once with `{ response: { status: 404 } }` then resolves → assert `http.put` called TWICE, no throw.
2. `play()` exhausts on persistent 500: `http.put` always rejects `{ response: { status: 500 } }` → assert exactly `MAX_ATTEMPTS` calls, no throw (swallowed), and `logger.warn` called once (logger provided).
3. Non-transient (403) is NOT retried: reject `{ response: { status: 403 } }` → exactly ONE call, no throw.
4. `429` honors a **capped** `Retry-After`: reject once `{ response: { status: 429, headers: { "retry-after": "1" } } }` then resolve → `sleep` called with a bounded delay (≤ the cap) and 2 calls total.
5. `transfer()` uses the same retry path (one representative non-`play` mutator) — 404-then-success → 2 calls.
- [ ] **Step 6.2: Confirm Red** (`npx vitest run src/music/spotify/connect-api.test.ts`).
- [ ] **Step 6.3: Implement retry/backoff.** Add module constants + a private helper and route each mutating PUT through it:
```ts
const TRANSIENT = new Set([404, 429, 500, 502, 503]);
const MAX_ATTEMPTS = 3;
const BASE_DELAY_MS = 150;
const MAX_DELAY_MS = 2_000;
// ...
private async mutateWithRetry(put: () => Promise<unknown>): Promise<void> {
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
try {
await put();
return;
} catch (err: any) {
const status = err?.response?.status;
if (!TRANSIENT.has(status) || attempt === MAX_ATTEMPTS) {
// C3.6: never reject up the queue path — swallow, but surface once.
this.logger?.warn({ status }, "Spotify Connect command failed (exhausted/non-retryable)");
return;
}
let delay = Math.min(BASE_DELAY_MS * 2 ** (attempt - 1), MAX_DELAY_MS);
if (status === 429) {
const ra = Number(err?.response?.headers?.["retry-after"]);
if (Number.isFinite(ra) && ra > 0) delay = Math.min(ra * 1000, MAX_DELAY_MS);
}
await this.sleep(delay);
}
}
}
```
In the constructor: `this.sleep = deps?.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));` and `this.logger = deps?.logger;`. Rewrite `transfer/play/pause/resume/seek` so their body becomes: guard `authHeaders()` as today (unauth → `return;`, no retry), then `await this.mutateWithRetry(() => this.http.put(<url>, <body>, { headers, ... }));`. Keep the exact URLs/bodies/params from the current methods.
- [ ] **Step 6.4: Thread the logger from the controller.** In `controller.ts` where the default connect is built:
```ts
this.connect = o.connect ?? new SpotifyConnectApi(() => this.oauth.getAccessToken(), { logger: this.logger });
```
(`Logger` is already imported in controller.ts.) Injected-connect tests are unaffected.
- [ ] **Step 6.5: Update any conflicting existing connect test.** The S3.3 (C3.6) tests assert mutating calls swallow errors. If any asserts a single `http.put` call on a *transient*-status rejection, it now expects `MAX_ATTEMPTS` — update ONLY that count (this task's contract change). Do NOT weaken the swallow/no-throw guarantees or the non-transient single-call behavior.
- [ ] **Step 6.6: Verify.** `npx vitest run src/music/spotify/connect-api.test.ts src/music/spotify/controller.test.ts` all pass; `npx tsc --noEmit` clean.
- [ ] **Step 6.7: Commit.**
```bash
git add src/music/spotify/connect-api.ts src/music/spotify/connect-api.test.ts src/music/spotify/controller.ts
git commit -m "feat(spotify): retry/backoff on Connect commands (device-latency/flakiness watchdog) [S4.6]"
```
**Notes:** Bounded retry adds latency only on the (off-audio-path) control commands and only on transient failure; the injected `sleep` makes tests instant. Not e2e-verifiable (no real account). The `202`-accepted-but-not-effective case from §4.3 is NOT handled here (it needs post-command state verification, which the existing poll-based track-end already tolerates) — noted as a follow-up.
---
### Task 7: Stage 4 verification + whole-branch final review + finish
**Files:** none (verification + review + branch finish).
- [ ] **Step 7.1: Full verification.** `npx vitest run --no-file-parallelism` (all green), `npx tsc --noEmit` (clean), `cd web && npm run build` (clean). Record counts.
- [ ] **Step 7.2: Whole-branch adversarial review.** Review the ENTIRE `feat/spotify-audio` branch (`git merge-base main HEAD`..HEAD) — Stages 2+3+4 — with a fan-out of finders across dimensions (lifecycle/teardown correctness, OAuth/token security + no-secret-leak, backend selection + gating, async races, error handling, test hygiene) and adversarial verification of each finding. Dispatch ONE fix subagent with the consolidated Critical/Important findings; roll up Minors.
- [ ] **Step 7.3: Address findings**, re-verify, then use superpowers:finishing-a-development-branch to complete the branch (tests green → present options → execute choice).
---
## Self-Review (author)
- **Spec coverage:** §8 Settings card → Task 4; §9 binaries/GPL note + §11 README → Task 5; §12 "docs, README" → Task 5; §12/§4.3/§13 "recovery/watchdog" (Connect retry/backoff) → Task 6 (device-visibility watchdog + per-track skip already landed in Stage 3); D9 → Task 3; D11 → Task 2; config editability (prereq for the card) → Task 1.
- **Deferred (documented, NOT built this stage), with rationale:** runtime binary auto-download (spec §9 calls it "optional"; README documents manual install + Docker instead — avoids a tar.gz/checksum/platform-detection downloader on the critical path); the §4.3 `202`-accepted-but-not-effective verification (needs post-command playback-state confirmation the poll-based track-end already tolerates); D10's separate play-not-reflected diagnostic beyond the retry-exhaustion `logger.warn` Task 6 adds. These are listed in the ledger as follow-ups.
- **Placeholder scan:** backend tasks (1–3) carry complete code; frontend/docs (4–5) carry the tested composable in full + explicit wiring contracts + named existing patterns to mirror (Settings.vue has no component-test harness, so exact `.vue` template text is intentionally pattern-referenced, not dictated line-by-line).
- **Type consistency:** `SpotifyBackendKind` canonicalized in `backend-select.ts`, re-exported from `controller.ts`; `getBackendInfo` return type updated in both `spotify.ts` and its `server.ts` supplier and the `/status` test; `buildSpotifyPayload`/`SpotifyStatus` shapes match Task 1's masked view (`hasClientSecret`) and Task 2's `/status` (`binaryAvailable`).
@@ -0,0 +1,202 @@
# Spotify audio source (optional, hybrid librespot) — design spec
- **Issue:** [#112 — Support for Spotify audio source](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/112)
- **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 `spotify` source: 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`:
```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.yml` with `server: { 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 with `O_WRONLY|O_NONBLOCK` and errors `ENXIO` if 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 /status` for 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 to `api.spotify.com` — so on this backend a separate Spotify Developer app is **optional**.
- Recovery: on reader death go-librespot's write returns `EPIPE` and 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()`: spawn `librespot --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 against `pipe.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 our `device_id` by `deviceName` → `PUT /v1/me/player/play?device_id={id}` body `{uris:[uri]}`. `pause/resume/seek` → `PUT /v1/me/player/{pause,play,seek}`.
- `trackEnded`: poll `GET /v1/me/player` (is_playing → false / `item` changed / `progress_ms ≈ duration_ms`) plus optional `--onevent` hook (read-only notifications). Add a watchdog + retry/backoff for device-visibility latency and command `202/404` flakiness.
- 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-collaborative` for 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 client `65b708073fc0480ea92a077233ca87bd`, redirect `http://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-api` proxy 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.ts` recognizes the sentinel and routes to the `SpotifyController`.
- 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; exposes `playTrack/pause/resume/seek/stop` and forwards `trackEnded`/`metadata`.
- **`AudioPlayer` — new external-PCM mode:** add `playPcmStream(readable, { onExternalEnd })` that feeds the existing `pcmBuffer` → 20 ms frame loop → encoder → `frame` path **without spawning a url-ffmpeg**. For Spotify, `trackEnd` is driven by the backend's `trackEnded` event (the librespot→ffmpeg pipeline is long-lived and does not exit per song). `pause/resume/seek` on a Spotify song are routed to the backend by `instance.ts` (and gate frame emission locally for crisp UI state).
- **`instance.ts`:** `getProviderFor("spotify")`, a `-s` command flag in `getProvider(flags)`. When a dequeued `song.platform === "spotify"`: ensure the controller/backend is started + device active, `playTrack(uri)`, attach the player to the backend PCM. On `trackEnded` → advance the queue. When a **non-Spotify** song is next, **pause the sidecar** (so it doesn't buffer ahead) and use the normal `player.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()`:
```ts
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 `spotify` to `platform` and `Source` unions (`web/src/stores/player.ts`, `web/src/stores/sourceTabs.ts`).
- `SourceTabs.vue`: add "Spotify" tab; `SongCard.vue`: green **#1DB954** badge; `variables.scss`: `--brand-spotify` tokens.
- `stores/player.ts`: extend `authStatus` / `recommendPlaylists` / `dailySongs` / `userPlaylists` maps + 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 in `bin/`).
- **go-librespot** ships **Linux-only** assets (`go-librespot_linux_{x86_64,arm64,armv6,armv6_rpi}.tar.gz`, ~6 MB, at `github.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/Album` mapping.
- `chooseBackend()` per platform/config.
- Auth: PKCE challenge, token refresh, credential persistence.
- Config load/sanitize (defaults, hand-edited/corrupt input).
- `SpotifyProvider` methods (mock webapi).
- Player external-PCM path: feed a fake `Readable` → assert `frame` emission + `trackEnd` on external end.
- go-librespot `/events` → `trackEnded` translation; 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
1. **Metadata + provider + config + UI plumbing** (no audio): `spotify` source searchable/browsable; playback returns "not yet playable". Fully testable without a sidecar.
2. **go-librespot backend (Linux/Docker):** real playback on Linux; REST + FIFO + WebSocket.
3. **Rust librespot backend (Windows):** stdout PCM + Web-API Connect control.
4. **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-token` from 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/play` body `{uri, skip_to_uri?, paused?}`. Server enabled only when `server.enabled: true`.
- WebSocket `/events` envelopes `{type, data}`; types include `metadata, 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 pipe` with no `--device` → raw PCM to **stdout**; default S16 / 44100 / stereo; `--format` for 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/librespot` wiki (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 endpoint `POST 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), `librespot` `docs/authentication.md`.
+388 -2
View File
@@ -1,8 +1,10 @@
import { describe, it, expect } from "vitest";
import { describe, it, expect, vi } from "vitest";
import { mkdtempSync, writeFileSync, existsSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { buildFfmpegArgs, shouldUsePowerShellDownload, cleanupTempDir, shouldEndOnStall, volumeToFactor } from "./player.js";
import { Readable } from "node:stream";
import { buildFfmpegArgs, shouldUsePowerShellDownload, cleanupTempDir, shouldEndOnStall, volumeToFactor, AudioPlayer } from "./player.js";
import type { Logger } from "../logger.js";
function getHeadersArg(args: string[]): string {
const idx = args.indexOf("-headers");
@@ -200,3 +202,387 @@ describe("shouldEndOnStall (#89 mid-track stall watchdog)", () => {
expect(shouldEndOnStall(10, false, MAX_EMPTY, MAX_STALL)).toBe(false);
});
});
// Minimal stub: AudioPlayer only calls debug/info/warn/error; child() returns self.
const silentLogger = {
debug() {},
info() {},
warn() {},
error() {},
fatal() {},
trace() {},
child() {
return silentLogger;
},
} as unknown as Logger;
// A readable we fully control: no underlying source; we push PCM manually and
// keep it open (never push(null)) to model the long-lived go-librespot sidecar.
function openPcmReadable(): Readable {
return new Readable({ read() {} });
}
const wait = (ms: number): Promise<void> => new Promise<void>((r) => setTimeout(r, ms));
const FRAME_BYTES = 3840; // PCM_FRAME_BYTES: 960 samples * 2ch * 2 bytes @48k s16le
describe("AudioPlayer external-PCM mode (playPcmStream)", () => {
it("emits Opus 'frame' events from the external PCM stream without spawning ffmpeg", async () => {
const player = new AudioPlayer(silentLogger);
const frames: Buffer[] = [];
player.on("frame", (f) => frames.push(f));
const stream = openPcmReadable();
player.playPcmStream(stream, {});
stream.push(Buffer.alloc(FRAME_BYTES * 10)); // ~10 frames of PCM
await wait(150); // ~7 frame ticks at 20ms
expect(player.getState()).toBe("playing");
expect(frames.length).toBeGreaterThan(0);
expect(Buffer.isBuffer(frames[0])).toBe(true);
player.stop();
});
it("does NOT emit 'trackEnd' on underrun while external (stream stays open)", async () => {
const player = new AudioPlayer(silentLogger);
let ended = 0;
const frames: Buffer[] = [];
player.on("trackEnd", () => ended++);
player.on("frame", (f) => frames.push(f));
const stream = openPcmReadable();
player.playPcmStream(stream, {});
stream.push(Buffer.alloc(FRAME_BYTES * 2)); // only 2 frames, then underrun
await wait(200); // long after those 2 frames have drained
// In the url path, ffmpeg===null + empty buffer would fire trackEnd; here it must not.
expect(ended).toBe(0);
// Silence frames keep the 20ms timeline alive -> more than the 2 fed frames emitted.
expect(frames.length).toBeGreaterThan(2);
expect(player.getState()).toBe("playing");
player.stop();
});
// CORRECTION C2 (c): stop() DETACHES the shared readable — it must NOT be destroyed
// (destroying the sidecar's long-lived ffmpeg stdout would kill it for every future
// track). The sessionId bump + listener removal fence stale PCM out of pcmBuffer.
it("stop() detaches external mode without destroying the readable, and fences via sessionId", async () => {
const player = new AudioPlayer(silentLogger);
const frames: Buffer[] = [];
player.on("frame", (f) => frames.push(f));
const stream = openPcmReadable();
player.playPcmStream(stream, {});
expect(stream.listenerCount("data")).toBe(1);
stream.push(Buffer.alloc(FRAME_BYTES * 5));
await wait(80);
player.stop();
expect(player.getState()).toBe("idle");
// C2: the shared sidecar stream must NOT be destroyed by teardown.
expect(stream.destroyed).toBe(false);
// Player's listeners are removed on detach (data/end/error).
expect(stream.listenerCount("data")).toBe(0);
expect(stream.listenerCount("end")).toBe(0);
expect(stream.listenerCount("error")).toBe(0);
const countAtStop = frames.length;
// sessionId fence + detached listeners: PCM pushed after stop must not
// resurrect the timeline or re-feed pcmBuffer.
stream.push(Buffer.alloc(FRAME_BYTES * 5));
await wait(80);
expect(frames.length).toBe(countAtStop);
});
// CORRECTION C2 (a): a gapless track change is driven by the sidecar pushing LATER
// PCM over the SAME already-attached stream. The player must NOT detach/re-attach
// (no second playPcmStream) — one persistent data listener serves every track.
it("(C2-a) feeds a later chunk over the SAME single attachment — gapless track change, no re-attach", async () => {
const player = new AudioPlayer(silentLogger);
const frames: Buffer[] = [];
player.on("frame", (f) => frames.push(f));
const stream = openPcmReadable();
player.playPcmStream(stream, {});
expect(stream.listenerCount("data")).toBe(1); // attached exactly once
stream.push(Buffer.alloc(FRAME_BYTES * 4)); // "track 1" PCM
await wait(120);
const afterFirst = frames.length;
expect(afterFirst).toBeGreaterThan(0);
stream.push(Buffer.alloc(FRAME_BYTES * 4)); // sidecar seamlessly rolls into "track 2"
await wait(120);
expect(frames.length).toBeGreaterThan(afterFirst);
// Still exactly ONE listener — no detach/re-attach across the handoff.
expect(stream.listenerCount("data")).toBe(1);
expect(player.getState()).toBe("playing");
player.stop();
});
// CORRECTION C2 (b): a second playPcmStream detaches the first (NOT destroyed, and it
// stops feeding pcmBuffer) and attaches the second.
it("(C2-b) a second playPcmStream detaches the first (not destroyed, stops feeding) and attaches the second", async () => {
const player = new AudioPlayer(silentLogger);
const frames: Buffer[] = [];
player.on("frame", (f) => frames.push(f));
const first = openPcmReadable();
player.playPcmStream(first, {});
first.push(Buffer.alloc(FRAME_BYTES * 4));
await wait(120);
expect(frames.length).toBeGreaterThan(0);
expect(first.listenerCount("data")).toBe(1);
const second = openPcmReadable();
player.playPcmStream(second, {}); // fences + detaches `first`, attaches `second`
// C2: `first` is DETACHED, not destroyed.
expect(first.destroyed).toBe(false);
// `first` no longer feeds pcmBuffer — its data listener was removed.
expect(first.listenerCount("data")).toBe(0);
// `second` is now the attached source.
expect(second.listenerCount("data")).toBe(1);
expect(player.getState()).toBe("playing");
player.stop();
});
it("fires onExternalEnd when the readable ends (drives controller-based advance)", async () => {
const player = new AudioPlayer(silentLogger);
let endedCb = 0;
const stream = openPcmReadable();
player.playPcmStream(stream, { onExternalEnd: () => endedCb++ });
stream.push(Buffer.alloc(FRAME_BYTES));
await wait(40);
stream.push(null); // end-of-stream
await wait(40);
expect(endedCb).toBe(1);
player.stop();
});
it("seek() is a local no-op in external mode (never respawns ffmpeg on a spotify sentinel)", async () => {
const player = new AudioPlayer(silentLogger);
const stream = openPcmReadable();
player.playPcmStream(stream, {});
stream.push(Buffer.alloc(FRAME_BYTES * 3));
await wait(40);
expect(() => player.seek(30)).not.toThrow();
// Still external, still playing — no url-ffmpeg respawn, state unchanged.
expect(player.getState()).toBe("playing");
player.stop();
});
it("isExternalActive() is false initially, true after playPcmStream, false after stop()", () => {
const player = new AudioPlayer(silentLogger);
// Idle: never attached.
expect(player.isExternalActive()).toBe(false);
const stream = openPcmReadable();
player.playPcmStream(stream, {});
// Attached to the external sidecar stream.
expect(player.isExternalActive()).toBe(true);
player.stop();
// Detached again — the orchestrator uses this to know it must re-attach.
expect(player.isExternalActive()).toBe(false);
});
it("pause()/resume() still gate local emission in external mode (unchanged semantics)", async () => {
const player = new AudioPlayer(silentLogger);
const stream = openPcmReadable();
player.playPcmStream(stream, {});
stream.push(Buffer.alloc(FRAME_BYTES * 3));
await wait(40);
player.pause();
expect(player.getState()).toBe("paused");
player.resume();
expect(player.getState()).toBe("playing");
player.stop();
});
// CORRECTION C1 (whole-branch): mixed queue [spotifyA, neteaseB, spotifyC].
// Advancing A -> B (a NON-spotify track) calls stop(), whose detachExternalStream()
// PAUSES the backend's long-lived SHARED readable (state.flowing = false). When the
// LATER spotify track C reuses the SAME backend, the orchestrator re-attaches that
// SAME readable via playPcmStream(). Node's Readable.on('data') only auto-resumes
// when flowing !== false, so without an explicit resume() the shared stream stays
// paused, onData never fires, pcmBuffer stays empty, and C plays only silence frames.
// Regression: after re-attach the shared stream MUST be flowing again and real PCM
// MUST reach the player.
it("(C1) resumes a re-attached, previously-paused SHARED stream so a later Spotify track isn't silent", async () => {
const player = new AudioPlayer(silentLogger);
const frames: Buffer[] = [];
player.on("frame", (f) => frames.push(f));
// The backend's long-lived, SHARED readable, reused across every track.
const shared = openPcmReadable();
// --- Spotify track A: first attach (auto-resumes, flowing was null !== false).
player.playPcmStream(shared, {});
shared.push(Buffer.alloc(FRAME_BYTES * 4));
await wait(120);
expect(frames.length).toBeGreaterThan(0);
// --- Advance to a NON-spotify track (neteaseB): play(url) begins with stop(),
// which detaches AND pauses the shared stream (state.flowing = false).
player.stop();
expect(shared.isPaused()).toBe(true); // shared stream is now paused
expect(player.getState()).toBe("idle");
// --- Spotify track C reuses the SAME backend: isExternalActive() is false so the
// orchestrator re-attaches the SAME (paused) shared readable.
expect(player.isExternalActive()).toBe(false);
// Spy on the shared stream to observe whether real PCM actually flows to the
// player. Adding a 'data' listener while flowing===false does NOT resume it
// (Node semantics), so this spy cannot mask the bug — pre-fix it stays at 0.
let spyBytes = 0;
shared.on("data", (c: Buffer) => {
spyBytes += c.length;
});
player.playPcmStream(shared, {}); // re-attach the SAME shared readable
// The re-attached stream must be flowing again, or track C is silent.
expect(shared.isPaused()).toBe(false);
shared.push(Buffer.alloc(FRAME_BYTES * 4)); // "track C" PCM
await wait(120);
// onData must have run (real PCM reached the player), not just silence frames.
expect(spyBytes).toBeGreaterThan(0);
expect(player.getState()).toBe("playing");
player.stop();
});
});
// R3-4: the 20ms frame loop keeps running while paused (so a live-but-silent
// stream can refill on resume). But the stall/EOF end-detection branches MUST
// only run while state==="playing" — otherwise pausing a stalled or
// unknown-duration stream still accumulates emptyFrameAttempts and auto-emits
// trackEnd (~5s later), making the controller skip the paused track.
//
// These tests drive the real url-path frame loop (this.ffmpeg !== null, NOT
// external mode), which cannot be exercised via playPcmStream (that sets
// externalMode and suppresses both branches). We inject a fake live ffmpeg +
// an empty pcmBuffer (a stream that stays alive but never yields a full PCM
// frame) and run the actual startFrameLoop() under fake timers. `performance`
// is faked in lockstep with the timer clock so each tick advances a real 20ms,
// letting us cheaply cross MAX_EMPTY_ATTEMPTS (250 ticks ≈ 5s) deterministically.
describe("AudioPlayer stall/EOF end-detection is gated on playing state (R3-4)", () => {
// Fake `performance` in lockstep with the timer clock so each advanced 20ms is
// a real frame tick (the loop computes its delay from performance.now()).
const FAKE_TIMER_OPTS: Parameters<typeof vi.useFakeTimers>[0] = {
toFake: ["setTimeout", "clearTimeout", "setInterval", "clearInterval", "Date", "performance"],
};
// A player primed as "playing" with a LIVE ffmpeg that never produces a full
// PCM frame (unknown duration -> isNearEnd forced true). startFrameLoop() runs
// the genuine loop; no real process is spawned (fake ffmpeg has no pid, so the
// end path never touches forceCleanup/process.kill).
function makeStalledPlaying(): AudioPlayer {
const player = new AudioPlayer(silentLogger);
const p = player as unknown as {
ffmpeg: unknown;
currentSongDuration: number;
pcmBuffer: Buffer;
emptyFrameAttempts: number;
framesPlayed: number;
state: string;
startFrameLoop(): void;
};
p.ffmpeg = { pid: undefined }; // live ffmpeg, but delivers no PCM
p.currentSongDuration = 0; // unknown duration -> isNearEnd === true
p.pcmBuffer = Buffer.alloc(0); // always < one PCM frame
p.emptyFrameAttempts = 0;
p.framesPlayed = 0;
p.state = "playing";
p.startFrameLoop();
return player;
}
it("does NOT emit trackEnd (and stays paused) when a stalled unknown-duration stream is paused past the stall threshold", () => {
vi.useFakeTimers(FAKE_TIMER_OPTS);
try {
const player = makeStalledPlaying();
let ended = 0;
player.on("trackEnd", () => ended++);
player.pause();
expect(player.getState()).toBe("paused");
// Advance well past MAX_EMPTY_ATTEMPTS (250 ticks ≈ 5s): ~300 ticks.
vi.advanceTimersByTime(20 * 300);
expect(ended).toBe(0);
expect(player.getState()).toBe("paused");
player.stop();
} finally {
vi.useRealTimers();
}
});
it("STILL emits trackEnd when the SAME stalled unknown-duration stream is left playing (dead-stream recovery #89 preserved)", () => {
vi.useFakeTimers(FAKE_TIMER_OPTS);
try {
const player = makeStalledPlaying(); // stays "playing"
let ended = 0;
player.on("trackEnd", () => ended++);
vi.advanceTimersByTime(20 * 300); // cross the 250-tick stall threshold
expect(ended).toBe(1);
expect(player.getState()).toBe("idle");
player.stop();
} finally {
vi.useRealTimers();
}
});
it("does not spuriously end after a brief pause+resume on a healthy stream", () => {
vi.useFakeTimers(FAKE_TIMER_OPTS);
try {
const player = new AudioPlayer(silentLogger);
const p = player as unknown as {
ffmpeg: unknown;
currentSongDuration: number;
pcmBuffer: Buffer;
emptyFrameAttempts: number;
framesPlayed: number;
state: string;
startFrameLoop(): void;
};
// Healthy: a live ffmpeg with a large buffered runway that never drains
// empty across the ticks below, so no underrun is ever seen.
p.ffmpeg = { pid: undefined };
p.currentSongDuration = 0;
p.pcmBuffer = Buffer.alloc(FRAME_BYTES * 400);
p.emptyFrameAttempts = 0;
p.framesPlayed = 0;
p.state = "playing";
p.startFrameLoop();
let ended = 0;
player.on("trackEnd", () => ended++);
vi.advanceTimersByTime(20 * 5); // play a few frames
player.pause();
vi.advanceTimersByTime(20 * 100); // brief pause (buffer NOT drained while paused)
player.resume();
expect(player.getState()).toBe("playing");
vi.advanceTimersByTime(20 * 100); // resume; still plenty of runway
expect(ended).toBe(0);
expect(player.getState()).toBe("playing");
player.stop();
} finally {
vi.useRealTimers();
}
});
});
+203 -26
View File
@@ -5,6 +5,7 @@ import { accessSync, chmodSync, constants, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createOpusEncoder, PCM_FRAME_BYTES, type Encoder } from "./encoder.js";
import type { Readable } from "node:stream";
import type { Logger } from "../logger.js";
const require = createRequire(import.meta.url);
@@ -47,7 +48,7 @@ const resolvedFfmpeg: string = (() => {
return "ffmpeg";
})();
function getFfmpegCommand(): string {
export function getFfmpegCommand(): string {
return resolvedFfmpeg;
}
@@ -187,6 +188,22 @@ export class AudioPlayer extends EventEmitter {
private static readonly MAX_STALL_ATTEMPTS = 3000;
private currentSongDuration = 0; // 当前歌曲总时长(秒)
// --- External PCM mode (Stage 2: go-librespot Spotify sidecar) ---
// When true, PCM arrives from a long-lived external Readable instead of a
// per-URL ffmpeg: this.ffmpeg stays null, and the underrun-driven trackEnd
// branches are suppressed (advance is driven by the controller, not EOF).
//
// CORRECTION C2: externalStream is the backend's LONG-LIVED, SHARED ffmpeg
// stdout (one stream reused across every track). Teardown must DETACH (remove
// the listeners we added + pause), never destroy it. We keep references to the
// exact handler functions so detach can removeListener precisely.
private externalMode = false;
private externalStream: Readable | null = null;
private onExternalEnd: (() => void) | null = null;
private externalDataHandler: ((chunk: Buffer) => void) | null = null;
private externalEndHandler: (() => void) | null = null;
private externalErrorHandler: ((err: Error) => void) | null = null;
constructor(logger: Logger) {
super();
this.encoder = createOpusEncoder();
@@ -390,6 +407,117 @@ export class AudioPlayer extends EventEmitter {
this.startFrameLoop();
}
/**
* External-PCM mode (Stage 2 go-librespot Spotify sidecar).
*
* Feeds an already-normalized 48kHz/s16le/stereo PCM Readable (the
* go-librespot FIFO -> ffmpeg output) straight into the existing pcmBuffer +
* 20ms frame loop + Opus encoder + "frame" emission, WITHOUT spawning a
* per-URL ffmpeg. The url play() path is left completely untouched.
*
* Track advance is NOT driven by buffer underrun here (the sidecar stream is
* continuous and never EOFs per song); the caller drives advance via the
* SpotifyController "trackEnded" WebSocket event. onExternalEnd fires only if
* the underlying readable itself ends or errors.
*
* CORRECTION C2: the readable is the backend's long-lived, SHARED ffmpeg
* stdout reused across every track — a gapless track change is just LATER PCM
* on this SAME already-attached stream (no re-attach). Teardown DETACHES
* (removes our listeners + pauses); it never destroys the shared stream.
*/
playPcmStream(readable: Readable, opts: { onExternalEnd?: () => void } = {}): void {
// 1. Fence current playback: stop() bumps sessionId, clears pcmBuffer, kills
// any ffmpeg, and DETACHES (never destroys) any prior external stream.
this.stop();
const currentSessionId = this.sessionId;
this.externalMode = true;
this.externalStream = readable;
this.onExternalEnd = opts.onExternalEnd ?? null;
// Leave this.ffmpeg = null; clear currentUrl so seek() cannot respawn ffmpeg.
this.currentUrl = "";
this.seekOffset = 0;
this.framesPlayed = 0;
this.healthyFrames = 0;
this.ffmpegPaused = false;
this.spawnFailed = false;
this.emptyFrameAttempts = 0;
this.currentSongDuration = 0;
// Same ingestion + high-water backpressure as the ffmpeg.stdout handler,
// but pausing the Readable instead of ffmpeg.stdout. sessionId-guarded so
// stale sidecar PCM can't leak into a new track after stop()/skip. Handler
// refs are stored so detach can remove exactly these listeners (C2).
const onData = (chunk: Buffer): void => {
if (this.sessionId !== currentSessionId) return;
this.pcmBuffer = Buffer.concat([this.pcmBuffer, chunk]);
if (
this.pcmBuffer.length > AudioPlayer.BUFFER_HIGH_WATER &&
!this.ffmpegPaused &&
this.externalStream === readable
) {
readable.pause();
this.ffmpegPaused = true;
}
};
const onEnd = (): void => {
if (this.sessionId !== currentSessionId) return;
this.onExternalEnd?.();
};
const onError = (err: Error): void => {
if (this.sessionId !== currentSessionId) return;
this.logger.warn({ err }, "External PCM stream error");
this.onExternalEnd?.();
};
this.externalDataHandler = onData;
this.externalEndHandler = onEnd;
this.externalErrorHandler = onError;
readable.on("data", onData);
readable.on("end", onEnd);
readable.on("error", onError);
// CORRECTION C1: explicitly resume a re-attached, previously-paused Readable.
// The backend's SHARED stdout is reused across every track; a prior non-spotify
// advance ran stop() -> detachExternalStream() which pause()d it (state.flowing =
// false). Node's Readable.on('data') only auto-resumes when flowing !== false, so
// re-attaching a paused stream would leave it stuck: onData never fires, pcmBuffer
// stays empty, and a later Spotify track plays only silence. resume() is safe/
// idempotent on a first attach (never-paused/already-flowing) stream.
readable.resume();
this.state = "playing";
this.startFrameLoop();
}
/**
* CORRECTION C2: DETACH, never destroy. The external readable is the backend's
* long-lived, SHARED ffmpeg stdout reused across every track; destroying it
* would kill the sidecar pipe for all future tracks. Remove only the listeners
* WE added and pause the flow so stale PCM stops landing in pcmBuffer, then
* clear the external-mode state.
*/
private detachExternalStream(): void {
const stream = this.externalStream;
if (stream) {
if (this.externalDataHandler) stream.off("data", this.externalDataHandler);
if (this.externalEndHandler) stream.off("end", this.externalEndHandler);
if (this.externalErrorHandler) stream.off("error", this.externalErrorHandler);
try {
stream.pause();
} catch {
/* best-effort: never destroy the shared sidecar stream */
}
}
this.externalDataHandler = null;
this.externalEndHandler = null;
this.externalErrorHandler = null;
this.externalStream = null;
this.externalMode = false;
this.onExternalEnd = null;
}
stop(): void {
// 3. 递增 ID 是最有效的逻辑“隔离墙”
this.sessionId++;
@@ -419,6 +547,11 @@ export class AudioPlayer extends EventEmitter {
this.currentTempDir = null;
}
// CORRECTION C2: tear down external mode by DETACHING (remove our listeners +
// pause) — never destroy the shared, long-lived sidecar stream. The
// sessionId++ above already fences the external data/end/error handlers.
this.detachExternalStream();
this.ffmpegPaused = false;
this.spawnFailed = false;
this.state = "idle";
@@ -480,7 +613,17 @@ export class AudioPlayer extends EventEmitter {
? (this.currentSongDuration - elapsed) <= 5 // 距离结尾不足5秒
: true; // 未知时长时保守处理
if (this.ffmpeg !== null && this.pcmBuffer.length < PCM_FRAME_BYTES) {
// External mode: the sidecar PCM stream is continuous and never EOFs per
// song; a transient underrun must NOT end the track (advance is driven by
// the controller). Skip BOTH drain/stall branches while externalMode.
//
// R3-4: gate BOTH end-detection branches on state==="playing". While paused
// the loop still ticks (so a resumed stream can refill), but it must NOT
// accumulate stall attempts or emit trackEnd — otherwise pausing a stalled/
// unknown-duration stream would auto-advance ~5s later. Because the if is
// now false while paused, the else resets emptyFrameAttempts to 0, so a
// resumed healthy stream starts fresh and never ends instantly.
if (this.state === "playing" && !this.externalMode && this.ffmpeg !== null && this.pcmBuffer.length < PCM_FRAME_BYTES) {
this.emptyFrameAttempts++;
// End the track when FFmpeg has gone silent: quickly if we're near the
@@ -505,20 +648,20 @@ export class AudioPlayer extends EventEmitter {
nearEnd: isNearEnd,
}, "FFmpeg stopped outputting data, ending track");
this.frameLoopRunning = false;
if (this.state !== "idle") {
this.state = "idle";
// 清理FFmpeg进程
if (this.ffmpeg) {
const procToKill = this.ffmpeg;
const pidToKill = procToKill.pid;
this.ffmpeg = null;
if (pidToKill) {
this.forceCleanup(procToKill, pidToKill);
}
// The outer gate guarantees state==="playing" here, so no !=="idle"
// guard is needed: end the track directly.
this.state = "idle";
// 清理FFmpeg进程
if (this.ffmpeg) {
const procToKill = this.ffmpeg;
const pidToKill = procToKill.pid;
this.ffmpeg = null;
if (pidToKill) {
this.forceCleanup(procToKill, pidToKill);
}
this.consecutiveFailures = 0;
this.emit("trackEnd");
}
this.consecutiveFailures = 0;
this.emit("trackEnd");
return;
}
} else {
@@ -526,14 +669,15 @@ export class AudioPlayer extends EventEmitter {
this.emptyFrameAttempts = 0;
}
if (!this.ffmpeg && this.pcmBuffer.length < PCM_FRAME_BYTES) {
// R3-4: likewise gated on state==="playing" — a drained/EOF'd stream must
// not emit trackEnd while paused; end-detection resumes on resume().
if (this.state === "playing" && !this.externalMode && !this.ffmpeg && this.pcmBuffer.length < PCM_FRAME_BYTES) {
this.frameLoopRunning = false;
if (this.state !== "idle") {
this.state = "idle";
if (!this.spawnFailed) {
this.consecutiveFailures = 0;
this.emit("trackEnd");
}
// Outer gate guarantees state==="playing"; end directly (no !=="idle" guard).
this.state = "idle";
if (!this.spawnFailed) {
this.consecutiveFailures = 0;
this.emit("trackEnd");
}
return;
}
@@ -542,13 +686,24 @@ export class AudioPlayer extends EventEmitter {
}
private sendNextFrame(): void {
if (this.pcmBuffer.length < PCM_FRAME_BYTES) return;
if (this.pcmBuffer.length < PCM_FRAME_BYTES) {
// External mode: the sidecar PCM stream is long-lived and must NOT end on
// a transient underrun. Emit an encoded silence frame so the 20ms voice
// timeline stays continuous instead of returning (which would desync TS).
if (this.externalMode) this.emitSilenceFrame();
return;
}
const pcmFrame = this.pcmBuffer.subarray(0, PCM_FRAME_BYTES);
this.pcmBuffer = this.pcmBuffer.subarray(PCM_FRAME_BYTES);
if (this.ffmpegPaused && this.pcmBuffer.length < AudioPlayer.BUFFER_LOW_WATER && this.ffmpeg?.stdout) {
this.ffmpeg.stdout.resume();
this.ffmpegPaused = false;
if (this.ffmpegPaused && this.pcmBuffer.length < AudioPlayer.BUFFER_LOW_WATER) {
if (this.externalMode && this.externalStream) {
this.externalStream.resume();
this.ffmpegPaused = false;
} else if (this.ffmpeg?.stdout) {
this.ffmpeg.stdout.resume();
this.ffmpegPaused = false;
}
}
try {
@@ -566,6 +721,16 @@ export class AudioPlayer extends EventEmitter {
}
}
private emitSilenceFrame(): void {
try {
const opusFrame = this.encoder.encode(Buffer.alloc(PCM_FRAME_BYTES));
this.emit("frame", opusFrame);
this.framesPlayed++;
} catch (err) {
this.emit("error", err as Error);
}
}
private applyVolume(pcm: Buffer): Buffer {
const factor = volumeToFactor(this.volume);
// factor === 1 only at volume 100; skip the per-sample loop at full loudness.
@@ -578,8 +743,16 @@ export class AudioPlayer extends EventEmitter {
return out;
}
// NOTE: in external (Spotify sidecar) mode getElapsed() is frame-count based
// (framesPlayed includes silence frames emitted on underrun) and therefore
// only APPROXIMATE — the authoritative position is the controller's live
// status.track.position. This approximation is acceptable for Spotify.
getElapsed(): number { return this.seekOffset + (this.framesPlayed * FRAME_DURATION_MS) / 1000; }
seek(seconds: number): void {
seek(seconds: number): void {
// External (Spotify sidecar) mode: local seek is a no-op. Respawning ffmpeg
// on the spotify: sentinel would collide with the continuous PCM source;
// transport is delegated to the SpotifyController by the caller (Task 7).
if (this.externalMode) return;
if (this.currentUrl && Number.isFinite(seconds) && seconds >= 0) {
this.play(this.currentUrl, seconds, this.currentSongDuration);
}
@@ -590,4 +763,8 @@ export class AudioPlayer extends EventEmitter {
setVolume(vol: number): void { this.volume = Math.max(0, Math.min(100, vol)); }
getVolume(): number { return this.volume; }
getState(): PlayerState { return this.state; }
// True only while attached to an external (Spotify sidecar) PCM stream. Used
// by the orchestrator to decide whether to re-attach: stop() detaches (sets
// externalMode=false) so this is false after any player.stop().
isExternalActive(): boolean { return this.externalMode; }
}
+2 -1
View File
@@ -10,10 +10,11 @@ export interface QueuedSong {
name: string;
artist: string;
album: string;
platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou";
platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou" | "spotify";
url?: string; // resolved lazily at play time
coverUrl: string;
duration: number; // seconds
requestedBy?: string;
}
export class PlayQueue {
+702 -1
View File
@@ -1,6 +1,13 @@
import { describe, it, expect, vi } from "vitest";
import { BotInstance, COMMAND_DENIED_MESSAGE } from "./instance.js";
import { BotInstance, COMMAND_DENIED_MESSAGE, spotifyPortsForBotId } from "./instance.js";
import type { BotInstanceOptions } from "./instance.js";
import type { TS3TextMessage } from "../ts-protocol/client.js";
import type { SpotifyController } from "../music/spotify/controller.js";
import type { SpotifyOAuth } from "../music/spotify/spotify-oauth.js";
import type { MusicProvider } from "../music/provider.js";
import type { BotDatabase } from "../data/database.js";
import type { AvatarStore } from "../data/avatars.js";
import type { BotConfig } from "../data/config.js";
// Constructing a real BotInstance is heavy (spawns a TS3Client, AudioPlayer,
// reads avatars, etc.), and runExclusive only touches a single private field
@@ -211,6 +218,700 @@ describe("BotInstance.handleTextMessage — command permission gate", () => {
});
});
describe("BotInstance.getProviderFor — spotify routing", () => {
it("getProviderFor routes 'spotify' to the injected spotify provider", () => {
const spotify = { platform: "spotify" } as any;
const ctx = { spotifyProvider: spotify, neteaseProvider: { platform: "netease" } } as any;
expect(BotInstance.prototype.getProviderFor.call(ctx, "spotify" as any)).toBe(spotify);
});
});
// --- Spotify orchestration (Task 7 + Correction C4) ------------------------
// These drive the REAL prototype methods on a hand-built ctx (the file's
// established `.call(ctx)` style) and assert the routing DECISIONS. Live audio
// is not testable here. C4 supersedes the brief where they conflict: switching
// a URL track -> spotify does NOT call player.stop() (playPcmStream fences the
// prior ffmpeg internally), and a spotify -> spotify handoff does NOT re-attach
// the persistent PCM stream (playPcmStream is called ONCE across both tracks).
const resolveAndPlay = BotInstance.prototype.resolveAndPlay as (
this: unknown,
song: any,
) => Promise<boolean>;
const setupPlayerEvents = (BotInstance.prototype as any).setupPlayerEvents as (
this: unknown,
) => void;
const cmdPause = (BotInstance.prototype as any).cmdPause as (this: unknown) => string;
const cmdResume = (BotInstance.prototype as any).cmdResume as (this: unknown) => string;
const cmdStop = (BotInstance.prototype as any).cmdStop as (this: unknown) => string;
const handleOccupancy = (BotInstance.prototype as any).handleOccupancy as (
this: unknown,
userCount: number,
) => void;
const seek = (BotInstance.prototype as any).seek as (this: unknown, seconds: number) => void;
const playNext = (BotInstance.prototype as any).playNext as (
this: unknown,
maxRetries?: number,
) => Promise<boolean>;
const cmdRemove = (BotInstance.prototype as any).cmdRemove as (
this: unknown,
cmd: any,
) => Promise<string> | string;
function makeController() {
return {
ensureStarted: vi.fn(async () => true),
playTrack: vi.fn(async () => true),
getPcmStream: vi.fn(() => ({ kind: "pcm" } as any)),
pause: vi.fn(async () => {}),
resume: vi.fn(async () => {}),
seek: vi.fn(async () => {}),
stop: vi.fn(() => {}),
on: vi.fn(),
};
}
function makePlayer() {
// `externalActive` mirrors the real AudioPlayer: playPcmStream attaches the
// external stream (true), and both stop() and play() detach it (false). The
// re-attach guard reads isExternalActive(), so this must track that state.
// `state` mirrors the real player's PlayerState transitions so tests can
// observe paused→playing (R3-3): playPcmStream/play → "playing", stop →
// "idle", pause → "paused" (only from playing), resume → "playing" (only
// from paused). Existing tests never read state, so tracking it is inert
// for them.
let externalActive = false;
let state: "idle" | "playing" | "paused" = "idle";
return {
play: vi.fn((..._args: any[]) => { externalActive = false; state = "playing"; }),
stop: vi.fn(() => { externalActive = false; state = "idle"; }),
playPcmStream: vi.fn((..._args: any[]) => { externalActive = true; state = "playing"; }),
pause: vi.fn(() => { if (state === "playing") state = "paused"; }),
resume: vi.fn(() => { if (state === "paused") state = "playing"; }),
seek: vi.fn(),
isExternalActive: vi.fn(() => externalActive),
getState: vi.fn(() => state),
};
}
function makeResolveCtx(opts: {
controller: ReturnType<typeof makeController>;
player: ReturnType<typeof makePlayer>;
url: string;
currentSourceIsSpotify?: boolean;
}) {
return {
connected: true,
config: {},
id: "bot1",
voteSkipUsers: new Set<string>(),
autoPaused: false,
currentSourceIsSpotify: opts.currentSourceIsSpotify ?? false,
effectiveDuration: undefined,
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
tsClient: { sendTextMessage: vi.fn(async () => {}) },
database: { addPlayHistory: vi.fn() },
spotifyController: opts.controller,
player: opts.player,
getProviderFor: vi.fn(() => ({ getSongUrl: async () => ({ url: opts.url }) })),
syncProfileToSong: vi.fn(async () => {}),
emit: vi.fn(),
} as any;
}
function spotifySong() {
return {
id: "abc",
name: "Song",
artist: "Artist",
album: "Album",
platform: "spotify",
coverUrl: "c",
duration: 200,
url: "",
};
}
describe("BotInstance.resolveAndPlay — Spotify routing (C4)", () => {
it("routes a spotify song to controller.playTrack + player.playPcmStream, not player.play", async () => {
const controller = makeController();
const player = makePlayer();
const ctx = makeResolveCtx({ controller, player, url: "spotify:track:abc" });
const ok = await resolveAndPlay.call(ctx, spotifySong());
expect(ok).toBe(true);
expect(controller.ensureStarted).toHaveBeenCalledTimes(1);
expect(controller.playTrack).toHaveBeenCalledWith("spotify:track:abc");
expect(player.playPcmStream).toHaveBeenCalledTimes(1);
expect(player.playPcmStream.mock.calls[0][0]).toEqual({ kind: "pcm" });
expect(player.play).not.toHaveBeenCalled();
// C4: playPcmStream fences the prior url-ffmpeg internally — no player.stop().
expect(player.stop).not.toHaveBeenCalled();
expect(ctx.currentSourceIsSpotify).toBe(true);
expect(ctx.database.addPlayHistory).toHaveBeenCalledTimes(1);
expect(ctx.emit).toHaveBeenCalledWith("stateChange");
});
it("returns false + sends the Stage-1 fallback when the backend is unavailable", async () => {
const controller = makeController();
controller.ensureStarted = vi.fn(async () => false);
const player = makePlayer();
const ctx = makeResolveCtx({ controller, player, url: "spotify:track:abc" });
const ok = await resolveAndPlay.call(ctx, spotifySong());
expect(ok).toBe(false);
expect(ctx.tsClient.sendTextMessage).toHaveBeenCalledTimes(1);
expect(controller.playTrack).not.toHaveBeenCalled();
expect(player.playPcmStream).not.toHaveBeenCalled();
expect(player.play).not.toHaveBeenCalled();
});
it("attaches the PCM stream once (no player.stop) when switching URL -> spotify", async () => {
const controller = makeController();
const player = makePlayer();
const ctx = makeResolveCtx({
controller, player, url: "spotify:track:abc", currentSourceIsSpotify: false,
});
await resolveAndPlay.call(ctx, spotifySong());
// C4: NO player.stop() on the URL -> spotify transition.
expect(player.stop).not.toHaveBeenCalled();
expect(player.playPcmStream).toHaveBeenCalledTimes(1);
});
it("does NOT re-attach the stream on a spotify -> spotify handoff (playPcmStream called once across two tracks)", async () => {
const controller = makeController();
const player = makePlayer();
const ctx = makeResolveCtx({
controller, player, url: "spotify:track:abc", currentSourceIsSpotify: false,
});
// First spotify track: coming from a URL/idle source -> attach.
await resolveAndPlay.call(ctx, spotifySong());
expect(ctx.currentSourceIsSpotify).toBe(true);
// Second spotify track: go-librespot changes tracks into the SAME FIFO.
await resolveAndPlay.call(ctx, spotifySong());
expect(player.playPcmStream).toHaveBeenCalledTimes(1); // NOT re-attached
expect(controller.playTrack).toHaveBeenCalledTimes(2); // both tracks played
expect(player.stop).not.toHaveBeenCalled();
});
it("RE-attaches on a spotify -> (command player.stop) -> spotify sequence (does not stay silent)", async () => {
// Regression: command paths (cmdPlay/cmdPlaylist/cmdAlbum/cmdFm) call
// player.stop() — which DETACHES the external stream — WITHOUT clearing the
// currentSourceIsSpotify flag. Gating re-attach on the stale flag skipped
// playPcmStream, silencing the next spotify track. We now gate on the
// player's actual external state, so the re-attach happens.
const controller = makeController();
const player = makePlayer();
const ctx = makeResolveCtx({
controller, player, url: "spotify:track:abc", currentSourceIsSpotify: false,
});
// First spotify track attaches the persistent PCM stream.
await resolveAndPlay.call(ctx, spotifySong());
expect(player.playPcmStream).toHaveBeenCalledTimes(1);
expect(player.isExternalActive()).toBe(true);
// A command path stops the player (detaches the stream) but leaves the
// spotify flag stale-true — exactly the state that used to cause silence.
player.stop();
expect(player.isExternalActive()).toBe(false);
expect(ctx.currentSourceIsSpotify).toBe(true); // flag NOT cleared by stop()
// Next spotify track MUST re-attach (gate on player external state, not flag).
await resolveAndPlay.call(ctx, spotifySong());
expect(player.playPcmStream).toHaveBeenCalledTimes(2);
expect(player.isExternalActive()).toBe(true);
});
it("returns false + sends the fallback when playTrack resolves false (dead/failed sidecar)", async () => {
const controller = makeController();
controller.playTrack = vi.fn(async () => false);
const player = makePlayer();
const ctx = makeResolveCtx({ controller, player, url: "spotify:track:abc" });
const ok = await resolveAndPlay.call(ctx, spotifySong());
expect(ok).toBe(false);
expect(controller.playTrack).toHaveBeenCalledTimes(1);
// Same Stage-1 fallback message as the backend-unavailable path.
expect(ctx.tsClient.sendTextMessage).toHaveBeenCalledTimes(1);
// Never attach the player to a dead stream.
expect(player.playPcmStream).not.toHaveBeenCalled();
expect(ctx.currentSourceIsSpotify).toBe(false);
});
it("recovers on mid-session sidecar death: onExternalEnd stops controller+player and clears the flag", async () => {
const controller = makeController();
const player = makePlayer();
const ctx = makeResolveCtx({ controller, player, url: "spotify:track:abc" });
await resolveAndPlay.call(ctx, spotifySong());
expect(ctx.currentSourceIsSpotify).toBe(true);
expect(player.playPcmStream).toHaveBeenCalledTimes(1);
// The sidecar PCM stream EOFs mid-session → fire the wired onExternalEnd.
const opts = player.playPcmStream.mock.calls[0][1] as { onExternalEnd?: () => void };
expect(typeof opts.onExternalEnd).toBe("function");
opts.onExternalEnd!();
// Recovery: controller torn down (next track rebuilds), player stopped
// (drops external mode so the next track re-attaches), flag cleared.
expect(controller.stop).toHaveBeenCalledTimes(1);
expect(player.stop).toHaveBeenCalledTimes(1);
expect(ctx.currentSourceIsSpotify).toBe(false);
});
it("pauses the sidecar and clears the flag when switching to a non-spotify track", async () => {
const controller = makeController();
const player = makePlayer();
const song = { ...spotifySong(), platform: "netease" };
const ctx = makeResolveCtx({
controller, player, url: "http://cdn/x.mp3", currentSourceIsSpotify: true,
});
const ok = await resolveAndPlay.call(ctx, song);
expect(ok).toBe(true);
expect(controller.pause).toHaveBeenCalledTimes(1);
expect(ctx.currentSourceIsSpotify).toBe(false);
expect(player.play).toHaveBeenCalledWith("http://cdn/x.mp3", 0, 200);
expect(player.playPcmStream).not.toHaveBeenCalled();
});
// R3-3: spotify A playing → pause → skip to spotify B. The persistent PCM
// stream stays attached through the pause, so the re-attach gate skips
// playPcmStream (which is what sets state='playing'). Without an explicit
// resume the player would sit 'paused' and emit silence while the sidecar
// decodes B. The reuse path must force the player back to PLAYING.
it("resumes the player when skipping from a PAUSED spotify track to another spotify track (R3-3)", async () => {
const controller = makeController();
const player = makePlayer();
const ctx = makeResolveCtx({
controller, player, url: "spotify:track:abc", currentSourceIsSpotify: false,
});
// Spotify A starts → PCM stream attached, player playing.
await resolveAndPlay.call(ctx, spotifySong());
expect(player.getState()).toBe("playing");
expect(player.isExternalActive()).toBe(true);
// User pauses: player paused; the external stream stays attached.
player.pause();
expect(player.getState()).toBe("paused");
expect(player.isExternalActive()).toBe(true);
// Skip to spotify B via the external-stream-reuse path (playPcmStream skipped).
await resolveAndPlay.call(ctx, spotifySong());
// Player must be PLAYING again (frames would emit), not stuck paused.
expect(player.getState()).toBe("playing");
// Reuse path — the persistent stream was NOT re-attached.
expect(player.playPcmStream).toHaveBeenCalledTimes(1);
expect(player.resume).toHaveBeenCalled();
});
// The normal (non-paused) spotify→spotify handoff must be unaffected: resume
// on an already-playing player is a no-op, so state stays 'playing'.
it("keeps a non-paused spotify→spotify handoff playing (R3-3 no-regression)", async () => {
const controller = makeController();
const player = makePlayer();
const ctx = makeResolveCtx({
controller, player, url: "spotify:track:abc", currentSourceIsSpotify: false,
});
await resolveAndPlay.call(ctx, spotifySong());
await resolveAndPlay.call(ctx, spotifySong());
expect(player.getState()).toBe("playing");
expect(player.playPcmStream).toHaveBeenCalledTimes(1);
});
});
describe("BotInstance.setupPlayerEvents — controller trackEnded wiring", () => {
function makeEventCtx(currentPlatform: string) {
return {
spotifyController: { on: vi.fn() },
player: { on: vi.fn() },
queue: { current: vi.fn(() => ({ platform: currentPlatform })) },
logger: { debug: vi.fn(), error: vi.fn() },
playNext: vi.fn(async () => true),
} as any;
}
function trackEndedHandler(ctx: any) {
const call = ctx.spotifyController.on.mock.calls.find(
(c: any[]) => c[0] === "trackEnded",
);
expect(call).toBeDefined();
return call[1] as (e: any) => void;
}
it("advances via playNext when the current song is spotify", () => {
const ctx = makeEventCtx("spotify");
setupPlayerEvents.call(ctx);
trackEndedHandler(ctx)({ uri: "spotify:track:x", reason: "ended" });
expect(ctx.playNext).toHaveBeenCalledTimes(1);
});
it("ignores controller trackEnded when the current song is not spotify", () => {
const ctx = makeEventCtx("netease");
setupPlayerEvents.call(ctx);
trackEndedHandler(ctx)({ uri: "spotify:track:x", reason: "ended" });
expect(ctx.playNext).not.toHaveBeenCalled();
});
});
describe("BotInstance transport delegation — spotify current song", () => {
function makeCmdCtx(currentPlatform: string) {
return {
player: { pause: vi.fn(), resume: vi.fn(), stop: vi.fn() },
spotifyController: {
pause: vi.fn(async () => {}),
resume: vi.fn(async () => {}),
stop: vi.fn(() => {}),
},
queue: { current: vi.fn(() => ({ platform: currentPlatform })), clear: vi.fn() },
logger: { warn: vi.fn() },
emit: vi.fn(),
autoPaused: true,
currentSourceIsSpotify: true,
sweepLocalAudio: vi.fn(),
disableFmMode: vi.fn(),
profileManager: { onSongChange: vi.fn(async () => {}) },
} as any;
}
it("cmdPause delegates to controller.pause when current is spotify", () => {
const ctx = makeCmdCtx("spotify");
cmdPause.call(ctx);
expect(ctx.player.pause).toHaveBeenCalled();
expect(ctx.spotifyController.pause).toHaveBeenCalledTimes(1);
});
it("cmdResume delegates to controller.resume when current is spotify", () => {
const ctx = makeCmdCtx("spotify");
cmdResume.call(ctx);
expect(ctx.player.resume).toHaveBeenCalled();
expect(ctx.spotifyController.resume).toHaveBeenCalledTimes(1);
});
it("cmdStop stops the sidecar + player and clears the spotify flag", () => {
const ctx = makeCmdCtx("spotify");
cmdStop.call(ctx);
expect(ctx.spotifyController.stop).toHaveBeenCalledTimes(1);
expect(ctx.player.stop).toHaveBeenCalledTimes(1);
expect(ctx.queue.clear).toHaveBeenCalledTimes(1);
expect(ctx.currentSourceIsSpotify).toBe(false);
});
it("does NOT touch the controller when current is not spotify", () => {
const ctx = makeCmdCtx("netease");
cmdPause.call(ctx);
cmdResume.call(ctx);
expect(ctx.spotifyController.pause).not.toHaveBeenCalled();
expect(ctx.spotifyController.resume).not.toHaveBeenCalled();
});
});
// --- R3-2: removing the currently-playing Spotify track ---------------------
// queue.remove() of the current index only decrements currentIndex; it never
// stops the player or sidecar. A spotify track has NO player self-EOF advance
// path, so leaving the sidecar running while queue.current() is no longer that
// track wedges the bot in silence. cmdRemove must reconcile: stop the sidecar
// and advance (or stop cleanly) — but ONLY for a current spotify track.
describe("BotInstance.cmdRemove — spotify current-track reconciliation (R3-2)", () => {
function makeRemoveCtx(opts: {
currentIndex: number;
currentSourceIsSpotify: boolean;
removed: any;
}) {
return {
currentSourceIsSpotify: opts.currentSourceIsSpotify,
queue: {
getCurrentIndex: vi.fn(() => opts.currentIndex),
remove: vi.fn(() => opts.removed),
},
spotifyController: makeController(),
player: makePlayer(),
playNext: vi.fn(async () => true),
sweepLocalAudio: vi.fn(),
emit: vi.fn(),
logger: { warn: vi.fn(), info: vi.fn(), error: vi.fn(), debug: vi.fn() },
} as any;
}
it("stops the sidecar and advances when removing the CURRENT spotify track", async () => {
const ctx = makeRemoveCtx({
currentIndex: 0,
currentSourceIsSpotify: true,
removed: { name: "A", platform: "spotify" },
});
const reply = await cmdRemove.call(ctx, { args: "1" }); // index 0 == current
expect(reply).toContain("Removed: A");
expect(ctx.spotifyController.stop).toHaveBeenCalledTimes(1);
expect(ctx.currentSourceIsSpotify).toBe(false);
expect(ctx.player.stop).toHaveBeenCalledTimes(1);
// Advance to whatever is now current (or stop cleanly if empty).
expect(ctx.playNext).toHaveBeenCalledTimes(1);
});
it("does NOT stop the sidecar when removing a NON-current track", async () => {
const ctx = makeRemoveCtx({
currentIndex: 0,
currentSourceIsSpotify: true,
removed: { name: "B", platform: "spotify" },
});
await cmdRemove.call(ctx, { args: "2" }); // index 1 != current (0)
expect(ctx.spotifyController.stop).not.toHaveBeenCalled();
expect(ctx.player.stop).not.toHaveBeenCalled();
expect(ctx.playNext).not.toHaveBeenCalled();
expect(ctx.currentSourceIsSpotify).toBe(true);
});
it("does NOT stop the sidecar when the current track is NOT spotify (URL self-heals)", async () => {
const ctx = makeRemoveCtx({
currentIndex: 0,
currentSourceIsSpotify: false, // current is a URL track
removed: { name: "A", platform: "netease" },
});
await cmdRemove.call(ctx, { args: "1" }); // index 0 == current
expect(ctx.spotifyController.stop).not.toHaveBeenCalled();
expect(ctx.player.stop).not.toHaveBeenCalled();
expect(ctx.playNext).not.toHaveBeenCalled();
});
it("returns 'Invalid position' without touching the sidecar on a bad index", async () => {
const ctx = makeRemoveCtx({
currentIndex: 0,
currentSourceIsSpotify: true,
removed: null, // queue.remove() rejects the index
});
const reply = await cmdRemove.call(ctx, { args: "9" });
expect(reply).toBe("Invalid position");
expect(ctx.spotifyController.stop).not.toHaveBeenCalled();
expect(ctx.playNext).not.toHaveBeenCalled();
});
});
// --- R3-6: queue exhausts on a spotify track --------------------------------
// playNext's exhausted (non-FM) branch only called player.stop(); it left the
// sidecar decoding and currentSourceIsSpotify stale — diverging from cmdStop.
describe("BotInstance.playNext — spotify teardown on queue exhaust (R3-6)", () => {
function makeExhaustCtx(opts: { currentSourceIsSpotify: boolean }) {
return {
connected: true,
isAdvancing: false,
isFmMode: false,
currentSourceIsSpotify: opts.currentSourceIsSpotify,
voteSkipUsers: new Set<string>(),
queue: { next: vi.fn(() => null), unplayedCount: vi.fn(() => 0) },
player: makePlayer(),
spotifyController: makeController(),
profileManager: { onSongChange: vi.fn(async () => {}) },
resolveAndPlay: vi.fn(async () => true),
sweepLocalAudio: vi.fn(),
emit: vi.fn(),
logger: { warn: vi.fn(), info: vi.fn(), error: vi.fn(), debug: vi.fn() },
} as any;
}
it("stops the sidecar and clears the flag when a spotify queue exhausts", async () => {
const ctx = makeExhaustCtx({ currentSourceIsSpotify: true });
const started = await playNext.call(ctx);
expect(started).toBe(false);
expect(ctx.spotifyController.stop).toHaveBeenCalledTimes(1);
expect(ctx.currentSourceIsSpotify).toBe(false);
expect(ctx.player.stop).toHaveBeenCalledTimes(1);
});
it("does NOT stop the sidecar when a NON-spotify queue exhausts", async () => {
const ctx = makeExhaustCtx({ currentSourceIsSpotify: false });
const started = await playNext.call(ctx);
expect(started).toBe(false);
expect(ctx.spotifyController.stop).not.toHaveBeenCalled();
expect(ctx.player.stop).toHaveBeenCalledTimes(1);
});
});
describe("BotInstance.handleOccupancy — spotify auto-pause delegation (C4)", () => {
function makeOccupancyCtx(currentPlatform: string, state: string) {
return {
player: { getState: () => state, pause: vi.fn(), resume: vi.fn() },
spotifyController: { pause: vi.fn(async () => {}), resume: vi.fn(async () => {}) },
queue: { current: vi.fn(() => ({ platform: currentPlatform })) },
config: { autoPauseOnEmpty: true },
autoPaused: false,
logger: { warn: vi.fn() },
emit: vi.fn(),
_scheduleIdleCheck: vi.fn(),
_cancelIdleTimer: vi.fn(),
} as any;
}
it("delegates pause to the controller when auto-pausing a spotify track (empty channel)", () => {
const ctx = makeOccupancyCtx("spotify", "playing");
handleOccupancy.call(ctx, 0);
expect(ctx.player.pause).toHaveBeenCalledTimes(1);
expect(ctx.spotifyController.pause).toHaveBeenCalledTimes(1);
expect(ctx.autoPaused).toBe(true);
});
it("delegates resume to the controller when a listener returns to a spotify track", () => {
const ctx = makeOccupancyCtx("spotify", "paused");
ctx.autoPaused = true;
handleOccupancy.call(ctx, 1);
expect(ctx.player.resume).toHaveBeenCalledTimes(1);
expect(ctx.spotifyController.resume).toHaveBeenCalledTimes(1);
expect(ctx.autoPaused).toBe(false);
});
it("does NOT touch the controller when auto-pausing a non-spotify track", () => {
const ctx = makeOccupancyCtx("netease", "playing");
handleOccupancy.call(ctx, 0);
expect(ctx.player.pause).toHaveBeenCalledTimes(1);
expect(ctx.spotifyController.pause).not.toHaveBeenCalled();
});
});
describe("BotInstance.seek — spotify routing (C4)", () => {
function makeSeekCtx(currentPlatform: string) {
return {
queue: { current: vi.fn(() => ({ platform: currentPlatform })) },
spotifyController: { seek: vi.fn(async () => {}) },
player: { seek: vi.fn() },
logger: { warn: vi.fn() },
} as any;
}
it("routes seek to the controller for a spotify track, converting seconds -> ms", () => {
const ctx = makeSeekCtx("spotify");
seek.call(ctx, 30); // 30 seconds
// SpotifyController.seek is millisecond-based: 30s -> 30000ms (not 30).
expect(ctx.spotifyController.seek).toHaveBeenCalledWith(30000);
expect(ctx.player.seek).not.toHaveBeenCalled();
});
it("routes seek to the player (seconds-based) for a non-spotify track", () => {
const ctx = makeSeekCtx("netease");
seek.call(ctx, 30);
expect(ctx.player.seek).toHaveBeenCalledWith(30);
expect(ctx.spotifyController.seek).not.toHaveBeenCalled();
});
});
// --- Spotify OAuth threading (Task 6, C3.1) --------------------------------
// The process-wide shared SpotifyOAuth must reach the SpotifyController via the
// controller factory. We drive the REAL BotInstance constructor with a fake
// controller factory that captures its param object, so the thread is observed
// end-to-end (options.spotifyOAuth -> buildController({ oauth })).
describe("BotInstance — spotifyOAuth threading to the controller factory (C3.1)", () => {
function makeInstanceOptions(over: Partial<BotInstanceOptions> = {}): {
options: BotInstanceOptions;
captured: { param?: { oauth?: SpotifyOAuth; instanceId?: string } };
} {
const captured: { param?: { oauth?: SpotifyOAuth; instanceId?: string } } = {};
const provider = { platform: "netease" } as unknown as MusicProvider;
const logger: any = {
info() {}, warn() {}, error() {}, debug() {},
child() { return logger; },
};
const database = {
getProfileConfig: () => ({}),
getCustomAvatarPath: () => null,
} as unknown as BotDatabase;
const options: BotInstanceOptions = {
id: "bot-oauth-test",
name: "OAuthBot",
tsOptions: { host: "localhost", port: 9987, queryPort: 10011, nickname: "OAuthBot" } as any,
neteaseProvider: provider,
qqProvider: provider,
bilibiliProvider: provider,
youtubeProvider: provider,
database,
config: { spotify: {} } as unknown as BotConfig,
logger,
avatarStore: { read: () => null } as unknown as AvatarStore,
spotifyControllerFactory: (o) => {
captured.param = o;
// Only `on` is touched during construction (setupPlayerEvents wires
// the "trackEnded" listener); return a minimal fake controller.
return { on: () => {} } as unknown as SpotifyController;
},
...over,
};
return { options, captured };
}
it("forwards the injected spotifyOAuth to the controller factory as `oauth`", () => {
const sentinel = {} as unknown as SpotifyOAuth;
const { options, captured } = makeInstanceOptions({ spotifyOAuth: sentinel });
// eslint-disable-next-line no-new
new BotInstance(options);
expect(captured.param).toBeDefined();
expect(captured.param?.oauth).toBe(sentinel);
});
it("leaves the factory `oauth` undefined when no spotifyOAuth is supplied (behavior-unchanged)", () => {
const { options, captured } = makeInstanceOptions();
// eslint-disable-next-line no-new
new BotInstance(options);
expect(captured.param).toBeDefined();
expect(captured.param?.oauth).toBeUndefined();
});
// R2-5: the bot id must reach the controller as `instanceId` so the backend
// derives a UNIQUE Spotify Connect device name (<base>-<id>). Without it two
// bots share the process-global deviceName and Connect commands misroute.
it("forwards the bot id to the controller factory as `instanceId` (corner-case R2-5)", () => {
const { options, captured } = makeInstanceOptions({ id: "bot-xyz" });
// eslint-disable-next-line no-new
new BotInstance(options);
expect(captured.param).toBeDefined();
expect(captured.param?.instanceId).toBe("bot-xyz");
});
});
describe("spotifyPortsForBotId — per-bot go-librespot ports (Fix 3)", () => {
it("yields the SAME ports for the same bot id (stable across restarts)", () => {
const a = spotifyPortsForBotId("bot-alpha");
const b = spotifyPortsForBotId("bot-alpha");
expect(a).toEqual(b);
});
it("yields DIFFERENT ports for different bot ids", () => {
const a = spotifyPortsForBotId("bot-alpha");
const b = spotifyPortsForBotId("bot-beta");
expect(a.apiPort).not.toBe(b.apiPort);
expect(a.callbackPort).not.toBe(b.callbackPort);
});
it("keeps apiPort and callbackPort in disjoint ranges", () => {
for (const id of ["bot-alpha", "bot-beta", "x", "a-very-long-bot-identifier-123"]) {
const { apiPort, callbackPort } = spotifyPortsForBotId(id);
expect(apiPort).toBeGreaterThanOrEqual(3700);
expect(apiPort).toBeLessThan(4700);
expect(callbackPort).toBeGreaterThanOrEqual(8700);
expect(callbackPort).toBeLessThan(9700);
// Same offset within each range → the two never collide with each other.
expect(callbackPort - apiPort).toBe(5000);
}
});
});
describe("BotInstance.handleTextMessage — response chunking (#116)", () => {
it("splits a long command response into multiple sends, each under the byte cap", async () => {
const ctx = makeGateCtx({ adminGroups: [] });
+332 -28
View File
@@ -16,7 +16,7 @@ import { parseSongRef, parseSelectionIndex } from "./song-ref.js";
import { splitTextIntoChunks } from "./text-chunk.js";
import type { Logger } from "../logger.js";
import type { BotDatabase, ProfileConfig } from "../data/database.js";
import type { BotConfig } from "../data/config.js";
import type { BotConfig, SpotifyConfig } from "../data/config.js";
import { BotProfileManager } from "./profile.js";
import type { AvatarStore } from "../data/avatars.js";
import {
@@ -24,10 +24,51 @@ import {
occupancyFromClientList,
shouldResumeOnReturn,
} from "./auto-pause.js";
import { isSpotifyUri } from "../music/spotify/webapi.js";
import path from "node:path";
import { SpotifyController } from "../music/spotify/controller.js";
import type { SpotifyTrackEndedEvent } from "../music/spotify/backend.js";
import type { SpotifyOAuth } from "../music/spotify/spotify-oauth.js";
/** Reply sent when a non-admin invokes an admin-only chat command. */
export const COMMAND_DENIED_MESSAGE = "⛔ 需要管理员权限(该命令仅限管理员服务器组)";
/** Fallback message when Spotify audio can't be served (backend unavailable
* OR a per-track playTrack failure against a dead/failed sidecar). */
const SPOTIFY_UNAVAILABLE_MESSAGE =
"⚠️ Spotify 播放尚未启用(需要 librespot 音频后端,将在后续版本支持)。";
/** FNV-1a deterministic string hash (unsigned 32-bit). Stable across restarts
* and processes, unlike a random/insertion-order value — used to derive
* per-bot go-librespot ports. */
function stableHash(s: string): number {
let h = 2166136261;
for (let i = 0; i < s.length; i++) {
h ^= s.charCodeAt(i);
h = Math.imul(h, 16777619);
}
return h >>> 0;
}
/**
* STABLE per-bot go-librespot ports derived from the bot id, kept fixed across
* restarts. The two ranges (37xx / 87xx) are disjoint so a single bot's API and
* callback ports never clash with each other.
*
* NOTE: the hash (`% 1000`) only REDUCES collisions between bots — it does NOT
* guarantee uniqueness. Two Spotify-enabled bots whose ids collide mod 1000
* (birthday-bound: likely well below 1000 bots) get IDENTICAL ports; the second
* bot's go-librespot sidecar then fails to bind 127.0.0.1:<apiPort>, start()
* throws, and that bot's Spotify stays permanently unavailable. Proper fix (out
* of scope here): assign each Spotify-enabled bot a unique free port —
* manager-coordinated allocation or bind-retry on EADDRINUSE — instead of a
* pure hash.
*/
export function spotifyPortsForBotId(id: string): { apiPort: number; callbackPort: number } {
const off = stableHash(id) % 1000;
return { apiPort: 3700 + off, callbackPort: 8700 + off };
}
export interface BotInstanceOptions {
id: string;
name: string;
@@ -38,10 +79,27 @@ export interface BotInstanceOptions {
youtubeProvider: MusicProvider;
localProvider?: MusicProvider;
kugouProvider?: MusicProvider;
spotifyProvider?: MusicProvider;
database: BotDatabase;
config: BotConfig;
logger: Logger;
avatarStore: AvatarStore;
/** Base dir (under DATA_DIR) for per-bot go-librespot work/config trees. */
spotifyDataDir?: string;
/** Process-wide shared Spotify OAuth (single account); injected into the
* SpotifyController so web-login authorization is visible to playback (C3.1). */
spotifyOAuth?: SpotifyOAuth;
/** Test seam: build a fake controller instead of a real go-librespot one. */
spotifyControllerFactory?: (o: {
config: SpotifyConfig;
workDir: string;
configDir: string;
logger: Logger;
instanceId: string;
apiPort: number;
callbackPort: number;
oauth?: SpotifyOAuth;
}) => SpotifyController;
}
export interface BotStatus {
@@ -65,6 +123,7 @@ export class BotInstance extends EventEmitter {
private tsClient: TS3Client;
private player: AudioPlayer;
private spotifyController: SpotifyController;
private queue: PlayQueue;
private neteaseProvider: MusicProvider;
private qqProvider: MusicProvider;
@@ -72,6 +131,7 @@ export class BotInstance extends EventEmitter {
private youtubeProvider: MusicProvider;
private localProvider: MusicProvider;
private kugouProvider: MusicProvider;
private spotifyProvider: MusicProvider;
private database: BotDatabase;
private config: BotConfig;
private logger: Logger;
@@ -83,9 +143,13 @@ export class BotInstance extends EventEmitter {
private idleTimer: ReturnType<typeof setTimeout> | null = null;
private channelUserCount = 0;
private autoPaused = false;
/** True while the audible track is served by the Spotify sidecar (external
* PCM mode) — drives fence/handoff decisions in resolveAndPlay + cmdStop. */
private currentSourceIsSpotify = false;
private profileManager: BotProfileManager;
private isFmMode = false;
private fmProvider: MusicProvider | null = null;
private fmRequesterName: string | undefined;
/** Results of the most recent !search, for "#N" selection (issue #90). */
private lastSearchResults: Song[] = [];
/** 当前曲实际播放时长(试听片段秒数或完整 duration);resolveAndPlay 赋值。 */
@@ -102,6 +166,7 @@ export class BotInstance extends EventEmitter {
this.youtubeProvider = options.youtubeProvider;
this.localProvider = options.localProvider ?? options.neteaseProvider;
this.kugouProvider = options.kugouProvider ?? options.neteaseProvider;
this.spotifyProvider = options.spotifyProvider ?? options.neteaseProvider;
this.database = options.database;
this.config = options.config;
this.logger = options.logger.child({ botId: this.id });
@@ -111,6 +176,35 @@ export class BotInstance extends EventEmitter {
this.player = new AudioPlayer(this.logger);
this.queue = new PlayQueue();
// One long-lived Spotify sidecar controller per bot. Construction is
// cheap and side-effect-free — nothing spawns until ensureStarted().
const spotifyBase =
options.spotifyDataDir ?? path.join(process.cwd(), "data", "spotify");
const spotifyWorkDir = path.join(spotifyBase, this.id, "work");
const spotifyConfigDir = path.join(spotifyBase, this.id, "config");
// Stable per-bot ports for the go-librespot control API / OAuth callback.
// These only reduce (not eliminate) cross-bot collisions: two bot ids that
// hash to the same % 1000 bucket share ports and the second sidecar fails to
// bind — see spotifyPortsForBotId() for the recommended per-bot free-port fix.
const { apiPort: spotifyApiPort, callbackPort: spotifyCallbackPort } =
spotifyPortsForBotId(this.id);
const buildController =
options.spotifyControllerFactory ??
((o) => new SpotifyController({ ...o }));
this.spotifyController = buildController({
config: this.config.spotify,
workDir: spotifyWorkDir,
configDir: spotifyConfigDir,
logger: this.logger,
// Per-bot id → unique Spotify Connect device name ("<base>-<id>"), so two
// bots under the one shared account never register the same name and
// misroute Connect commands (corner-case R2-5).
instanceId: this.id,
apiPort: spotifyApiPort,
callbackPort: spotifyCallbackPort,
oauth: options.spotifyOAuth,
});
const profileConfig = this.database.getProfileConfig(this.id);
this.profileManager = new BotProfileManager(
this.tsClient,
@@ -152,6 +246,19 @@ export class BotInstance extends EventEmitter {
this.logger.error({ err: err2 }, "playNext failed after player error");
});
});
// Spotify advances exclusively via the sidecar's WebSocket "trackEnded"
// (the continuous go-librespot→ffmpeg pipe never EOFs per track, so the
// player's own underrun "trackEnd" is suppressed in external mode). Guard
// on the current song being spotify so a stray event can't double-advance
// a URL track; playNext()'s isAdvancing guard covers any residual race.
this.spotifyController.on("trackEnded", (_e: SpotifyTrackEndedEvent) => {
if (this.queue.current()?.platform !== "spotify") return;
this.logger.debug("Spotify track ended, advancing queue");
this.playNext().catch((err) => {
this.logger.error({ err }, "playNext failed after spotify trackEnded");
});
});
}
isLocalAudioEnabled(): boolean {
@@ -202,6 +309,8 @@ export class BotInstance extends EventEmitter {
// this.connected was never flipped to true. Previously this handler
// short-circuited on !this.connected, leaving player stuck as "playing".
this.connected = false;
this.spotifyController.stop();
this.currentSourceIsSpotify = false;
this.player.stop();
this.queue.clear();
this.sweepLocalAudio("disconnected");
@@ -285,6 +394,8 @@ export class BotInstance extends EventEmitter {
disconnect(): void {
this._cancelIdleTimer();
this.spotifyController.stop();
this.currentSourceIsSpotify = false;
this.player.stop();
this.queue.clear();
this.sweepLocalAudio("disconnected");
@@ -307,6 +418,10 @@ export class BotInstance extends EventEmitter {
this.config.autoPauseOnEmpty = enabled;
if (!enabled && this.autoPaused && this.player.getState() === "paused") {
this.player.resume();
if (this.queue.current()?.platform === "spotify") {
this.spotifyController.resume().catch((err) =>
this.logger.warn({ err }, "Spotify resume failed (auto-pause disabled)"));
}
this.autoPaused = false;
this.emit("stateChange");
}
@@ -340,10 +455,21 @@ export class BotInstance extends EventEmitter {
);
if (action === "pause") {
this.player.pause();
// Occupancy paths drive player.pause()/resume() DIRECTLY (bypassing the
// cmd handlers), so they must ALSO stop/resume the sidecar — else it
// keeps decoding into an empty channel.
if (this.queue.current()?.platform === "spotify") {
this.spotifyController.pause().catch((err) =>
this.logger.warn({ err }, "Spotify pause failed (occupancy)"));
}
this.autoPaused = true;
this.emit("stateChange");
} else if (action === "resume") {
this.player.resume();
if (this.queue.current()?.platform === "spotify") {
this.spotifyController.resume().catch((err) =>
this.logger.warn({ err }, "Spotify resume failed (occupancy)"));
}
this.autoPaused = false;
this.emit("stateChange");
}
@@ -451,7 +577,8 @@ export class BotInstance extends EventEmitter {
async executeCommand(
cmd: ParsedCommand,
msg?: TS3TextMessage
msg?: TS3TextMessage,
requesterName = this.requesterNameFromMessage(msg),
): Promise<string | null> {
// Reject commands that would push audio when the bot isn't connected:
// otherwise ffmpeg spawns and voice goes to a half-initialized or
@@ -479,12 +606,12 @@ export class BotInstance extends EventEmitter {
case "find":
return this.cmdSearch(cmd);
case "play":
return this.cmdPlay(cmd);
return this.cmdPlay(cmd, requesterName);
case "add":
return this.cmdAdd(cmd);
return this.cmdAdd(cmd, requesterName);
case "playnext":
case "pn":
return this.cmdPlayNext(cmd);
return this.cmdPlayNext(cmd, requesterName);
case "pause":
return this.cmdPause();
case "resume":
@@ -510,13 +637,13 @@ export class BotInstance extends EventEmitter {
case "mode":
return this.cmdMode(cmd);
case "playlist":
return this.cmdPlaylist(cmd);
return this.cmdPlaylist(cmd, requesterName);
case "album":
return this.cmdAlbum(cmd);
return this.cmdAlbum(cmd, requesterName);
case "fm":
return this.cmdFm(cmd);
return this.cmdFm(cmd, requesterName);
case "artist":
return this.cmdArtist(cmd);
return this.cmdArtist(cmd, requesterName);
case "vote":
return this.cmdVote(msg);
case "lyrics":
@@ -532,17 +659,19 @@ export class BotInstance extends EventEmitter {
}
}
getProviderFor(platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou"): MusicProvider {
getProviderFor(platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou" | "spotify"): MusicProvider {
if (platform === "bilibili") return this.bilibiliProvider;
if (platform === "youtube") return this.youtubeProvider;
if (platform === "local") return this.localProvider;
if (platform === "kugou") return this.kugouProvider;
if (platform === "spotify") return this.spotifyProvider;
return platform === "qq" ? this.qqProvider : this.neteaseProvider;
}
private disableFmMode(): void {
this.isFmMode = false;
this.fmProvider = null;
this.fmRequesterName = undefined;
}
private getProvider(flags: Set<string>): MusicProvider {
@@ -550,9 +679,23 @@ export class BotInstance extends EventEmitter {
if (flags.has("q")) return this.qqProvider;
if (flags.has("y")) return this.youtubeProvider;
if (flags.has("k")) return this.kugouProvider;
if (flags.has("s")) return this.spotifyProvider;
return this.neteaseProvider;
}
private requesterNameFromMessage(msg?: TS3TextMessage): string | undefined {
const name = msg?.invokerName?.trim();
return name || undefined;
}
private withRequester<T extends Song | QueuedSong>(
song: T,
requesterName?: string,
): T & { requestedBy?: string } {
const requestedBy = requesterName?.trim();
return requestedBy ? { ...song, requestedBy } : { ...song };
}
/** Resolve URL for a song and start playing it. Skips to next if URL fails. */
async resolveAndPlay(song: QueuedSong): Promise<boolean> {
if (!this.connected) {
@@ -586,6 +729,96 @@ export class BotInstance extends EventEmitter {
);
return false;
}
// Stage 2: a `spotify:` sentinel URI means the go-librespot sidecar
// serves the audio, NOT ffmpeg. Start the per-bot sidecar on demand; if
// it can't run (disabled / non-Linux / binary missing) keep the Stage-1
// fallback message + skip so the queue keeps moving.
if (isSpotifyUri(result.url)) {
const ready = await this.spotifyController.ensureStarted();
if (!ready) {
this.logger.info({ songId: song.id, name: song.name }, "Spotify backend unavailable — skipping");
await this.tsClient.sendTextMessage(SPOTIFY_UNAVAILABLE_MESSAGE);
return false;
}
// `spotify:track:<id>` is the URI. go-librespot decodes into a SINGLE
// continuous FIFO/PCM stream, so per-track playback is just a REST
// playTrack — the stream keeps flowing. A false result means the
// sidecar failed the play (dead/errored backend): never attach the
// player to a dead stream — send the same fallback and skip.
const played = await this.spotifyController.playTrack(result.url);
if (!played) {
this.logger.info({ songId: song.id, name: song.name }, "Spotify playTrack failed — skipping");
await this.tsClient.sendTextMessage(SPOTIFY_UNAVAILABLE_MESSAGE);
return false;
}
// Only ATTACH the persistent PCM stream when the player is NOT already
// attached to it. Gate on the player's ACTUAL external state, not the
// currentSourceIsSpotify flag: command paths (cmdPlay/cmdPlaylist/…)
// call player.stop() (which detaches the external stream) WITHOUT
// clearing the flag, so a stale-true flag would skip the re-attach and
// silence playback. playPcmStream internally fences the prior url-ffmpeg
// (so NO player.stop() here). On the gapless auto-advance path the
// player is still attached (isExternalActive() === true) so we do NOT
// re-attach — the sidecar rolls the SAME FIFO into the next track.
// KNOWN LIMITATION: on a gapless spotify->spotify advance the player's
// frame counter is not reset, so player.getElapsed() over-reads for the
// 2nd+ consecutive Spotify track. Cosmetic only — the authoritative
// elapsed shown to users is status.track.position from the backend poll.
if (!this.player.isExternalActive()) {
this.player.playPcmStream(this.spotifyController.getPcmStream(), {
// The sidecar PCM pipe is long-lived; per-track end arrives via the
// controller "trackEnded" WS event, not stream EOF. A real EOF here
// means the sidecar died mid-session — RECOVER instead of emitting
// silence forever (which would also leave the player stuck in
// externalMode, so the re-attach gate skips every future track).
// Tear the controller down (next ensureStarted() rebuilds a fresh
// backend), stop the player (drop external mode so it re-attaches),
// and clear the flag so a non-spotify track isn't mis-handled.
onExternalEnd: () => {
this.logger.warn("Spotify PCM stream ended unexpectedly — recovering");
this.spotifyController.stop();
this.player.stop();
this.currentSourceIsSpotify = false;
},
});
} else {
// External-stream-reuse path: the persistent PCM stream is still
// attached (e.g. we arrived here via pause → skip-within-spotify,
// where the stream stays attached through the pause). playPcmStream —
// which is what puts the player into the 'playing' state — is skipped,
// so without this the player would stay 'paused' and emit silence
// while the sidecar decodes the new track (corner-case R3-3). resume()
// is a no-op on an already-playing player, so the normal (non-paused)
// spotify→spotify handoff is unaffected.
this.player.resume();
}
this.currentSourceIsSpotify = true;
song.url = result.url;
// No trial clip for Spotify — full-track duration only (the near-end
// stall watchdog is disabled for the external stream anyway).
this.effectiveDuration = song.duration;
this.autoPaused = false;
this.database.addPlayHistory({
botId: this.id,
songId: song.id,
songName: song.name,
artist: song.artist,
album: song.album,
platform: song.platform,
coverUrl: song.coverUrl,
requestedBy: song.requestedBy,
});
await this.syncProfileToSong(song);
this.emit("stateChange");
return true;
}
// Non-Spotify track: if we were on Spotify, pause the sidecar so it stops
// decoding ahead before the URL ffmpeg reclaims the PCM buffer.
if (this.currentSourceIsSpotify) {
this.spotifyController.pause().catch((err) =>
this.logger.warn({ err }, "Failed to pause Spotify sidecar on source switch"));
this.currentSourceIsSpotify = false;
}
song.url = result.url;
// 试听片段用试听时长(让 player nearEnd 正确触发自动切歌);完整曲回退 song.duration
this.effectiveDuration = result.trialDuration ?? song.duration;
@@ -601,6 +834,7 @@ export class BotInstance extends EventEmitter {
album: song.album,
platform: song.platform,
coverUrl: song.coverUrl,
requestedBy: song.requestedBy,
});
// Keep TeamSpeak-side profile updates on the same path for play/next/FM.
await this.syncProfileToSong(song);
@@ -673,7 +907,7 @@ export class BotInstance extends EventEmitter {
].join("\n");
}
private async cmdPlay(cmd: ParsedCommand): Promise<string> {
private async cmdPlay(cmd: ParsedCommand, requesterName?: string): Promise<string> {
if (!cmd.args) return `Usage: ${this.config.commandPrefix}play <song name | #N | id:<id> | URL>`;
const { song, error } = await this.resolvePlayQuery(cmd);
if (error) return error;
@@ -684,7 +918,7 @@ export class BotInstance extends EventEmitter {
}
this.queue.clear();
this.disableFmMode();
this.queue.add({ ...song0 });
this.queue.add(this.withRequester(song0, requesterName));
this.queue.play();
// Reset failure counter on user-initiated play
@@ -698,14 +932,14 @@ export class BotInstance extends EventEmitter {
return `Now playing: ${song0.name} - ${song0.artist}`;
}
private async cmdAdd(cmd: ParsedCommand): Promise<string> {
private async cmdAdd(cmd: ParsedCommand, requesterName?: string): Promise<string> {
if (!cmd.args) return `Usage: ${this.config.commandPrefix}add <song name | #N | id:<id> | URL>`;
const { song, error } = await this.resolvePlayQuery(cmd);
if (error) return error;
const s = song!;
const wasIdle = this.player.getState() === "idle";
this.queue.add({ ...s });
this.queue.add(this.withRequester(s, requesterName));
// If nothing was playing, start this newly-added song immediately.
// Matches /api/player/:id/add-by-id behavior so both add paths feel
@@ -722,7 +956,7 @@ export class BotInstance extends EventEmitter {
return `Added to queue: ${s.name} - ${s.artist} (position ${this.queue.size()})`;
}
private async cmdPlayNext(cmd: ParsedCommand): Promise<string> {
private async cmdPlayNext(cmd: ParsedCommand, requesterName?: string): Promise<string> {
if (!cmd.args) return `Usage: ${this.config.commandPrefix}playnext <song name | #N | id:<id> | URL>`;
const { song, error } = await this.resolvePlayQuery(cmd);
if (error) return error;
@@ -738,7 +972,7 @@ export class BotInstance extends EventEmitter {
this.queue.getCurrentIndex() < 0
? this.queue.size()
: this.queue.getCurrentIndex() + 1;
this.queue.addNext({ ...s });
this.queue.addNext(this.withRequester(s, requesterName));
if (wasIdle) {
this.queue.playAt(insertedAt);
@@ -755,6 +989,10 @@ export class BotInstance extends EventEmitter {
private cmdPause(): string {
this.player.pause();
if (this.queue.current()?.platform === "spotify") {
this.spotifyController.pause().catch((err) =>
this.logger.warn({ err }, "Spotify pause failed"));
}
// User-initiated pause — clear auto-pause so occupancy won't auto-resume it.
this.autoPaused = false;
this.emit("stateChange");
@@ -763,6 +1001,10 @@ export class BotInstance extends EventEmitter {
private cmdResume(): string {
this.player.resume();
if (this.queue.current()?.platform === "spotify") {
this.spotifyController.resume().catch((err) =>
this.logger.warn({ err }, "Spotify resume failed"));
}
// User-initiated resume — drop any auto-pause flag.
this.autoPaused = false;
this.emit("stateChange");
@@ -770,6 +1012,12 @@ export class BotInstance extends EventEmitter {
}
private cmdStop(): string {
// Read the current song BEFORE queue.clear() so we can tell whether the
// sidecar needs stopping.
if (this.queue.current()?.platform === "spotify") {
this.spotifyController.stop();
}
this.currentSourceIsSpotify = false;
this.player.stop();
this.autoPaused = false;
this.queue.clear();
@@ -830,6 +1078,8 @@ export class BotInstance extends EventEmitter {
}
private cmdClear(): string {
this.spotifyController.stop();
this.currentSourceIsSpotify = false;
this.player.stop();
this.queue.clear();
this.sweepLocalAudio("queue_cleared");
@@ -841,11 +1091,31 @@ export class BotInstance extends EventEmitter {
return "Queue cleared";
}
private cmdRemove(cmd: ParsedCommand): string {
private async cmdRemove(cmd: ParsedCommand): Promise<string> {
const index = parseInt(cmd.args, 10) - 1;
if (isNaN(index) || index < 0) return "Usage: !remove <number>";
// Capture BEFORE the splice whether we're removing the currently-playing
// Spotify track: queue.remove() decrements currentIndex, so getCurrentIndex()
// is only meaningful pre-remove.
const removingCurrentSpotify =
index === this.queue.getCurrentIndex() && this.currentSourceIsSpotify;
const removed = this.queue.remove(index);
if (!removed) return "Invalid position";
// Corner-case R3-2: removing the track the Spotify sidecar is decoding
// right now leaves it running while queue.current() is no longer that
// track. Since a spotify track has NO player self-EOF advance path, the
// controller "trackEnded" handler would return early (current is no longer
// spotify) and the bot would wedge in silence. Reconcile like cmdStop/skip:
// tear the sidecar down, then advance to whatever is now current — or stop
// cleanly if the queue is now empty (playNext's exhausted branch stops the
// player). Non-current or non-spotify removals are untouched (a URL current
// track self-heals via its own EOF).
if (removingCurrentSpotify) {
this.spotifyController.stop();
this.currentSourceIsSpotify = false;
this.player.stop();
await this.playNext();
}
// Sweep after the entry is gone — the file is deleted only if no other
// queue position (or bot) still references this upload.
this.sweepLocalAudio("removed_from_queue");
@@ -867,7 +1137,7 @@ export class BotInstance extends EventEmitter {
return `Play mode set to: ${cmd.args}`;
}
private async cmdPlaylist(cmd: ParsedCommand): Promise<string> {
private async cmdPlaylist(cmd: ParsedCommand, requesterName?: string): Promise<string> {
if (!cmd.args) return "Usage: !playlist <playlist name or ID>";
const provider = this.getProvider(cmd.flags);
@@ -912,7 +1182,7 @@ export class BotInstance extends EventEmitter {
this.queue.clear();
this.disableFmMode();
for (const song of songs) {
this.queue.add({ ...song, platform: provider.platform });
this.queue.add(this.withRequester({ ...song, platform: provider.platform }, requesterName));
}
const first = this.queue.play();
if (first) await this.resolveAndPlay(first);
@@ -921,7 +1191,7 @@ export class BotInstance extends EventEmitter {
return `Loaded ${songs.length} songs. Now playing: ${first?.name ?? "unknown"}`;
}
private async cmdAlbum(cmd: ParsedCommand): Promise<string> {
private async cmdAlbum(cmd: ParsedCommand, requesterName?: string): Promise<string> {
if (!cmd.args) return "Usage: !album <album name or ID>";
const provider = this.getProvider(cmd.flags);
@@ -949,7 +1219,7 @@ export class BotInstance extends EventEmitter {
this.queue.clear();
this.disableFmMode();
for (const song of songs) {
this.queue.add({ ...song, platform: provider.platform });
this.queue.add(this.withRequester({ ...song, platform: provider.platform }, requesterName));
}
const first = this.queue.play();
if (first) await this.resolveAndPlay(first);
@@ -958,11 +1228,11 @@ export class BotInstance extends EventEmitter {
return `Loaded ${songs.length} songs. Now playing: ${first?.name ?? "unknown"}`;
}
private async cmdFm(cmd: ParsedCommand): Promise<string> {
return this.startFm(this.getProvider(cmd.flags));
private async cmdFm(cmd: ParsedCommand, requesterName?: string): Promise<string> {
return this.startFm(this.getProvider(cmd.flags), requesterName);
}
async startFm(provider: MusicProvider = this.neteaseProvider): Promise<string> {
async startFm(provider: MusicProvider = this.neteaseProvider, requesterName?: string): Promise<string> {
// Match the !fm chat-command guard: refuse before mutating the queue when
// offline, so the web /fm route can't wipe the queue + flip into FM mode
// while nothing can actually play.
@@ -979,11 +1249,12 @@ export class BotInstance extends EventEmitter {
this.player.stop();
this.queue.clear();
for (const song of songs) {
this.queue.add({ ...song, platform: provider.platform });
this.queue.add(this.withRequester({ ...song, platform: provider.platform }, requesterName));
}
this.queue.setMode(PlayMode.Random);
this.isFmMode = true;
this.fmProvider = provider;
this.fmRequesterName = requesterName?.trim() || undefined;
this.player.resetFailures();
const first = this.queue.play();
@@ -994,7 +1265,7 @@ export class BotInstance extends EventEmitter {
return `${label} started: ${first?.name ?? "unknown"} - ${first?.artist ?? ""}`;
}
private async cmdArtist(cmd: ParsedCommand): Promise<string> {
private async cmdArtist(cmd: ParsedCommand, requesterName?: string): Promise<string> {
if (!cmd.args) return "Usage: !artist <artist name>";
const provider = this.getProvider(cmd.flags);
const result = await provider.search(cmd.args, 50);
@@ -1015,7 +1286,7 @@ export class BotInstance extends EventEmitter {
this.queue.clear();
this.disableFmMode();
for (const song of filtered) {
this.queue.add({ ...song, platform: provider.platform });
this.queue.add(this.withRequester({ ...song, platform: provider.platform }, requesterName));
}
this.queue.setMode(PlayMode.Loop);
this.player.resetFailures();
@@ -1034,7 +1305,7 @@ export class BotInstance extends EventEmitter {
const songs = await provider.getPersonalFm();
if (songs.length === 0) return;
for (const song of songs) {
this.queue.add({ ...song, platform: provider.platform });
this.queue.add(this.withRequester({ ...song, platform: provider.platform }, this.fmRequesterName));
}
this.logger.debug({ count: songs.length, platform: provider.platform }, "FM queue refilled");
} catch (err) {
@@ -1167,6 +1438,16 @@ export class BotInstance extends EventEmitter {
this.profileManager.onSongChange(null).catch(() => {});
}
} else {
// Queue exhausted on a non-FM source (skip-past-end or natural
// last-track end via trackEnded→playNext). If the ending track was
// served by the Spotify sidecar, tear it down like cmdStop —
// otherwise the go-librespot Connect device stays active with the
// track loaded (decoding into a detached/backpressured stream) and
// currentSourceIsSpotify stays stale (corner-case R3-6).
if (this.currentSourceIsSpotify) {
this.spotifyController.stop();
this.currentSourceIsSpotify = false;
}
this.player.stop();
this.profileManager.onSongChange(null).catch(() => {});
}
@@ -1222,6 +1503,29 @@ export class BotInstance extends EventEmitter {
return this.player;
}
/** The per-bot Spotify sidecar controller. Exposed like getPlayer()/
* getQueueManager() so the shared, process-wide OAuth threaded in at
* construction (C3.1) is observable to callers/tests via getOAuth(). */
getSpotifyController(): SpotifyController {
return this.spotifyController;
}
/**
* Route a seek to the Spotify sidecar for a spotify track (its PCM stream is
* external — AudioPlayer.seek would respawn ffmpeg on the `spotify:` sentinel
* and collide with the running stream), otherwise to the URL player.
*/
seek(seconds: number): void {
if (this.queue.current()?.platform === "spotify") {
// The web route + AudioPlayer.seek are seconds-based, but
// SpotifyController.seek expects milliseconds — convert here.
this.spotifyController.seek(seconds * 1000).catch((err) =>
this.logger.warn({ err }, "Spotify seek failed"));
return;
}
this.player.seek(seconds);
}
getQueueManager(): PlayQueue {
return this.queue;
}
+66
View File
@@ -9,6 +9,7 @@ import { getDefaultConfig, loadConfig, saveConfig, type BotConfig } from "../dat
import type { Logger } from "../logger.js";
import type { MusicProvider } from "../music/provider.js";
import type { AvatarStore } from "../data/avatars.js";
import type { SpotifyOAuth } from "../music/spotify/spotify-oauth.js";
// removeBot only calls logger.info; provide the full shape it could touch.
const stubLogger = {
@@ -85,3 +86,68 @@ describe("BotManager.removeBot — guest scope pruning", () => {
expect(loadConfig(configPath).guestMode.bots).toBe("all");
});
});
// --- Spotify OAuth threading (Task 6, C3.1) --------------------------------
// The single process-wide SpotifyOAuth built in index.ts must reach every bot's
// SpotifyController: index -> BotManager (trailing positional arg) -> BotInstance
// -> controller. createBot() builds a REAL (side-effect-free) SpotifyController,
// so we assert the shared instance surfaces via the controller's getOAuth().
describe("BotManager — spotifyOAuth threading to bot controllers (C3.1)", () => {
const dirs: string[] = [];
let db: BotDatabase | undefined;
afterEach(() => {
try {
db?.close();
} catch {
/* ignore */
}
db = undefined;
for (const d of dirs) {
rmSync(d, { recursive: true, force: true });
}
dirs.length = 0;
});
it("forwards its shared SpotifyOAuth into a created bot's controller", async () => {
const dir = mkdtempSync(join(tmpdir(), "tsmusicbot-oauth-thread-"));
dirs.push(dir);
const configPath = join(dir, "config.json");
const config = getDefaultConfig();
saveConfig(configPath, config);
db = createDatabase(":memory:");
const permissions = createPermissionStore(db.db);
const provider = {} as unknown as MusicProvider;
const sentinel = {} as unknown as SpotifyOAuth;
const manager = new BotManager(
provider,
provider,
provider,
db,
config,
stubLogger,
{} as unknown as AvatarStore,
permissions,
configPath,
undefined, // localProvider
undefined, // kugouProvider
undefined, // spotifyProvider
join(dir, "spotify"), // spotifyDataDir
sentinel, // spotifyOAuth (the single shared instance)
);
const bot = await manager.createBot({
name: "b1",
serverAddress: "localhost",
serverPort: 9987,
nickname: "b1",
});
// Full chain observed: the manager's single shared instance is the exact
// one the per-bot controller now owns (getOAuth() returns it unchanged).
expect(bot.getSpotifyController().getOAuth()).toBe(sentinel);
bot.disconnect();
});
});
+21 -1
View File
@@ -1,5 +1,6 @@
import crypto from "node:crypto";
import { EventEmitter } from "node:events";
import path from "node:path";
import {
BotInstance,
type BotInstanceOptions,
@@ -13,6 +14,7 @@ import type { Logger } from "../logger.js";
import type { ServerProtocol } from "../ts-protocol/client.js";
import type { AvatarStore } from "../data/avatars.js";
import type { PermissionStore } from "../data/permissions.js";
import type { SpotifyOAuth } from "../music/spotify/spotify-oauth.js";
/**
* Run bot.connect() with a hard deadline. If the handshake hangs (e.g. the
@@ -76,6 +78,9 @@ export class BotManager extends EventEmitter {
private youtubeProvider: MusicProvider;
private localProvider: MusicProvider;
private kugouProvider: MusicProvider;
private spotifyProvider: MusicProvider;
private spotifyDataDir: string;
private readonly spotifyOAuth?: SpotifyOAuth;
private database: BotDatabase;
private config: BotConfig;
private logger: Logger;
@@ -94,7 +99,10 @@ export class BotManager extends EventEmitter {
permissions: PermissionStore,
configPath: string,
localProvider?: MusicProvider,
kugouProvider?: MusicProvider
kugouProvider?: MusicProvider,
spotifyProvider?: MusicProvider,
spotifyDataDir?: string,
spotifyOAuth?: SpotifyOAuth
) {
super();
this.neteaseProvider = neteaseProvider;
@@ -103,6 +111,9 @@ export class BotManager extends EventEmitter {
this.youtubeProvider = new YouTubeProvider();
this.localProvider = localProvider ?? neteaseProvider;
this.kugouProvider = kugouProvider ?? neteaseProvider;
this.spotifyProvider = spotifyProvider ?? neteaseProvider;
this.spotifyDataDir = spotifyDataDir ?? path.join(process.cwd(), "data", "spotify");
this.spotifyOAuth = spotifyOAuth;
// Let the local provider see which uploads are still referenced by any
// bot's queue, so it never deletes a file another queue/bot still needs.
const referenceable = this.localProvider as Partial<{
@@ -141,10 +152,13 @@ export class BotManager extends EventEmitter {
youtubeProvider: this.youtubeProvider,
localProvider: this.localProvider,
kugouProvider: this.kugouProvider,
spotifyProvider: this.spotifyProvider,
database: this.database,
config: this.config,
logger: this.logger,
avatarStore: this.avatarStore,
spotifyDataDir: this.spotifyDataDir,
spotifyOAuth: this.spotifyOAuth,
});
this.bots.set(id, bot);
@@ -281,10 +295,13 @@ export class BotManager extends EventEmitter {
youtubeProvider: this.youtubeProvider,
localProvider: this.localProvider,
kugouProvider: this.kugouProvider,
spotifyProvider: this.spotifyProvider,
database: this.database,
config: this.config,
logger: this.logger,
avatarStore: this.avatarStore,
spotifyDataDir: this.spotifyDataDir,
spotifyOAuth: this.spotifyOAuth,
});
this.bots.set(id, bot);
this.emit("botInstance", bot);
@@ -335,10 +352,13 @@ export class BotManager extends EventEmitter {
youtubeProvider: this.youtubeProvider,
localProvider: this.localProvider,
kugouProvider: this.kugouProvider,
spotifyProvider: this.spotifyProvider,
database: this.database,
config: this.config,
logger: this.logger,
avatarStore: this.avatarStore,
spotifyDataDir: this.spotifyDataDir,
spotifyOAuth: this.spotifyOAuth,
});
this.bots.set(saved.id, bot);
+248 -2
View File
@@ -1,9 +1,32 @@
import { describe, it, expect, afterEach } from "vitest";
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
import { join } from "node:path";
import { mkdtempSync, rmSync, writeFileSync, existsSync, readFileSync } from "node:fs";
import {
mkdtempSync,
rmSync,
writeFileSync,
existsSync,
readFileSync,
readdirSync,
renameSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { getDefaultConfig, loadConfig, saveConfig, migrateLegacyConfig } from "./config.js";
// Wrap the fs functions config.ts uses in call-through spies so the atomic-write
// and transient-read-error paths can be observed/forced. Everything else (mkdtemp,
// rmSync, existsSync, …) is the real implementation via `...actual`, so all other
// tests keep their real filesystem behavior. `vi.spyOn` can't be used here because
// the node:fs ESM namespace is non-configurable in this setup.
vi.mock("node:fs", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:fs")>();
return {
...actual,
readFileSync: vi.fn(actual.readFileSync),
writeFileSync: vi.fn(actual.writeFileSync),
renameSync: vi.fn(actual.renameSync),
};
});
describe("config", () => {
const dirs: string[] = [];
@@ -205,3 +228,226 @@ describe("adminGroups normalization", () => {
expect(loadAdminGroups({ adminGroups: "6" })).toEqual([]);
});
});
describe("spotify config", () => {
it("defaults are present and disabled", () => {
const c = getDefaultConfig();
expect(c.spotify).toEqual({
enabled: false,
backend: "auto",
clientId: "",
clientSecret: "",
deviceName: "TSMusicBot",
bitrate: 320,
});
});
it("loadConfig coerces bad spotify values back to safe defaults", () => {
const dir = mkdtempSync(join(tmpdir(), "cfg-"));
const p = join(dir, "config.json");
writeFileSync(
p,
JSON.stringify({
spotify: { enabled: "yes", backend: "bogus", bitrate: 7, clientId: 5 },
})
);
const c = loadConfig(p);
expect(c.spotify.enabled).toBe(false); // non-boolean → false
expect(c.spotify.backend).toBe("auto"); // invalid enum → auto
expect(c.spotify.bitrate).toBe(320); // invalid → 320
expect(c.spotify.clientId).toBe(""); // non-string → ""
expect(c.spotify.deviceName).toBe("TSMusicBot"); // missing → default
});
it("loadConfig preserves valid spotify values", () => {
const dir = mkdtempSync(join(tmpdir(), "cfg-"));
const p = join(dir, "config.json");
writeFileSync(
p,
JSON.stringify({
spotify: {
enabled: true,
backend: "librespot",
clientId: "abc",
clientSecret: "def",
deviceName: "MyBot",
bitrate: 160,
},
})
);
const c = loadConfig(p);
expect(c.spotify).toEqual({
enabled: true,
backend: "librespot",
clientId: "abc",
clientSecret: "def",
deviceName: "MyBot",
bitrate: 160,
});
});
});
// --- R2-1: saveConfig must write atomically (temp file + rename), never truncate ---
describe("saveConfig atomic write", () => {
const dirs: string[] = [];
function makeTmpDir(): string {
const dir = mkdtempSync(join(tmpdir(), "tsmb-atomic-"));
dirs.push(dir);
return dir;
}
beforeEach(() => {
vi.clearAllMocks(); // reset call history, keep the call-through implementations
});
afterEach(() => {
for (const d of dirs) rmSync(d, { recursive: true, force: true });
dirs.length = 0;
});
it("round-trips (save then load equals) and leaves NO .tmp file behind", () => {
const dir = makeTmpDir();
const path = join(dir, "config.json");
const config = { ...getDefaultConfig(), webPort: 4567, adminPassword: "pw" };
saveConfig(path, config);
expect(loadConfig(path)).toEqual(config);
// No temp remnants in the target directory.
expect(readdirSync(dir).filter((f) => f.includes(".tmp"))).toEqual([]);
});
it("writes via a same-dir temp file then renameSync onto the final path", () => {
const dir = makeTmpDir();
const path = join(dir, "config.json");
saveConfig(path, getDefaultConfig());
expect(vi.mocked(renameSync)).toHaveBeenCalled();
const [from, to] = vi.mocked(renameSync).mock.calls[0] as [string, string];
expect(to).toBe(path); // renamed ONTO the real path
expect(String(from)).not.toBe(path); // ...from a distinct temp file
expect(join(String(from), "..")).toBe(join(path, "..")); // ...in the SAME directory
});
it("does not corrupt a pre-existing valid config when saving over it", () => {
const dir = makeTmpDir();
const path = join(dir, "config.json");
saveConfig(path, { ...getDefaultConfig(), adminPassword: "first", webPort: 1234 });
// Overwrite with a different, fully-formed config.
saveConfig(path, { ...getDefaultConfig(), adminPassword: "second", webPort: 9999 });
const loaded = loadConfig(path);
expect(loaded.adminPassword).toBe("second");
expect(loaded.webPort).toBe(9999);
// The on-disk file is a single complete JSON document (no partial/truncated write).
expect(() => JSON.parse(readFileSync(path, "utf-8"))).not.toThrow();
expect(readdirSync(dir).filter((f) => f.includes(".tmp"))).toEqual([]);
});
it("cleans up the temp file (no .tmp remnant) when the rename fails", () => {
const dir = makeTmpDir();
const path = join(dir, "config.json");
vi.mocked(renameSync).mockImplementationOnce(() => {
throw new Error("rename boom");
});
expect(() => saveConfig(path, getDefaultConfig())).toThrow(/rename boom/);
// The failed write left no temp file lying around.
expect(readdirSync(dir).filter((f) => f.includes(".tmp"))).toEqual([]);
});
});
// --- R2-2: loadConfig must not treat a transient/corrupt read as "missing" ---
describe("loadConfig error handling", () => {
const dirs: string[] = [];
function makeTmpDir(): string {
const dir = mkdtempSync(join(tmpdir(), "tsmb-load-"));
dirs.push(dir);
return dir;
}
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
for (const d of dirs) rmSync(d, { recursive: true, force: true });
dirs.length = 0;
});
it("(a) ENOENT (missing file) returns defaults — unchanged first-run behavior", () => {
const dir = makeTmpDir();
expect(loadConfig(join(dir, "config.json"))).toEqual(getDefaultConfig());
});
it("(b) a non-ENOENT read error (EBUSY) rethrows instead of returning defaults", () => {
const dir = makeTmpDir();
const path = join(dir, "config.json");
// A REAL config exists on disk; a transient lock must NOT collapse to defaults
// (the caller would otherwise overwrite this real config with defaults).
saveConfig(path, { ...getDefaultConfig(), adminPassword: "keep-me" });
vi.mocked(readFileSync).mockImplementationOnce(() => {
const err = new Error("EBUSY: resource busy or locked") as NodeJS.ErrnoException;
err.code = "EBUSY";
throw err;
});
expect(() => loadConfig(path)).toThrow(/EBUSY/);
// The on-disk config is untouched and still readable once the lock clears.
expect(loadConfig(path).adminPassword).toBe("keep-me");
});
it("(c) corrupt JSON returns defaults AND backs up the original to *.corrupt-*", () => {
const dir = makeTmpDir();
const path = join(dir, "config.json");
const garbage = "{ not: valid json, ";
writeFileSync(path, garbage, "utf-8");
const loaded = loadConfig(path);
expect(loaded).toEqual(getDefaultConfig());
const backups = readdirSync(dir).filter((f) => f.includes(".corrupt-"));
expect(backups.length).toBeGreaterThan(0);
// The corrupt original is preserved verbatim (recoverable, never deleted).
expect(readFileSync(join(dir, backups[0]), "utf-8")).toBe(garbage);
});
// (d)/(e) Valid JSON that is NOT a non-null object (null / [] / 42 / "str") passes
// JSON.parse but would throw a raw TypeError in the per-field sanitize block
// (property access on a non-object), bypassing the corrupt-backup path. It must be
// treated EXACTLY like corrupt JSON: back up to *.corrupt-* (original preserved),
// return defaults — NOT a thrown TypeError, and NOT a silent defaults-with-no-backup.
it("(d) a `null` config is treated as corrupt: defaults + *.corrupt-* backup (original preserved)", () => {
const dir = makeTmpDir();
const path = join(dir, "config.json");
writeFileSync(path, "null", "utf-8");
let loaded: ReturnType<typeof getDefaultConfig>;
expect(() => {
loaded = loadConfig(path);
}).not.toThrow();
expect(loaded!).toEqual(getDefaultConfig());
const backups = readdirSync(dir).filter((f) => f.includes(".corrupt-"));
expect(backups.length).toBeGreaterThan(0);
expect(readFileSync(join(dir, backups[0]), "utf-8")).toBe("null");
});
it("(e) a non-object config (`[]` / `42`) is backed up + defaults, not a thrown TypeError", () => {
for (const content of ["[]", "42"]) {
const dir = makeTmpDir();
const path = join(dir, "config.json");
writeFileSync(path, content, "utf-8");
let loaded: ReturnType<typeof getDefaultConfig>;
expect(() => {
loaded = loadConfig(path);
}).not.toThrow();
expect(loaded!).toEqual(getDefaultConfig());
const backups = readdirSync(dir).filter((f) => f.includes(".corrupt-"));
expect(backups.length).toBeGreaterThan(0);
expect(readFileSync(join(dir, backups[0]), "utf-8")).toBe(content);
}
});
});
+130 -7
View File
@@ -1,4 +1,12 @@
import { readFileSync, writeFileSync, mkdirSync, existsSync, copyFileSync, rmSync } from "node:fs";
import {
readFileSync,
writeFileSync,
mkdirSync,
existsSync,
copyFileSync,
rmSync,
renameSync,
} from "node:fs";
import { dirname } from "node:path";
import type { BotAccess, GuestPermissions } from "./permissions.js";
import { GUEST_PERMISSION_FLAGS } from "./permissions.js";
@@ -9,6 +17,15 @@ export interface GuestModeConfig {
permissions: GuestPermissions;
}
export interface SpotifyConfig {
enabled: boolean;
backend: "auto" | "go-librespot" | "librespot";
clientId: string;
clientSecret: string;
deviceName: string;
bitrate: number;
}
export interface BotConfig {
webPort: number;
locale: "zh" | "en";
@@ -33,6 +50,7 @@ export interface BotConfig {
// behind HTTPS-terminating proxies.
trustProxy: boolean;
guestMode: GuestModeConfig;
spotify: SpotifyConfig;
}
export function getDefaultConfig(): BotConfig {
@@ -69,15 +87,80 @@ export function getDefaultConfig(): BotConfig {
playCollection: false,
},
},
spotify: {
enabled: false,
backend: "auto",
clientId: "",
clientSecret: "",
deviceName: "TSMusicBot",
bitrate: 320,
},
};
}
/**
* Move an unusable config aside to a timestamped `*.corrupt-*` backup so the data
* stays recoverable (it is NEVER deleted), for both the corrupt-JSON case and the
* parses-but-not-an-object case. Prefer an atomic same-dir rename; if that fails,
* copy instead. If it can't be preserved at all, rethrow rather than let the caller
* overwrite unrecoverable data.
*/
function backupCorruptConfig(path: string): void {
const backup = `${path}.corrupt-${Date.now()}`;
try {
renameSync(path, backup);
} catch {
try {
copyFileSync(path, backup);
} catch (backupErr) {
throw backupErr;
}
}
}
export function loadConfig(path: string): BotConfig {
const defaults = getDefaultConfig();
try {
const raw = readFileSync(path, "utf-8");
const partial = JSON.parse(raw) as Partial<BotConfig>;
// Distinguish the three failure modes so a *real* on-disk config is NEVER
// silently replaced with defaults (the caller saveConfig()s right after load,
// which would otherwise erase spotify creds / adminPassword / adminGroups /
// guestMode permanently):
// (a) file ABSENT (ENOENT) — normal first run → defaults.
// (b) any OTHER read error (EBUSY/EACCES/EPERM/EISDIR/…) on an existing file —
// rethrow (fail-fast at boot). A loud crash beats silent credential loss.
// (c) file readable but JSON.parse fails (corrupt) — back the file up first
// (never delete it), THEN return defaults so boot can proceed.
let raw: string;
try {
raw = readFileSync(path, "utf-8");
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
return defaults; // (a) missing file — first run
}
throw err; // (b) transient/permission error on an existing file — do not clobber it
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
// (c) Corrupt content: move the unreadable file aside to a timestamped backup
// so the data stays recoverable, then fall back to defaults.
backupCorruptConfig(path);
return defaults;
}
// (d) Parses cleanly but is NOT a non-null object (e.g. `null`, `42`, `"str"`,
// `[]`). The per-field sanitize below assumes an object and would throw a raw
// TypeError (or silently spread junk), bypassing the corrupt-backup path. Treat
// it EXACTLY like corrupt JSON: back it up (never delete), then return defaults.
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
backupCorruptConfig(path);
return defaults;
}
const partial = parsed as Partial<BotConfig>;
{
// Normalize/sanitize guestMode on load. The WRITE path (POST /api/bot/settings)
// sanitizes too, but a hand-edited/legacy/corrupt config.json reaches the gate
// directly — so coerce it here as well, mirroring that write-path logic.
@@ -118,20 +201,60 @@ export function loadConfig(path: string): BotConfig {
)
: defaults.adminGroups;
const partialSp = (partial.spotify ?? {}) as Partial<SpotifyConfig>;
const validBackends = ["auto", "go-librespot", "librespot"] as const;
const validBitrates = [96, 160, 320];
const spotify: SpotifyConfig = {
enabled: partialSp.enabled === true,
backend: (validBackends as readonly string[]).includes(partialSp.backend as string)
? (partialSp.backend as SpotifyConfig["backend"])
: defaults.spotify.backend,
clientId: typeof partialSp.clientId === "string" ? partialSp.clientId : defaults.spotify.clientId,
clientSecret:
typeof partialSp.clientSecret === "string" ? partialSp.clientSecret : defaults.spotify.clientSecret,
deviceName:
typeof partialSp.deviceName === "string" && partialSp.deviceName.trim()
? partialSp.deviceName
: defaults.spotify.deviceName,
bitrate: validBitrates.includes(partialSp.bitrate as number)
? (partialSp.bitrate as number)
: defaults.spotify.bitrate,
};
return {
...defaults,
...partial,
adminGroups,
guestMode: gm,
spotify,
};
} catch {
return defaults;
}
}
export function saveConfig(path: string, config: BotConfig): void {
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, JSON.stringify(config, null, 2), "utf-8");
const json = JSON.stringify(config, null, 2);
// Atomic write: serialize to a sibling temp file in the SAME directory, then
// rename it onto the final path. rename is an atomic replace on POSIX and modern
// Windows, so a crash / power loss / ENOSPC mid-write can never leave config.json
// truncated — a reader always sees either the previous file or the fully-written
// new one, never a partial. The temp lives in the same dir so the rename stays on
// one filesystem (a cross-device rename would fail); pid + timestamp keep
// concurrent writers from colliding on the temp name.
const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
try {
writeFileSync(tmp, json, "utf-8");
renameSync(tmp, path);
} catch (err) {
// Never leave a partial temp file behind on failure.
try {
rmSync(tmp, { force: true });
} catch {
/* best-effort cleanup */
}
throw err;
}
}
/**
+2
View File
@@ -60,6 +60,7 @@ describe("database", () => {
album: "Test Album",
platform: "netease",
coverUrl: "https://example.com/cover.jpg",
requestedBy: "alice",
});
botDb.addPlayHistory({
@@ -76,6 +77,7 @@ describe("database", () => {
expect(history).toHaveLength(2);
expect(history[0].songName).toBe("Another Song");
expect(history[1].songName).toBe("Test Song");
expect(history[1].requestedBy).toBe("alice");
});
it("saves and loads bot instances", () => {
+12 -4
View File
@@ -8,8 +8,9 @@ export interface PlayHistoryEntry {
songName: string;
artist: string;
album: string;
platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou";
platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou" | "spotify";
coverUrl: string;
requestedBy?: string;
}
export interface PlayHistoryRecord extends PlayHistoryEntry {
@@ -124,6 +125,12 @@ function migrateSchema(db: Database.Database): void {
if (!userColNames.includes("role")) {
db.exec("ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'admin'");
}
const historyColumns = db.prepare("PRAGMA table_info(play_history)").all() as Array<{ name: string }>;
const historyColNames = historyColumns.map((c) => c.name);
if (!historyColNames.includes("requestedBy")) {
db.exec("ALTER TABLE play_history ADD COLUMN requestedBy TEXT NOT NULL DEFAULT ''");
}
}
function initTables(db: Database.Database): void {
@@ -137,6 +144,7 @@ function initTables(db: Database.Database): void {
album TEXT NOT NULL,
platform TEXT NOT NULL,
coverUrl TEXT NOT NULL,
requestedBy TEXT NOT NULL DEFAULT '',
playedAt TEXT NOT NULL DEFAULT (datetime('now'))
);
@@ -265,8 +273,8 @@ export function createDatabase(dbPath: string): BotDatabase {
ensureGuestUser(db);
const insertHistory = db.prepare(`
INSERT INTO play_history (botId, songId, songName, artist, album, platform, coverUrl)
VALUES (@botId, @songId, @songName, @artist, @album, @platform, @coverUrl)
INSERT INTO play_history (botId, songId, songName, artist, album, platform, coverUrl, requestedBy)
VALUES (@botId, @songId, @songName, @artist, @album, @platform, @coverUrl, @requestedBy)
`);
const selectHistory = db.prepare(`
@@ -338,7 +346,7 @@ export function createDatabase(dbPath: string): BotDatabase {
db,
addPlayHistory(record) {
insertHistory.run(record);
insertHistory.run({ ...record, requestedBy: record.requestedBy ?? "" });
},
getPlayHistory(botId, limit) {
+33 -1
View File
@@ -9,6 +9,8 @@ import { QQMusicProvider } from "./music/qq.js";
import { BiliBiliProvider } from "./music/bilibili.js";
import { LocalMusicProvider } from "./music/local.js";
import { KugouProvider } from "./music/kugou.js";
import { SpotifyProvider } from "./music/spotify/provider.js";
import { SpotifyOAuth, createFileOAuthTokenStore } from "./music/spotify/spotify-oauth.js";
import { createCookieStore } from "./music/auth.js";
import { createAvatarStore } from "./data/avatars.js";
import { createPermissionStore } from "./data/permissions.js";
@@ -28,6 +30,7 @@ const LOG_DIR = path.join(DATA_DIR, "logs");
const COOKIE_DIR = path.join(DATA_DIR, "cookies");
const AVATAR_DIR = path.join(DATA_DIR, "avatars");
const LOCAL_AUDIO_DIR = path.join(DATA_DIR, "local-audio");
const SPOTIFY_DATA_DIR = path.join(DATA_DIR, "spotify");
const STATIC_DIR = path.join(ROOT_DIR, "web", "dist");
async function main() {
@@ -59,6 +62,14 @@ async function main() {
const bilibiliProvider = new BiliBiliProvider();
const localProvider = new LocalMusicProvider(LOCAL_AUDIO_DIR);
const kugouProvider = new KugouProvider();
const spotifyProvider = new SpotifyProvider();
// Safety gate (spec §7): the source is inert unless EXPLICITLY enabled.
// Only feed credentials when enabled — otherwise the provider has no creds,
// hasCreds() is false, search returns empty, and getAuthStatus() is loggedIn:false,
// so setting a Client ID/Secret alone (enabled:false) never activates Spotify.
if (config.spotify.enabled && config.spotify.clientId) {
spotifyProvider.setCreds(config.spotify.clientId, config.spotify.clientSecret);
}
const cookieStore = createCookieStore(COOKIE_DIR);
const avatarStore = createAvatarStore(AVATAR_DIR);
@@ -73,6 +84,22 @@ async function main() {
const permissions = createPermissionStore(db.db);
// Single process-wide Spotify authorization (one Premium account for Stage 3).
// Threaded into BOTH the web OAuth router and every bot's SpotifyController so
// a web login immediately authorizes playback (C3.1). Own-app clientId => the
// redirect points at this bot's web callback; empty clientId leaves OAuth
// disabled (isAuthorized() stays false and the Rust backend never starts).
const spotifyOAuthClientId = config.spotify.clientId.trim();
const spotifyOAuth = new SpotifyOAuth({
clientId: spotifyOAuthClientId || undefined,
redirectUri: spotifyOAuthClientId
? `http://127.0.0.1:${config.webPort}/api/spotify/callback`
: undefined,
store: createFileOAuthTokenStore(
path.join(SPOTIFY_DATA_DIR, "oauth", "oauth-tokens.json"),
),
});
const botManager = new BotManager(
neteaseProvider,
qqProvider,
@@ -84,7 +111,10 @@ async function main() {
permissions,
CONFIG_PATH,
localProvider,
kugouProvider
kugouProvider,
spotifyProvider,
SPOTIFY_DATA_DIR,
spotifyOAuth
);
await botManager.loadSavedBots();
@@ -96,6 +126,7 @@ async function main() {
bilibiliProvider,
localProvider,
kugouProvider,
spotifyProvider,
database: db,
avatarStore,
config,
@@ -103,6 +134,7 @@ async function main() {
logger,
cookieStore,
staticDir: STATIC_DIR,
spotifyOAuth,
});
await webServer.start();
+4 -4
View File
@@ -5,7 +5,7 @@ export interface Song {
album: string;
duration: number; // seconds
coverUrl: string;
platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou";
platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou" | "spotify";
/** VIP / copyright-restricted: non-VIP users can only play a trial fragment
* (NetEase fee=1 VIP / fee=4 album-only, or QQ pay.payplay/paytrackprice=1). */
vip?: boolean;
@@ -27,7 +27,7 @@ export interface Playlist {
name: string;
coverUrl: string;
songCount: number;
platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou";
platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou" | "spotify";
}
export interface PlaylistDetail {
@@ -44,7 +44,7 @@ export interface Album {
artist: string;
coverUrl: string;
songCount: number;
platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou";
platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou" | "spotify";
}
export interface LyricLine {
@@ -72,7 +72,7 @@ export interface AuthStatus {
}
export interface MusicProvider {
readonly platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou";
readonly platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou" | "spotify";
search(query: string, limit?: number, offset?: number): Promise<SearchResult>;
getSongUrl(songId: string, quality?: string): Promise<SongUrlResult | null>;
+12
View File
@@ -0,0 +1,12 @@
import { describe, it, expect } from "vitest";
import { resolveSpotifyBackendKind as pick } from "./backend-select.js";
describe("resolveSpotifyBackendKind", () => {
it("auto: go present -> go-librespot", () => expect(pick("auto", true, true)).toBe("go-librespot"));
it("auto: go absent, rust present -> librespot", () => expect(pick("auto", false, true)).toBe("librespot"));
it("auto: neither -> null", () => expect(pick("auto", false, false)).toBeNull());
it("go-librespot: present -> go-librespot", () => expect(pick("go-librespot", true, true)).toBe("go-librespot"));
it("go-librespot: absent -> null even if rust present", () => expect(pick("go-librespot", false, true)).toBeNull());
it("librespot: present -> librespot", () => expect(pick("librespot", true, true)).toBe("librespot"));
it("librespot: absent -> null even if go present", () => expect(pick("librespot", true, false)).toBeNull());
it("auto default fallthrough matches auto", () => expect(pick("auto", true, false)).toBe("go-librespot"));
});
+25
View File
@@ -0,0 +1,25 @@
/** Which concrete backend runs for a given config + host binary availability. */
export type SpotifyBackendKind = "go-librespot" | "librespot";
/**
* Pure backend selection shared by SpotifyController.chooseBackend() (per-bot)
* and the web /status endpoint (process-wide). Booleans in, no IO — the caller
* supplies platform+binary presence.
*/
export function resolveSpotifyBackendKind(
backend: "auto" | "go-librespot" | "librespot",
goPresent: boolean,
rustPresent: boolean,
): SpotifyBackendKind | null {
switch (backend) {
case "go-librespot":
return goPresent ? "go-librespot" : null;
case "librespot":
return rustPresent ? "librespot" : null;
case "auto":
default:
if (goPresent) return "go-librespot";
if (rustPresent) return "librespot";
return null;
}
}
+41
View File
@@ -0,0 +1,41 @@
// src/music/spotify/backend.ts
// Type contract for the Spotify audio backend (go-librespot sidecar).
// Interface-only: this module intentionally contains NO runtime code so it
// can be imported for types by go-librespot.ts and controller.ts without
// pulling in child_process/ws/ffmpeg at type-check time.
/** Emitted when the currently playing Spotify track finishes or is stopped. */
export interface SpotifyTrackEndedEvent {
uri: string;
reason: "ended" | "stopped" | "error";
}
/** Now-playing metadata surfaced from the go-librespot "metadata" event. */
export interface SpotifyNowPlaying {
uri: string;
name: string;
artist: string;
album: string;
coverUrl: string;
durationMs: number;
}
/**
* Long-lived Spotify audio source: owns the go-librespot sidecar + FIFO->ffmpeg
* PCM pipe and exposes transport control plus a continuous 48kHz s16le stereo
* PCM stream to feed AudioPlayer.playPcmStream().
*/
export interface SpotifyAudioBackend {
start(): Promise<void>;
stop(): void;
isReady(): boolean;
playTrack(uri: string): Promise<void>;
pause(): Promise<void>;
resume(): Promise<void>;
seek(ms: number): Promise<void>;
getPcmStream(): import("node:stream").Readable;
getPositionMs(): number;
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;
}
+363
View File
@@ -0,0 +1,363 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { join } from "node:path";
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import {
isGoLibrespotSupported,
pickGoLibrespotPath,
findGoLibrespot,
checkGoLibrespotAvailable,
resetGoLibrespotBinaryCache,
__setGoLibrespotVersionProbe,
isRustLibrespotSupported,
pickLibrespotPath,
findLibrespot,
checkLibrespotAvailable,
resetLibrespotBinaryCache,
__setLibrespotVersionProbe,
resolveExecutable,
isLibrespotPresent,
isGoLibrespotPresent,
} from "./binary.js";
const origPlatform = process.platform;
function setPlatform(p: NodeJS.Platform): void {
Object.defineProperty(process, "platform", { value: p, configurable: true });
}
// Snapshot the PATH env so PATH-resolution tests can point it at temp dirs and
// restore afterwards. PATHEXT is optional (undefined on posix); track presence.
const origPath = process.env.PATH;
const origPathExt = process.env.PATHEXT;
const tmpDirs: string[] = [];
function mkTmpDir(prefix: string): string {
const dir = mkdtempSync(join(tmpdir(), prefix));
tmpDirs.push(dir);
return dir;
}
afterEach(() => {
setPlatform(origPlatform);
__setGoLibrespotVersionProbe(null);
resetGoLibrespotBinaryCache();
__setLibrespotVersionProbe(null);
resetLibrespotBinaryCache();
process.env.PATH = origPath;
if (origPathExt === undefined) delete process.env.PATHEXT;
else process.env.PATHEXT = origPathExt;
for (const dir of tmpDirs.splice(0)) {
try {
rmSync(dir, { recursive: true, force: true });
} catch {
/* ignore */
}
}
});
describe("isGoLibrespotSupported", () => {
it("is true only on linux", () => {
setPlatform("linux");
expect(isGoLibrespotSupported()).toBe(true);
setPlatform("win32");
expect(isGoLibrespotSupported()).toBe(false);
setPlatform("darwin");
expect(isGoLibrespotSupported()).toBe(false);
});
});
describe("pickGoLibrespotPath (bin/ then PATH ordering)", () => {
const binPath = join("some", "root", "bin", "go-librespot");
it("prefers the bin/ path when the file exists", () => {
expect(
pickGoLibrespotPath([binPath, "go-librespot"], (p) => p === binPath),
).toBe(binPath);
});
it("falls through to the bare PATH name when the bin/ file is missing", () => {
expect(pickGoLibrespotPath([binPath, "go-librespot"], () => false)).toBe(
"go-librespot",
);
});
it("returns bare command names without touching the filesystem", () => {
const exists = vi.fn(() => false);
expect(pickGoLibrespotPath(["go-librespot"], exists)).toBe("go-librespot");
expect(exists).not.toHaveBeenCalled();
});
});
describe("findGoLibrespot", () => {
it("returns the bare command name when bin/go-librespot is absent", () => {
// No go-librespot binary is committed under bin/, so resolution must
// fall back to the bare PATH name (execFile resolves it at run time).
expect(findGoLibrespot()).toBe("go-librespot");
});
});
describe("checkGoLibrespotAvailable", () => {
it("returns false immediately on unsupported platforms without probing", async () => {
setPlatform("darwin");
const probe = vi.fn(async () => {});
__setGoLibrespotVersionProbe(probe);
expect(await checkGoLibrespotAvailable()).toBe(false);
expect(probe).not.toHaveBeenCalled();
});
it("returns true when the binary responds to --version on linux", async () => {
setPlatform("linux");
__setGoLibrespotVersionProbe(async () => {});
expect(await checkGoLibrespotAvailable()).toBe(true);
});
it("caches only positive results and probes once", async () => {
setPlatform("linux");
const probe = vi.fn(async () => {});
__setGoLibrespotVersionProbe(probe);
expect(await checkGoLibrespotAvailable()).toBe(true);
expect(await checkGoLibrespotAvailable()).toBe(true);
expect(probe).toHaveBeenCalledTimes(1);
});
it("does not cache a failed probe (retries on the next call)", async () => {
setPlatform("linux");
__setGoLibrespotVersionProbe(async () => {
throw new Error("ENOENT");
});
expect(await checkGoLibrespotAvailable()).toBe(false);
// A later successful probe must now succeed — negatives are not cached.
__setGoLibrespotVersionProbe(async () => {});
expect(await checkGoLibrespotAvailable()).toBe(true);
});
it("resetGoLibrespotBinaryCache clears a cached positive", async () => {
setPlatform("linux");
__setGoLibrespotVersionProbe(async () => {});
expect(await checkGoLibrespotAvailable()).toBe(true);
resetGoLibrespotBinaryCache();
__setGoLibrespotVersionProbe(async () => {
throw new Error("gone");
});
expect(await checkGoLibrespotAvailable()).toBe(false);
});
});
describe("isRustLibrespotSupported", () => {
it("is true on every platform (pipe->stdout works everywhere)", () => {
setPlatform("linux");
expect(isRustLibrespotSupported()).toBe(true);
setPlatform("win32");
expect(isRustLibrespotSupported()).toBe(true);
setPlatform("darwin");
expect(isRustLibrespotSupported()).toBe(true);
});
});
describe("pickLibrespotPath (bin/ then PATH ordering, win32 exe)", () => {
it("prefers the bin/ path when the file exists", () => {
const binPath = join("some", "root", "bin", "librespot");
expect(
pickLibrespotPath([binPath, "librespot"], (p) => p === binPath),
).toBe(binPath);
});
it("prefers the bin/librespot.exe path on win32 when it exists", () => {
setPlatform("win32");
const binExe = join("some", "root", "bin", "librespot.exe");
expect(
pickLibrespotPath([binExe, "librespot.exe"], (p) => p === binExe),
).toBe(binExe);
});
it("falls through to the bare PATH name (librespot) on posix when bin/ is missing", () => {
setPlatform("linux");
const binPath = join("some", "root", "bin", "librespot");
expect(pickLibrespotPath([binPath, "librespot"], () => false)).toBe(
"librespot",
);
});
it("falls through to librespot.exe on win32 when bin/ is missing", () => {
setPlatform("win32");
const binExe = join("some", "root", "bin", "librespot.exe");
expect(pickLibrespotPath([binExe], () => false)).toBe("librespot.exe");
});
it("returns bare command names without touching the filesystem", () => {
const exists = vi.fn(() => false);
expect(pickLibrespotPath(["librespot"], exists)).toBe("librespot");
expect(exists).not.toHaveBeenCalled();
});
});
describe("findLibrespot", () => {
it("returns the bare command name when bin/librespot is absent", () => {
// No librespot binary is committed under bin/, so resolution must fall
// back to the bare PATH name (execFile resolves it at run time).
setPlatform("linux");
expect(findLibrespot()).toBe("librespot");
});
it("returns librespot.exe on win32 when bin/librespot.exe is absent", () => {
setPlatform("win32");
expect(findLibrespot()).toBe("librespot.exe");
});
});
describe("checkLibrespotAvailable", () => {
it("returns true when the binary responds to --version (any platform)", async () => {
setPlatform("win32");
__setLibrespotVersionProbe(async () => {});
expect(await checkLibrespotAvailable()).toBe(true);
});
it("returns true on darwin too (no platform gate)", async () => {
setPlatform("darwin");
__setLibrespotVersionProbe(async () => {});
expect(await checkLibrespotAvailable()).toBe(true);
});
it("caches only positive results and probes once", async () => {
setPlatform("linux");
const probe = vi.fn(async () => {});
__setLibrespotVersionProbe(probe);
expect(await checkLibrespotAvailable()).toBe(true);
expect(await checkLibrespotAvailable()).toBe(true);
expect(probe).toHaveBeenCalledTimes(1);
});
it("does not cache a failed probe (retries on the next call)", async () => {
setPlatform("win32");
__setLibrespotVersionProbe(async () => {
throw new Error("ENOENT");
});
expect(await checkLibrespotAvailable()).toBe(false);
__setLibrespotVersionProbe(async () => {});
expect(await checkLibrespotAvailable()).toBe(true);
});
it("resetLibrespotBinaryCache clears a cached positive", async () => {
setPlatform("linux");
__setLibrespotVersionProbe(async () => {});
expect(await checkLibrespotAvailable()).toBe(true);
resetLibrespotBinaryCache();
__setLibrespotVersionProbe(async () => {
throw new Error("gone");
});
expect(await checkLibrespotAvailable()).toBe(false);
});
it("de-dupes concurrent in-flight probes", async () => {
setPlatform("linux");
const probe = vi.fn(async () => {});
__setLibrespotVersionProbe(probe);
const [a, b] = await Promise.all([
checkLibrespotAvailable(),
checkLibrespotAvailable(),
]);
expect(a).toBe(true);
expect(b).toBe(true);
expect(probe).toHaveBeenCalledTimes(1);
});
});
describe("resolveExecutable", () => {
it("returns a bin/-style path (contains a separator) that exists, unchanged", () => {
const dir = mkTmpDir("tsmb-resolve-");
const full = join(dir, "some-binary");
writeFileSync(full, "#!/bin/sh\n");
// The path contains a separator so it is checked directly, not via PATH.
expect(resolveExecutable(full)).toBe(full);
});
it("returns null for a bin/-style path that does not exist", () => {
const full = join(tmpdir(), "tsmb-resolve-missing", "nope-binary");
expect(resolveExecutable(full)).toBeNull();
});
it("resolves a BARE command name found in a PATH directory (the I3 fix)", () => {
// This is the regression the fix targets: a PATH-installed binary (bare
// name, no separator) must resolve against $PATH, not process.cwd().
const dir = mkTmpDir("tsmb-resolve-path-");
const exeName = process.platform === "win32" ? "faketool.exe" : "faketool";
const full = join(dir, exeName);
writeFileSync(full, "#!/bin/sh\n");
process.env.PATH = dir;
expect(resolveExecutable(exeName)).toBe(full);
});
it("returns null for a bare name not present in any PATH directory", () => {
process.env.PATH = mkTmpDir("tsmb-resolve-empty-");
expect(resolveExecutable("definitely-not-a-real-binary-xyz")).toBeNull();
});
it("appends PATHEXT extensions on win32 to resolve a bare (extensionless) name", () => {
setPlatform("win32");
const dir = mkTmpDir("tsmb-resolve-pathext-");
const full = join(dir, "mytool.CMD");
writeFileSync(full, "@echo hi\n");
process.env.PATH = dir;
process.env.PATHEXT = ".EXE;.CMD;.BAT;.COM";
// Bare "mytool" has no extension; win32 resolution must try PATHEXT and
// find mytool.CMD.
expect(resolveExecutable("mytool")).toBe(full);
});
it("does not touch PATH for a bin/-style relative path with a separator", () => {
// A candidate with a separator is checked directly even if PATH is empty.
process.env.PATH = "";
const dir = mkTmpDir("tsmb-resolve-direct-");
const full = join(dir, "direct-binary");
writeFileSync(full, "#!/bin/sh\n");
expect(resolveExecutable(full)).toBe(full);
});
});
describe("isLibrespotPresent (PATH-aware, sync)", () => {
it("true when the bare librespot name resolves on PATH (posix)", () => {
setPlatform("linux");
const dir = mkTmpDir("tsmb-librespot-path-");
writeFileSync(join(dir, "librespot"), "#!/bin/sh\n");
process.env.PATH = dir;
expect(isLibrespotPresent()).toBe(true);
});
it("true when librespot.exe resolves on PATH (win32)", () => {
setPlatform("win32");
const dir = mkTmpDir("tsmb-librespot-win-");
writeFileSync(join(dir, "librespot.exe"), "MZ\n");
process.env.PATH = dir;
process.env.PATHEXT = ".EXE;.CMD;.BAT;.COM";
expect(isLibrespotPresent()).toBe(true);
});
it("false when librespot is not on PATH", () => {
setPlatform("linux");
process.env.PATH = mkTmpDir("tsmb-librespot-empty-");
expect(isLibrespotPresent()).toBe(false);
});
});
describe("isGoLibrespotPresent (PATH-aware, sync)", () => {
it("true when go-librespot resolves on PATH (linux)", () => {
setPlatform("linux");
const dir = mkTmpDir("tsmb-go-path-");
writeFileSync(join(dir, "go-librespot"), "#!/bin/sh\n");
process.env.PATH = dir;
expect(isGoLibrespotPresent()).toBe(true);
});
it("false on non-linux platforms regardless of PATH (unsupported gate)", () => {
setPlatform("win32");
const dir = mkTmpDir("tsmb-go-win-");
writeFileSync(join(dir, "go-librespot"), "#!/bin/sh\n");
process.env.PATH = dir;
expect(isGoLibrespotPresent()).toBe(false);
});
it("false on linux when go-librespot is not on PATH", () => {
setPlatform("linux");
process.env.PATH = mkTmpDir("tsmb-go-empty-");
expect(isGoLibrespotPresent()).toBe(false);
});
});
+244
View File
@@ -0,0 +1,244 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { existsSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join, delimiter } from "node:path";
const execFileAsync = promisify(execFile);
const __dirname = dirname(fileURLToPath(import.meta.url));
/**
* Resolve a command to an existing absolute path, SYNCHRONOUSLY. A candidate
* that already contains a path separator (a project bin/ path) is checked
* directly; a BARE command name (e.g. "librespot" / "go-librespot") is searched
* across the $PATH directories — with PATHEXT extensions appended on win32 — so
* a scoop/choco/cargo/apt PATH install resolves. Returns the resolved path, or
* null when nothing exists.
*
* This is the sync counterpart to checkLibrespotAvailable()/…GoLibrespot… and
* is what the hot-path presence gates (chooseBackend/isAvailable) rely on: a
* bare name must NOT be handed to existsSync() directly, since existsSync
* resolves it against process.cwd() rather than PATH (Bug I3/m1).
*/
export function resolveExecutable(cmd: string): string | null {
if (cmd.includes("/") || cmd.includes("\\")) {
return existsSync(cmd) ? cmd : null;
}
const dirs = (process.env.PATH || "").split(delimiter).filter(Boolean);
const exts =
process.platform === "win32"
? (process.env.PATHEXT || ".EXE;.CMD;.BAT;.COM").split(";").filter(Boolean)
: [""];
for (const dir of dirs) {
for (const ext of exts) {
const hasExt = ext && cmd.toLowerCase().endsWith(ext.toLowerCase());
const full = join(dir, hasExt ? cmd : cmd + ext);
if (existsSync(full)) return full;
}
}
return null;
}
/**
* True only on Linux. go-librespot ships Linux-only release binaries and the
* sidecar relies on a POSIX FIFO (mkfifo), so the Spotify audio backend is
* gated to Linux/Docker. Everywhere else the caller falls back to the Stage-1
* sentinel-skip message.
*/
export function isGoLibrespotSupported(): boolean {
return process.platform === "linux";
}
/**
* Pure resolver core behind findGoLibrespot(). Returns the first candidate
* that is either a bare command name (left for execFile to resolve via PATH)
* or an existing bin/ file. Exported so tests can inject candidates + a fake
* existence predicate and need no real binary on disk.
*/
export function pickGoLibrespotPath(
candidates: string[],
exists: (p: string) => boolean,
): string {
for (const c of candidates) {
// bin/ paths only count when the file is actually present; bare names are
// returned unconditionally and resolved later via PATH.
const isBinPath = c.includes(join("bin", "go-librespot"));
if (!isBinPath || exists(c)) return c;
}
return "go-librespot";
}
/** Resolve the go-librespot binary path: project bin/ dir first, then PATH. */
export function findGoLibrespot(): string {
// src/music/spotify -> ../../../bin (one level deeper than youtube.ts).
const binPath = join(__dirname, "..", "..", "..", "bin", "go-librespot");
return pickGoLibrespotPath([binPath, "go-librespot"], existsSync);
}
/**
* SYNCHRONOUS presence gate for go-librespot: supported platform (linux) AND
* the resolved binary (project bin/ OR a PATH install) actually exists. This is
* what chooseBackend()/isAvailable() must use — NOT existsSync(findGoLibrespot())
* which cannot see a bare PATH name (Bug I3/m1).
*/
export function isGoLibrespotPresent(): boolean {
return isGoLibrespotSupported() && resolveExecutable(findGoLibrespot()) !== null;
}
// Injectable `--version` probe. Defaults to the real execFile call; tests
// override it so checkGoLibrespotAvailable() needs no real binary. Keeps the
// public checkGoLibrespotAvailable() signature param-free per the contract.
type VersionProbe = (bin: string) => Promise<void>;
const realProbe: VersionProbe = async (bin) => {
await execFileAsync(bin, ["--version"], { timeout: 5_000, maxBuffer: 1024 });
};
let versionProbe: VersionProbe = realProbe;
/** Test hook: override the `--version` probe, or restore the default with null. */
export function __setGoLibrespotVersionProbe(
probe: VersionProbe | null,
): void {
versionProbe = probe ?? realProbe;
}
/**
* Availability check for go-librespot. Returns false immediately on non-Linux
* platforms (unsupported). Otherwise runs `go-librespot --version` (5s timeout)
* and caches ONLY the positive result — a missing binary is retried on the
* next call so the operator can install it without restarting the server.
*/
let cachedAvailable = false;
let pendingCheck: Promise<boolean> | null = null;
export async function checkGoLibrespotAvailable(): Promise<boolean> {
if (!isGoLibrespotSupported()) return false;
if (cachedAvailable) return true;
if (pendingCheck) return pendingCheck;
pendingCheck = (async () => {
try {
await versionProbe(findGoLibrespot());
cachedAvailable = true;
return true;
} catch {
return false;
} finally {
pendingCheck = null;
}
})();
return pendingCheck;
}
/** Force re-detection on the next call (for tests). */
export function resetGoLibrespotBinaryCache(): void {
cachedAvailable = false;
pendingCheck = null;
}
// ---------------------------------------------------------------------------
// Rust librespot (librespot-org) resolver — mirrors the go-librespot fns
// above. Unlike go-librespot, Rust librespot's `--backend pipe` writes PCM to
// *stdout* on every platform (no FIFO, no audio device), so it is supported
// on Windows/macOS/Linux alike and the binary is named librespot.exe on win32.
// ---------------------------------------------------------------------------
/**
* True on ALL platforms. The Rust librespot pipe backend writes raw bytes to
* process stdout, which Node's spawned child.stdout receives unmodified on
* Windows too — so there is no platform gate here (contrast
* isGoLibrespotSupported, which is Linux-only).
*/
export function isRustLibrespotSupported(): boolean {
return true;
}
/**
* Pure resolver core behind findLibrespot(). Returns the first candidate that
* is either a bare command name (left for execFile to resolve via PATH) or an
* existing bin/ file. Exported so tests can inject candidates + a fake
* existence predicate and need no real binary on disk. Mirrors
* pickGoLibrespotPath but keys off the win32 exe name.
*/
export function pickLibrespotPath(
candidates: string[],
exists: (p: string) => boolean,
): string {
const exe = process.platform === "win32" ? "librespot.exe" : "librespot";
for (const c of candidates) {
// bin/ paths only count when the file is actually present; bare names are
// returned unconditionally and resolved later via PATH.
const isBinPath = c.includes(join("bin", "librespot"));
if (!isBinPath || exists(c)) return c;
}
return exe;
}
/**
* Resolve the Rust librespot binary path: project bin/ dir first, then PATH.
* On win32 both the bin/librespot.exe candidate and the bare "librespot.exe"
* fallback are used so a PATH-installed librespot.exe (scoop/choco) resolves.
*/
export function findLibrespot(): string {
const exe = process.platform === "win32" ? "librespot.exe" : "librespot";
// src/music/spotify -> ../../../bin (same depth as findGoLibrespot).
const binExe = join(__dirname, "..", "..", "..", "bin", exe);
const binBare = join(__dirname, "..", "..", "..", "bin", "librespot");
return pickLibrespotPath([binExe, binBare, exe], existsSync);
}
/**
* SYNCHRONOUS presence gate for Rust librespot: supported everywhere AND the
* resolved binary (project bin/ OR a scoop/choco/cargo PATH install) actually
* exists. This is what chooseBackend()/isAvailable() must use — NOT
* existsSync(findLibrespot()) which cannot see a bare PATH name (Bug I3/m1).
*/
export function isLibrespotPresent(): boolean {
return isRustLibrespotSupported() && resolveExecutable(findLibrespot()) !== null;
}
// Injectable `--version` probe. Defaults to the real execFile call; tests
// override it so checkLibrespotAvailable() needs no real binary. Keeps the
// public checkLibrespotAvailable() signature param-free per the contract.
type LibrespotVersionProbe = (bin: string) => Promise<void>;
const realLibrespotProbe: LibrespotVersionProbe = async (bin) => {
await execFileAsync(bin, ["--version"], { timeout: 5_000, maxBuffer: 1024 });
};
let librespotVersionProbe: LibrespotVersionProbe = realLibrespotProbe;
/** Test hook: override the `--version` probe, or restore the default with null. */
export function __setLibrespotVersionProbe(
probe: LibrespotVersionProbe | null,
): void {
librespotVersionProbe = probe ?? realLibrespotProbe;
}
/**
* Availability check for Rust librespot. No platform gate (supported
* everywhere). Runs `librespot --version` (5s timeout) and caches ONLY the
* positive result — a missing binary is retried on the next call so the
* operator can install it (cargo/scoop/choco) without restarting the server.
*/
let rustCachedAvailable = false;
let rustPendingCheck: Promise<boolean> | null = null;
export async function checkLibrespotAvailable(): Promise<boolean> {
if (!isRustLibrespotSupported()) return false;
if (rustCachedAvailable) return true;
if (rustPendingCheck) return rustPendingCheck;
rustPendingCheck = (async () => {
try {
await librespotVersionProbe(findLibrespot());
rustCachedAvailable = true;
return true;
} catch {
return false;
} finally {
rustPendingCheck = null;
}
})();
return rustPendingCheck;
}
/** Force re-detection on the next call (for tests). */
export function resetLibrespotBinaryCache(): void {
rustCachedAvailable = false;
rustPendingCheck = null;
}
+396
View File
@@ -0,0 +1,396 @@
import { describe, it, expect, vi } from "vitest";
import type { AxiosInstance } from "axios";
import { SpotifyConnectApi } from "./connect-api.js";
/** Minimal axios stub: only get/put are exercised by the Connect client. */
function makeHttp(overrides?: Partial<Record<"get" | "put", any>>) {
return {
get: vi.fn().mockResolvedValue({ status: 200, data: {} }),
put: vi.fn().mockResolvedValue({ status: 200, data: {} }),
...overrides,
} as unknown as AxiosInstance;
}
const AUTH = { headers: { Authorization: "Bearer tok123" } };
const token = () =>
vi.fn<() => Promise<string | null>>().mockResolvedValue("tok123");
describe("SpotifyConnectApi.getDevices", () => {
it("GETs /v1/me/player/devices with the bearer header and maps the list", async () => {
const http = makeHttp({
get: vi.fn().mockResolvedValue({
status: 200,
data: {
devices: [
{ id: "dev-1", name: "TS Bot", is_active: true, type: "Speaker" },
{ id: "dev-2", name: "Phone", is_active: false },
],
},
}),
});
const api = new SpotifyConnectApi(token(), { http });
const devices = await api.getDevices();
expect(http.get).toHaveBeenCalledWith("/v1/me/player/devices", AUTH);
expect(devices).toEqual([
{ id: "dev-1", name: "TS Bot", is_active: true },
{ id: "dev-2", name: "Phone", is_active: false },
]);
});
it("returns [] when getToken() is null (unauthorized) without calling http", async () => {
const http = makeHttp();
const api = new SpotifyConnectApi(vi.fn().mockResolvedValue(null), { http });
await expect(api.getDevices()).resolves.toEqual([]);
expect(http.get).not.toHaveBeenCalled();
});
it("returns [] on a 401/network rejection (graceful)", async () => {
const err: any = new Error("unauthorized");
err.response = { status: 401 };
const http = makeHttp({ get: vi.fn().mockRejectedValue(err) });
const api = new SpotifyConnectApi(token(), { http });
await expect(api.getDevices()).resolves.toEqual([]);
});
});
describe("SpotifyConnectApi.findDeviceByName", () => {
it("returns the matching device id", async () => {
const http = makeHttp({
get: vi.fn().mockResolvedValue({
status: 200,
data: { devices: [{ id: "dev-1", name: "TS Bot", is_active: false }] },
}),
});
const api = new SpotifyConnectApi(token(), { http });
await expect(api.findDeviceByName("TS Bot")).resolves.toBe("dev-1");
});
it("returns null when no device name matches", async () => {
const http = makeHttp({
get: vi.fn().mockResolvedValue({
status: 200,
data: { devices: [{ id: "dev-1", name: "Other", is_active: false }] },
}),
});
const api = new SpotifyConnectApi(token(), { http });
await expect(api.findDeviceByName("TS Bot")).resolves.toBeNull();
});
});
describe("SpotifyConnectApi mutating calls", () => {
it("transfer() PUTs /v1/me/player with device_ids + play=false default", async () => {
const http = makeHttp();
const api = new SpotifyConnectApi(token(), { http });
await api.transfer("dev-1");
expect(http.put).toHaveBeenCalledWith(
"/v1/me/player",
{ device_ids: ["dev-1"], play: false },
AUTH,
);
});
it("transfer(id, true) forwards play=true", async () => {
const http = makeHttp();
const api = new SpotifyConnectApi(token(), { http });
await api.transfer("dev-1", true);
expect(http.put).toHaveBeenCalledWith(
"/v1/me/player",
{ device_ids: ["dev-1"], play: true },
AUTH,
);
});
it("play() PUTs /v1/me/player/play?device_id= with the uris body", async () => {
const http = makeHttp();
const api = new SpotifyConnectApi(token(), { http });
await api.play("dev-1", "spotify:track:abc");
expect(http.put).toHaveBeenCalledWith(
"/v1/me/player/play",
{ uris: ["spotify:track:abc"] },
{ headers: { Authorization: "Bearer tok123" }, params: { device_id: "dev-1" } },
);
});
it("pause() PUTs /v1/me/player/pause (no params) with no body", async () => {
const http = makeHttp();
const api = new SpotifyConnectApi(token(), { http });
await api.pause();
expect(http.put).toHaveBeenCalledWith("/v1/me/player/pause", undefined, {
headers: { Authorization: "Bearer tok123" },
params: undefined,
});
});
it("pause(id) forwards device_id param", async () => {
const http = makeHttp();
const api = new SpotifyConnectApi(token(), { http });
await api.pause("dev-1");
expect(http.put).toHaveBeenCalledWith("/v1/me/player/pause", undefined, {
headers: { Authorization: "Bearer tok123" },
params: { device_id: "dev-1" },
});
});
it("resume() PUTs /v1/me/player/play with no uris body (resume)", async () => {
const http = makeHttp();
const api = new SpotifyConnectApi(token(), { http });
await api.resume();
expect(http.put).toHaveBeenCalledWith("/v1/me/player/play", undefined, {
headers: { Authorization: "Bearer tok123" },
params: undefined,
});
});
it("resume(id) forwards device_id param", async () => {
const http = makeHttp();
const api = new SpotifyConnectApi(token(), { http });
await api.resume("dev-1");
expect(http.put).toHaveBeenCalledWith("/v1/me/player/play", undefined, {
headers: { Authorization: "Bearer tok123" },
params: { device_id: "dev-1" },
});
});
it("seek() PUTs /v1/me/player/seek?position_ms=", async () => {
const http = makeHttp();
const api = new SpotifyConnectApi(token(), { http });
await api.seek(42000);
expect(http.put).toHaveBeenCalledWith("/v1/me/player/seek", undefined, {
headers: { Authorization: "Bearer tok123" },
params: { position_ms: 42000 },
});
});
it("seek(ms, id) adds device_id param", async () => {
const http = makeHttp();
const api = new SpotifyConnectApi(token(), { http });
await api.seek(1000, "dev-1");
expect(http.put).toHaveBeenCalledWith("/v1/me/player/seek", undefined, {
headers: { Authorization: "Bearer tok123" },
params: { position_ms: 1000, device_id: "dev-1" },
});
});
it("mutating calls no-op (no http.put) when unauthorized", async () => {
const http = makeHttp();
const api = new SpotifyConnectApi(vi.fn().mockResolvedValue(null), { http });
await api.transfer("dev-1");
await api.play("dev-1", "spotify:track:x");
await api.pause();
expect(http.put).not.toHaveBeenCalled();
});
});
/**
* REQUIRED CORRECTION C3.6: mutating calls must NOT reject up the queue-advance
* path. A transient 403 (non-Premium) / 404 (no active device) / 429
* (rate-limited) from Spotify must be swallowed (resolve to void), never thrown,
* so a failed play() degrades to "couldn't play" instead of an unhandled
* rejection that crashes the backend.
*/
describe("SpotifyConnectApi C3.6 — mutating calls are resilient (no throw)", () => {
// S4.6: transient statuses (404/429) now retry with backoff; inject a no-op
// sleep so these swallow-guarantee tests stay instant (no real timers). The
// no-throw/swallow contract asserted here is unchanged.
const noSleep = async () => {};
function rejectingHttp(status: number) {
const err: any = new Error(`http ${status}`);
err.response = { status };
return makeHttp({ put: vi.fn().mockRejectedValue(err) });
}
it("play() does NOT throw on a 404 (no active device)", async () => {
const api = new SpotifyConnectApi(token(), {
http: rejectingHttp(404),
sleep: noSleep,
});
await expect(api.play("dev-1", "spotify:track:abc")).resolves.toBeUndefined();
});
it("play() does NOT throw on a 429 (rate-limited)", async () => {
const api = new SpotifyConnectApi(token(), {
http: rejectingHttp(429),
sleep: noSleep,
});
await expect(api.play("dev-1", "spotify:track:abc")).resolves.toBeUndefined();
});
it("transfer() does NOT throw on a 403 (non-Premium)", async () => {
const api = new SpotifyConnectApi(token(), { http: rejectingHttp(403) });
await expect(api.transfer("dev-1", true)).resolves.toBeUndefined();
});
it("pause/resume/seek do NOT throw on a rejection", async () => {
const api = new SpotifyConnectApi(token(), {
http: rejectingHttp(404),
sleep: noSleep,
});
await expect(api.pause("dev-1")).resolves.toBeUndefined();
await expect(api.resume("dev-1")).resolves.toBeUndefined();
await expect(api.seek(1000, "dev-1")).resolves.toBeUndefined();
});
it("play() does NOT throw on a raw network error (no response)", async () => {
const http = makeHttp({ put: vi.fn().mockRejectedValue(new Error("ECONNRESET")) });
const api = new SpotifyConnectApi(token(), { http });
await expect(api.play("dev-1", "spotify:track:abc")).resolves.toBeUndefined();
});
});
/**
* Task S4.6: bounded retry/backoff on the mutating Connect commands
* (spec §4.3/§13 recovery/watchdog). Transient statuses {404,429,500,502,503}
* retry up to MAX_ATTEMPTS (3) with exponential backoff; non-transient statuses
* are NOT retried. Every path still preserves C3.6 (swallow, never throw). A
* no-op injected `sleep` keeps the tests instant (no real timers).
*/
describe("SpotifyConnectApi S4.6 — retry/backoff on mutating commands", () => {
const MAX_ATTEMPTS = 3;
const noSleep = async () => {};
function rejectStatus(status: number, headers?: Record<string, string>) {
const err: any = new Error(`http ${status}`);
err.response = { status, headers };
return err;
}
it("play() retries a transient 404 then succeeds (2 calls, no throw)", async () => {
const put = vi
.fn()
.mockRejectedValueOnce(rejectStatus(404))
.mockResolvedValueOnce({ status: 200, data: {} });
const http = makeHttp({ put });
const api = new SpotifyConnectApi(token(), { http, sleep: noSleep });
await expect(api.play("dev-1", "spotify:track:abc")).resolves.toBeUndefined();
expect(put).toHaveBeenCalledTimes(2);
});
it("play() exhausts on a persistent 500 (MAX_ATTEMPTS calls, swallowed, warns once)", async () => {
const put = vi.fn().mockRejectedValue(rejectStatus(500));
const http = makeHttp({ put });
const warn = vi.fn();
const logger = { warn } as any;
const api = new SpotifyConnectApi(token(), { http, sleep: noSleep, logger });
await expect(api.play("dev-1", "spotify:track:abc")).resolves.toBeUndefined();
expect(put).toHaveBeenCalledTimes(MAX_ATTEMPTS);
expect(warn).toHaveBeenCalledTimes(1);
});
it("does NOT retry a non-transient 403 (exactly ONE call, no throw)", async () => {
const put = vi.fn().mockRejectedValue(rejectStatus(403));
const http = makeHttp({ put });
const api = new SpotifyConnectApi(token(), { http, sleep: noSleep });
await expect(api.play("dev-1", "spotify:track:abc")).resolves.toBeUndefined();
expect(put).toHaveBeenCalledTimes(1);
});
it("429 honors a CAPPED Retry-After then succeeds (2 calls, bounded sleep)", async () => {
const put = vi
.fn()
.mockRejectedValueOnce(rejectStatus(429, { "retry-after": "1" }))
.mockResolvedValueOnce({ status: 200, data: {} });
const http = makeHttp({ put });
const sleep = vi.fn<(ms: number) => Promise<void>>(async () => {});
const api = new SpotifyConnectApi(token(), { http, sleep });
await expect(api.play("dev-1", "spotify:track:abc")).resolves.toBeUndefined();
expect(put).toHaveBeenCalledTimes(2);
expect(sleep).toHaveBeenCalledTimes(1);
const delay = sleep.mock.calls[0][0];
expect(delay).toBe(1000);
expect(delay).toBeLessThanOrEqual(2000);
});
it("transfer() shares the retry path — 404 then success (2 calls)", async () => {
const put = vi
.fn()
.mockRejectedValueOnce(rejectStatus(404))
.mockResolvedValueOnce({ status: 200, data: {} });
const http = makeHttp({ put });
const api = new SpotifyConnectApi(token(), { http, sleep: noSleep });
await expect(api.transfer("dev-1", true)).resolves.toBeUndefined();
expect(put).toHaveBeenCalledTimes(2);
});
});
describe("SpotifyConnectApi.getPlaybackState", () => {
it("GETs /v1/me/player and maps is_playing/progress/item", async () => {
const http = makeHttp({
get: vi.fn().mockResolvedValue({
status: 200,
data: {
is_playing: true,
progress_ms: 12345,
item: { uri: "spotify:track:abc", duration_ms: 200000 },
},
}),
});
const api = new SpotifyConnectApi(token(), { http });
const state = await api.getPlaybackState();
expect(http.get).toHaveBeenCalledWith("/v1/me/player", AUTH);
expect(state).toEqual({
isPlaying: true,
progressMs: 12345,
trackUri: "spotify:track:abc",
durationMs: 200000,
});
});
// R4-4 (multi-bot): the account-wide /v1/me/player response names the single
// ACTIVE Connect device. Expose it as activeDeviceId so the Rust backend can
// tell "our device" from a foreign device another bot stole the session with.
it("maps device.id -> activeDeviceId", async () => {
const http = makeHttp({
get: vi.fn().mockResolvedValue({
status: 200,
data: {
is_playing: true,
progress_ms: 1000,
item: { uri: "spotify:track:abc", duration_ms: 200000 },
device: { id: "dev-1", name: "TS Bot", is_active: true },
},
}),
});
const api = new SpotifyConnectApi(token(), { http });
const state = await api.getPlaybackState();
expect(state).toEqual({
isPlaying: true,
progressMs: 1000,
trackUri: "spotify:track:abc",
durationMs: 200000,
activeDeviceId: "dev-1",
});
});
it("returns null on 204 (no active device)", async () => {
const http = makeHttp({ get: vi.fn().mockResolvedValue({ status: 204, data: "" }) });
const api = new SpotifyConnectApi(token(), { http });
await expect(api.getPlaybackState()).resolves.toBeNull();
});
it("returns null when item is missing / trackUri null", async () => {
const http = makeHttp({
get: vi.fn().mockResolvedValue({ status: 200, data: { is_playing: false, progress_ms: 0, item: null } }),
});
const api = new SpotifyConnectApi(token(), { http });
await expect(api.getPlaybackState()).resolves.toEqual({
isPlaying: false,
progressMs: 0,
trackUri: null,
durationMs: 0,
});
});
it("returns null on rejection (e.g. 401) instead of throwing", async () => {
const http = makeHttp({ get: vi.fn().mockRejectedValue(new Error("boom")) });
const api = new SpotifyConnectApi(token(), { http });
await expect(api.getPlaybackState()).resolves.toBeNull();
});
it("returns null when getToken() is null (unauthorized) without calling http", async () => {
const http = makeHttp();
const api = new SpotifyConnectApi(vi.fn().mockResolvedValue(null), { http });
await expect(api.getPlaybackState()).resolves.toBeNull();
expect(http.get).not.toHaveBeenCalled();
});
});
+205
View File
@@ -0,0 +1,205 @@
import axios, { type AxiosInstance } from "axios";
const API_BASE = "https://api.spotify.com";
/**
* Task S4.6 (spec §4.3/§13 recovery/watchdog): transient Connect-command
* failures that warrant a bounded retry with exponential backoff. 404 is the
* device-visibility latency case (device not yet enumerated), 429 is rate-limit,
* 5xx are Spotify-side flakiness. Everything else (401/403/network) is NOT
* retried — it is swallowed immediately (C3.6).
*/
const TRANSIENT = new Set([404, 429, 500, 502, 503]);
const MAX_ATTEMPTS = 3;
const BASE_DELAY_MS = 150;
const MAX_DELAY_MS = 2_000;
export interface SpotifyDevice {
id: string;
name: string;
is_active: boolean;
}
export interface PlaybackState {
isPlaying: boolean;
progressMs: number;
trackUri: string | null;
durationMs: number;
/**
* R4-4 (multi-bot): the id of the single ACTIVE Connect device the
* account-wide GET /v1/me/player is reporting (from `device.id`). Absent when
* Spotify omits the device block. The Rust backend uses it to tell "our
* device" from a foreign device another bot stole the shared session with, so
* it never misattributes that bot's track/stop as ours.
*/
activeDeviceId?: string;
}
/**
* Spotify Web API "Connect" remote-control client. Wraps an axios instance and
* attaches a live user Bearer token from getToken() to every request.
*
* Error policy:
* - Read-only calls (getDevices/getPlaybackState) degrade to []/null on error.
* - Mutating calls (transfer/play/pause/resume/seek) no-op when unauthorized.
* - REQUIRED CORRECTION C3.6: mutating calls ALSO swallow transport errors
* (403 non-Premium / 404 no active device / 429 rate-limited / network) and
* resolve to void instead of rejecting. The contract keeps the Promise<void>
* signatures, so the backend treats a failed play as "couldn't play" and
* falls back — a transient Spotify error can never surface as an unhandled
* rejection that crashes the queue-advance path.
*/
export class SpotifyConnectApi {
private getToken: () => Promise<string | null>;
private http: AxiosInstance;
private sleep: (ms: number) => Promise<void>;
private logger?: import("pino").Logger;
constructor(
getToken: () => Promise<string | null>,
deps?: {
http?: AxiosInstance;
sleep?: (ms: number) => Promise<void>;
logger?: import("pino").Logger;
},
) {
this.getToken = getToken;
this.http = deps?.http ?? axios.create({ baseURL: API_BASE, timeout: 15_000 });
this.sleep = deps?.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
this.logger = deps?.logger;
}
/** Bearer auth headers, or null when no valid user token is available. */
private async authHeaders(): Promise<{ Authorization: string } | null> {
const token = await this.getToken();
if (!token) return null;
return { Authorization: `Bearer ${token}` };
}
/**
* S4.6 recovery/watchdog: run a mutating PUT with bounded retry + exponential
* backoff on TRANSIENT statuses only. On exhaustion OR a non-transient error
* it SWALLOWS and warns once — preserving C3.6 (a mutating command NEVER
* rejects up the queue-advance path). Tokens are never logged.
*/
private async mutateWithRetry(put: () => Promise<unknown>): Promise<void> {
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
try {
await put();
return;
} catch (err: any) {
const status = err?.response?.status;
if (!TRANSIENT.has(status) || attempt === MAX_ATTEMPTS) {
// C3.6: never reject up the queue path — swallow, but surface once.
this.logger?.warn(
{ status },
"Spotify Connect command failed (exhausted/non-retryable)",
);
return;
}
let delay = Math.min(BASE_DELAY_MS * 2 ** (attempt - 1), MAX_DELAY_MS);
if (status === 429) {
const ra = Number(err?.response?.headers?.["retry-after"]);
if (Number.isFinite(ra) && ra > 0) delay = Math.min(ra * 1000, MAX_DELAY_MS);
}
await this.sleep(delay);
}
}
}
async getDevices(): Promise<SpotifyDevice[]> {
const headers = await this.authHeaders();
if (!headers) return [];
try {
const { data } = await this.http.get("/v1/me/player/devices", { headers });
const list = Array.isArray(data?.devices) ? data.devices : [];
return list.map((d: any) => ({
id: d?.id ?? "",
name: d?.name ?? "",
is_active: Boolean(d?.is_active),
}));
} catch {
return [];
}
}
async findDeviceByName(name: string): Promise<string | null> {
const devices = await this.getDevices();
const match = devices.find((d) => d.name === name);
return match ? match.id : null;
}
async transfer(deviceId: string, play = false): Promise<void> {
const headers = await this.authHeaders();
if (!headers) return;
await this.mutateWithRetry(() =>
this.http.put("/v1/me/player", { device_ids: [deviceId], play }, { headers }),
);
}
async play(deviceId: string, trackUri: string): Promise<void> {
const headers = await this.authHeaders();
if (!headers) return;
await this.mutateWithRetry(() =>
this.http.put(
"/v1/me/player/play",
{ uris: [trackUri] },
{ headers, params: { device_id: deviceId } },
),
);
}
async pause(deviceId?: string): Promise<void> {
const headers = await this.authHeaders();
if (!headers) return;
await this.mutateWithRetry(() =>
this.http.put("/v1/me/player/pause", undefined, {
headers,
params: deviceId ? { device_id: deviceId } : undefined,
}),
);
}
async resume(deviceId?: string): Promise<void> {
const headers = await this.authHeaders();
if (!headers) return;
await this.mutateWithRetry(() =>
this.http.put("/v1/me/player/play", undefined, {
headers,
params: deviceId ? { device_id: deviceId } : undefined,
}),
);
}
async seek(ms: number, deviceId?: string): Promise<void> {
const headers = await this.authHeaders();
if (!headers) return;
const params: Record<string, unknown> = { position_ms: ms };
if (deviceId) params.device_id = deviceId;
await this.mutateWithRetry(() =>
this.http.put("/v1/me/player/seek", undefined, { headers, params }),
);
}
async getPlaybackState(): Promise<PlaybackState | null> {
const headers = await this.authHeaders();
if (!headers) return null;
try {
const res = await this.http.get("/v1/me/player", { headers });
// 204 = no active device / playback; body is empty.
if (res.status === 204 || !res.data) return null;
const d = res.data;
return {
isPlaying: Boolean(d.is_playing),
progressMs: Number(d.progress_ms ?? 0),
trackUri: d.item?.uri ?? null,
durationMs: Number(d.item?.duration_ms ?? 0),
// R4-4: expose the account's single active device so a foreign steal is
// distinguishable from our own device. Left undefined when absent.
activeDeviceId: d.device?.id ?? undefined,
};
} catch {
return null;
}
}
}
+709
View File
@@ -0,0 +1,709 @@
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from "vitest";
import { EventEmitter } from "node:events";
import { Readable } from "node:stream";
import { writeFileSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import type { Logger } from "pino";
import type { SpotifyConfig } from "../../data/config.js";
import type {
SpotifyAudioBackend,
SpotifyTrackEndedEvent,
SpotifyNowPlaying,
} from "./backend.js";
import type { SpotifyOAuth } from "./spotify-oauth.js";
// Controllable, hoisted so the vi.mock factory can close over it.
// go-* keys keep their Stage-2 names (`supported`/`path`) so existing tests are
// untouched; rust* keys drive the new librespot selection paths.
const bin = vi.hoisted(() => ({
supported: true,
path: "",
rustSupported: true,
rustPath: "",
}));
vi.mock("./binary.js", async () => {
// existsSync mirrors the real resolveExecutable(find*()) result for the
// bin/-style temp paths this suite uses (existingBin exists, missingBin does
// not), keeping the chooseBackend/isAvailable matrix behavior-identical.
const { existsSync } = await import("node:fs");
return {
isGoLibrespotSupported: () => bin.supported,
findGoLibrespot: () => bin.path,
resetGoLibrespotBinaryCache: () => {},
checkGoLibrespotAvailable: async () => bin.supported && !!bin.path,
isRustLibrespotSupported: () => bin.rustSupported,
findLibrespot: () => bin.rustPath,
resetLibrespotBinaryCache: () => {},
checkLibrespotAvailable: async () => bin.rustSupported && !!bin.rustPath,
// PATH-aware presence gates (Bug I3): supported gate AND the resolved
// binary actually exists on disk.
isGoLibrespotPresent: () => bin.supported && existsSync(bin.path),
isLibrespotPresent: () => bin.rustSupported && existsSync(bin.rustPath),
};
});
// Capture options the DEFAULT factory hands to the real GoLibrespotBackend so
// we can assert per-bot ports (Fix 3) are threaded through. Every other test
// injects its own backendFactory, so this mock is inert for them.
const goLibrespotCtor = vi.hoisted(() => vi.fn());
vi.mock("./go-librespot.js", async () => {
const { EventEmitter } = await import("node:events");
return {
GoLibrespotBackend: class extends EventEmitter {
constructor(opts: any) {
super();
goLibrespotCtor(opts);
}
async start(): Promise<void> {}
isReady(): boolean {
return true;
}
stop(): void {}
async playTrack(): Promise<void> {}
async pause(): Promise<void> {}
async resume(): Promise<void> {}
async seek(): Promise<void> {}
getPcmStream(): any {
return null;
}
getPositionMs(): number {
return 0;
}
},
};
});
// Import AFTER vi.mock so the mocked binary module is used.
const { SpotifyController, perBotDeviceName } = await import("./controller.js");
const existingBin = join(tmpdir(), `tsmb-golibrespot-${process.pid}`);
const missingBin = join(tmpdir(), `tsmb-golibrespot-missing-${process.pid}`);
beforeAll(() => {
writeFileSync(existingBin, "#!/bin/sh\n");
});
afterAll(() => {
try {
rmSync(existingBin);
} catch {
/* ignore */
}
});
beforeEach(() => {
bin.supported = true;
bin.path = existingBin;
bin.rustSupported = true;
bin.rustPath = missingBin;
});
class FakeBackend extends EventEmitter implements SpotifyAudioBackend {
startCalls = 0;
stopCalls = 0;
playCalls: string[] = [];
pauseCalls = 0;
resumeCalls = 0;
seekCalls: number[] = [];
ready = false;
startShouldReject = false;
playShouldReject = false;
/** When set, start() awaits this before resolving — lets a test interleave
* stop()/error DURING a mid-flight start (Bug I1). */
startGate?: Promise<void>;
readonly pcm = Readable.from([Buffer.alloc(0)]);
async start(): Promise<void> {
this.startCalls++;
if (this.startGate) await this.startGate;
if (this.startShouldReject) throw new Error("start boom");
this.ready = true;
}
stop(): void {
this.stopCalls++;
this.ready = false;
}
isReady(): boolean {
return this.ready;
}
async playTrack(uri: string): Promise<void> {
this.playCalls.push(uri);
if (this.playShouldReject) throw new Error("play boom");
}
async pause(): Promise<void> {
this.pauseCalls++;
}
async resume(): Promise<void> {
this.resumeCalls++;
}
async seek(ms: number): Promise<void> {
this.seekCalls.push(ms);
}
getPcmStream(): Readable {
return this.pcm;
}
getPositionMs(): number {
return 0;
}
}
const silentLogger = {
info() {},
error() {},
warn() {},
debug() {},
trace() {},
fatal() {},
child() {
return silentLogger;
},
} as unknown as Logger;
function cfg(over: Partial<SpotifyConfig> = {}): SpotifyConfig {
return {
enabled: true,
backend: "auto",
clientId: "",
clientSecret: "",
deviceName: "TSMusicBot",
bitrate: 320,
...over,
};
}
function makeCtrl(over: {
config?: Partial<SpotifyConfig>;
backendFactory?: () => SpotifyAudioBackend;
oauth?: import("./spotify-oauth.js").SpotifyOAuth;
} = {}) {
const be = new FakeBackend();
const ctrl = new SpotifyController({
config: cfg(over.config),
workDir: "/tmp/work",
configDir: "/tmp/cfg",
logger: silentLogger,
backendFactory: over.backendFactory ?? (() => be),
oauth: over.oauth,
});
return { ctrl, be };
}
function fakeOAuth(
authorized: boolean,
hooks: { onIsAuthorized?: () => void } = {},
): SpotifyOAuth {
return {
isAuthorized: () => {
hooks.onIsAuthorized?.();
return authorized;
},
getAccessToken: async () => (authorized ? "tok" : null),
getClientId: () => "cid",
getRedirectUri: () => "http://127.0.0.1:5588/login",
buildAuthorizeUrl: () => ({ url: "https://accounts.spotify.com/authorize", state: "s" }),
handleCallback: async () => true,
} as unknown as SpotifyOAuth;
}
describe("SpotifyController.isAvailable", () => {
it("true when enabled + supported + binary present", () => {
const { ctrl } = makeCtrl();
expect(ctrl.isAvailable()).toBe(true);
});
it("false when config disabled", () => {
const { ctrl } = makeCtrl({ config: { enabled: false } });
expect(ctrl.isAvailable()).toBe(false);
});
it("false when platform unsupported", () => {
bin.supported = false;
const { ctrl } = makeCtrl();
expect(ctrl.isAvailable()).toBe(false);
});
it("false when binary file is absent", () => {
bin.path = missingBin;
const { ctrl } = makeCtrl();
expect(ctrl.isAvailable()).toBe(false);
});
});
describe("SpotifyController.ensureStarted", () => {
it("starts the backend exactly once across repeated calls", async () => {
let built = 0;
const be = new FakeBackend();
const { ctrl } = makeCtrl({
backendFactory: () => {
built++;
return be;
},
});
expect(await ctrl.ensureStarted()).toBe(true);
expect(await ctrl.ensureStarted()).toBe(true);
expect(built).toBe(1);
expect(be.startCalls).toBe(1);
});
it("is idempotent under concurrent calls (single start)", async () => {
let built = 0;
const be = new FakeBackend();
const { ctrl } = makeCtrl({
backendFactory: () => {
built++;
return be;
},
});
const [a, b] = await Promise.all([ctrl.ensureStarted(), ctrl.ensureStarted()]);
expect(a).toBe(true);
expect(b).toBe(true);
expect(built).toBe(1);
expect(be.startCalls).toBe(1);
});
it("returns false and does not build a backend when unavailable", async () => {
let built = 0;
const { ctrl } = makeCtrl({
config: { enabled: false },
backendFactory: () => {
built++;
return new FakeBackend();
},
});
expect(await ctrl.ensureStarted()).toBe(false);
expect(built).toBe(0);
});
it("returns false when backend.start() throws, and allows a later retry", async () => {
const be = new FakeBackend();
be.startShouldReject = true;
let built = 0;
const { ctrl } = makeCtrl({
backendFactory: () => {
built++;
return be;
},
});
expect(await ctrl.ensureStarted()).toBe(false);
// start failure clears the cached promise so a subsequent call retries.
be.startShouldReject = false;
expect(await ctrl.ensureStarted()).toBe(true);
expect(built).toBe(2);
expect(be.startCalls).toBe(2);
});
});
describe("SpotifyController.playTrack", () => {
it("ensures started then delegates the uri, returning true", async () => {
const { ctrl, be } = makeCtrl();
expect(await ctrl.playTrack("spotify:track:abc")).toBe(true);
expect(be.startCalls).toBe(1);
expect(be.playCalls).toEqual(["spotify:track:abc"]);
});
it("returns false when the controller is unavailable", async () => {
const { ctrl, be } = makeCtrl({ config: { enabled: false } });
expect(await ctrl.playTrack("spotify:track:abc")).toBe(false);
expect(be.playCalls).toEqual([]);
});
it("returns false when backend.playTrack rejects", async () => {
const be = new FakeBackend();
be.playShouldReject = true;
const { ctrl } = makeCtrl({ backendFactory: () => be });
expect(await ctrl.playTrack("spotify:track:abc")).toBe(false);
});
});
describe("SpotifyController transport delegation", () => {
it("pause/resume/seek forward to the backend after start", async () => {
const { ctrl, be } = makeCtrl();
await ctrl.ensureStarted();
await ctrl.pause();
await ctrl.resume();
await ctrl.seek(4200);
expect(be.pauseCalls).toBe(1);
expect(be.resumeCalls).toBe(1);
expect(be.seekCalls).toEqual([4200]);
});
it("pause/resume/seek are safe no-ops before start", async () => {
const { ctrl, be } = makeCtrl();
await expect(ctrl.pause()).resolves.toBeUndefined();
await expect(ctrl.resume()).resolves.toBeUndefined();
await expect(ctrl.seek(10)).resolves.toBeUndefined();
expect(be.pauseCalls).toBe(0);
});
it("getPcmStream returns the backend stream", async () => {
const { ctrl, be } = makeCtrl();
await ctrl.ensureStarted();
expect(ctrl.getPcmStream()).toBe(be.pcm);
});
it("getPcmStream throws before the backend is started", () => {
const { ctrl } = makeCtrl();
expect(() => ctrl.getPcmStream()).toThrow();
});
});
// R4-1: the web progress bar computes seekTime = ratio * duration (fractional
// seconds); BotInstance routes Spotify seeks through seek(seconds * 1000),
// yielding a NON-integer ms (e.g. 71610.00000000001). go-librespot decodes
// `position` into an int64 and Go's encoding/json REJECTS a JSON number with a
// decimal point -> HTTP 400 -> the error is swallowed -> the track never seeks.
// The controller must round ONCE so BOTH backends get a valid integer ms.
describe("SpotifyController.seek integer rounding (R4-1)", () => {
it("rounds a fractional seek to an integer ms before forwarding to the backend", async () => {
const { ctrl, be } = makeCtrl();
await ctrl.ensureStarted();
await ctrl.seek(71610.00000000001);
expect(be.seekCalls).toHaveLength(1);
expect(Number.isInteger(be.seekCalls[0])).toBe(true);
expect(be.seekCalls[0]).toBe(71610);
});
it("clamps a negative seek to 0", async () => {
const { ctrl, be } = makeCtrl();
await ctrl.ensureStarted();
await ctrl.seek(-5);
expect(be.seekCalls).toEqual([0]);
});
it("passes an already-integer seek through unchanged", async () => {
const { ctrl, be } = makeCtrl();
await ctrl.ensureStarted();
await ctrl.seek(4200);
expect(be.seekCalls).toEqual([4200]);
});
});
describe("SpotifyController event re-emission", () => {
it("re-emits backend trackEnded with the same payload", async () => {
const { ctrl, be } = makeCtrl();
await ctrl.ensureStarted();
const got: SpotifyTrackEndedEvent[] = [];
ctrl.on("trackEnded", (e) => got.push(e));
const evt: SpotifyTrackEndedEvent = { uri: "spotify:track:x", reason: "ended" };
be.emit("trackEnded", evt);
expect(got).toEqual([evt]);
});
it("re-emits backend metadata with the same payload", async () => {
const { ctrl, be } = makeCtrl();
await ctrl.ensureStarted();
const got: SpotifyNowPlaying[] = [];
ctrl.on("metadata", (m) => got.push(m));
const meta: SpotifyNowPlaying = {
uri: "spotify:track:x",
name: "Song",
artist: "Artist",
album: "Album",
coverUrl: "http://img",
durationMs: 1000,
};
be.emit("metadata", meta);
expect(got).toEqual([meta]);
});
});
describe("SpotifyController backend error handling (C3)", () => {
it("does NOT throw on a backend 'error' with no controller listener, and marks not-ready", async () => {
const be1 = new FakeBackend();
const be2 = new FakeBackend();
const backends = [be1, be2];
let built = 0;
const { ctrl } = makeCtrl({
backendFactory: () => {
built++;
return backends.shift()!;
},
});
await ctrl.ensureStarted();
expect(built).toBe(1);
// The controller itself has NO "error" listener. A raw re-emit would make
// Node throw here; the controller must swallow+log instead.
expect(() => be1.emit("error", new Error("sidecar boom"))).not.toThrow();
// Marked not-ready: a fresh ensureStarted relaunches a new backend.
expect(await ctrl.ensureStarted()).toBe(true);
expect(built).toBe(2);
expect(be2.startCalls).toBe(1);
});
it("tears down the errored backend (stop + detach + null) so it is not orphaned", async () => {
const be1 = new FakeBackend();
const be2 = new FakeBackend();
const backends = [be1, be2];
let built = 0;
const { ctrl } = makeCtrl({
backendFactory: () => {
built++;
return backends.shift()!;
},
});
await ctrl.ensureStarted();
expect(built).toBe(1);
// On "error" the controller must stop() the errored backend (cleans its
// ffmpeg/go-librespot children + FIFO) and detach ALL its listeners.
expect(() => be1.emit("error", new Error("sidecar boom"))).not.toThrow();
expect(be1.stopCalls).toBe(1);
expect(be1.listenerCount("error")).toBe(0);
expect(be1.listenerCount("trackEnded")).toBe(0);
expect(be1.listenerCount("metadata")).toBe(0);
// Internal backend is null: a transport call no-ops (delegates to nothing)
// and getPcmStream throws until a rebuild.
await ctrl.pause();
expect(be1.pauseCalls).toBe(0);
expect(() => ctrl.getPcmStream()).toThrow();
// A fresh ensureStarted builds a NEW backend instance.
expect(await ctrl.ensureStarted()).toBe(true);
expect(built).toBe(2);
expect(be2.startCalls).toBe(1);
expect(ctrl.getPcmStream()).toBe(be2.pcm);
});
it("does not cross-talk: a later error from the ORPHANED backend leaves the healthy rebuilt controller ready", async () => {
const be1 = new FakeBackend();
const be2 = new FakeBackend();
const backends = [be1, be2];
let built = 0;
const { ctrl } = makeCtrl({
backendFactory: () => {
built++;
return backends.shift()!;
},
});
await ctrl.ensureStarted();
// First error tears down be1 and the controller rebuilds onto healthy be2.
be1.emit("error", new Error("sidecar boom"));
expect(await ctrl.ensureStarted()).toBe(true);
expect(built).toBe(2);
await ctrl.pause();
expect(be2.pauseCalls).toBe(1);
// A SECOND error emitted by the OLD (orphaned) backend must NOT reach the
// controller: the healthy be2 stays owned, ready, and untouched.
be1.on("error", () => {}); // controller detached; re-arm to avoid unhandled throw
expect(() => be1.emit("error", new Error("orphan boom"))).not.toThrow();
expect(be2.stopCalls).toBe(0); // healthy backend not torn down
// Still ready: ensureStarted returns true without rebuilding a third time.
expect(await ctrl.ensureStarted()).toBe(true);
expect(built).toBe(2);
await ctrl.pause();
expect(be2.pauseCalls).toBe(2);
expect(ctrl.getPcmStream()).toBe(be2.pcm);
});
});
describe("SpotifyController.stop", () => {
it("stops the backend and tears down state so a later start rebuilds", async () => {
const be1 = new FakeBackend();
const be2 = new FakeBackend();
const backends = [be1, be2];
const { ctrl } = makeCtrl({ backendFactory: () => backends.shift()! });
await ctrl.ensureStarted();
ctrl.stop();
expect(be1.stopCalls).toBe(1);
// After teardown getPcmStream is invalid again until re-started.
expect(() => ctrl.getPcmStream()).toThrow();
// A fresh ensureStarted builds a new backend.
expect(await ctrl.ensureStarted()).toBe(true);
expect(be2.startCalls).toBe(1);
});
it("stop before start is a safe no-op", () => {
const { ctrl, be } = makeCtrl();
expect(() => ctrl.stop()).not.toThrow();
expect(be.stopCalls).toBe(0);
});
it("Bug I1: stop() DURING a mid-flight start tears the sidecar down instead of resurrecting it", async () => {
// A Deferred the test resolves to complete backend.start() on demand.
let resolveStart!: () => void;
const startGate = new Promise<void>((res) => {
resolveStart = res;
});
const be = new FakeBackend();
be.startGate = startGate;
const { ctrl } = makeCtrl({ backendFactory: () => be });
// Kick off ensureStarted but DO NOT await — start() is parked on the gate.
const startedP = ctrl.ensureStarted();
// Let the ensureStarted IIFE run up to `await backend.start()`.
await Promise.resolve();
await Promise.resolve();
expect(be.startCalls).toBe(1); // start() was entered and is now pending
// Caller tears the controller down while start() is still in flight
// (a user `!stop`/disconnect). Pre-fix this.backend is still null so this
// is a no-op and the spawned sidecar is orphaned.
ctrl.stop();
// Now let start() finally resolve. Pre-fix the IIFE would set this.backend
// and started=true, RESURRECTING the sidecar the caller already stopped.
resolveStart();
const result = await startedP;
// Post-fix: the mid-flight backend is torn down, not promoted.
expect(result).toBe(false);
expect(be.stopCalls).toBeGreaterThanOrEqual(1); // the fake WAS stopped
expect(be.listenerCount("trackEnded")).toBe(0); // listeners detached
expect(() => ctrl.getPcmStream()).toThrow(); // not resurrected
});
});
describe("SpotifyController per-bot ports (Fix 3)", () => {
it("threads apiPort + callbackPort into the default GoLibrespotBackend", async () => {
goLibrespotCtor.mockClear();
// No injected backendFactory → the controller builds the (mocked) real backend.
const ctrl = new SpotifyController({
config: cfg(),
workDir: "/tmp/work",
configDir: "/tmp/cfg",
logger: silentLogger,
apiPort: 3712,
callbackPort: 8712,
});
expect(await ctrl.ensureStarted()).toBe(true);
expect(goLibrespotCtor).toHaveBeenCalledWith(
expect.objectContaining({ apiPort: 3712, callbackPort: 8712 }),
);
});
});
describe("perBotDeviceName (corner-case R2-5)", () => {
it("suffixes the base name with the instanceId", () => {
expect(perBotDeviceName("TSMusicBot", "bot1")).toBe("TSMusicBot-bot1");
});
it("returns the base name unchanged when no instanceId is given", () => {
expect(perBotDeviceName("TSMusicBot", undefined)).toBe("TSMusicBot");
});
});
describe("SpotifyController per-bot device name (corner-case R2-5)", () => {
it("applies the per-bot suffix to the backend deviceName when instanceId is set", async () => {
goLibrespotCtor.mockClear();
// No injected backendFactory → the controller builds the (mocked) real backend.
const ctrl = new SpotifyController({
config: cfg(),
workDir: "/tmp/work",
configDir: "/tmp/cfg",
logger: silentLogger,
instanceId: "bot1",
});
expect(await ctrl.ensureStarted()).toBe(true);
// The Connect identity is UNIQUE per bot ("<base>-<id>") so two bots under
// one account never register the same name (misroute / false-readiness).
expect(goLibrespotCtor).toHaveBeenCalledWith(
expect.objectContaining({ deviceName: "TSMusicBot-bot1" }),
);
});
it("leaves the base deviceName unchanged when no instanceId (behavior-preserving)", async () => {
goLibrespotCtor.mockClear();
const ctrl = new SpotifyController({
config: cfg(),
workDir: "/tmp/work",
configDir: "/tmp/cfg",
logger: silentLogger,
});
expect(await ctrl.ensureStarted()).toBe(true);
expect(goLibrespotCtor).toHaveBeenCalledWith(
expect.objectContaining({ deviceName: "TSMusicBot" }),
);
});
});
describe("SpotifyController.chooseBackend (platform x config matrix)", () => {
function pick(
backend: SpotifyConfig["backend"],
opts: { go: boolean; goSupported?: boolean; rust: boolean; rustSupported?: boolean },
) {
bin.supported = opts.goSupported ?? true;
bin.path = opts.go ? existingBin : missingBin;
bin.rustSupported = opts.rustSupported ?? true;
bin.rustPath = opts.rust ? existingBin : missingBin;
const { ctrl } = makeCtrl({ config: { backend } });
return ctrl.chooseBackend();
}
it("auto: prefers go-librespot when linux + go binary present", () => {
expect(pick("auto", { go: true, rust: true })).toBe("go-librespot");
});
it("auto: falls back to librespot when go unsupported (e.g. Windows) but librespot present", () => {
expect(pick("auto", { go: true, goSupported: false, rust: true })).toBe("librespot");
});
it("auto: falls back to librespot when go binary is absent", () => {
expect(pick("auto", { go: false, rust: true })).toBe("librespot");
});
it("auto: null when neither backend is usable", () => {
expect(pick("auto", { go: false, goSupported: false, rust: false })).toBeNull();
});
it("go-librespot: selected when supported + present", () => {
expect(pick("go-librespot", { go: true, rust: true })).toBe("go-librespot");
});
it("go-librespot: null when unsupported, even if librespot is present", () => {
expect(pick("go-librespot", { go: true, goSupported: false, rust: true })).toBeNull();
});
it("librespot: selected when the librespot binary is present", () => {
expect(pick("librespot", { go: true, rust: true })).toBe("librespot");
});
it("librespot: null when the librespot binary is absent, even if go is present", () => {
expect(pick("librespot", { go: true, rust: false })).toBeNull();
});
});
describe("SpotifyController Rust-backend auth gate", () => {
it("isAvailable is true for a present librespot binary regardless of auth", () => {
bin.rustPath = existingBin;
const { ctrl } = makeCtrl({
config: { backend: "librespot" },
oauth: fakeOAuth(false),
});
expect(ctrl.isAvailable()).toBe(true);
});
it("ensureStarted returns false (no backend built) when Rust chosen but unauthorized", async () => {
bin.rustPath = existingBin;
let built = 0;
const { ctrl } = makeCtrl({
config: { backend: "librespot" },
oauth: fakeOAuth(false),
backendFactory: () => {
built++;
return new FakeBackend();
},
});
expect(await ctrl.ensureStarted()).toBe(false);
expect(built).toBe(0);
});
it("ensureStarted starts the Rust backend once authorized", async () => {
bin.rustPath = existingBin;
const be = new FakeBackend();
let built = 0;
const { ctrl } = makeCtrl({
config: { backend: "librespot" },
oauth: fakeOAuth(true),
backendFactory: () => {
built++;
return be;
},
});
expect(await ctrl.ensureStarted()).toBe(true);
expect(built).toBe(1);
expect(be.startCalls).toBe(1);
});
it("go-librespot path never consults oauth.isAuthorized()", async () => {
// auto + go present -> go-librespot; the auth gate must be skipped so a
// throwing isAuthorized() is never reached.
const oauth = fakeOAuth(false, {
onIsAuthorized: () => {
throw new Error("isAuthorized must not be called on the go path");
},
});
const { ctrl } = makeCtrl({ config: { backend: "auto" }, oauth });
expect(await ctrl.ensureStarted()).toBe(true);
});
it("exposes the shared oauth + connect instances", () => {
const oauth = fakeOAuth(true);
const { ctrl } = makeCtrl({ config: { backend: "auto" }, oauth });
expect(ctrl.getOAuth()).toBe(oauth);
expect(ctrl.getConnect()).toBeDefined();
});
});
+405
View File
@@ -0,0 +1,405 @@
import { EventEmitter } from "node:events";
import {
existsSync,
readFileSync,
writeFileSync,
mkdirSync,
rmSync,
} from "node:fs";
import { dirname, join } from "node:path";
import type { Readable } from "node:stream";
import type { Logger } from "pino";
import type { SpotifyConfig } from "../../data/config.js";
import type {
SpotifyAudioBackend,
SpotifyTrackEndedEvent,
SpotifyNowPlaying,
} from "./backend.js";
import { isGoLibrespotPresent, isLibrespotPresent } from "./binary.js";
import { GoLibrespotBackend } from "./go-librespot.js";
import { RustLibrespotBackend } from "./rust-librespot.js";
import {
SpotifyOAuth,
type OAuthTokens,
type OAuthTokenStore,
} from "./spotify-oauth.js";
import { SpotifyConnectApi } from "./connect-api.js";
import {
resolveSpotifyBackendKind,
type SpotifyBackendKind,
} from "./backend-select.js";
export type { SpotifyBackendKind }; // keep the name exported for existing importers
/**
* Derive the per-bot Spotify Connect device name from the user-configured base.
*
* `config.spotify` is a single process-wide object shared by every BotInstance,
* so `config.spotify.deviceName` is identical for all bots. On the Rust
* (librespot) backend each bot would otherwise spawn `librespot --name <base>`
* with NO uniqueness, registering TWO Connect devices with the SAME name under
* the one shared Premium account — so `findDeviceByName` / `waitForDevice`
* (which match by name) could target the OTHER bot's device, misrouting
* transfer()+play() and reporting false readiness (corner-case R2-5).
*
* Suffixing the base with the bot's instanceId makes the Connect identity
* unique per bot. Pure + deterministic: same inputs → same name across
* restarts. Returns the base unchanged when no instanceId is supplied (keeps
* existing single-bot / non-injected behavior byte-for-byte).
*/
export function perBotDeviceName(base: string, instanceId?: string): string {
return instanceId ? `${base}-${instanceId}` : base;
}
/**
* Minimal file-backed OAuth token store used when the caller does not inject a
* SpotifyOAuth. Persists the rotating refresh-token JSON next to the bot config
* (0600). All IO is lazy + guarded so construction never throws and a
* missing/corrupt file simply reads as "unauthorized".
*/
class FileOAuthTokenStore implements OAuthTokenStore {
constructor(private readonly file: string) {}
load(): OAuthTokens | null {
try {
if (!existsSync(this.file)) return null;
return JSON.parse(readFileSync(this.file, "utf8")) as OAuthTokens;
} catch {
return null;
}
}
save(t: OAuthTokens): void {
mkdirSync(dirname(this.file), { recursive: true });
writeFileSync(this.file, JSON.stringify(t), { mode: 0o600 });
}
clear(): void {
try {
rmSync(this.file, { force: true });
} catch {
/* ignore */
}
}
}
export interface SpotifyControllerOptions {
config: SpotifyConfig;
workDir: string;
configDir: string;
logger: Logger;
/** Owning bot's id; suffixed onto the shared base deviceName so each bot's
* Spotify Connect identity is unique (avoids multi-bot device collision /
* misroute — corner-case R2-5). Omitted → the base name is used unchanged. */
instanceId?: string;
/** Per-bot go-librespot control-API port (distinct per bot to avoid binds). */
apiPort?: number;
/** Per-bot go-librespot OAuth callback port (distinct per bot). */
callbackPort?: number;
/** Injected for tests; when set it overrides the per-kind default builders. */
backendFactory?: () => SpotifyAudioBackend;
/** Injected for tests; defaults to a file-backed SpotifyOAuth in configDir. */
oauth?: SpotifyOAuth;
/** Injected for tests; defaults to a SpotifyConnectApi wired to oauth. */
connect?: SpotifyConnectApi;
}
/**
* Per-bot orchestrator for the Spotify sidecar. Selects a backend for this
* host+config (chooseBackend), owns backend lifecycle, gates on availability
* (config + platform + binary) plus — for the Rust librespot backend — OAuth
* authorization, delegates transport, and re-emits "trackEnded"/"metadata" so
* BotInstance can advance the queue exactly as for the ffmpeg path.
*
* Correction C3 (unchanged): this controller does NOT re-emit a raw "error"
* event. It subscribes to the backend's "error", logs it, tears the backend
* down, and marks itself not-ready so the next ensureStarted() relaunches a
* fresh backend. getPcmStream() proxies the backend's SINGLE persistent stream.
*/
export class SpotifyController extends EventEmitter {
private readonly config: SpotifyConfig;
private readonly workDir: string;
private readonly configDir: string;
private readonly logger: Logger;
private readonly instanceId?: string;
private readonly apiPort?: number;
private readonly callbackPort?: number;
private readonly injectedFactory?: () => SpotifyAudioBackend;
private readonly oauth: SpotifyOAuth;
private readonly connect: SpotifyConnectApi;
private backend: SpotifyAudioBackend | null = null;
// The in-flight backend during a start() that has not yet completed. A
// stop()/handleBackendError() DURING start() clears (or replaces) this so the
// mid-start sidecar is torn down and a completing start is discarded by the
// ensureStarted post-await guard instead of resurrecting a backend the caller
// already tore down (Bug I1).
private pendingBackend: SpotifyAudioBackend | null = null;
private started = false;
private startPromise: Promise<boolean> | null = null;
constructor(o: SpotifyControllerOptions) {
super();
this.config = o.config;
this.workDir = o.workDir;
this.configDir = o.configDir;
this.logger = o.logger;
this.instanceId = o.instanceId;
this.apiPort = o.apiPort;
this.callbackPort = o.callbackPort;
this.injectedFactory = o.backendFactory;
// The controller OWNS a shared OAuth + Connect pair (Task 6 web router and
// the Rust backend reuse these exact instances). Constructing the defaults
// performs no IO/network — the file store loads lazily on first use.
this.oauth =
o.oauth ??
new SpotifyOAuth({
store: new FileOAuthTokenStore(
join(this.configDir, "spotify-oauth.json"),
),
});
this.connect =
o.connect ??
new SpotifyConnectApi(() => this.oauth.getAccessToken(), {
logger: this.logger,
});
}
/** Shared OAuth client (web router + Rust backend reuse this instance). */
getOAuth(): SpotifyOAuth {
return this.oauth;
}
/** Shared Connect API client (Rust backend reuses this instance). */
getConnect(): SpotifyConnectApi {
return this.connect;
}
private goPresent(): boolean {
// PATH-aware, sync presence gate (Bug I3): a bare PATH-installed binary is
// resolved against $PATH, not existsSync()'d against process.cwd().
return isGoLibrespotPresent();
}
private rustPresent(): boolean {
return isLibrespotPresent();
}
/**
* Resolve which backend to run for this platform + config, or null when none
* is usable (caller falls back to the Stage-1 sentinel message):
* "go-librespot" -> GoLibrespot iff supported (linux) + binary present
* "librespot" -> Rust iff librespot(.exe) present (all platforms)
* "auto" -> GoLibrespot when (linux + go binary), else Rust when
* librespot present, else null.
*/
chooseBackend(): SpotifyBackendKind | null {
return resolveSpotifyBackendKind(
this.config.backend,
this.goPresent(),
this.rustPresent(),
);
}
/** enabled in config AND a backend is selectable (platform + binary present). */
isAvailable(): boolean {
return this.config.enabled && this.chooseBackend() !== null;
}
/** Build the concrete backend for the chosen kind (or the injected fake). */
private buildBackend(kind: SpotifyBackendKind): SpotifyAudioBackend {
if (this.injectedFactory) return this.injectedFactory();
// Compute the effective (per-bot-unique) Connect device name ONCE and pass
// the SAME value to whichever backend we build, so `librespot --name`,
// findDeviceByName(), and waitForDevice() all key on one identity — that
// consistency is what prevents the multi-bot misroute / false-readiness
// (corner-case R2-5). The user-configured base (config.deviceName) is left
// untouched; the suffix is applied only to the backend/Connect identity.
const deviceName = perBotDeviceName(this.config.deviceName, this.instanceId);
if (kind === "librespot") {
return new RustLibrespotBackend({
deviceName,
bitrate: this.config.bitrate,
cacheDir: join(this.workDir, "librespot-cache"),
oauth: this.oauth,
connect: this.connect,
logger: this.logger,
});
}
return new GoLibrespotBackend({
deviceName,
bitrate: this.config.bitrate,
workDir: this.workDir,
configDir: this.configDir,
apiPort: this.apiPort,
callbackPort: this.callbackPort,
logger: this.logger,
});
}
/**
* Idempotently start the selected backend. Returns false (without building a
* backend) when unavailable, or — for the Rust backend — when OAuth is not
* yet authorized, so callers show the login-needed / fallback message. A
* failed start clears the cached promise so a later call can retry.
*/
async ensureStarted(): Promise<boolean> {
if (!this.isAvailable()) return false;
const kind = this.chooseBackend();
if (!kind) return false;
// The Rust librespot device only appears in Spotify Connect once the user
// has authorized OAuth; without it, do not spawn a dead sidecar.
if (kind === "librespot" && !this.oauth.isAuthorized()) return false;
if (this.started) {
if (this.backend?.isReady()) return true;
this.stop();
}
if (this.startPromise) return this.startPromise;
this.startPromise = (async () => {
const backend = this.buildBackend(kind);
// Publish the in-flight backend BEFORE the (potentially ~20s) start()
// await so a concurrent stop()/handleBackendError() can reach and tear
// down this mid-start sidecar (Bug I1).
this.pendingBackend = backend;
backend.on("trackEnded", (e: SpotifyTrackEndedEvent) =>
this.emit("trackEnded", e),
);
backend.on("metadata", (m: SpotifyNowPlaying) =>
this.emit("metadata", m),
);
// C3: do NOT re-emit "error". Log and mark not-ready so the next
// ensureStarted() relaunches a fresh backend.
backend.on("error", (err?: unknown) => this.handleBackendError(err));
try {
await backend.start();
} catch (err) {
this.logger.error({ err }, "Spotify backend failed to start");
if (this.pendingBackend === backend) this.pendingBackend = null;
this.startPromise = null;
return false;
}
// Post-await guard (Bug I1): if teardown ran DURING start() — stop() or
// handleBackendError() cleared/replaced pendingBackend — do NOT promote
// this backend. Tear the just-started sidecar down so it is neither
// leaked nor resurrected, and report failure to the caller.
if (this.pendingBackend !== backend) {
this.teardownBackend(
backend,
"Spotify backend stop() threw tearing down a superseded start",
);
return false;
}
this.pendingBackend = null;
this.backend = backend;
this.started = true;
return true;
})();
return this.startPromise;
}
/**
* Stop a backend and detach ALL its listeners, swallowing+logging any throw
* from stop() so teardown never propagates. Shared by the error/stop paths
* and the ensureStarted post-await guard.
*/
private teardownBackend(be: SpotifyAudioBackend, stopMsg: string): void {
try {
be.stop();
} catch (stopErr) {
this.logger.error({ err: stopErr }, stopMsg);
}
(be as unknown as EventEmitter).removeAllListeners();
}
/**
* Tear down an in-flight (mid-start) backend so a start() still awaiting is
* discarded by ensureStarted's post-await guard rather than promoted, and its
* spawned sidecar is killed rather than orphaned (Bug I1).
*/
private teardownPendingBackend(): void {
if (!this.pendingBackend) return;
this.teardownBackend(
this.pendingBackend,
"Spotify backend stop() threw tearing down in-flight start",
);
this.pendingBackend = null;
}
/**
* C3 backend-error handler. Never re-emits "error" (an unhandled "error" on
* an EventEmitter throws). Logs, tears the errored backend down, and marks
* the controller not-ready so the next ensureStarted() relaunches it.
*/
private handleBackendError(err: unknown): void {
this.logger.error({ err }, "Spotify backend error; marking not-ready");
if (this.backend) {
this.teardownBackend(
this.backend,
"Spotify backend stop() threw during error teardown",
);
}
this.backend = null;
// Also kill an in-flight start so its post-await guard discards it.
this.teardownPendingBackend();
this.started = false;
this.startPromise = null;
}
/** Ensure started, then play the spotify: URI. False on any failure. */
async playTrack(uri: string): Promise<boolean> {
const ok = await this.ensureStarted();
if (!ok || !this.backend) return false;
try {
await this.backend.playTrack(uri);
return true;
} catch (err) {
this.logger.error({ err, uri }, "Spotify playTrack failed");
return false;
}
}
async pause(): Promise<void> {
if (this.backend) await this.backend.pause();
}
async resume(): Promise<void> {
if (this.backend) await this.backend.resume();
}
async seek(ms: number): Promise<void> {
// R4-1: round to an integer ms ONCE here so BOTH backends receive a valid
// integer position. The web progress bar computes seekTime = ratio *
// duration (fractional seconds), so seek(seconds * 1000) is a NON-integer ms
// (e.g. 71610.00000000001). go-librespot decodes `position` into an int64
// and Go's encoding/json rejects a JSON number with a decimal point → HTTP
// 400 → the error is swallowed → the track never seeks. The Spotify Web API
// (Rust path) likewise expects an integer position_ms. Clamp negatives to 0.
const position = Math.max(0, Math.round(ms));
if (this.backend) await this.backend.seek(position);
}
getPcmStream(): Readable {
if (!this.backend) {
throw new Error("Spotify backend not started");
}
return this.backend.getPcmStream();
}
/**
* Tear down the backend and reset lifecycle state (safe before start).
* Mirrors handleBackendError's teardown so the NEXT ensureStarted() rebuilds
* a fresh backend.
*/
stop(): void {
if (this.backend) {
this.teardownBackend(
this.backend,
"Spotify backend stop() threw during teardown",
);
}
this.backend = null;
// Also kill an in-flight start so its post-await guard discards it rather
// than resurrecting the sidecar this stop() just tore down (Bug I1).
this.teardownPendingBackend();
this.started = false;
this.startPromise = null;
}
}
+299
View File
@@ -0,0 +1,299 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { EventEmitter } from "node:events";
import type { AxiosInstance } from "axios";
import { GoLibrespotRestClient, GoLibrespotEventClient } from "./go-librespot-api.js";
/** Minimal axios stub: only get/post are exercised by the client. */
function makeHttp(overrides?: Partial<Record<"get" | "post", any>>) {
return {
get: vi.fn().mockResolvedValue({ status: 200, data: {} }),
post: vi.fn().mockResolvedValue({ status: 200, data: {} }),
...overrides,
} as unknown as AxiosInstance;
}
describe("GoLibrespotRestClient", () => {
it("ping() returns true on GET / -> 200", async () => {
const http = makeHttp();
const client = new GoLibrespotRestClient("http://127.0.0.1:3678", { http });
await expect(client.ping()).resolves.toBe(true);
expect(http.get).toHaveBeenCalledWith("/");
});
it("ping() returns false when GET / rejects (daemon not up)", async () => {
const http = makeHttp({ get: vi.fn().mockRejectedValue(new Error("ECONNREFUSED")) });
const client = new GoLibrespotRestClient("http://127.0.0.1:3678", { http });
await expect(client.ping()).resolves.toBe(false);
});
it("playTrack() POSTs /player/play with the uri body", async () => {
const http = makeHttp();
const client = new GoLibrespotRestClient("http://127.0.0.1:3678", { http });
await client.playTrack("spotify:track:abc123");
expect(http.post).toHaveBeenCalledWith("/player/play", { uri: "spotify:track:abc123" });
});
it("pause/resume/stop POST their bodyless endpoints", async () => {
const http = makeHttp();
const client = new GoLibrespotRestClient("http://127.0.0.1:3678", { http });
await client.pause();
await client.resume();
await client.stop();
expect(http.post).toHaveBeenNthCalledWith(1, "/player/pause");
expect(http.post).toHaveBeenNthCalledWith(2, "/player/resume");
expect(http.post).toHaveBeenNthCalledWith(3, "/player/stop");
});
it("seek() POSTs /player/seek with position(ms) and relative:false", async () => {
const http = makeHttp();
const client = new GoLibrespotRestClient("http://127.0.0.1:3678", { http });
await client.seek(42000);
expect(http.post).toHaveBeenCalledWith("/player/seek", { position: 42000, relative: false });
});
it("playTrack() rejects when the POST fails (surfaced to caller)", async () => {
const http = makeHttp({ post: vi.fn().mockRejectedValue(new Error("boom")) });
const client = new GoLibrespotRestClient("http://127.0.0.1:3678", { http });
await expect(client.playTrack("spotify:track:x")).rejects.toThrow("boom");
});
it("getStatus() normalizes the /status shape (ms position/duration)", async () => {
const http = makeHttp({
get: vi.fn().mockResolvedValue({
status: 200,
data: {
stopped: false,
paused: false,
buffering: false,
track: {
uri: "spotify:track:abc",
name: "Song",
artist_names: ["A", "B"],
album_name: "Alb",
album_cover_url: "https://i.scdn.co/c.jpg",
position: 12345,
duration: 200000,
},
},
}),
});
const client = new GoLibrespotRestClient("http://127.0.0.1:3678", { http });
const status = await client.getStatus();
expect(http.get).toHaveBeenCalledWith("/status");
expect(status).toEqual({
stopped: false,
paused: false,
buffering: false,
track: {
uri: "spotify:track:abc",
name: "Song",
artist_names: ["A", "B"],
album_name: "Alb",
album_cover_url: "https://i.scdn.co/c.jpg",
position: 12345,
duration: 200000,
},
});
});
it("getStatus() returns null with a null track when nothing is loaded", async () => {
const http = makeHttp({ get: vi.fn().mockResolvedValue({ status: 200, data: { stopped: true, paused: false, buffering: false, track: null } }) });
const client = new GoLibrespotRestClient("http://127.0.0.1:3678", { http });
const status = await client.getStatus();
expect(status).toEqual({ stopped: true, paused: false, buffering: false, track: null });
});
it("getStatus() returns null when GET /status rejects", async () => {
const http = makeHttp({ get: vi.fn().mockRejectedValue(new Error("down")) });
const client = new GoLibrespotRestClient("http://127.0.0.1:3678", { http });
await expect(client.getStatus()).resolves.toBeNull();
});
});
/** Fake ws: records instances, lets tests drive open/message/close/error. */
class FakeWebSocket extends EventEmitter {
static instances: FakeWebSocket[] = [];
closed = false;
constructor(public url: string) {
super();
FakeWebSocket.instances.push(this);
}
close() {
this.closed = true;
this.emit("close");
}
}
function frame(type: string, data: unknown): Buffer {
return Buffer.from(JSON.stringify({ type, data }));
}
describe("GoLibrespotEventClient", () => {
beforeEach(() => {
FakeWebSocket.instances = [];
});
it("emits 'not_playing' (track-end) with its data payload", () => {
const client = new GoLibrespotEventClient("ws://127.0.0.1:3678/events", {
WebSocketCtor: FakeWebSocket as any,
});
const onEnded = vi.fn();
client.on("not_playing", onEnded);
client.start();
const ws = FakeWebSocket.instances[0];
expect(ws.url).toBe("ws://127.0.0.1:3678/events");
ws.emit("message", frame("not_playing", { uri: "spotify:track:abc", play_origin: "go-librespot" }));
expect(onEnded).toHaveBeenCalledTimes(1);
expect(onEnded).toHaveBeenCalledWith({ uri: "spotify:track:abc", play_origin: "go-librespot" });
client.stop();
});
it("emits 'metadata' with the now-playing object", () => {
const client = new GoLibrespotEventClient("ws://127.0.0.1:3678/events", {
WebSocketCtor: FakeWebSocket as any,
});
const onMeta = vi.fn();
client.on("metadata", onMeta);
client.start();
FakeWebSocket.instances[0].emit(
"message",
frame("metadata", { uri: "spotify:track:xyz", name: "Song", artist_names: ["Q"], duration: 200000 }),
);
expect(onMeta).toHaveBeenCalledWith({ uri: "spotify:track:xyz", name: "Song", artist_names: ["Q"], duration: 200000 });
client.stop();
});
it("ignores non-JSON frames without throwing", () => {
const client = new GoLibrespotEventClient("ws://x/events", { WebSocketCtor: FakeWebSocket as any });
const onAny = vi.fn();
client.on("metadata", onAny);
client.start();
expect(() => FakeWebSocket.instances[0].emit("message", Buffer.from("not json"))).not.toThrow();
expect(onAny).not.toHaveBeenCalled();
client.stop();
});
it("reconnects with backoff after the socket closes", () => {
vi.useFakeTimers();
try {
const client = new GoLibrespotEventClient("ws://x/events", { WebSocketCtor: FakeWebSocket as any });
client.start();
expect(FakeWebSocket.instances).toHaveLength(1);
FakeWebSocket.instances[0].emit("close");
expect(FakeWebSocket.instances).toHaveLength(1); // not immediate
vi.advanceTimersByTime(500);
expect(FakeWebSocket.instances).toHaveLength(2); // reconnected
client.stop();
} finally {
vi.useRealTimers();
}
});
it("stop() closes the socket and prevents reconnect", () => {
vi.useFakeTimers();
try {
const client = new GoLibrespotEventClient("ws://x/events", { WebSocketCtor: FakeWebSocket as any });
client.start();
const ws = FakeWebSocket.instances[0];
client.stop();
expect(ws.closed).toBe(true);
vi.advanceTimersByTime(60000);
expect(FakeWebSocket.instances).toHaveLength(1); // no new socket
} finally {
vi.useRealTimers();
}
});
it("does not throw on socket 'error' when no error listener is attached", () => {
const client = new GoLibrespotEventClient("ws://x/events", { WebSocketCtor: FakeWebSocket as any });
client.start();
expect(() => FakeWebSocket.instances[0].emit("error", new Error("net"))).not.toThrow();
client.stop();
});
// R4-3: a WS drop at a track boundary can lose the not_playing/stopped event.
// After a successful RE-open the client emits "reconnected" so the backend can
// re-query GET /status and reconcile. The FIRST connect must NOT emit it (there
// is nothing to reconcile yet).
it("emits 'reconnected' after a reconnect open, but NOT on the initial connect", () => {
vi.useFakeTimers();
try {
const client = new GoLibrespotEventClient("ws://x/events", { WebSocketCtor: FakeWebSocket as any });
const onReconnected = vi.fn();
client.on("reconnected", onReconnected);
client.start();
FakeWebSocket.instances[0].emit("open"); // initial connect
expect(onReconnected).not.toHaveBeenCalled(); // no re-sync on first connect
FakeWebSocket.instances[0].emit("close");
vi.advanceTimersByTime(500);
expect(FakeWebSocket.instances).toHaveLength(2); // reconnected socket
FakeWebSocket.instances[1].emit("open"); // reconnect open
expect(onReconnected).toHaveBeenCalledTimes(1);
client.stop();
} finally {
vi.useRealTimers();
}
});
// R4-5: a socket that is accepted then immediately closed (a flap) must let the
// exponential backoff GROW — the old code reset it to 500ms on every 'open',
// pinning reconnects at ~2 Hz.
it("grows the reconnect backoff across open→immediate-close flaps (no 500ms pin)", () => {
vi.useFakeTimers();
try {
const client = new GoLibrespotEventClient("ws://x/events", { WebSocketCtor: FakeWebSocket as any });
client.start();
// Flap #1: accept then immediately drop -> next reconnect at 500ms.
FakeWebSocket.instances[0].emit("open");
FakeWebSocket.instances[0].emit("close");
vi.advanceTimersByTime(500);
expect(FakeWebSocket.instances).toHaveLength(2);
// Flap #2: accept then immediately drop -> backoff has doubled to 1000ms.
FakeWebSocket.instances[1].emit("open");
FakeWebSocket.instances[1].emit("close");
vi.advanceTimersByTime(500);
expect(FakeWebSocket.instances).toHaveLength(2); // still 2: 500ms is NOT enough now
vi.advanceTimersByTime(500);
expect(FakeWebSocket.instances).toHaveLength(3); // reconnects only after 1000ms
client.stop();
} finally {
vi.useRealTimers();
}
});
// R4-5: once a connection has been STABLE (up for >= STABLE_CONNECTION_MS) the
// backoff resets, so a later drop reconnects promptly again.
it("resets the reconnect backoff after a stable connection", () => {
vi.useFakeTimers();
try {
const client = new GoLibrespotEventClient("ws://x/events", { WebSocketCtor: FakeWebSocket as any });
client.start();
// Flap once to grow the backoff to 1000ms.
FakeWebSocket.instances[0].emit("open");
FakeWebSocket.instances[0].emit("close");
vi.advanceTimersByTime(500);
expect(FakeWebSocket.instances).toHaveLength(2);
// Now a STABLE connection: open and stay up past the stability threshold.
FakeWebSocket.instances[1].emit("open");
vi.advanceTimersByTime(5000); // >= STABLE_CONNECTION_MS -> backoff reset to 500
FakeWebSocket.instances[1].emit("close");
vi.advanceTimersByTime(499);
expect(FakeWebSocket.instances).toHaveLength(2); // not yet
vi.advanceTimersByTime(1);
expect(FakeWebSocket.instances).toHaveLength(3); // reconnected at 500ms -> backoff was reset
client.stop();
} finally {
vi.useRealTimers();
}
});
});
+235
View File
@@ -0,0 +1,235 @@
import { EventEmitter } from "node:events";
import axios, { type AxiosInstance } from "axios";
import WebSocket from "ws";
export interface GoLibrespotStatusTrack {
uri: string;
name: string;
artist_names: string[];
album_name: string;
album_cover_url: string | null;
position: number;
duration: number;
}
export interface GoLibrespotStatus {
stopped: boolean;
paused: boolean;
buffering: boolean;
track: GoLibrespotStatusTrack | null;
}
export class GoLibrespotRestClient {
private http: AxiosInstance;
constructor(baseUrl: string, deps?: { http?: AxiosInstance }) {
this.http =
deps?.http ??
axios.create({
baseURL: baseUrl,
timeout: 10000,
headers: { "Content-Type": "application/json" },
});
}
async ping(): Promise<boolean> {
try {
const res = await this.http.get("/");
return res.status === 200;
} catch {
return false;
}
}
async playTrack(uri: string): Promise<void> {
await this.http.post("/player/play", { uri });
}
async pause(): Promise<void> {
await this.http.post("/player/pause");
}
async resume(): Promise<void> {
await this.http.post("/player/resume");
}
async stop(): Promise<void> {
await this.http.post("/player/stop");
}
async seek(ms: number): Promise<void> {
await this.http.post("/player/seek", { position: ms, relative: false });
}
async getStatus(): Promise<GoLibrespotStatus | null> {
try {
const res = await this.http.get("/status");
const d = res.data ?? {};
const t = d.track;
return {
stopped: Boolean(d.stopped),
paused: Boolean(d.paused),
buffering: Boolean(d.buffering),
track: t
? {
uri: t.uri ?? "",
name: t.name ?? "",
artist_names: Array.isArray(t.artist_names) ? t.artist_names : [],
album_name: t.album_name ?? "",
album_cover_url: t.album_cover_url ?? null,
position: t.position ?? 0,
duration: t.duration ?? 0,
}
: null,
};
} catch {
return null;
}
}
}
export type GoLibrespotEventType =
| "metadata"
| "playing"
| "paused"
| "not_playing"
| "stopped"
| "will_play"
| "seek"
| "active"
| "inactive"
| "volume"
| "playback_ready";
interface WsLike {
on(event: string, cb: (...args: any[]) => void): void;
close(): void;
}
type WebSocketCtor = new (url: string) => WsLike;
const INITIAL_RECONNECT_MS = 500;
const MAX_RECONNECT_MS = 10000;
// R4-5: a connection must stay up at least this long before we treat it as
// "stable" and reset the reconnect backoff. A socket that is accepted and then
// immediately closed (a flap) never reaches this, so the exponential backoff
// keeps growing instead of pinning the reconnect interval at INITIAL_RECONNECT_MS.
const STABLE_CONNECTION_MS = 5000;
/**
* Emitted (in addition to the go-librespot event types) after the socket has
* SUCCESSFULLY re-opened following a drop — never on the very first connect.
* Consumers use it to re-query GET /status and reconcile any track-end that was
* emitted by go-librespot during the WS-down window (R4-3).
*/
export type GoLibrespotSyntheticEvent = "reconnected";
export class GoLibrespotEventClient extends EventEmitter {
private wsUrl: string;
private WebSocketCtor: WebSocketCtor;
private ws: WsLike | null = null;
private stopped = false;
private reconnectDelay = INITIAL_RECONNECT_MS;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
// R4-3: false until the FIRST successful open. A later open is therefore a
// reconnect and warrants a "reconnected" re-sync signal.
private hasConnected = false;
// R4-5: fires STABLE_CONNECTION_MS after an open; only then is the backoff reset.
private stableTimer: ReturnType<typeof setTimeout> | null = null;
constructor(wsUrl: string, deps?: { WebSocketCtor?: WebSocketCtor }) {
super();
this.wsUrl = wsUrl;
this.WebSocketCtor = deps?.WebSocketCtor ?? (WebSocket as unknown as WebSocketCtor);
}
start(): void {
this.stopped = false;
this.connect();
}
stop(): void {
this.stopped = true;
this.clearStableTimer();
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
if (this.ws) {
this.ws.close();
this.ws = null;
}
}
private connect(): void {
if (this.stopped) return;
const ws = new this.WebSocketCtor(this.wsUrl);
this.ws = ws;
ws.on("open", () => this.onOpen());
ws.on("message", (buf: unknown) => this.handleMessage(buf));
ws.on("close", () => {
this.ws = null;
// R4-5: the connection is gone — cancel the pending stability reset so a
// short-lived (flapping) socket never resets the backoff.
this.clearStableTimer();
this.scheduleReconnect();
});
ws.on("error", (err: unknown) => {
if (this.listenerCount("error") > 0) this.emit("error", err);
});
}
private onOpen(): void {
// R4-3: only a RE-open (a socket that had connected before, then dropped)
// needs reconciliation; the initial connect has nothing to catch up on.
const isReconnect = this.hasConnected;
this.hasConnected = true;
// R4-5: do NOT reset the backoff here. Arm a timer that resets it only once
// the connection has stayed up for STABLE_CONNECTION_MS; a flap that closes
// before then leaves the exponential backoff to keep growing.
this.armStableTimer();
if (isReconnect) this.emit("reconnected");
}
private armStableTimer(): void {
this.clearStableTimer();
this.stableTimer = setTimeout(() => {
this.stableTimer = null;
this.reconnectDelay = INITIAL_RECONNECT_MS;
}, STABLE_CONNECTION_MS);
(this.stableTimer as { unref?: () => void }).unref?.();
}
private clearStableTimer(): void {
if (this.stableTimer) {
clearTimeout(this.stableTimer);
this.stableTimer = null;
}
}
private handleMessage(buf: unknown): void {
let parsed: unknown;
try {
const text = Buffer.isBuffer(buf)
? buf.toString("utf8")
: typeof buf === "string"
? buf
: String(buf);
parsed = JSON.parse(text);
} catch {
return;
}
if (parsed && typeof (parsed as any).type === "string") {
this.emit((parsed as any).type, (parsed as any).data ?? {});
}
}
private scheduleReconnect(): void {
if (this.stopped || this.reconnectTimer) return;
const delay = this.reconnectDelay;
this.reconnectDelay = Math.min(delay * 2, MAX_RECONNECT_MS);
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
this.connect();
}, delay);
}
}
@@ -0,0 +1,119 @@
// src/music/spotify/go-librespot-config.test.ts
import { describe, it, expect } from "vitest";
import { renderConfigYml, type GoLibrespotConfigOptions } from "./go-librespot-config.js";
const OPTS: GoLibrespotConfigOptions = {
deviceName: "TeamSpeak Music Bot",
bitrate: 320,
fifoPath: "/tmp/go-librespot.fifo",
apiAddress: "0.0.0.0",
apiPort: 3678,
callbackPort: 8080,
};
/**
* Minimal 2-level YAML reader for the exact shape renderConfigYml emits
* (flat scalars + one level of nesting under `server:` / `credentials:`).
* Avoids adding a yaml dependency while still proving the output round-trips.
*/
function parseTinyYaml(src: string): Record<string, unknown> {
const root: Record<string, unknown> = {};
const stack: Array<{ indent: number; obj: Record<string, unknown> }> = [
{ indent: -1, obj: root },
];
for (const rawLine of src.split("\n")) {
if (rawLine.trim() === "") continue;
const indent = rawLine.length - rawLine.trimStart().length;
const line = rawLine.trim();
const idx = line.indexOf(":");
const key = line.slice(0, idx).trim();
let valRaw = line.slice(idx + 1).trim();
while (stack.length > 1 && indent <= stack[stack.length - 1].indent) {
stack.pop();
}
const parent = stack[stack.length - 1].obj;
if (valRaw === "") {
const child: Record<string, unknown> = {};
parent[key] = child;
stack.push({ indent, obj: child });
continue;
}
let val: unknown = valRaw;
if (valRaw.startsWith('"') && valRaw.endsWith('"')) val = JSON.parse(valRaw);
else if (valRaw === "true") val = true;
else if (valRaw === "false") val = false;
else if (/^-?\d+$/.test(valRaw)) val = Number(valRaw);
parent[key] = val;
}
return root;
}
describe("renderConfigYml", () => {
it("emits the exact top-level go-librespot keys/values", () => {
const lines = renderConfigYml(OPTS).split("\n");
expect(lines).toContain('device_name: "TeamSpeak Music Bot"');
expect(lines).toContain("device_type: computer");
expect(lines).toContain("bitrate: 320");
expect(lines).toContain("audio_backend: pipe");
expect(lines).toContain("audio_output_pipe: /tmp/go-librespot.fifo");
expect(lines).toContain("audio_output_pipe_format: s16le");
});
it("emits a server block with enabled/address/port set explicitly", () => {
const parsed = parseTinyYaml(renderConfigYml(OPTS));
expect(parsed.server).toEqual({
enabled: true,
address: "0.0.0.0",
port: 3678,
});
});
it("emits interactive OAuth credentials with the callback port", () => {
const parsed = parseTinyYaml(renderConfigYml(OPTS));
expect(parsed.credentials).toEqual({
type: "interactive",
interactive: { callback_port: 8080 },
});
});
it("full round-trip reflects every provided option", () => {
const parsed = parseTinyYaml(renderConfigYml(OPTS));
expect(parsed).toEqual({
device_name: "TeamSpeak Music Bot",
device_type: "computer",
bitrate: 320,
audio_backend: "pipe",
audio_output_pipe: "/tmp/go-librespot.fifo",
audio_output_pipe_format: "s16le",
server: { enabled: true, address: "0.0.0.0", port: 3678 },
credentials: { type: "interactive", interactive: { callback_port: 8080 } },
});
});
it("threads distinct option values through unchanged (no hard-coded ports)", () => {
const parsed = parseTinyYaml(
renderConfigYml({
deviceName: "Other Bot",
bitrate: 160,
fifoPath: "/run/librespot/pipe",
apiAddress: "127.0.0.1",
apiPort: 4000,
callbackPort: 9099,
}),
);
expect(parsed).toMatchObject({
device_name: "Other Bot",
bitrate: 160,
audio_output_pipe: "/run/librespot/pipe",
server: { address: "127.0.0.1", port: 4000 },
credentials: { interactive: { callback_port: 9099 } },
});
});
it("safely quotes device names containing special characters", () => {
const yml = renderConfigYml({ ...OPTS, deviceName: 'My "Cool" Bot' });
expect(yml.split("\n")).toContain('device_name: "My \\"Cool\\" Bot"');
// and still round-trips back to the original string
expect(parseTinyYaml(yml).device_name).toBe('My "Cool" Bot');
});
});
+42
View File
@@ -0,0 +1,42 @@
// src/music/spotify/go-librespot-config.ts
// Hand-built go-librespot config.yml. Keys/values verified against
// devgianlu/go-librespot cmd/daemon/cli_config.go koanf tags. No yaml
// dependency is used; the file is a small, fixed-shape document.
export interface GoLibrespotConfigOptions {
deviceName: string;
bitrate: number;
fifoPath: string;
apiAddress: string;
apiPort: number;
callbackPort: number;
}
/**
* Render a headless go-librespot config.yml:
* - pipe audio backend writing raw 44.1kHz/s16le stereo PCM to a FIFO,
* - HTTP+WebSocket control server enabled (port has NO built-in default,
* so it is always written explicitly),
* - interactive OAuth credentials (persisted automatically to
* <config_dir>/credentials.json after first login).
*/
export function renderConfigYml(o: GoLibrespotConfigOptions): string {
return (
[
`device_name: ${JSON.stringify(o.deviceName)}`,
`device_type: computer`,
`bitrate: ${o.bitrate}`,
`audio_backend: pipe`,
`audio_output_pipe: ${o.fifoPath}`,
`audio_output_pipe_format: s16le`,
`server:`,
` enabled: true`,
` address: ${o.apiAddress}`,
` port: ${o.apiPort}`,
`credentials:`,
` type: interactive`,
` interactive:`,
` callback_port: ${o.callbackPort}`,
].join("\n") + "\n"
);
}
+411
View File
@@ -0,0 +1,411 @@
import { describe, it, expect, vi } from "vitest";
import { EventEmitter } from "node:events";
import { PassThrough } from "node:stream";
import pino from "pino";
import { GoLibrespotBackend } from "./go-librespot.js";
const log = pino({ level: "silent" });
/** A minimal stand-in for a spawned ChildProcess with real Readable stdout/stderr. */
function makeFakeChild() {
const child: any = new EventEmitter();
child.stdout = new PassThrough();
child.stderr = new PassThrough();
child.kill = vi.fn();
return child;
}
function makeHarness(portOpts: { apiPort?: number; callbackPort?: number } = {}) {
const calls: string[] = [];
const ffmpegChild = makeFakeChild();
const gliChild = makeFakeChild();
const spawn = vi.fn((cmd: string, ..._rest: any[]) => {
const isGli = cmd.includes("go-librespot");
calls.push(`spawn:${isGli ? "go-librespot" : cmd}`);
return isGli ? gliChild : ffmpegChild;
});
const execFileSync = vi.fn((cmd: string) => {
calls.push(`exec:${cmd}`);
return Buffer.from("");
});
const writeFileSync = vi.fn(() => calls.push("write:config"));
const mkdirSync = vi.fn();
const unlinkSync = vi.fn(() => calls.push("unlink:fifo"));
const existsSync = vi.fn(() => false);
const rest = {
ping: vi.fn(async () => true),
playTrack: vi.fn(async () => {}),
pause: vi.fn(async () => {}),
resume: vi.fn(async () => {}),
stop: vi.fn(async () => {}),
seek: vi.fn(async () => {}),
getStatus: vi.fn(async () => null),
};
const events: any = new EventEmitter();
events.start = vi.fn(() => calls.push("ws:start"));
events.stop = vi.fn();
const backend = new GoLibrespotBackend({
deviceName: "Test Bot",
bitrate: 320,
workDir: "/tmp/work",
configDir: "/tmp/cfg",
apiPort: portOpts.apiPort ?? 3678,
callbackPort: portOpts.callbackPort,
logger: log,
deps: {
spawn,
execFileSync,
writeFileSync,
mkdirSync,
unlinkSync,
existsSync,
// C1: pin the ffmpeg command so the arg-array/order assertions below stay
// stable while production resolves ffmpeg via getFfmpegCommand() (which
// falls back to bundled ffmpeg-static when `ffmpeg` isn't on PATH).
ffmpegCommand: "ffmpeg",
findBinary: () => "/bin/go-librespot",
makeRest: () => rest,
makeEvents: () => events,
sleep: async () => {},
pollIntervalMs: 1,
pollTimeoutMs: 100,
} as any,
});
return { backend, calls, spawn, execFileSync, writeFileSync, existsSync, unlinkSync, rest, events, ffmpegChild, gliChild };
}
describe("GoLibrespotBackend.start", () => {
it("creates the FIFO with mkfifo before spawning ffmpeg, and spawns ffmpeg BEFORE go-librespot", async () => {
const h = makeHarness();
await h.backend.start();
expect(h.execFileSync).toHaveBeenCalledWith("mkfifo", ["/tmp/work/go-librespot.fifo"]);
expect(h.writeFileSync).toHaveBeenCalled();
const mkfifoIdx = h.calls.indexOf("exec:mkfifo");
const ffmpegIdx = h.calls.indexOf("spawn:ffmpeg");
const gliIdx = h.calls.indexOf("spawn:go-librespot");
expect(mkfifoIdx).toBeGreaterThanOrEqual(0);
expect(ffmpegIdx).toBeGreaterThan(mkfifoIdx); // ffmpeg attaches to the FIFO first
expect(gliIdx).toBeGreaterThan(ffmpegIdx); // then the writer (go-librespot)
expect(h.calls.indexOf("ws:start")).toBeGreaterThan(gliIdx); // WS connects last
});
it("passes --config_dir to go-librespot using the resolved binary path", async () => {
const h = makeHarness();
await h.backend.start();
expect(h.spawn).toHaveBeenCalledWith(
"/bin/go-librespot",
["--config_dir", "/tmp/cfg"],
expect.anything(),
);
});
it("uses the 44100->48000 s16le ffmpeg command reading the FIFO", async () => {
const h = makeHarness();
await h.backend.start();
const ffmpegArgs = h.spawn.mock.calls.find((c) => c[0] === "ffmpeg")![1] as string[];
expect(ffmpegArgs).toEqual([
"-hide_banner", "-loglevel", "error",
"-f", "s16le", "-ar", "44100", "-ac", "2", "-i", "/tmp/work/go-librespot.fifo",
"-f", "s16le", "-ar", "48000", "-ac", "2", "-acodec", "pcm_s16le", "pipe:1",
]);
});
it("emits 'ready' and reports isReady() true once the REST ping succeeds", async () => {
const h = makeHarness();
const ready = vi.fn();
h.backend.on("ready", ready);
await h.backend.start();
expect(h.rest.ping).toHaveBeenCalled();
expect(ready).toHaveBeenCalledTimes(1);
expect(h.backend.isReady()).toBe(true);
});
it("keeps polling ping() until it returns true", async () => {
const h = makeHarness();
h.rest.ping.mockResolvedValueOnce(false).mockResolvedValueOnce(false).mockResolvedValue(true);
await h.backend.start();
expect(h.rest.ping).toHaveBeenCalledTimes(3);
expect(h.backend.isReady()).toBe(true);
});
});
describe("GoLibrespotBackend config binding (Fix 1 loopback + Fix 3 ports)", () => {
function writtenYml(h: ReturnType<typeof makeHarness>): string {
const calls = h.writeFileSync.mock.calls as unknown as any[][];
const call = calls.find((c) => String(c[0]).endsWith("config.yml"));
expect(call).toBeDefined();
return call![1] as string;
}
it("binds the control API to loopback (127.0.0.1), never 0.0.0.0", async () => {
const h = makeHarness();
await h.backend.start();
const yml = writtenYml(h);
expect(yml).toContain("address: 127.0.0.1");
expect(yml).not.toContain("0.0.0.0");
});
it("threads apiPort + callbackPort into the rendered config", async () => {
const h = makeHarness({ apiPort: 3712, callbackPort: 8712 });
await h.backend.start();
const yml = writtenYml(h);
expect(yml).toContain("port: 3712");
expect(yml).toContain("callback_port: 8712");
});
it("defaults callbackPort to 8080 when unset", async () => {
const h = makeHarness();
await h.backend.start();
expect(writtenYml(h)).toContain("callback_port: 8080");
});
});
describe("GoLibrespotBackend WebSocket event mapping", () => {
it("maps a not_playing event to trackEnded{reason:'ended'}", async () => {
const h = makeHarness();
await h.backend.start();
const ended = vi.fn();
h.backend.on("trackEnded", ended);
h.events.emit("not_playing", { uri: "spotify:track:abc" });
expect(ended).toHaveBeenCalledWith({ uri: "spotify:track:abc", reason: "ended" });
});
it("maps a stopped event to trackEnded{reason:'stopped'}", async () => {
const h = makeHarness();
await h.backend.start();
const ended = vi.fn();
h.backend.on("trackEnded", ended);
h.events.emit("stopped", { uri: "spotify:track:xyz" });
expect(ended).toHaveBeenCalledWith({ uri: "spotify:track:xyz", reason: "stopped" });
});
it("maps a metadata event to a SpotifyNowPlaying and updates getPositionMs()", async () => {
const h = makeHarness();
await h.backend.start();
const meta = vi.fn();
h.backend.on("metadata", meta);
h.events.emit("metadata", {
uri: "spotify:track:abc",
name: "Song",
artist_names: ["A", "B"],
album_name: "Alb",
album_cover_url: "http://x/y.jpg",
position: 1234,
duration: 200000,
});
expect(meta).toHaveBeenCalledWith({
uri: "spotify:track:abc",
name: "Song",
artist: "A, B",
album: "Alb",
coverUrl: "http://x/y.jpg",
durationMs: 200000,
});
expect(h.backend.getPositionMs()).toBe(1234);
});
});
describe("GoLibrespotBackend transport delegation + PCM", () => {
it("playTrack delegates to the REST client", async () => {
const h = makeHarness();
await h.backend.start();
await h.backend.playTrack("spotify:track:go");
expect(h.rest.playTrack).toHaveBeenCalledWith("spotify:track:go");
});
it("pause/resume/seek delegate to the REST client and seek updates position", async () => {
const h = makeHarness();
await h.backend.start();
await h.backend.pause();
await h.backend.resume();
await h.backend.seek(5000);
expect(h.rest.pause).toHaveBeenCalled();
expect(h.rest.resume).toHaveBeenCalled();
expect(h.rest.seek).toHaveBeenCalledWith(5000);
expect(h.backend.getPositionMs()).toBe(5000);
});
it("getPcmStream() returns the ffmpeg stdout Readable", async () => {
const h = makeHarness();
await h.backend.start();
expect(h.backend.getPcmStream()).toBe(h.ffmpegChild.stdout);
});
});
describe("GoLibrespotBackend.stop", () => {
it("kills ffmpeg + go-librespot, stops the WS, removes the FIFO, and clears ready", async () => {
const h = makeHarness();
await h.backend.start();
h.existsSync.mockReturnValue(true); // FIFO now present, so stop() unlinks it
h.backend.stop();
expect(h.ffmpegChild.kill).toHaveBeenCalled();
expect(h.gliChild.kill).toHaveBeenCalled();
expect(h.events.stop).toHaveBeenCalled();
expect(h.unlinkSync).toHaveBeenCalledWith("/tmp/work/go-librespot.fifo");
expect(h.backend.isReady()).toBe(false);
});
});
describe("GoLibrespotBackend.start failure cleanup", () => {
it("tears down ffmpeg, go-librespot, and the FIFO when readiness polling never succeeds", async () => {
const h = makeHarness();
// ping() never returns true → waitUntilReady() times out → start() rejects
// AFTER both processes were spawned and the FIFO was created.
h.rest.ping.mockResolvedValue(false);
// FIFO present so the cleanup path unlinks it.
h.existsSync.mockReturnValue(true);
await expect(h.backend.start()).rejects.toThrow(/did not become ready/);
expect(h.ffmpegChild.kill).toHaveBeenCalled(); // ffmpeg killed on failed startup
expect(h.gliChild.kill).toHaveBeenCalled(); // go-librespot killed on failed startup
expect(h.unlinkSync).toHaveBeenCalledWith("/tmp/work/go-librespot.fifo"); // FIFO removed
expect(h.backend.isReady()).toBe(false);
});
});
describe("GoLibrespotBackend child-process error handling", () => {
it("does not throw when a child 'error' is emitted with no backend 'error' listener attached", async () => {
const h = makeHarness();
await h.backend.start();
// No "error" listener on the backend: an unhandled 'error' event would crash
// Node, so the backend must swallow+log it instead of re-emitting.
expect(h.backend.listenerCount("error")).toBe(0);
expect(() => h.ffmpegChild.emit("error", new Error("ffmpeg boom"))).not.toThrow();
expect(() => h.gliChild.emit("error", new Error("gli boom"))).not.toThrow();
});
it("re-emits a child 'error' to an attached backend 'error' listener", async () => {
const h = makeHarness();
await h.backend.start();
const onErr = vi.fn();
h.backend.on("error", onErr);
const err = new Error("ffmpeg boom");
h.ffmpegChild.emit("error", err);
expect(onErr).toHaveBeenCalledWith(err);
});
});
// Let queued microtasks (the async reconnect re-sync) settle.
const flush = () => new Promise<void>((r) => setTimeout(r, 0));
describe("GoLibrespotBackend R4-2: unexpected sidecar exit recovery", () => {
it("degrades to trackEnded{reason:'error'}, surfaces 'error', and stops the WS on an UNEXPECTED exit", async () => {
const h = makeHarness();
await h.backend.start();
await h.backend.playTrack("spotify:track:cur"); // sets the current uri
const ended = vi.fn();
const onErr = vi.fn();
h.backend.on("trackEnded", ended);
h.backend.on("error", onErr);
// Sidecar dies under us (nonzero exit, no stop() from us).
h.gliChild.emit("exit", 1, null);
expect(ended).toHaveBeenCalledWith({ uri: "spotify:track:cur", reason: "error" });
expect(onErr).toHaveBeenCalledTimes(1); // controller told -> rebuilds on next ensureStarted
expect(h.events.stop).toHaveBeenCalled(); // WS reconnect loop stopped (no dead-port hammer)
expect(h.backend.isReady()).toBe(false);
});
it("emits trackEnded BEFORE error so a listener that tears down still receives the skip", async () => {
const h = makeHarness();
await h.backend.start();
await h.backend.playTrack("spotify:track:cur");
const order: string[] = [];
h.backend.on("trackEnded", () => order.push("trackEnded"));
h.backend.on("error", () => order.push("error"));
h.gliChild.emit("exit", null, "SIGKILL");
expect(order).toEqual(["trackEnded", "error"]);
});
it("a stop()-initiated exit emits NEITHER trackEnded NOR error", async () => {
const h = makeHarness();
await h.backend.start();
await h.backend.playTrack("spotify:track:cur");
const ended = vi.fn();
const onErr = vi.fn();
h.backend.on("trackEnded", ended);
h.backend.on("error", onErr);
h.backend.stop(); // intentional teardown -> the ensuing 'exit' must be silent
h.gliChild.emit("exit", 0, "SIGTERM");
expect(ended).not.toHaveBeenCalled();
expect(onErr).not.toHaveBeenCalled();
});
});
describe("GoLibrespotBackend R4-3: WS reconnect re-sync", () => {
const notPlaying = { stopped: true, paused: false, buffering: false, track: null };
const stillPlaying = {
stopped: false,
paused: false,
buffering: false,
track: {
uri: "spotify:track:cur",
name: "Song",
artist_names: ["A"],
album_name: "Alb",
album_cover_url: null,
position: 1000,
duration: 200000,
},
};
it("re-queries GET /status on reconnect and emits trackEnded when playback is no longer active", async () => {
const h = makeHarness();
await h.backend.start();
await h.backend.playTrack("spotify:track:cur");
h.rest.getStatus.mockResolvedValue(notPlaying as any);
const ended = vi.fn();
h.backend.on("trackEnded", ended);
h.events.emit("reconnected");
await flush();
expect(h.rest.getStatus).toHaveBeenCalled();
expect(ended).toHaveBeenCalledWith({ uri: "spotify:track:cur", reason: "ended" });
});
it("does NOT emit a spurious trackEnded on reconnect when status shows still-playing", async () => {
const h = makeHarness();
await h.backend.start();
await h.backend.playTrack("spotify:track:cur");
h.rest.getStatus.mockResolvedValue(stillPlaying as any);
const ended = vi.fn();
h.backend.on("trackEnded", ended);
h.events.emit("reconnected");
await flush();
expect(h.rest.getStatus).toHaveBeenCalled();
expect(ended).not.toHaveBeenCalled();
});
it("re-sync is idempotent: a reconnect then a live not_playing emits trackEnded only once", async () => {
const h = makeHarness();
await h.backend.start();
await h.backend.playTrack("spotify:track:cur");
h.rest.getStatus.mockResolvedValue(notPlaying as any);
const ended = vi.fn();
h.backend.on("trackEnded", ended);
h.events.emit("reconnected");
await flush();
// A duplicate live event for the same track must not double-advance the queue.
h.events.emit("not_playing", { uri: "spotify:track:cur" });
expect(ended).toHaveBeenCalledTimes(1);
});
});
+402
View File
@@ -0,0 +1,402 @@
import { EventEmitter } from "node:events";
// The go-librespot sidecar is Linux/Docker-gated (mkfifo + Linux-only binary),
// so the FIFO and config-dir paths are ALWAYS POSIX. Use posix.join so the
// separators are correct on the Linux target regardless of the host OS.
import { posix as posixPath } from "node:path";
import type { Readable } from "node:stream";
import type { ChildProcess } from "node:child_process";
import {
execFileSync as realExecFileSync,
spawn as realSpawn,
} from "node:child_process";
import {
existsSync as realExistsSync,
mkdirSync as realMkdirSync,
unlinkSync as realUnlinkSync,
writeFileSync as realWriteFileSync,
} from "node:fs";
import type { Logger } from "pino";
import type {
SpotifyAudioBackend,
SpotifyTrackEndedEvent,
SpotifyNowPlaying,
} from "./backend.js";
import { findGoLibrespot } from "./binary.js";
import { renderConfigYml } from "./go-librespot-config.js";
import { GoLibrespotRestClient, GoLibrespotEventClient } from "./go-librespot-api.js";
import { getFfmpegCommand } from "../../audio/player.js";
export interface GoLibrespotBackendOptions {
deviceName: string;
bitrate: number;
workDir: string;
configDir: string;
apiPort?: number;
callbackPort?: number;
logger: Logger;
deps?: GoLibrespotBackendDeps;
}
/** Injectable seams so the whole lifecycle is testable without a real binary/FIFO/network. */
export interface GoLibrespotBackendDeps {
spawn?: typeof realSpawn;
execFileSync?: typeof realExecFileSync;
existsSync?: typeof realExistsSync;
mkdirSync?: typeof realMkdirSync;
unlinkSync?: typeof realUnlinkSync;
writeFileSync?: typeof realWriteFileSync;
findBinary?: () => string;
/**
* C1: override the ffmpeg command. Production resolves it via
* getFfmpegCommand() (bundled ffmpeg-static fallback when `ffmpeg` isn't on
* PATH, the Docker case); tests pin it to "ffmpeg" for stable arg assertions.
*/
ffmpegCommand?: string;
makeRest?: (baseUrl: string) => GoLibrespotRestClient;
makeEvents?: (wsUrl: string) => GoLibrespotEventClient;
sleep?: (ms: number) => Promise<void>;
pollIntervalMs?: number;
pollTimeoutMs?: number;
}
const DEFAULT_API_PORT = 3678;
const DEFAULT_CALLBACK_PORT = 8080;
const FIFO_NAME = "go-librespot.fifo";
export class GoLibrespotBackend extends EventEmitter implements SpotifyAudioBackend {
private readonly opts: GoLibrespotBackendOptions;
private readonly log: Logger;
private readonly deps: GoLibrespotBackendDeps;
private readonly apiPort: number;
private readonly callbackPort: number;
private readonly fifoPath: string;
private ffmpeg: ChildProcess | null = null;
private proc: ChildProcess | null = null;
private rest: GoLibrespotRestClient | null = null;
private events: GoLibrespotEventClient | null = null;
private ready = false;
private positionMs = 0;
// R4-2: distinguishes an INTENTIONAL teardown (stop()/failed start()) — where a
// sidecar exit is expected and must be silent — from an UNEXPECTED death that
// must degrade-to-skip + surface an error so the controller relaunches.
private stopping = false;
// The uri of the track currently loaded in the sidecar (set by playTrack and
// refreshed from metadata). Used as the trackEnded uri on an unexpected death
// (R4-2) and on reconnect reconciliation (R4-3).
private currentUri = "";
// At-most-one trackEnded per track. Set when we emit a track-end, cleared when
// a new track begins. Keeps the reconnect re-sync (R4-3) from double-emitting
// with a live not_playing/stopped event.
private endLatched = false;
constructor(o: GoLibrespotBackendOptions) {
super();
this.opts = o;
this.log = o.logger;
this.deps = o.deps ?? {};
this.apiPort = o.apiPort ?? DEFAULT_API_PORT;
this.callbackPort = o.callbackPort ?? DEFAULT_CALLBACK_PORT;
this.fifoPath = posixPath.join(o.workDir, FIFO_NAME);
}
async start(): Promise<void> {
// Fresh lifecycle (also covers a start() after a previous stop() on the same
// instance): clear the teardown flag and per-track end state.
this.stopping = false;
this.currentUri = "";
this.endLatched = false;
const spawn = this.deps.spawn ?? realSpawn;
const execFileSync = this.deps.execFileSync ?? realExecFileSync;
const existsSync = this.deps.existsSync ?? realExistsSync;
const mkdirSync = this.deps.mkdirSync ?? realMkdirSync;
const unlinkSync = this.deps.unlinkSync ?? realUnlinkSync;
const writeFileSync = this.deps.writeFileSync ?? realWriteFileSync;
const findBinary = this.deps.findBinary ?? findGoLibrespot;
// C1: resolve ffmpeg via the repo's getFfmpegCommand() (ffmpeg-static
// fallback) unless a command is injected for tests.
const ffmpegCommand = this.deps.ffmpegCommand ?? getFfmpegCommand();
// 1. Ensure work + config directories exist.
mkdirSync(this.opts.workDir, { recursive: true });
mkdirSync(this.opts.configDir, { recursive: true });
// 2. (Re)create the FIFO — mkfifo fails if the path already exists.
if (existsSync(this.fifoPath)) unlinkSync(this.fifoPath);
execFileSync("mkfifo", [this.fifoPath]);
// Everything past this point spawns processes / opens sockets. If any step
// throws (e.g. the readiness poll times out), tear down whatever was already
// created via stop() (kill ffmpeg + go-librespot, close WS, remove FIFO) so
// we don't leak child processes or leave the FIFO on disk, then rethrow.
try {
// 3. Spawn ffmpeg FIRST so the PCM reader is attached to the FIFO before
// go-librespot (the writer) starts pushing raw 44.1k s16le into it.
// Opening the FIFO for writing before a reader exists errors with ENXIO.
this.ffmpeg = spawn(
ffmpegCommand,
[
"-hide_banner", "-loglevel", "error",
"-f", "s16le", "-ar", "44100", "-ac", "2", "-i", this.fifoPath,
"-f", "s16le", "-ar", "48000", "-ac", "2", "-acodec", "pcm_s16le", "pipe:1",
],
{ stdio: ["ignore", "pipe", "pipe"] },
);
this.ffmpeg.stderr?.on("data", (b: Buffer) =>
this.log.debug({ ffmpeg: b.toString().trim() }, "ffmpeg"),
);
this.ffmpeg.on("error", (err) => this.emitError(err));
// 4. Render + write config.yml into the config dir.
const yml = renderConfigYml({
deviceName: this.opts.deviceName,
bitrate: this.opts.bitrate,
fifoPath: this.fifoPath,
// The go-librespot control API is UNAUTHENTICATED and the client only
// ever connects via 127.0.0.1; the sidecar runs in the SAME container
// as the bot, so bind to loopback rather than exposing it on 0.0.0.0.
apiAddress: "127.0.0.1",
apiPort: this.apiPort,
callbackPort: this.callbackPort,
});
writeFileSync(posixPath.join(this.opts.configDir, "config.yml"), yml, "utf8");
// 5. Spawn go-librespot AFTER ffmpeg is listening on the FIFO. Its stdout/
// stderr carry the interactive OAuth URL on first run — surface via logger.
const bin = findBinary();
this.proc = spawn(bin, ["--config_dir", this.opts.configDir], {
stdio: ["ignore", "pipe", "pipe"],
});
const onLog = (b: Buffer) => this.log.info({ golibrespot: b.toString().trim() }, "go-librespot");
this.proc.stdout?.on("data", onLog);
this.proc.stderr?.on("data", onLog);
this.proc.on("error", (err) => this.emitError(err));
this.proc.on("exit", (code, signal) => {
this.ready = false;
this.log.warn({ code, signal }, "go-librespot exited");
// R4-2: a stop()-initiated exit is expected — stay silent. Any other exit
// is the sidecar dying under us and must be recovered.
if (this.stopping) return;
this.onUnexpectedExit(code, signal);
});
// 6. REST client, then poll GET / until the HTTP server answers.
const baseUrl = `http://127.0.0.1:${this.apiPort}`;
this.rest = this.deps.makeRest
? this.deps.makeRest(baseUrl)
: new GoLibrespotRestClient(baseUrl);
await this.waitUntilReady();
// 7. Connect the WebSocket event stream and wire event mapping.
const wsUrl = `ws://127.0.0.1:${this.apiPort}/events`;
this.events = this.deps.makeEvents
? this.deps.makeEvents(wsUrl)
: new GoLibrespotEventClient(wsUrl);
this.wireEvents(this.events);
this.events.start();
this.ready = true;
this.emit("ready");
} catch (e) {
this.stop();
throw e;
}
}
/**
* Re-emit a child-process "error" only when a consumer is listening; Node
* throws on an unhandled "error" event (can crash the process), so with no
* listener we log via the injected logger instead. Mirrors the WS client's
* listenerCount("error") gate in go-librespot-api.ts.
*/
private emitError(err: unknown): void {
if (this.listenerCount("error") > 0) {
this.emit("error", err);
} else {
this.log.error({ err }, "go-librespot backend error (no listener)");
}
}
/**
* At-most-one track-end per track. The WS not_playing/stopped events, the
* unexpected-death degrade (R4-2) and the reconnect re-sync (R4-3) all funnel
* through here so a track can only advance the queue once.
*/
private emitTrackEnded(e: SpotifyTrackEndedEvent): void {
if (this.endLatched) return;
this.endLatched = true;
this.emit("trackEnded", e);
}
/**
* R4-2: the go-librespot sidecar died without a stop() from us. Spotify
* auto-advance is driven ONLY by the WS trackEnded event (the player-side stall
* watchdog is disabled in external mode), so a silent death would stall the
* queue forever. Mirror the Rust I4 degrade-to-skip:
* (a) emit trackEnded{reason:"error"} so BotInstance advances the queue;
* (b) stop the WS reconnect loop so it stops hammering the now-dead API port;
* (c) surface via emitError so the controller tears down + relaunches a fresh
* backend on the next ensureStarted().
* trackEnded MUST be emitted BEFORE emitError: the controller's error handler
* removeAllListeners() on teardown, so a trackEnded emitted after would be lost.
*/
private onUnexpectedExit(code: number | null, signal: NodeJS.Signals | null): void {
// (b) Kill the reconnect loop first — the API port is dead.
try {
this.events?.stop();
} catch {
/* ignore */
}
this.events = null;
// (a) Degrade-to-skip only if a track was actually loaded; a death during
// startup with nothing playing has no queue item to advance.
if (this.currentUri) {
this.emitTrackEnded({ uri: this.currentUri, reason: "error" });
}
// (c) Surface so the controller rebuilds.
this.emitError(
new Error(
`go-librespot exited unexpectedly (code=${code ?? "null"}, signal=${signal ?? "null"})`,
),
);
}
/**
* R4-3: the WS reconnected after a drop. go-librespot only pushes events (it is
* never polled after startup), so a not_playing/stopped emitted during the
* down window is lost and the queue would stall. Re-query GET /status and, if
* playback is no longer active (the track ended in the gap), emit trackEnded so
* the queue advances. Idempotent via the end-latch (no double-emit with a live
* event). Only fired on a real reconnect — never the initial connect.
*/
private async reconcileAfterReconnect(): Promise<void> {
const rest = this.rest;
if (!rest) return;
const status = await rest.getStatus();
// Couldn't read status — don't guess a track-end.
if (!status) return;
const active = status.track != null && !status.stopped;
if (!active) {
this.emitTrackEnded({ uri: this.currentUri, reason: "ended" });
}
}
private async waitUntilReady(): Promise<void> {
const sleep = this.deps.sleep ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)));
const interval = this.deps.pollIntervalMs ?? 200;
const timeout = this.deps.pollTimeoutMs ?? 15_000;
const deadline = Date.now() + timeout;
while (Date.now() < deadline) {
if (this.rest && (await this.rest.ping())) return;
await sleep(interval);
}
throw new Error("go-librespot API did not become ready within timeout");
}
private wireEvents(ev: GoLibrespotEventClient): void {
ev.on("metadata", (d: any) => {
const np: SpotifyNowPlaying = {
uri: typeof d?.uri === "string" ? d.uri : "",
name: typeof d?.name === "string" ? d.name : "",
artist: Array.isArray(d?.artist_names) ? d.artist_names.join(", ") : "",
album: typeof d?.album_name === "string" ? d.album_name : "",
coverUrl: typeof d?.album_cover_url === "string" ? d.album_cover_url : "",
durationMs: typeof d?.duration === "number" ? d.duration : 0,
};
if (typeof d?.position === "number") this.positionMs = d.position;
// A new track is now playing: remember it (for R4-2/R4-3) and clear the
// per-track end-latch so its eventual end can advance the queue.
if (np.uri && np.uri !== this.currentUri) {
this.currentUri = np.uri;
this.endLatched = false;
}
this.emit("metadata", np);
});
ev.on("seek", (d: any) => {
if (typeof d?.position === "number") this.positionMs = d.position;
});
ev.on("not_playing", (d: any) => {
this.emitTrackEnded({ uri: typeof d?.uri === "string" ? d.uri : "", reason: "ended" });
});
ev.on("stopped", (d: any) => {
this.emitTrackEnded({ uri: typeof d?.uri === "string" ? d.uri : "", reason: "stopped" });
});
// R4-3: reconcile any track-end missed while the WS was down.
ev.on("reconnected", () => {
void this.reconcileAfterReconnect();
});
}
isReady(): boolean {
return this.ready;
}
async playTrack(uri: string): Promise<void> {
if (!this.rest) throw new Error("go-librespot backend not started");
// Track what is loaded so an unexpected death (R4-2) / reconnect re-sync
// (R4-3) can advance the queue for the right uri, and reset the end-latch.
this.currentUri = uri;
this.endLatched = false;
await this.rest.playTrack(uri);
}
async pause(): Promise<void> {
if (this.rest) await this.rest.pause();
}
async resume(): Promise<void> {
if (this.rest) await this.rest.resume();
}
async seek(ms: number): Promise<void> {
if (this.rest) await this.rest.seek(ms);
this.positionMs = ms;
}
getPcmStream(): Readable {
const out = this.ffmpeg?.stdout;
if (!out) throw new Error("PCM stream unavailable (go-librespot backend not started)");
return out;
}
getPositionMs(): number {
return this.positionMs;
}
stop(): void {
// R4-2: mark this an INTENTIONAL teardown so the go-librespot 'exit' handler
// (fired by the SIGTERM below) stays silent instead of degrading-to-skip.
this.stopping = true;
this.ready = false;
try {
this.events?.stop();
} catch {
/* ignore */
}
this.events = null;
this.rest = null;
if (this.proc) {
try {
this.proc.kill("SIGTERM");
} catch {
/* ignore */
}
this.proc = null;
}
if (this.ffmpeg) {
try {
this.ffmpeg.kill("SIGTERM");
} catch {
/* ignore */
}
this.ffmpeg = null;
}
const existsSync = this.deps.existsSync ?? realExistsSync;
const unlinkSync = this.deps.unlinkSync ?? realUnlinkSync;
try {
if (existsSync(this.fifoPath)) unlinkSync(this.fifoPath);
} catch {
/* ignore */
}
}
}
+63
View File
@@ -0,0 +1,63 @@
import { describe, it, expect, vi } from "vitest";
import { SpotifyProvider } from "./provider.js";
import { SpotifyWebApi } from "./webapi.js";
function fakeApi(over: Partial<SpotifyWebApi> = {}): SpotifyWebApi {
return {
hasCreds: () => true,
setCreds: vi.fn(),
search: vi.fn().mockResolvedValue({ songs: [], playlists: [], albums: [] }),
getTrack: vi.fn().mockResolvedValue(null),
getAlbumTracks: vi.fn().mockResolvedValue([]),
getPlaylistTracks: vi.fn().mockResolvedValue([]),
...over,
} as unknown as SpotifyWebApi;
}
describe("SpotifyProvider", () => {
it("has platform 'spotify'", () => {
expect(new SpotifyProvider(fakeApi()).platform).toBe("spotify");
});
it("getSongUrl returns the spotify: sentinel, not a real URL", async () => {
const p = new SpotifyProvider(fakeApi());
const r = await p.getSongUrl("4iV5W9uYEdYUVa79Axb7Rh");
expect(r).toEqual({ url: "spotify:track:4iV5W9uYEdYUVa79Axb7Rh" });
});
it("search delegates to the web API", async () => {
const api = fakeApi({
search: vi.fn().mockResolvedValue({
songs: [{ id: "t1", platform: "spotify" }],
playlists: [],
albums: [],
}),
});
const out = await new SpotifyProvider(api).search("queen", 5);
expect(api.search).toHaveBeenCalledWith("queen", 5);
expect(out.songs[0].id).toBe("t1");
});
it("getAuthStatus reflects credential presence", async () => {
expect((await new SpotifyProvider(fakeApi({ hasCreds: () => true })).getAuthStatus()).loggedIn).toBe(true);
expect((await new SpotifyProvider(fakeApi({ hasCreds: () => false })).getAuthStatus()).loggedIn).toBe(false);
});
it("getPlaylistSongs / getAlbumSongs delegate to the web API", async () => {
const api = fakeApi({
getPlaylistTracks: vi.fn().mockResolvedValue([{ id: "p", platform: "spotify" }]),
getAlbumTracks: vi.fn().mockResolvedValue([{ id: "a", platform: "spotify" }]),
});
const p = new SpotifyProvider(api);
expect((await p.getPlaylistSongs("37i9"))[0].id).toBe("p");
expect((await p.getAlbumSongs("1abc"))[0].id).toBe("a");
});
it("no-op auth surfaces (QR expired, empty lyrics/recommend)", async () => {
const p = new SpotifyProvider(fakeApi());
expect(await p.getLyrics("x")).toEqual([]);
expect(await p.getRecommendPlaylists()).toEqual([]);
expect((await p.getQrCode()).key).toBe("");
expect(await p.checkQrCodeStatus("k")).toBe("expired");
});
});
+86
View File
@@ -0,0 +1,86 @@
import type {
MusicProvider,
Song,
SongUrlResult,
Playlist,
Album,
SearchResult,
LyricLine,
QrCodeResult,
AuthStatus,
} from "../provider.js";
import { SpotifyWebApi, type SpotifyCreds } from "./webapi.js";
export class SpotifyProvider implements MusicProvider {
readonly platform = "spotify" as const;
private api: SpotifyWebApi;
private creds: SpotifyCreds = { clientId: "", clientSecret: "" };
private quality = "320";
constructor(api?: SpotifyWebApi) {
this.api = api ?? new SpotifyWebApi(() => this.creds);
}
setCreds(clientId: string, clientSecret: string): void {
this.creds = { clientId: clientId ?? "", clientSecret: clientSecret ?? "" };
this.api.setCreds(this.creds);
}
async search(query: string, limit = 20): Promise<SearchResult> {
return this.api.search(query, limit);
}
// Stage 1: return a sentinel URI. The play path recognizes `spotify:` and
// skips with a "not playable yet" message; real audio arrives in Stage 2/3.
async getSongUrl(songId: string): Promise<SongUrlResult | null> {
return { url: `spotify:track:${songId}` };
}
setQuality(quality: string): void {
this.quality = quality;
}
getQuality(): string {
return this.quality;
}
async getSongDetail(songId: string): Promise<Song | null> {
return this.api.getTrack(songId);
}
async getPlaylistSongs(playlistId: string): Promise<Song[]> {
return this.api.getPlaylistTracks(playlistId);
}
async getAlbumSongs(albumId: string): Promise<Song[]> {
return this.api.getAlbumTracks(albumId);
}
async getRecommendPlaylists(): Promise<Playlist[]> {
return [];
}
async getLyrics(_songId: string): Promise<LyricLine[]> {
return [];
}
async getQrCode(): Promise<QrCodeResult> {
return { qrUrl: "", key: "" };
}
async checkQrCodeStatus(
_key: string
): Promise<"waiting" | "scanned" | "confirmed" | "expired"> {
return "expired";
}
setCookie(_cookie: string): void {}
getCookie(): string {
return "";
}
async getAuthStatus(): Promise<AuthStatus> {
return this.api.hasCreds()
? { loggedIn: true, nickname: "Spotify" }
: { loggedIn: false, nickname: "Spotify (未配置 Client ID/Secret)" };
}
}
+814
View File
@@ -0,0 +1,814 @@
import { describe, it, expect, vi } from "vitest";
import { EventEmitter } from "node:events";
import { PassThrough } from "node:stream";
import pino from "pino";
import { RustLibrespotBackend, type RustLibrespotBackendDeps } from "./rust-librespot.js";
const log = pino({ level: "silent" });
/** ChildProcess stand-in with real Readable/Writable pipes so stdout->stdin piping works. */
function makeFakeChild() {
const child: any = new EventEmitter();
child.stdout = new PassThrough();
child.stderr = new PassThrough();
child.stdin = new PassThrough();
child.kill = vi.fn();
return child;
}
function makeConnect() {
return {
getDevices: vi.fn(async () => [{ id: "dev1", name: "Test Bot", is_active: false }]),
findDeviceByName: vi.fn(async () => "dev1"),
transfer: vi.fn(async () => {}),
play: vi.fn(async () => {}),
pause: vi.fn(async () => {}),
resume: vi.fn(async () => {}),
seek: vi.fn(async () => {}),
getPlaybackState: vi.fn(async () => null as any),
};
}
function makeOAuth() {
return {
getAccessToken: vi.fn(async () => "tok-123" as string | null),
isAuthorized: () => true,
};
}
function makeHarness(
over: { connect?: any; oauth?: any; deps?: Partial<RustLibrespotBackendDeps> } = {},
) {
const calls: string[] = [];
const librespotChild = makeFakeChild();
const ffmpegChild = makeFakeChild();
const spawn = vi.fn((cmd: string, ..._rest: any[]) => {
const isLibrespot = cmd.includes("librespot");
calls.push(`spawn:${isLibrespot ? "librespot" : cmd}`);
return isLibrespot ? librespotChild : ffmpegChild;
});
const mkdirSync = vi.fn();
const connect = over.connect ?? makeConnect();
const oauth = over.oauth ?? makeOAuth();
const backend = new RustLibrespotBackend({
deviceName: "Test Bot",
bitrate: 320,
cacheDir: "/tmp/cache",
oauth: oauth as any,
connect: connect as any,
logger: log,
deps: {
spawn: spawn as any,
mkdirSync: mkdirSync as any,
findBinary: () => "/bin/librespot",
// C1: pin ffmpeg so arg-array assertions stay stable while prod uses getFfmpegCommand().
ffmpegCommand: "ffmpeg",
sleep: async () => {},
readyPollIntervalMs: 1,
readyTimeoutMs: 100,
// huge so the background setInterval never fires; tests drive pollState() directly.
statePollIntervalMs: 10_000_000,
...over.deps,
},
});
return { backend, calls, spawn, mkdirSync, connect, oauth, librespotChild, ffmpegChild };
}
describe("RustLibrespotBackend.start", () => {
it("spawns librespot with the pipe/stdout arg set and the OAuth access token", async () => {
const h = makeHarness();
await h.backend.start();
expect(h.spawn).toHaveBeenCalledWith(
"/bin/librespot",
[
"--name", "Test Bot",
"--backend", "pipe",
"--bitrate", "320",
"--format", "S16",
"--cache", "/tmp/cache",
"--device-type", "speaker",
"--access-token", "tok-123",
],
expect.anything(),
);
// NO --device (=> stdout) and NO --passthrough (=> decoded PCM, not Ogg).
const args = h.spawn.mock.calls.find((c) => String(c[0]).includes("librespot"))![1] as string[];
expect(args).not.toContain("--device");
expect(args).not.toContain("--passthrough");
h.backend.stop();
});
it("spawns ffmpeg (reader) before librespot (writer) with the exact 44100->48000 s16le args", async () => {
const h = makeHarness();
await h.backend.start();
const ffmpegArgs = h.spawn.mock.calls.find((c) => c[0] === "ffmpeg")![1] as string[];
expect(ffmpegArgs).toEqual([
"-f", "s16le", "-ar", "44100", "-ac", "2", "-i", "pipe:0",
"-f", "s16le", "-ar", "48000", "-ac", "2", "-acodec", "pcm_s16le", "pipe:1",
]);
const ffmpegIdx = h.calls.indexOf("spawn:ffmpeg");
const librespotIdx = h.calls.indexOf("spawn:librespot");
expect(ffmpegIdx).toBeGreaterThanOrEqual(0);
expect(librespotIdx).toBeGreaterThan(ffmpegIdx);
h.backend.stop();
});
it("getPcmStream() returns the ffmpeg stdout Readable", async () => {
const h = makeHarness();
await h.backend.start();
expect(h.backend.getPcmStream()).toBe(h.ffmpegChild.stdout);
h.backend.stop();
});
it("emits 'ready' and reports isReady() true once our device appears in getDevices()", async () => {
const h = makeHarness();
const ready = vi.fn();
h.backend.on("ready", ready);
await h.backend.start();
expect(h.connect.getDevices).toHaveBeenCalled();
expect(ready).toHaveBeenCalledTimes(1);
expect(h.backend.isReady()).toBe(true);
h.backend.stop();
});
it("keeps polling getDevices() until the device name appears", async () => {
const h = makeHarness();
h.connect.getDevices
.mockResolvedValueOnce([])
.mockResolvedValueOnce([{ id: "other", name: "Someone else", is_active: true }])
.mockResolvedValue([{ id: "dev1", name: "Test Bot", is_active: false }]);
await h.backend.start();
expect(h.connect.getDevices).toHaveBeenCalledTimes(3);
expect(h.backend.isReady()).toBe(true);
h.backend.stop();
});
it("throws (and does not spawn) when the OAuth token is null", async () => {
const oauth = makeOAuth();
oauth.getAccessToken.mockResolvedValue(null);
const h = makeHarness({ oauth });
await expect(h.backend.start()).rejects.toThrow(/authorized|token/i);
expect(h.spawn).not.toHaveBeenCalled();
});
});
describe("RustLibrespotBackend transport delegation (Connect API)", () => {
it("playTrack resolves the device then transfer(false) then play(uri)", async () => {
const h = makeHarness();
await h.backend.playTrack("spotify:track:go");
expect(h.connect.findDeviceByName).toHaveBeenCalledWith("Test Bot");
expect(h.connect.transfer).toHaveBeenCalledWith("dev1", false);
expect(h.connect.play).toHaveBeenCalledWith("dev1", "spotify:track:go");
// ordering: transfer before play
expect(h.connect.transfer.mock.invocationCallOrder[0])
.toBeLessThan(h.connect.play.mock.invocationCallOrder[0]);
});
it("playTrack throws when the device cannot be found", async () => {
const h = makeHarness();
h.connect.findDeviceByName.mockResolvedValue(null);
await expect(h.backend.playTrack("spotify:track:x")).rejects.toThrow(/device/i);
});
// R4-4 (multi-bot): control must be scoped to OUR device. playTrack resolves
// and stores our device id (dev1); pause/resume/seek then pass it to the
// Connect API so bot A's pause/resume/seek can't act on bot B's playback (the
// account-wide default would pause whatever device is currently active).
it("pause/resume/seek delegate to the Connect API scoped to OUR device, and seek updates position", async () => {
const h = makeHarness();
await h.backend.playTrack("spotify:track:go"); // stores our device id (dev1)
await h.backend.pause();
await h.backend.resume();
await h.backend.seek(5000);
expect(h.connect.pause).toHaveBeenCalledWith("dev1");
expect(h.connect.resume).toHaveBeenCalledWith("dev1");
expect(h.connect.seek).toHaveBeenCalledWith(5000, "dev1");
expect(h.backend.getPositionMs()).toBe(5000);
});
});
describe("RustLibrespotBackend track-end poll loop", () => {
it("emits trackEnded when progress reaches the end-of-track window", async () => {
const h = makeHarness();
const ended = vi.fn();
const meta = vi.fn();
h.backend.on("trackEnded", ended);
h.backend.on("metadata", meta);
// Arm detection the way production does — via our own playTrack().
await h.backend.playTrack("spotify:track:A");
h.connect.getPlaybackState
.mockResolvedValueOnce({ isPlaying: true, progressMs: 1000, trackUri: "spotify:track:A", durationMs: 200000 })
.mockResolvedValueOnce({ isPlaying: true, progressMs: 199000, trackUri: "spotify:track:A", durationMs: 200000 });
await (h.backend as any).pollState();
expect(meta).toHaveBeenCalledWith(expect.objectContaining({ uri: "spotify:track:A", durationMs: 200000 }));
expect(h.backend.getPositionMs()).toBe(1000);
expect(ended).not.toHaveBeenCalled();
await (h.backend as any).pollState();
expect(ended).toHaveBeenCalledWith({ uri: "spotify:track:A", reason: "ended" });
});
// C1(pause-skip): a USER pause reports is_playing:false with the SAME uri on
// the Rust backend. That MUST NOT be read as a track end (it would skip the
// paused track and break pause + occupancy auto-pause). Formerly the
// "!isPlaying after having played" test asserted the opposite — that encoded
// the bug; it is now split into this pause-no-skip test plus the two-poll
// external-stop test below.
it("does NOT emit trackEnded when the user PAUSES (self-initiated pause is not a track end)", async () => {
const h = makeHarness();
const ended = vi.fn();
h.backend.on("trackEnded", ended);
await h.backend.playTrack("spotify:track:A");
// Observe our track actually playing first.
h.connect.getPlaybackState.mockResolvedValueOnce({
isPlaying: true, progressMs: 5000, trackUri: "spotify:track:A", durationMs: 200000,
});
await (h.backend as any).pollState();
// User pauses: the Connect device stays loaded but reports is_playing:false
// with the SAME uri across every subsequent poll while paused.
await h.backend.pause();
h.connect.getPlaybackState.mockResolvedValue({
isPlaying: false, progressMs: 5000, trackUri: "spotify:track:A", durationMs: 200000,
});
await (h.backend as any).pollState();
await (h.backend as any).pollState(); // stays paused across multiple polls
expect(ended).not.toHaveBeenCalled();
// Resuming keeps the same track playing — still no spurious end.
await h.backend.resume();
h.connect.getPlaybackState.mockResolvedValue({
isPlaying: true, progressMs: 6000, trackUri: "spotify:track:A", durationMs: 200000,
});
await (h.backend as any).pollState();
expect(ended).not.toHaveBeenCalled();
});
it("emits trackEnded once on an EXTERNAL stop only after TWO consecutive !isPlaying polls (a transient mid-track !isPlaying is not a skip)", async () => {
const h = makeHarness();
const ended = vi.fn();
h.backend.on("trackEnded", ended);
await h.backend.playTrack("spotify:track:A");
h.connect.getPlaybackState
.mockResolvedValueOnce({ isPlaying: true, progressMs: 5000, trackUri: "spotify:track:A", durationMs: 200000 })
.mockResolvedValueOnce({ isPlaying: false, progressMs: 5000, trackUri: "spotify:track:A", durationMs: 200000 })
.mockResolvedValue({ isPlaying: false, progressMs: 5000, trackUri: "spotify:track:A", durationMs: 200000 });
await (h.backend as any).pollState(); // observed playing
await (h.backend as any).pollState(); // FIRST !isPlaying -> unconfirmed (could be transient buffering)
expect(ended).not.toHaveBeenCalled();
await (h.backend as any).pollState(); // SECOND consecutive !isPlaying -> confirmed external stop
await (h.backend as any).pollState(); // idempotent: no second emit for same track
expect(ended).toHaveBeenCalledTimes(1);
expect(ended).toHaveBeenCalledWith({ uri: "spotify:track:A", reason: "ended" });
});
it("a transient single !isPlaying poll followed by playing again does NOT emit trackEnded (buffering hiccup)", async () => {
const h = makeHarness();
const ended = vi.fn();
h.backend.on("trackEnded", ended);
await h.backend.playTrack("spotify:track:A");
h.connect.getPlaybackState
.mockResolvedValueOnce({ isPlaying: true, progressMs: 5000, trackUri: "spotify:track:A", durationMs: 200000 })
.mockResolvedValueOnce({ isPlaying: false, progressMs: 5000, trackUri: "spotify:track:A", durationMs: 200000 })
.mockResolvedValue({ isPlaying: true, progressMs: 6000, trackUri: "spotify:track:A", durationMs: 200000 });
await (h.backend as any).pollState(); // playing
await (h.backend as any).pollState(); // momentary !isPlaying (buffering)
await (h.backend as any).pollState(); // playing again -> stop confirmation reset
expect(ended).not.toHaveBeenCalled();
});
// m(sub-window): a track SHORTER than the end-of-track window must not be
// declared finished on its first observed-playing poll (durationMs - window
// is negative, so the old near-end check fired unconditionally).
it("does NOT false-finish a sub-window (< END_OF_TRACK_WINDOW_MS) duration on the first playing poll", async () => {
const h = makeHarness();
const ended = vi.fn();
h.backend.on("trackEnded", ended);
await h.backend.playTrack("spotify:track:short");
h.connect.getPlaybackState.mockResolvedValue({
isPlaying: true, progressMs: 100, trackUri: "spotify:track:short", durationMs: 1200,
});
await (h.backend as any).pollState();
expect(ended).not.toHaveBeenCalled();
});
// C1(pause-skip) residual: a self-initiated pause within the FINAL
// END_OF_TRACK_WINDOW_MS freezes progress at >= dur-window with is_playing:false
// and the SAME uri. The finishedByProgress near-end heuristic must NOT fire
// while paused (it would skip the paused track). After resume(), the track
// plays on and finishes exactly once.
it("does NOT skip when the user PAUSES within the final end-of-track window, but ends once after resume", async () => {
const h = makeHarness();
const ended = vi.fn();
h.backend.on("trackEnded", ended);
await h.backend.playTrack("spotify:track:A");
// Observe our track actually playing first (mid-track).
h.connect.getPlaybackState.mockResolvedValueOnce({
isPlaying: true, progressMs: 5000, trackUri: "spotify:track:A", durationMs: 200000,
});
await (h.backend as any).pollState();
// User pauses within the final ~1.5s: frozen progress >= dur-window,
// is_playing:false, SAME uri, across every subsequent paused poll.
await h.backend.pause();
h.connect.getPlaybackState.mockResolvedValue({
isPlaying: false, progressMs: 199000, trackUri: "spotify:track:A", durationMs: 200000,
});
await (h.backend as any).pollState();
await (h.backend as any).pollState(); // stays paused across multiple polls
expect(ended).not.toHaveBeenCalled();
// Resume -> the track plays on to its natural end and finishes exactly once.
await h.backend.resume();
h.connect.getPlaybackState.mockResolvedValue({
isPlaying: true, progressMs: 199000, trackUri: "spotify:track:A", durationMs: 200000,
});
await (h.backend as any).pollState();
expect(ended).toHaveBeenCalledTimes(1);
expect(ended).toHaveBeenCalledWith({ uri: "spotify:track:A", reason: "ended" });
});
// C1(pause-skip) residual: during a LONG self-initiated pause the Connect
// device can idle out to a 204 / null playback state. That null state while
// paused must NOT be read as a track end (it would skip the paused track).
// After resume() a genuinely dead device (persistent null) IS detected.
it("does NOT skip a PAUSED track when the device idles out to null/204, but DOES after resume", async () => {
const h = makeHarness();
const ended = vi.fn();
h.backend.on("trackEnded", ended);
await h.backend.playTrack("spotify:track:A");
// Observe our track actually playing first.
h.connect.getPlaybackState.mockResolvedValueOnce({
isPlaying: true, progressMs: 5000, trackUri: "spotify:track:A", durationMs: 200000,
});
await (h.backend as any).pollState();
// Pause, then the Connect device idles out to a 204 / null state.
await h.backend.pause();
h.connect.getPlaybackState.mockResolvedValue(null as any);
await (h.backend as any).pollState();
await (h.backend as any).pollState(); // stays paused across multiple null polls
expect(ended).not.toHaveBeenCalled();
// Resume -> a genuinely dead device (persistent null) is now detected once.
await h.backend.resume();
await (h.backend as any).pollState();
expect(ended).toHaveBeenCalledTimes(1);
expect(ended).toHaveBeenCalledWith({ uri: "spotify:track:A", reason: "ended" });
});
// Regression: a NON-paused track that idles out to a null/204 state after
// having played must STILL emit exactly one trackEnded (the pause gate must
// not suppress genuine device death for a non-paused track).
it("regression: a NON-paused track that idles out to null/204 after playing still emits exactly one trackEnded", async () => {
const h = makeHarness();
const ended = vi.fn();
h.backend.on("trackEnded", ended);
await h.backend.playTrack("spotify:track:A");
h.connect.getPlaybackState.mockResolvedValueOnce({
isPlaying: true, progressMs: 5000, trackUri: "spotify:track:A", durationMs: 200000,
});
await (h.backend as any).pollState(); // observed playing, not paused
h.connect.getPlaybackState.mockResolvedValue(null as any); // device idles out
await (h.backend as any).pollState();
await (h.backend as any).pollState(); // idempotent: no second emit
expect(ended).toHaveBeenCalledTimes(1);
expect(ended).toHaveBeenCalledWith({ uri: "spotify:track:A", reason: "ended" });
});
// R3-1: a GENUINE end where the item stays null across TWO consecutive
// non-paused polls still emits exactly one trackEnded. The null-item path now
// shares the external-stop two-poll confirmation, so a single transient null
// (Connect handoff / market relink) no longer skips a still-playing track —
// but a persistent null is still detected. (Previously this test asserted a
// single null poll ended the track; that encoded the R3-1 corner-case bug.)
it("emits trackEnded once when the track uri stays null across TWO consecutive polls after playing", async () => {
const h = makeHarness();
const ended = vi.fn();
h.backend.on("trackEnded", ended);
await h.backend.playTrack("spotify:track:A");
h.connect.getPlaybackState
.mockResolvedValueOnce({ isPlaying: true, progressMs: 1000, trackUri: "spotify:track:A", durationMs: 200000 })
.mockResolvedValue({ isPlaying: true, progressMs: 0, trackUri: null, durationMs: 0 });
await (h.backend as any).pollState(); // observed playing our uri
await (h.backend as any).pollState(); // FIRST null item -> unconfirmed (could be transient)
expect(ended).not.toHaveBeenCalled();
await (h.backend as any).pollState(); // SECOND consecutive null -> confirmed end
await (h.backend as any).pollState(); // idempotent: no second emit for same track
expect(ended).toHaveBeenCalledTimes(1);
expect(ended).toHaveBeenCalledWith({ uri: "spotify:track:A", reason: "ended" });
});
// R3-1: Spotify legitimately returns item:null transiently at a track/Connect
// handoff boundary, and getPlaybackState omits the `market` param so a
// region-relinked/restricted item can momentarily map to uri:null. A SINGLE
// {isPlaying:true, trackUri:null} poll mid-track must NOT skip a still-playing
// track; a following poll showing our real uri again confirms it kept playing.
it("a transient single null-item poll (isPlaying:true, trackUri:null) followed by our uri again does NOT emit trackEnded", async () => {
const h = makeHarness();
const ended = vi.fn();
h.backend.on("trackEnded", ended);
await h.backend.playTrack("spotify:track:A");
h.connect.getPlaybackState
.mockResolvedValueOnce({ isPlaying: true, progressMs: 5000, trackUri: "spotify:track:A", durationMs: 200000 })
.mockResolvedValueOnce({ isPlaying: true, progressMs: 0, trackUri: null, durationMs: 0 })
.mockResolvedValue({ isPlaying: true, progressMs: 6000, trackUri: "spotify:track:A", durationMs: 200000 });
await (h.backend as any).pollState(); // playing our uri
await (h.backend as any).pollState(); // momentary null item (handoff / market relink)
await (h.backend as any).pollState(); // our uri again -> confirmation reset, still playing
expect(ended).not.toHaveBeenCalled();
});
// R3-1 (pause invariant): a self-paused track must NEVER skip. A {trackUri:null}
// poll while WE hold a pause must not be read as a track end (same invariant
// the finishedByStop / null-204 paths already enforce with a `!paused` guard).
it("does NOT emit trackEnded on a null-item poll while the track is self-paused", async () => {
const h = makeHarness();
const ended = vi.fn();
h.backend.on("trackEnded", ended);
await h.backend.playTrack("spotify:track:A");
h.connect.getPlaybackState.mockResolvedValueOnce({
isPlaying: true, progressMs: 5000, trackUri: "spotify:track:A", durationMs: 200000,
});
await (h.backend as any).pollState(); // observed playing
await h.backend.pause();
// While paused, a null item appears across multiple polls (state present, no item).
h.connect.getPlaybackState.mockResolvedValue({
isPlaying: false, progressMs: 5000, trackUri: null, durationMs: 0,
});
await (h.backend as any).pollState();
await (h.backend as any).pollState();
expect(ended).not.toHaveBeenCalled();
});
it("ignores a null playback state (no active device) without emitting", async () => {
const h = makeHarness();
const ended = vi.fn();
h.backend.on("trackEnded", ended);
h.connect.getPlaybackState.mockResolvedValue(null);
await (h.backend as any).pollState();
expect(ended).not.toHaveBeenCalled();
});
it("C3.4: a startup poll before playTrack never emits (foreign track near its end)", async () => {
const h = makeHarness();
const ended = vi.fn();
const meta = vi.fn();
h.backend.on("trackEnded", ended);
h.backend.on("metadata", meta);
// Backend started, but playTrack has NOT been called => detection disarmed.
await h.backend.start();
// First poll observes a FOREIGN track that is actively playing near its end.
h.connect.getPlaybackState.mockResolvedValue({
isPlaying: true,
progressMs: 199000,
trackUri: "spotify:foreign",
durationMs: 200000,
});
await (h.backend as any).pollState();
// No spurious end-of-track and no bogus metadata before the bot ever plays.
expect(ended).not.toHaveBeenCalled();
expect(meta).not.toHaveBeenCalled();
expect(h.backend.getPositionMs()).toBe(0);
h.backend.stop();
});
it("after playTrack, a normal finish emits trackEnded exactly once for our uri", async () => {
const h = makeHarness();
const ended = vi.fn();
h.backend.on("trackEnded", ended);
// Arm detection via our own play, then confirm-then-finish our uri.
await h.backend.playTrack("spotify:track:ours");
h.connect.getPlaybackState
.mockResolvedValueOnce({ isPlaying: true, progressMs: 1000, trackUri: "spotify:track:ours", durationMs: 200000 })
.mockResolvedValueOnce({ isPlaying: true, progressMs: 199000, trackUri: "spotify:track:ours", durationMs: 200000 });
await (h.backend as any).pollState(); // confirms our uri playing
expect(ended).not.toHaveBeenCalled();
await (h.backend as any).pollState(); // finishes
expect(ended).toHaveBeenCalledTimes(1);
expect(ended).toHaveBeenCalledWith({ uri: "spotify:track:ours", reason: "ended" });
});
});
// R4-4 (multi-bot): config.spotify (and thus the Connect session) is shared by
// every bot under one Premium account, which supports only ONE active playback
// stream. GET /v1/me/player is account-wide, so once bot B steals the active
// session our poll would see B's device + B's track. The backend now stores OUR
// device id and, when the reported activeDeviceId differs, refuses to treat the
// foreign playback as ours: no foreign metadata, no misattribution — the stolen
// session instead advances OUR queue cleanly via the existing two-poll stop.
describe("RustLibrespotBackend multi-bot device scoping (R4-4)", () => {
it("ignores foreign-device poll state: no foreign metadata, no misattribution, and a stolen session advances OUR queue once", async () => {
const h = makeHarness();
const ended = vi.fn();
const meta = vi.fn();
h.backend.on("trackEnded", ended);
h.backend.on("metadata", meta);
// Our track plays on OUR device (dev1 — the findDeviceByName mock id).
await h.backend.playTrack("spotify:track:ours");
h.connect.getPlaybackState.mockResolvedValueOnce({
isPlaying: true, progressMs: 5000, trackUri: "spotify:track:ours",
durationMs: 200000, activeDeviceId: "dev1",
});
await (h.backend as any).pollState(); // our track observed playing on our device
expect(meta).toHaveBeenCalledTimes(1);
expect(meta).toHaveBeenCalledWith(expect.objectContaining({ uri: "spotify:track:ours" }));
expect(h.backend.getPositionMs()).toBe(5000);
meta.mockClear();
// Bot B steals the single active Connect session: /v1/me/player now reports
// THEIR device (dev2) + THEIR track, and would keep doing so every poll.
h.connect.getPlaybackState.mockResolvedValue({
isPlaying: true, progressMs: 123000, trackUri: "spotify:track:foreign",
durationMs: 200000, activeDeviceId: "dev2",
});
await (h.backend as any).pollState(); // FIRST foreign poll -> unconfirmed stop, no side effects
expect(meta).not.toHaveBeenCalled(); // foreign metadata NOT surfaced as ours
expect(ended).not.toHaveBeenCalled(); // two-poll confirmation not met yet
expect(h.backend.getPositionMs()).toBe(5000); // foreign progress NOT misattributed
await (h.backend as any).pollState(); // SECOND foreign poll -> confirmed -> OUR queue advances
await (h.backend as any).pollState(); // idempotent: no second emit for our track
expect(ended).toHaveBeenCalledTimes(1);
expect(ended).toHaveBeenCalledWith({ uri: "spotify:track:ours", reason: "ended" });
expect(meta).not.toHaveBeenCalled(); // never emitted metadata for the foreign uri
});
it("when the active device IS ours (activeDeviceId === our id), end-detection behaves exactly as today", async () => {
const h = makeHarness();
const ended = vi.fn();
h.backend.on("trackEnded", ended);
await h.backend.playTrack("spotify:track:A");
h.connect.getPlaybackState
.mockResolvedValueOnce({ isPlaying: true, progressMs: 1000, trackUri: "spotify:track:A", durationMs: 200000, activeDeviceId: "dev1" })
.mockResolvedValueOnce({ isPlaying: true, progressMs: 199000, trackUri: "spotify:track:A", durationMs: 200000, activeDeviceId: "dev1" });
await (h.backend as any).pollState(); // confirms our uri playing on our device
expect(ended).not.toHaveBeenCalled();
await (h.backend as any).pollState(); // near-end -> finishes normally
expect(ended).toHaveBeenCalledTimes(1);
expect(ended).toHaveBeenCalledWith({ uri: "spotify:track:A", reason: "ended" });
});
});
// R4-6: finishedByProgress is a SINGLE-poll near-end heuristic. If a user
// deliberately SEEKS to within the final END_OF_TRACK_WINDOW_MS, the next poll
// would see progressMs >= durationMs-1500 and emit trackEnded — skipping the ~1s
// the user seeked into. A one-poll "just-seeked" grace suppresses that single
// misfire; the track then plays its remaining <=1.5s and ends naturally on the
// following poll via the stop/null detection. Natural near-end (reached by
// PLAYING, no seek) must be UNCHANGED.
describe("RustLibrespotBackend seek-into-end grace (R4-6)", () => {
it("does NOT skip when a seek lands within the final end-of-track window; ends naturally on the following poll", async () => {
const h = makeHarness();
const ended = vi.fn();
h.backend.on("trackEnded", ended);
await h.backend.playTrack("spotify:track:A");
// Observe our track actually playing first (mid-track).
h.connect.getPlaybackState.mockResolvedValueOnce({
isPlaying: true, progressMs: 5000, trackUri: "spotify:track:A", durationMs: 200000,
});
await (h.backend as any).pollState();
// User deliberately seeks to ~1s before the end (inside the 1.5s window).
await h.backend.seek(199000);
// First poll after the seek: progress is already inside the near-end window
// and still playing. The grace must suppress this single finishedByProgress.
h.connect.getPlaybackState.mockResolvedValueOnce({
isPlaying: true, progressMs: 199000, trackUri: "spotify:track:A", durationMs: 200000,
});
await (h.backend as any).pollState();
expect(ended).not.toHaveBeenCalled();
// The remaining <=1.5s plays out and librespot goes idle (null/204) — the
// genuine end. It emits exactly one trackEnded.
h.connect.getPlaybackState.mockResolvedValue(null as any);
await (h.backend as any).pollState();
expect(ended).toHaveBeenCalledTimes(1);
expect(ended).toHaveBeenCalledWith({ uri: "spotify:track:A", reason: "ended" });
});
it("the seek grace only spans ONE poll: a second in-window playing poll still finishes by progress", async () => {
const h = makeHarness();
const ended = vi.fn();
h.backend.on("trackEnded", ended);
await h.backend.playTrack("spotify:track:A");
h.connect.getPlaybackState.mockResolvedValueOnce({
isPlaying: true, progressMs: 5000, trackUri: "spotify:track:A", durationMs: 200000,
});
await (h.backend as any).pollState(); // observed playing
await h.backend.seek(199000); // seek into the final window
// Grace poll: suppressed.
h.connect.getPlaybackState.mockResolvedValueOnce({
isPlaying: true, progressMs: 199000, trackUri: "spotify:track:A", durationMs: 200000,
});
await (h.backend as any).pollState();
expect(ended).not.toHaveBeenCalled();
// Second in-window playing poll: grace already consumed -> natural near-end
// detection fires exactly once (grace must not permanently disable it).
h.connect.getPlaybackState.mockResolvedValueOnce({
isPlaying: true, progressMs: 199500, trackUri: "spotify:track:A", durationMs: 200000,
});
await (h.backend as any).pollState();
expect(ended).toHaveBeenCalledTimes(1);
expect(ended).toHaveBeenCalledWith({ uri: "spotify:track:A", reason: "ended" });
});
it("regression: a track that reaches the final window by PLAYING (no seek) still emits trackEnded on the first in-window poll", async () => {
const h = makeHarness();
const ended = vi.fn();
h.backend.on("trackEnded", ended);
await h.backend.playTrack("spotify:track:A");
h.connect.getPlaybackState
.mockResolvedValueOnce({ isPlaying: true, progressMs: 1000, trackUri: "spotify:track:A", durationMs: 200000 })
.mockResolvedValueOnce({ isPlaying: true, progressMs: 199000, trackUri: "spotify:track:A", durationMs: 200000 });
await (h.backend as any).pollState(); // observed playing (no seek)
expect(ended).not.toHaveBeenCalled();
await (h.backend as any).pollState(); // reaches window by PLAYING -> natural end
expect(ended).toHaveBeenCalledTimes(1);
expect(ended).toHaveBeenCalledWith({ uri: "spotify:track:A", reason: "ended" });
});
});
describe("RustLibrespotBackend playback-start watchdog (I4 degrade-to-skip)", () => {
/** A controllable timer seam matching the file's injected-deps style. */
function makeFakeTimer() {
const pending: Array<{ cb: () => void; ms: number; handle: object }> = [];
const setTimer = vi.fn((cb: () => void, ms: number) => {
const handle = {};
pending.push({ cb, ms, handle });
return handle;
});
const clearTimer = vi.fn((h: unknown) => {
const i = pending.findIndex((p) => p.handle === h);
if (i >= 0) pending.splice(i, 1);
});
// "advance fake timers": run (and drain) every armed callback.
const advance = () => pending.splice(0).forEach((p) => p.cb());
return { setTimer, clearTimer, advance, pending };
}
it("emits exactly ONE trackEnded{reason:'error'} when playback never starts", async () => {
const timer = makeFakeTimer();
const h = makeHarness({
deps: {
playbackStartTimeoutMs: 8000,
setTimeout: timer.setTimer as any,
clearTimeout: timer.clearTimer as any,
},
});
const ended = vi.fn();
h.backend.on("trackEnded", ended);
// The connect layer NEVER reports our track playing (204 / idle).
h.connect.getPlaybackState.mockResolvedValue(null);
await h.backend.playTrack("spotify:track:stuck");
// Polls that never observe playback must NOT emit anything on their own.
await (h.backend as any).pollState();
await (h.backend as any).pollState();
expect(ended).not.toHaveBeenCalled();
// The watchdog is armed exactly once; advance past playbackStartTimeoutMs.
expect(timer.pending).toHaveLength(1);
expect(timer.pending[0].ms).toBe(8000);
timer.advance();
// Degraded-to-skip: one trackEnded with reason "error" for our uri.
expect(ended).toHaveBeenCalledTimes(1);
expect(ended).toHaveBeenCalledWith({ uri: "spotify:track:stuck", reason: "error" });
// A late idle poll after the watchdog fired does not double-emit.
await (h.backend as any).pollState();
expect(ended).toHaveBeenCalledTimes(1);
h.backend.stop();
});
it("does NOT fire the watchdog when the device actually starts playing", async () => {
const timer = makeFakeTimer();
const h = makeHarness({
deps: {
playbackStartTimeoutMs: 8000,
setTimeout: timer.setTimer as any,
clearTimeout: timer.clearTimer as any,
},
});
const ended = vi.fn();
h.backend.on("trackEnded", ended);
await h.backend.playTrack("spotify:track:ok");
// Our device reports the track actually playing -> watchdog is disarmed.
h.connect.getPlaybackState.mockResolvedValue({
isPlaying: true,
progressMs: 1000,
trackUri: "spotify:track:ok",
durationMs: 200000,
});
await (h.backend as any).pollState();
// Real playback observed -> the watchdog was cleared, not left armed.
expect(timer.clearTimer).toHaveBeenCalled();
expect(timer.pending).toHaveLength(0);
// Even if a stale timer somehow fired, no "error" end must be emitted.
timer.advance();
expect(ended).not.toHaveBeenCalledWith(
expect.objectContaining({ reason: "error" }),
);
h.backend.stop();
});
it("a new playTrack() cancels the previous track's watchdog (at-most-one per track)", async () => {
const timer = makeFakeTimer();
const h = makeHarness({
deps: {
playbackStartTimeoutMs: 8000,
setTimeout: timer.setTimer as any,
clearTimeout: timer.clearTimer as any,
},
});
const ended = vi.fn();
h.backend.on("trackEnded", ended);
h.connect.getPlaybackState.mockResolvedValue(null);
await h.backend.playTrack("spotify:track:one");
await h.backend.playTrack("spotify:track:two");
// The first track's watchdog was cleared; only the second remains armed.
expect(timer.clearTimer).toHaveBeenCalled();
expect(timer.pending).toHaveLength(1);
timer.advance();
expect(ended).toHaveBeenCalledTimes(1);
expect(ended).toHaveBeenCalledWith({ uri: "spotify:track:two", reason: "error" });
h.backend.stop();
});
});
describe("RustLibrespotBackend.stop", () => {
it("kills librespot + ffmpeg, clears ready, and is idempotent", async () => {
const h = makeHarness();
await h.backend.start();
h.backend.stop();
h.backend.stop(); // second call must not throw
expect(h.librespotChild.kill).toHaveBeenCalled();
expect(h.ffmpegChild.kill).toHaveBeenCalled();
expect(h.backend.isReady()).toBe(false);
});
});
describe("RustLibrespotBackend.start failure cleanup", () => {
it("tears down librespot + ffmpeg when the device never appears", async () => {
const h = makeHarness();
h.connect.getDevices.mockResolvedValue([]); // device never shows up -> waitForDevice times out
await expect(h.backend.start()).rejects.toThrow(/did not appear/i);
expect(h.librespotChild.kill).toHaveBeenCalled();
expect(h.ffmpegChild.kill).toHaveBeenCalled();
expect(h.backend.isReady()).toBe(false);
});
});
describe("RustLibrespotBackend child-process error handling", () => {
it("swallows+logs a child 'error' when no backend 'error' listener is attached", async () => {
const h = makeHarness();
await h.backend.start();
expect(h.backend.listenerCount("error")).toBe(0);
expect(() => h.librespotChild.emit("error", new Error("boom"))).not.toThrow();
expect(() => h.ffmpegChild.emit("error", new Error("boom"))).not.toThrow();
h.backend.stop();
});
it("re-emits a child 'error' to an attached backend 'error' listener", async () => {
const h = makeHarness();
await h.backend.start();
const onErr = vi.fn();
h.backend.on("error", onErr);
const err = new Error("ffmpeg boom");
h.ffmpegChild.emit("error", err);
expect(onErr).toHaveBeenCalledWith(err);
h.backend.stop();
});
// I(pipe): ffmpeg dying mid-track while librespot keeps producing PCM raises
// EPIPE on ffmpeg.stdin. With no stdin 'error' listener Node escalates it to
// process 'uncaughtException'. The backend must handle it in-band.
it("swallows an EPIPE 'error' on ffmpeg.stdin (ffmpeg died mid-track) without an unhandled throw", async () => {
const h = makeHarness();
await h.backend.start();
expect(h.backend.listenerCount("error")).toBe(0);
const epipe = Object.assign(new Error("write EPIPE"), { code: "EPIPE" });
expect(() => h.ffmpegChild.stdin.emit("error", epipe)).not.toThrow();
h.backend.stop(); // idempotent second teardown must not throw
});
it("routes an ffmpeg.stdin EPIPE to the backend 'error' listener and tears down cleanly", async () => {
const h = makeHarness();
await h.backend.start();
const onErr = vi.fn();
h.backend.on("error", onErr);
const epipe = Object.assign(new Error("write EPIPE"), { code: "EPIPE" });
expect(() => h.ffmpegChild.stdin.emit("error", epipe)).not.toThrow();
expect(onErr).toHaveBeenCalledWith(epipe);
// Broken pipe -> clean teardown: children killed, not ready.
expect(h.librespotChild.kill).toHaveBeenCalled();
expect(h.ffmpegChild.kill).toHaveBeenCalled();
expect(h.backend.isReady()).toBe(false);
h.backend.stop(); // second teardown must not throw (no double-teardown crash)
expect(onErr).toHaveBeenCalledTimes(1); // single emit despite both pipe ends
});
it("does not throw when librespot proc.stdout emits an EPIPE on the broken pipe", async () => {
const h = makeHarness();
await h.backend.start();
const epipe = Object.assign(new Error("read/write EPIPE"), { code: "EPIPE" });
expect(() => h.librespotChild.stdout.emit("error", epipe)).not.toThrow();
h.backend.stop();
});
});
+620
View File
@@ -0,0 +1,620 @@
import { EventEmitter } from "node:events";
import type { Readable } from "node:stream";
import type { ChildProcess } from "node:child_process";
import { spawn as realSpawn } from "node:child_process";
import { mkdirSync as realMkdirSync } from "node:fs";
import type { Logger } from "pino";
import type {
SpotifyAudioBackend,
SpotifyTrackEndedEvent,
SpotifyNowPlaying,
} from "./backend.js";
import { findLibrespot } from "./binary.js";
import { SpotifyConnectApi } from "./connect-api.js";
import type { PlaybackState, SpotifyDevice } from "./connect-api.js";
import type { SpotifyOAuth } from "./spotify-oauth.js";
import { getFfmpegCommand } from "../../audio/player.js";
export interface RustLibrespotBackendOptions {
deviceName: string;
bitrate: number;
cacheDir: string;
oauth: SpotifyOAuth;
connect?: SpotifyConnectApi;
logger: Logger;
deps?: RustLibrespotBackendDeps;
}
/** Injectable seams so the whole lifecycle is testable without a real binary/network. */
export interface RustLibrespotBackendDeps {
spawn?: typeof realSpawn;
mkdirSync?: typeof realMkdirSync;
findBinary?: () => string;
/**
* C1: override the ffmpeg command. Production resolves it via
* getFfmpegCommand() (bundled ffmpeg-static fallback when `ffmpeg` isn't on
* PATH); tests pin it to "ffmpeg" for stable arg assertions.
*/
ffmpegCommand?: string;
sleep?: (ms: number) => Promise<void>;
readyPollIntervalMs?: number;
readyTimeoutMs?: number;
statePollIntervalMs?: number;
/**
* I4 (§13): bounded window after playTrack() within which our own track must
* be observed playing, else we degrade-to-skipped (emit trackEnded
* reason:"error"). Injectable timer seams (default real setTimeout, unref'd)
* let tests advance it deterministically.
*/
playbackStartTimeoutMs?: number;
setTimeout?: (cb: () => void, ms: number) => unknown;
clearTimeout?: (handle: unknown) => void;
}
const DEFAULT_READY_POLL_MS = 500;
const DEFAULT_READY_TIMEOUT_MS = 20_000;
const DEFAULT_STATE_POLL_MS = 2_000;
/** How close to the end (ms) counts as "track finished" when polling player state. */
const END_OF_TRACK_WINDOW_MS = 1_500;
/**
* I4 (§13): if our own track has not been observed playing within this window
* after playTrack(), degrade-to-skipped so the queue advances instead of
* stalling on a persistently-failing play (403 non-Premium, 404 outliving
* retries). Bounded and injectable for tests.
*/
const DEFAULT_PLAYBACK_START_TIMEOUT_MS = 8_000;
const defaultSleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
export class RustLibrespotBackend extends EventEmitter implements SpotifyAudioBackend {
private readonly opts: RustLibrespotBackendOptions;
private readonly log: Logger;
private readonly deps: RustLibrespotBackendDeps;
private readonly oauth: SpotifyOAuth;
private readonly connect: SpotifyConnectApi;
private proc: ChildProcess | null = null;
private ffmpeg: ChildProcess | null = null;
private pollTimer: ReturnType<typeof setInterval> | null = null;
// I4 (§13): "did our track actually start playing?" watchdog handle (opaque —
// produced by the injectable setTimeout seam). null when disarmed.
private watchdogTimer: unknown = null;
private ready = false;
private positionMs = 0;
// R4-4 (multi-bot): OUR resolved Connect device id, captured from
// findDeviceByName() in playTrack(). config.spotify (hence this Connect
// session) is shared process-wide across every BotInstance under one Premium
// account, and both the control API (pause/resume/seek) and the state read
// (GET /v1/me/player) are ACCOUNT-wide by default. Persisting our device id
// lets us (a) device-scope our control commands so we only ever act on our own
// device, and (b) in pollState, ignore state reported for a foreign device
// another bot stole the single active session with. null until first playTrack.
private deviceId: string | null = null;
// track-end poll state machine
private currentUri: string | null = null;
private hasPlayed = false;
private endedForCurrent = false;
// C3.4: end-detection is DISARMED until our own playTrack() runs. A poll that
// fires before the bot ever asks to play (e.g. at startup) must produce no
// side effects at all, so a foreign track already near its end can't emit a
// spurious trackEnded/metadata and wrongly advance the queue.
private armed = false;
// C1(pause-skip): our own pause state. A self-initiated pause makes the
// Connect device report is_playing:false with the SAME uri; without tracking
// it we'd misread that as a track end and SKIP the paused track (breaking the
// pause command and occupancy auto-pause-when-alone on the Rust backend). Set
// by pause(), cleared by resume() and playTrack().
private paused = false;
// Robustness (finishedByStop + finishedByNull / R3-1): a "not clearly playing
// our track" signal — either a non-paused is_playing:false (external stop) OR
// a null item (trackUri===null) observed while our track was playing — must be
// confirmed across TWO consecutive non-paused polls before we emit trackEnded.
// Both signals can be momentary: is_playing:false from a buffering hiccup; a
// null item from a Connect/track handoff boundary or a region-relinked /
// restricted item that momentarily maps to uri:null (getPlaybackState omits
// the `market` param). Set on the first such poll; reset only when a poll
// clearly shows our track playing again (is_playing AND a real item), on track
// change, or on playTrack(). Sharing ONE flag keeps both sibling paths from
// false-skipping on a single transient poll.
private stopSeen = false;
// R4-6: one-poll "just-seeked" grace. finishedByProgress is a SINGLE-poll
// near-end heuristic; if a user deliberately seeks to within the final
// END_OF_TRACK_WINDOW_MS, the very next poll would see progressMs >=
// durationMs-window and emit trackEnded, SKIPPING the ~1s the user seeked
// into. Set by seek(); consumed on the next poll so it suppresses that single
// finishedByProgress misfire only — the track then plays its remaining <=1.5s
// and ends naturally on the following poll via the stop/null detection. It
// deliberately does NOT touch the paused/stop/null two-poll logic, and a track
// reaching the window by PLAYING (no preceding seek) still ends normally.
private seekGrace = false;
// I(pipe): one teardown per broken librespot->ffmpeg pipe. Both stream ends
// (ffmpeg.stdin write side, proc.stdout read side) can report the same EPIPE;
// this guard prevents a double teardown / double error-emit.
private pipeBroke = false;
constructor(o: RustLibrespotBackendOptions) {
super();
this.opts = o;
this.log = o.logger;
this.deps = o.deps ?? {};
this.oauth = o.oauth;
// The Connect API shares the backend's OAuth token source. Reuse the
// injected instance in tests; otherwise build one over oauth.getAccessToken().
this.connect = o.connect ?? new SpotifyConnectApi(() => this.oauth.getAccessToken());
}
async start(): Promise<void> {
const spawn = this.deps.spawn ?? realSpawn;
const mkdirSync = this.deps.mkdirSync ?? realMkdirSync;
const findBinary = this.deps.findBinary ?? findLibrespot;
// C1: resolve ffmpeg via getFfmpegCommand() unless injected for tests.
const ffmpegCommand = this.deps.ffmpegCommand ?? getFfmpegCommand();
// A valid USER control token is required before we spawn anything.
const token = await this.oauth.getAccessToken();
if (!token) {
throw new Error("Spotify not authorized (no access token) — sign in first");
}
mkdirSync(this.opts.cacheDir, { recursive: true });
// Everything past here spawns children / opens the state poll. On any
// failure (e.g. the device never appears), tear it all down via stop().
try {
this.pipeBroke = false; // fresh pipe for this start()
// 1. Spawn ffmpeg FIRST (the reader) so its stdin pipe is ready before
// librespot starts pushing raw 44.1k s16le PCM into it.
this.ffmpeg = spawn(
ffmpegCommand,
[
"-f", "s16le", "-ar", "44100", "-ac", "2", "-i", "pipe:0",
"-f", "s16le", "-ar", "48000", "-ac", "2", "-acodec", "pcm_s16le", "pipe:1",
],
{ stdio: ["pipe", "pipe", "pipe"] },
);
this.ffmpeg.stderr?.on("data", (b: Buffer) =>
this.log.debug({ ffmpeg: b.toString().trim() }, "ffmpeg"),
);
this.ffmpeg.on("error", (err) => this.emitError(err));
// I(pipe): if ffmpeg dies mid-track while librespot keeps producing PCM,
// the next write into its stdin hits the now-closed pipe -> EPIPE 'error'
// on stdin. With no listener Node escalates that to process
// 'uncaughtException' (the global handler logs but leaves undefined
// state). Handle it in-band: tear down cleanly and surface via emitError.
this.ffmpeg.stdin?.on("error", (err) => this.onPipeError(err));
// 2. Spawn librespot: --backend pipe with NO --device => raw s16le/44100/2
// on stdout, NO --passthrough (that would emit raw Ogg). --access-token
// authenticates it as a Connect device controllable via the Web API.
const bin = findBinary();
this.proc = spawn(
bin,
[
"--name", this.opts.deviceName,
"--backend", "pipe",
"--bitrate", String(this.opts.bitrate),
"--format", "S16",
"--cache", this.opts.cacheDir,
"--device-type", "speaker",
// KNOWN LIMITATION (CWE-214): the live Spotify access token is passed in
// the child argv, so on a shared/multi-tenant host a co-located local
// process could read it via `ps` / /proc/<pid>/cmdline. Bounded (~1h token,
// needs local access) and `--access-token` is librespot's supported bootstrap.
"--access-token", token,
],
{ stdio: ["ignore", "pipe", "pipe"] },
);
// stdout carries PCM — pipe it, never attach a data listener that consumes it.
if (this.proc.stdout && this.ffmpeg.stdin) {
this.proc.stdout.pipe(this.ffmpeg.stdin);
// Guard the read side too: a broken pipe can surface as an 'error' on
// proc.stdout when ffmpeg's stdin closes underneath it. Same handler,
// guarded against a double teardown.
this.proc.stdout.on("error", (err) => this.onPipeError(err));
}
this.proc.stderr?.on("data", (b: Buffer) =>
this.log.info({ librespot: b.toString().trim() }, "librespot"),
);
this.proc.on("error", (err) => this.emitError(err));
this.proc.on("exit", (code, signal) => {
this.ready = false;
this.log.warn({ code, signal }, "librespot exited");
});
// 3. Poll the Connect device list until our device registers.
await this.waitForDevice();
// 4. Begin the player-state poll loop (track-end / position / metadata).
this.startPollLoop();
this.ready = true;
this.emit("ready");
} catch (e) {
this.stop();
throw e;
}
}
/**
* Re-emit a child "error" only when a consumer is listening; Node throws on an
* unhandled "error" event, so with no listener we log via the injected logger.
*/
private emitError(err: unknown): void {
if (this.listenerCount("error") > 0) {
this.emit("error", err);
} else {
this.log.error({ err }, "rust-librespot backend error (no listener)");
}
}
/**
* I(pipe): the librespot->ffmpeg PCM pipe broke — typically ffmpeg died
* mid-track and librespot's next write hit the closed pipe (EPIPE), or the
* stream was destroyed (ERR_STREAM_DESTROYED). Both are expected teardown
* signals, not programming errors. Log, tear down cleanly (stop() is
* idempotent), then surface via emitError so a listening controller reacts.
* Guarded so both stream ends reporting the same break don't double-teardown.
*/
private onPipeError(err: unknown): void {
if (this.pipeBroke) return;
this.pipeBroke = true;
this.log.warn(
{ err },
"rust-librespot: librespot->ffmpeg pipe broke (ffmpeg died?) — tearing down",
);
this.stop();
this.emitError(err);
}
private async waitForDevice(): Promise<void> {
const sleep = this.deps.sleep ?? defaultSleep;
const interval = this.deps.readyPollIntervalMs ?? DEFAULT_READY_POLL_MS;
const timeout = this.deps.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS;
const deadline = Date.now() + timeout;
while (Date.now() < deadline) {
let devices: SpotifyDevice[] = [];
try {
devices = await this.connect.getDevices();
} catch (err) {
this.log.debug({ err }, "getDevices failed during readiness poll");
}
if (devices.some((d) => d.name === this.opts.deviceName)) return;
await sleep(interval);
}
throw new Error(`librespot device "${this.opts.deviceName}" did not appear within timeout`);
}
private startPollLoop(): void {
const interval = this.deps.statePollIntervalMs ?? DEFAULT_STATE_POLL_MS;
this.pollTimer = setInterval(() => {
void this.pollState();
}, interval);
// Don't keep the event loop / test process alive on account of the poll timer.
this.pollTimer.unref?.();
}
/** One player-state poll iteration: updates position/metadata and detects track end. */
private async pollState(): Promise<void> {
let state: PlaybackState | null;
try {
state = await this.connect.getPlaybackState();
} catch (err) {
this.log.debug({ err }, "getPlaybackState failed");
return;
}
// C3.4: detection is armed ONLY by our own playTrack(). Until then a poll
// must have NO side effects — no metadata/trackEnded emit and no mutation of
// currentUri/hasPlayed/positionMs. This single gate covers the null-state
// (C3.5 post-play-204) branch, the metadata-emit block, and every end
// heuristic below, so a foreign track sitting near its end at startup can
// never spuriously advance the queue. (Device-readiness polling via
// getDevices is separate and stays active regardless of this flag.)
if (!this.armed) return;
// R4-6: consume the one-poll "just-seeked" grace up front so it spans exactly
// ONE poll regardless of which branch this poll takes. `justSeeked` then
// suppresses a single finishedByProgress misfire below (a deliberate seek
// into the final END_OF_TRACK_WINDOW_MS must not be read as a natural end).
const justSeeked = this.seekGrace;
this.seekGrace = false;
if (!state) {
// C3.5: a 204 / no-active-device response. AFTER our own track has been
// seen playing, librespot going idle means the track ended — emit once so
// the queue advances instead of stalling. BEFORE any play (hasPlayed
// false), a null state is just "nothing active yet" and is ignored.
// C1(pause-skip) residual: while WE hold a self-initiated pause, a long
// pause can let the Connect device idle out to 204/null. That is still
// "paused", NOT a track end — do nothing this poll, or we'd skip the
// paused track. Once resume() clears `paused`, a genuinely-dead device
// (persistent null) is detected and skipped on the next poll as before.
if (this.hasPlayed && this.currentUri && !this.endedForCurrent && !this.paused) {
this.endedForCurrent = true;
const endedUri = this.currentUri;
this.currentUri = null;
this.positionMs = 0;
const e: SpotifyTrackEndedEvent = { uri: endedUri, reason: "ended" };
this.emit("trackEnded", e);
}
return;
}
// R4-4 (multi-bot): is the account's single ACTIVE Connect device ours? The
// Premium account allows one active playback stream, so if another bot stole
// the session, GET /v1/me/player now reports THAT device + its track. Only
// when the active device is foreign (we know our id AND the state names a
// DIFFERENT active id) do we refuse to treat this poll as our own track:
// skip metadata/track-change (so B's now-playing isn't surfaced as ours),
// don't advance our position/hasPlayed off foreign playback, and feed the
// stop/null two-poll detection so the stolen session cleanly ends OUR track
// and advances OUR queue instead of thrashing/misattributing. LENIENT: if
// our id is unknown or activeDeviceId is absent we can't tell, so we fall
// back to today's behavior byte-for-byte (single-bot: activeDeviceId === ours).
const foreignActive =
this.deviceId != null &&
state.activeDeviceId != null &&
state.activeDeviceId !== this.deviceId;
// Don't misattribute a foreign device's playback position as ours.
if (!foreignActive) this.positionMs = state.progressMs;
// Track change -> reset the end-detection state and surface best-effort
// metadata. Skipped entirely when a foreign device is active: its uri is NOT
// our track, so adopting it / emitting metadata would surface another bot's
// now-playing as ours and later misread that bot's stop as our track's end.
if (!foreignActive && state.trackUri && state.trackUri !== this.currentUri) {
this.currentUri = state.trackUri;
this.hasPlayed = false;
this.endedForCurrent = false;
this.stopSeen = false; // new track -> drop any pending stop confirmation
const np: SpotifyNowPlaying = {
uri: state.trackUri,
name: "",
artist: "",
album: "",
coverUrl: "",
durationMs: state.durationMs,
};
this.emit("metadata", np);
}
// R4-4: only OUR device actually playing counts as our track playing. When a
// foreign device is active, `state.isPlaying` reflects THAT bot's playback,
// not ours — so it must not mark hasPlayed or clear the stop confirmation.
const ourTrackPlaying = state.isPlaying && !foreignActive;
if (ourTrackPlaying) {
this.hasPlayed = true;
// Real playback observed -> the I4 degrade-to-skip watchdog is moot.
this.clearPlaybackWatchdog();
}
// Clearly playing our track again (is_playing AND a real item) -> any earlier
// transient is_playing:false (buffering) or null item (Connect handoff /
// market relink) was a hiccup; drop the pending two-poll end confirmation.
// A null item under is_playing:true is NOT "clearly playing" and must NOT
// reset the confirmation, or a genuine null-item end could never accumulate
// its second poll.
if (ourTrackPlaying && state.trackUri !== null) {
this.stopSeen = false;
}
if (!this.currentUri || this.endedForCurrent) return;
// C3.4: EVERY end condition is gated on hasPlayed so no end can fire until
// the bot's own track has actually been observed playing. Without this, the
// first poll (before playTrack) could observe the account's stale/paused
// track sitting near its end and spuriously emit "trackEnded".
// m(sub-window): only apply the near-end window when the track is LONGER
// than the window. For durationMs in [1, END_OF_TRACK_WINDOW_MS],
// `durationMs - window` is negative, so the old `> 0` guard made this
// unconditionally true and finished the track on its first poll. A
// sub-window track instead relies on normal stop/next-track detection.
// C1(pause-skip) residual: a self-initiated pause within the final window
// freezes progress at >= dur-window with is_playing:false and the SAME uri;
// without `!this.paused` this near-end heuristic would fire and skip the
// paused track. resume() clears `paused`, so a genuine natural end is still
// detected afterwards.
// R4-4: progress/duration under a foreign-active state belong to another
// bot's track, so the near-end heuristic must not fire off them. A stolen
// session ends OUR track through the stop/null two-poll path below instead.
const finishedByProgress =
this.hasPlayed &&
!this.paused &&
!foreignActive &&
state.durationMs > END_OF_TRACK_WINDOW_MS &&
state.progressMs >= state.durationMs - END_OF_TRACK_WINDOW_MS;
// C1(pause-skip) + R3-1(null-item): a self-initiated pause (this.paused)
// reports is_playing:false (and can idle to a null item) with the SAME uri —
// that is NOT a track end, so never finish while paused. Beyond pause, the
// two "not clearly playing our track" signals — an external stop
// (is_playing:false) and a null item (trackUri===null) — are BOTH momentary
// at the boundaries (buffering; Connect handoff / market relink), so they
// share ONE two-poll confirmation (stopSeen): require the signal to persist
// across two consecutive non-paused polls before ending. A single transient
// stop OR null is absorbed; a confirmed end still emits within ~one extra
// poll interval, and a genuine end (item stays null / stopped across two
// polls) still fires exactly once.
// R4-4: a foreign device becoming the active one means OUR device is no
// longer playing our track — the same signal as an external stop / null
// item, so it shares the two-poll confirmation: one foreign poll is
// unconfirmed (could be a transient handoff), two consecutive foreign polls
// cleanly end our track and advance our queue.
let finishedByStopOrNull = false;
if (
this.hasPlayed &&
!this.paused &&
(foreignActive || !state.isPlaying || state.trackUri === null)
) {
if (this.stopSeen) {
finishedByStopOrNull = true;
} else {
this.stopSeen = true; // first such poll — await confirmation
}
}
// R4-6: a deliberate seek into the near-end window suppresses this single
// finishedByProgress. `justSeeked` was already consumed above, so the NEXT
// in-window poll finishes normally — the grace never permanently disables
// near-end detection, and it never touches the stop/null two-poll path.
if ((finishedByProgress && !justSeeked) || finishedByStopOrNull) {
this.endedForCurrent = true; // latch: emit at most once per track
const endedUri = this.currentUri;
this.currentUri = null;
this.positionMs = 0;
const e: SpotifyTrackEndedEvent = { uri: endedUri, reason: "ended" };
this.emit("trackEnded", e);
}
}
isReady(): boolean {
return this.ready;
}
async playTrack(uri: string): Promise<void> {
const deviceId = await this.connect.findDeviceByName(this.opts.deviceName);
if (!deviceId) throw new Error(`Connect device "${this.opts.deviceName}" not found`);
// R4-4: persist OUR device id so control (pause/resume/seek) is device-scoped
// and pollState can distinguish our device from a foreign one that stole the
// shared account's single active Connect session.
this.deviceId = deviceId;
// Reset the track-end state machine for the new track: clear the once-only
// latch and drop hasPlayed so no end can fire until a poll re-confirms this
// uri playing. currentUri is cleared so the next poll re-detects the track
// (fresh metadata) rather than treating it as unchanged. Arming here is the
// primary guarantee that no end/metadata can fire before the bot plays.
// Cancel the previous track's degrade-to-skip watchdog first (at-most-one
// trackEnded per track, I4).
this.clearPlaybackWatchdog();
this.currentUri = null;
this.hasPlayed = false;
this.endedForCurrent = false;
this.armed = true;
// A newly-started track is not paused, and carries no pending stop
// confirmation from the previous track. (C1 pause-skip.)
this.paused = false;
this.stopSeen = false;
// R4-6: a fresh track carries no pending seek grace from the previous one.
this.seekGrace = false;
// transfer(false) activates our device WITHOUT starting audio; play() then
// actually starts the uri. The two-step is required — transfer alone won't
// begin playback.
await this.connect.transfer(deviceId, false);
await this.connect.play(deviceId, uri);
// I4 (§13): arm the "did it actually start playing?" watchdog. If our own
// track is never seen playing within the window, degrade-to-skipped so the
// queue advances instead of hanging on a silent, persistently-failing play.
this.armPlaybackWatchdog(uri);
}
/**
* I4 (§13) degrade-to-skip watchdog. Arms a bounded timer; if our own track
* has not been observed playing by the time it fires, emit a single
* trackEnded{reason:"error"} so the controller re-emits it and BotInstance
* advances the queue. Cleared when real playback is observed, on stop(), and
* at the start of a new playTrack().
*/
private armPlaybackWatchdog(uri: string): void {
this.clearPlaybackWatchdog();
const timeoutMs = this.deps.playbackStartTimeoutMs ?? DEFAULT_PLAYBACK_START_TIMEOUT_MS;
const setTimer =
this.deps.setTimeout ??
((cb: () => void, ms: number) => {
const t = setTimeout(cb, ms);
(t as { unref?: () => void }).unref?.();
return t;
});
this.watchdogTimer = setTimer(() => {
this.watchdogTimer = null;
this.onPlaybackStartTimeout(uri);
}, timeoutMs);
}
private clearPlaybackWatchdog(): void {
if (this.watchdogTimer == null) return;
const clearTimer =
this.deps.clearTimeout ??
((h: unknown) => clearTimeout(h as ReturnType<typeof setTimeout>));
clearTimer(this.watchdogTimer);
this.watchdogTimer = null;
}
/**
* Fired when the playback-start window elapses with no observed playback.
* C3.6: never throws up the queue path. Guarded by the same endedForCurrent
* latch as normal end-detection so at most one trackEnded per track (no
* double-emit with the poll-loop end detection).
*/
private onPlaybackStartTimeout(uri: string): void {
// Real playback was seen (hasPlayed) or the track already ended -> moot.
if (this.hasPlayed || this.endedForCurrent) return;
this.endedForCurrent = true; // latch
this.currentUri = null;
this.positionMs = 0;
const e: SpotifyTrackEndedEvent = { uri, reason: "error" };
this.emit("trackEnded", e);
}
async pause(): Promise<void> {
// R4-4: device-scope to OUR device so bot A's pause / auto-pause-when-alone
// can't pause whatever device is currently active for the shared account
// (= bot B's playback). Falls back to account-wide only before first play.
await this.connect.pause(this.deviceId ?? undefined);
// C1(pause-skip): mark our own pause so the next poll's is_playing:false
// (same uri) is not misread as a track end and skipped.
this.paused = true;
}
async resume(): Promise<void> {
// R4-4: device-scope to OUR device (see pause()).
await this.connect.resume(this.deviceId ?? undefined);
// Resumed -> normal end-detection applies again.
this.paused = false;
}
async seek(ms: number): Promise<void> {
// R4-4: device-scope to OUR device (see pause()).
await this.connect.seek(ms, this.deviceId ?? undefined);
this.positionMs = ms;
// R4-6: arm the one-poll grace so a seek landing inside the final
// END_OF_TRACK_WINDOW_MS is not immediately treated as a natural end.
this.seekGrace = true;
}
getPcmStream(): Readable {
const out = this.ffmpeg?.stdout;
if (!out) throw new Error("PCM stream unavailable (rust-librespot backend not started)");
return out;
}
getPositionMs(): number {
return this.positionMs;
}
stop(): void {
this.ready = false;
// Disarm the I4 degrade-to-skip watchdog so a stopped backend never emits.
this.clearPlaybackWatchdog();
// Clear the state poll interval FIRST so no poll fires mid-teardown.
if (this.pollTimer) {
clearInterval(this.pollTimer);
this.pollTimer = null;
}
if (this.proc) {
try {
this.proc.kill();
} catch {
/* ignore */
}
this.proc = null;
}
if (this.ffmpeg) {
try {
this.ffmpeg.kill();
} catch {
/* ignore */
}
this.ffmpeg = null;
}
}
}
+587
View File
@@ -0,0 +1,587 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import {
mkdtempSync,
rmSync,
readdirSync,
readFileSync,
renameSync,
statSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
SpotifyOAuth,
SPOTIFY_CONTROL_SCOPES,
generateCodeVerifier,
codeChallengeS256,
createFileOAuthTokenStore,
type OAuthTokens,
type OAuthTokenStore,
} from "./spotify-oauth.js";
// Wrap the fs functions the token store uses in call-through spies so the
// atomic-write path (temp file + rename) can be observed/forced. Everything else
// (mkdtemp, rmSync, readdir, …) is the real implementation via `...actual`, so
// all other tests keep real filesystem behavior. `vi.spyOn` can't be used here
// because the node:fs ESM namespace is non-configurable in this setup.
vi.mock("node:fs", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:fs")>();
return {
...actual,
writeFileSync: vi.fn(actual.writeFileSync),
renameSync: vi.fn(actual.renameSync),
};
});
// Correction C3.2: the control OAuth REQUIRES a user-provided client_id (their
// own Spotify Developer app) + caller-supplied loopback redirect. There is NO
// librespot public-client / :5588 default.
const CLIENT_ID = "test-client-id";
const REDIRECT_URI = "http://127.0.0.1:8888/api/spotify/callback";
/** In-memory store exposing `.value` so tests can assert persistence. */
function memStore(
initial: OAuthTokens | null = null,
): OAuthTokenStore & { value: OAuthTokens | null } {
const s = {
value: initial,
load() {
return s.value;
},
save(t: OAuthTokens) {
s.value = t;
},
clear() {
s.value = null;
},
};
return s;
}
/** A manually-settled promise, to hold a token POST "in flight" during a test. */
function deferred<T>() {
let resolve!: (v: T) => void;
let reject!: (e: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
describe("PKCE helpers", () => {
it("generateCodeVerifier returns 64 chars from the unreserved set", () => {
const v = generateCodeVerifier();
expect(v).toHaveLength(64);
expect(v).toMatch(/^[A-Za-z0-9\-._~]{64}$/);
expect(generateCodeVerifier()).not.toBe(v); // random
});
it("codeChallengeS256 is base64url(sha256) with no padding (43 chars)", () => {
const c = codeChallengeS256("abc123");
expect(c).toHaveLength(43); // 32-byte digest -> 43 base64url chars
expect(c).not.toContain("=");
expect(c).toMatch(/^[A-Za-z0-9_-]+$/);
});
});
describe("SpotifyOAuth.buildAuthorizeUrl", () => {
it("builds accounts.spotify.com/authorize with the caller's clientId + redirectUri + S256", () => {
const oauth = new SpotifyOAuth({
clientId: CLIENT_ID,
redirectUri: REDIRECT_URI,
store: memStore(),
});
const { url, state } = oauth.buildAuthorizeUrl();
const u = new URL(url);
expect(u.origin + u.pathname).toBe("https://accounts.spotify.com/authorize");
const p = u.searchParams;
expect(p.get("client_id")).toBe(CLIENT_ID);
expect(p.get("response_type")).toBe("code");
expect(p.get("redirect_uri")).toBe(REDIRECT_URI);
expect(p.get("code_challenge_method")).toBe("S256");
expect(p.get("code_challenge")).toHaveLength(43);
expect(p.get("scope")).toBe(SPOTIFY_CONTROL_SCOPES);
expect(p.get("state")).toBe(state);
expect(state).toMatch(/^[0-9a-f]{32}$/);
expect(oauth.getClientId()).toBe(CLIENT_ID);
expect(oauth.getRedirectUri()).toBe(REDIRECT_URI);
});
// Correction C3.2: no clientId => cannot start OAuth; throw a clear message.
it("throws a clear error when clientId is empty", () => {
const oauth = new SpotifyOAuth({ redirectUri: REDIRECT_URI, store: memStore() });
expect(() => oauth.buildAuthorizeUrl()).toThrow(/Client ID/i);
});
});
describe("SpotifyOAuth.isAuthorized (C3.2)", () => {
it("is false without a clientId even if a refresh token is stored", () => {
const store = memStore({
accessToken: "a",
refreshToken: "r",
expiresAt: Date.now() + 60_000,
scope: "s",
});
const oauth = new SpotifyOAuth({ redirectUri: REDIRECT_URI, store });
expect(oauth.isAuthorized()).toBe(false);
});
it("is true with a clientId and a stored refresh token", () => {
const store = memStore({
accessToken: "a",
refreshToken: "r",
expiresAt: Date.now() + 60_000,
scope: "s",
});
const oauth = new SpotifyOAuth({ clientId: CLIENT_ID, store });
expect(oauth.isAuthorized()).toBe(true);
});
});
describe("SpotifyOAuth.handleCallback", () => {
it("exchanges the code (PKCE verifier matches the authorize challenge) and persists tokens", async () => {
const store = memStore();
const http = {
post: vi.fn().mockResolvedValue({
data: {
access_token: "a1",
refresh_token: "r1",
expires_in: 3600,
scope: SPOTIFY_CONTROL_SCOPES,
},
}),
} as any;
const oauth = new SpotifyOAuth({
clientId: CLIENT_ID,
redirectUri: REDIRECT_URI,
store,
deps: { http },
});
const { url, state } = oauth.buildAuthorizeUrl();
const challenge = new URL(url).searchParams.get("code_challenge")!;
const ok = await oauth.handleCallback("CODE123", state);
expect(ok).toBe(true);
const [path, bodyStr, cfg] = http.post.mock.calls[0];
expect(path).toBe("/api/token");
expect(cfg.headers["Content-Type"]).toBe("application/x-www-form-urlencoded");
const body = new URLSearchParams(bodyStr as string);
expect(body.get("grant_type")).toBe("authorization_code");
expect(body.get("code")).toBe("CODE123");
expect(body.get("redirect_uri")).toBe(REDIRECT_URI);
expect(body.get("client_id")).toBe(CLIENT_ID);
// The verifier sent MUST hash to the challenge advertised in the authorize URL.
const verifier = body.get("code_verifier")!;
expect(codeChallengeS256(verifier)).toBe(challenge);
expect(store.value?.accessToken).toBe("a1");
expect(store.value?.refreshToken).toBe("r1");
expect(store.value?.expiresAt).toBeGreaterThan(Date.now());
expect(oauth.isAuthorized()).toBe(true);
});
it("rejects an unknown state without calling the token endpoint (CSRF guard)", async () => {
const http = { post: vi.fn() } as any;
const oauth = new SpotifyOAuth({
clientId: CLIENT_ID,
redirectUri: REDIRECT_URI,
store: memStore(),
deps: { http },
});
expect(await oauth.handleCallback("CODE", "not-a-real-state")).toBe(false);
expect(http.post).not.toHaveBeenCalled();
});
// Correction C3.7: the state->verifier entry is deleted on EVERY terminal
// path (finally), so a failed login never leaks it and cannot be replayed.
it("deletes the pending verifier even when the token exchange fails", async () => {
const http = {
post: vi.fn().mockRejectedValue({
response: { status: 400, data: { error: "invalid_grant" } },
}),
} as any;
const oauth = new SpotifyOAuth({
clientId: CLIENT_ID,
redirectUri: REDIRECT_URI,
store: memStore(),
deps: { http },
});
const { state } = oauth.buildAuthorizeUrl();
// First attempt fails at the network/token step.
expect(await oauth.handleCallback("CODE", state)).toBe(false);
expect(http.post).toHaveBeenCalledTimes(1);
// Replaying the same state now fails the CSRF guard (verifier was deleted),
// WITHOUT hitting the token endpoint again.
expect(await oauth.handleCallback("CODE", state)).toBe(false);
expect(http.post).toHaveBeenCalledTimes(1);
});
});
describe("SpotifyOAuth.getAccessToken", () => {
it("returns the cached token without refreshing when still valid", async () => {
const http = { post: vi.fn() } as any;
const store = memStore({
accessToken: "cached",
refreshToken: "r1",
expiresAt: Date.now() + 60_000,
scope: "s",
});
const oauth = new SpotifyOAuth({ clientId: CLIENT_ID, store, deps: { http } });
expect(await oauth.getAccessToken()).toBe("cached");
expect(http.post).not.toHaveBeenCalled();
});
it("refreshes when expired and persists the ROTATED refresh token", async () => {
const store = memStore({
accessToken: "old",
refreshToken: "r1",
expiresAt: Date.now() - 1000,
scope: "s",
});
const http = {
post: vi.fn().mockResolvedValue({
data: { access_token: "a2", refresh_token: "r2", expires_in: 3600 },
}),
} as any;
const oauth = new SpotifyOAuth({ clientId: CLIENT_ID, store, deps: { http } });
expect(await oauth.getAccessToken()).toBe("a2");
const body = new URLSearchParams(http.post.mock.calls[0][1] as string);
expect(body.get("grant_type")).toBe("refresh_token");
expect(body.get("refresh_token")).toBe("r1");
expect(body.get("client_id")).toBe(CLIENT_ID);
expect(store.value?.refreshToken).toBe("r2"); // rotated + persisted
expect(store.value?.accessToken).toBe("a2");
});
it("keeps the old refresh token when the refresh response omits a new one", async () => {
const store = memStore({
accessToken: "old",
refreshToken: "r1",
expiresAt: Date.now() - 1000,
scope: "s",
});
const http = {
post: vi.fn().mockResolvedValue({ data: { access_token: "a2", expires_in: 3600 } }),
} as any;
const oauth = new SpotifyOAuth({ clientId: CLIENT_ID, store, deps: { http } });
expect(await oauth.getAccessToken()).toBe("a2");
expect(store.value?.refreshToken).toBe("r1");
});
it("clears the store and returns null on invalid_grant (expired refresh token)", async () => {
const store = memStore({
accessToken: "old",
refreshToken: "r1",
expiresAt: Date.now() - 1000,
scope: "s",
});
const http = {
post: vi.fn().mockRejectedValue({
response: { status: 400, data: { error: "invalid_grant" } },
}),
} as any;
const oauth = new SpotifyOAuth({ clientId: CLIENT_ID, store, deps: { http } });
expect(await oauth.getAccessToken()).toBeNull();
expect(store.value).toBeNull();
expect(oauth.isAuthorized()).toBe(false);
});
it("returns null when unauthorized (no stored refresh token)", async () => {
const http = { post: vi.fn() } as any;
const oauth = new SpotifyOAuth({
clientId: CLIENT_ID,
store: memStore(),
deps: { http },
});
expect(await oauth.getAccessToken()).toBeNull();
expect(oauth.isAuthorized()).toBe(false);
expect(http.post).not.toHaveBeenCalled();
});
});
// S4.3: refresh-token rotation makes two concurrent refreshes race — the second
// would POST with a token the first already invalidated. Collapse them into one.
describe("SpotifyOAuth.getAccessToken (in-flight refresh, S4.3)", () => {
it("collapses concurrent refreshes into a single token POST", async () => {
let t = 0;
const now = () => t;
const store = memStore({
accessToken: "old",
refreshToken: "r1",
expiresAt: 0, // now()=0 is not < 0 -> expired -> must refresh
scope: "s",
});
const d = deferred<any>();
const http = { post: vi.fn().mockReturnValue(d.promise) } as any;
const oauth = new SpotifyOAuth({
clientId: CLIENT_ID,
store,
deps: { http, now },
});
// Fire two calls while the POST is still pending.
const p1 = oauth.getAccessToken();
const p2 = oauth.getAccessToken();
expect(http.post).toHaveBeenCalledTimes(1); // collapsed to ONE POST
d.resolve({
data: { access_token: "a2", refresh_token: "r2", expires_in: 3600 },
});
expect(await p1).toBe("a2");
expect(await p2).toBe("a2");
expect(http.post).toHaveBeenCalledTimes(1);
expect(store.value?.refreshToken).toBe("r2"); // rotated once, not twice
});
it("clears the in-flight refresh after it settles, allowing a later refresh", async () => {
let t = 0;
const now = () => t;
const store = memStore({
accessToken: "old",
refreshToken: "r1",
expiresAt: 0,
scope: "s",
});
const http = {
post: vi
.fn()
.mockResolvedValueOnce({
data: { access_token: "a2", refresh_token: "r2", expires_in: 3600 },
})
.mockResolvedValueOnce({
data: { access_token: "a3", refresh_token: "r3", expires_in: 3600 },
}),
} as any;
const oauth = new SpotifyOAuth({
clientId: CLIENT_ID,
store,
deps: { http, now },
});
expect(await oauth.getAccessToken()).toBe("a2");
expect(http.post).toHaveBeenCalledTimes(1);
// toTokens now uses this.now(): saved expiresAt = 0 + 3600s - 30s skew.
// Still valid at t=0 -> cached, no new POST.
expect(await oauth.getAccessToken()).toBe("a2");
expect(http.post).toHaveBeenCalledTimes(1);
// Advance past the newly-saved expiry -> in-flight was cleared, so a fresh
// refresh fires (proves .finally() reset refreshInFlight).
t = 3600 * 1000;
expect(await oauth.getAccessToken()).toBe("a3");
expect(http.post).toHaveBeenCalledTimes(2);
expect(store.value?.refreshToken).toBe("r3");
});
});
// S4.3: bound the PKCE verifier map so abandoned logins can't accumulate.
describe("SpotifyOAuth PKCE verifier TTL + cap (S4.3)", () => {
it("expires a pending verifier after the TTL (handleCallback returns false)", async () => {
let t = 0;
const now = () => t;
const http = { post: vi.fn() } as any;
const oauth = new SpotifyOAuth({
clientId: CLIENT_ID,
redirectUri: REDIRECT_URI,
store: memStore(),
deps: { http, now },
});
const { state } = oauth.buildAuthorizeUrl();
t = 10 * 60 * 1000 + 1; // VERIFIER_TTL_MS + 1
expect(await oauth.handleCallback("CODE", state)).toBe(false);
expect(http.post).not.toHaveBeenCalled(); // never reached the token step
});
it("caps the verifier map, evicting the oldest state (behavioral)", async () => {
let t = 0;
const now = () => t;
const http = {
post: vi.fn().mockResolvedValue({
data: { access_token: "a1", refresh_token: "r1", expires_in: 3600 },
}),
} as any;
const oauth = new SpotifyOAuth({
clientId: CLIENT_ID,
redirectUri: REDIRECT_URI,
store: memStore(),
deps: { http, now },
});
// First (oldest) state, then enough more to exceed VERIFIER_MAX (32).
const first = oauth.buildAuthorizeUrl().state;
let last = first;
for (let i = 0; i < 32; i++) last = oauth.buildAuthorizeUrl().state;
// The oldest was evicted -> unknown state -> CSRF guard, no token POST.
expect(await oauth.handleCallback("CODE", first)).toBe(false);
expect(http.post).not.toHaveBeenCalled();
// A still-pending (newest) state DOES resolve -> only the oldest was dropped.
expect(await oauth.handleCallback("CODE", last)).toBe(true);
expect(http.post).toHaveBeenCalledTimes(1);
});
});
// Whole-branch I2: a UI-entered Client ID (saved in Settings) must reach the
// single live SpotifyOAuth WITHOUT a process restart. configure() re-arms the
// runtime credentials; an empty clientId re-disables OAuth.
describe("SpotifyOAuth.configure (runtime credentials, whole-branch I2)", () => {
it("applies a UI-entered clientId + redirectUri so OAuth arms without a restart", () => {
// Fresh install: boot-time config had no clientId, so OAuth is disabled.
const store = memStore({
accessToken: "a",
refreshToken: "r",
expiresAt: Date.now() + 60_000,
scope: "s",
});
const oauth = new SpotifyOAuth({ clientId: "", store });
expect(oauth.isAuthorized()).toBe(false);
expect(() => oauth.buildAuthorizeUrl()).toThrow(/Client ID/i);
// Operator enters creds in Settings -> POST /settings calls configure().
const REDIRECT = "http://127.0.0.1:3000/api/spotify/callback";
oauth.configure("cid", REDIRECT);
expect(oauth.getClientId()).toBe("cid");
expect(oauth.getRedirectUri()).toBe(REDIRECT);
// Now authorize + isAuthorized work against the newly-supplied app.
expect(oauth.isAuthorized()).toBe(true);
const { url } = oauth.buildAuthorizeUrl();
const p = new URL(url).searchParams;
expect(p.get("client_id")).toBe("cid");
expect(p.get("redirect_uri")).toBe(REDIRECT);
});
it("trims the clientId and re-disables OAuth when cleared", () => {
const oauth = new SpotifyOAuth({
clientId: "old",
redirectUri: "http://old",
store: memStore(),
});
oauth.configure(" cid ", "http://127.0.0.1:3000/api/spotify/callback");
expect(oauth.getClientId()).toBe("cid"); // trimmed
// Empty clientId disables OAuth again (gate on buildAuthorizeUrl/isAuthorized).
oauth.configure("");
expect(oauth.getClientId()).toBe("");
expect(oauth.getRedirectUri()).toBe("");
expect(oauth.isAuthorized()).toBe(false);
expect(() => oauth.buildAuthorizeUrl()).toThrow(/Client ID/i);
});
});
describe("createFileOAuthTokenStore", () => {
it("round-trips save/load and clear() removes it", () => {
const dir = mkdtempSync(join(tmpdir(), "sp-oauth-"));
const file = join(dir, "nested", "tokens.json");
try {
const store = createFileOAuthTokenStore(file);
expect(store.load()).toBeNull(); // missing file
const t: OAuthTokens = {
accessToken: "a",
refreshToken: "r",
expiresAt: 123,
scope: "s",
};
store.save(t);
expect(store.load()).toEqual(t);
store.clear();
expect(store.load()).toBeNull();
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
// R3-5: the token store save() must be atomic (same-dir temp file + rename), so
// a crash / power loss / ENOSPC while persisting a ROTATED refresh token (Spotify
// invalidates the OLD one the instant it responds — the new one lives only in
// memory until this write lands) can never truncate the file and silently
// de-authenticate the operator. Mirrors the hardened config.ts saveConfig.
describe("createFileOAuthTokenStore atomic write (R3-5)", () => {
const dirs: string[] = [];
function makeTmpDir(): string {
const dir = mkdtempSync(join(tmpdir(), "sp-oauth-atomic-"));
dirs.push(dir);
return dir;
}
beforeEach(() => {
vi.clearAllMocks(); // reset call history, keep the call-through implementations
});
afterEach(() => {
for (const d of dirs) rmSync(d, { recursive: true, force: true });
dirs.length = 0;
});
const TOK: OAuthTokens = {
accessToken: "a",
refreshToken: "r",
expiresAt: 123,
scope: "s",
};
it("round-trips save/load and leaves NO .tmp file behind", () => {
const dir = makeTmpDir();
const file = join(dir, "tokens.json");
const store = createFileOAuthTokenStore(file);
store.save(TOK);
expect(store.load()).toEqual(TOK);
// No temp remnants in the target directory.
expect(readdirSync(dir).filter((f) => f.includes(".tmp"))).toEqual([]);
});
it("writes via a same-dir temp file then renameSync onto the final path", () => {
const dir = makeTmpDir();
const file = join(dir, "tokens.json");
createFileOAuthTokenStore(file).save(TOK);
expect(vi.mocked(renameSync)).toHaveBeenCalled();
const [from, to] = vi.mocked(renameSync).mock.calls[0] as [string, string];
expect(to).toBe(file); // renamed ONTO the real path
expect(String(from)).not.toBe(file); // ...from a distinct temp file
expect(join(String(from), "..")).toBe(join(file, "..")); // ...in the SAME dir
});
it("persists the token file with 0600 permissions (POSIX)", () => {
if (process.platform === "win32") return; // mode bits aren't meaningful on Windows
const dir = makeTmpDir();
const file = join(dir, "tokens.json");
createFileOAuthTokenStore(file).save(TOK);
expect(statSync(file).mode & 0o777).toBe(0o600);
});
it("does NOT truncate/corrupt a pre-existing valid token file when the write fails mid-way", () => {
const dir = makeTmpDir();
const file = join(dir, "tokens.json");
const store = createFileOAuthTokenStore(file);
// A valid, previously-persisted token file (the live refresh token on disk).
store.save(TOK);
const before = readFileSync(file, "utf-8");
// Simulate a crash / ENOSPC at the atomic-replace step while persisting a
// ROTATED refresh token.
const rotated: OAuthTokens = { ...TOK, accessToken: "a2", refreshToken: "r2" };
vi.mocked(renameSync).mockImplementationOnce(() => {
throw new Error("rename boom");
});
expect(() => store.save(rotated)).toThrow(/rename boom/);
// The original file is untouched: present, byte-identical, still parseable.
expect(readFileSync(file, "utf-8")).toBe(before);
expect(store.load()).toEqual(TOK);
// ...and the failed write left no temp file lying around.
expect(readdirSync(dir).filter((f) => f.includes(".tmp"))).toEqual([]);
});
});
+298
View File
@@ -0,0 +1,298 @@
import axios, { type AxiosInstance } from "axios";
import { createHash, randomBytes } from "node:crypto";
import {
existsSync,
mkdirSync,
readFileSync,
renameSync,
rmSync,
writeFileSync,
} from "node:fs";
import { dirname } from "node:path";
/**
* Player-control scopes requested for the USER token: streaming (drives
* librespot as a Connect device) + read/modify playback + currently-playing +
* private-playlist reads.
*/
export const SPOTIFY_CONTROL_SCOPES =
"streaming user-read-playback-state user-modify-playback-state user-read-currently-playing playlist-read-private";
const ACCOUNTS_BASE = "https://accounts.spotify.com";
// Hand a token back only if it survives ~30s, matching webapi.ts's skew.
const EXPIRY_SKEW_MS = 30_000;
// RFC 7636 §4.1 unreserved set: [A-Za-z0-9-._~].
const PKCE_CHARS =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~";
const FORM_HEADERS = { "Content-Type": "application/x-www-form-urlencoded" };
export interface OAuthTokens {
accessToken: string;
refreshToken: string;
expiresAt: number;
scope: string;
}
export interface OAuthTokenStore {
load(): OAuthTokens | null;
save(t: OAuthTokens): void;
clear(): void;
}
export interface SpotifyOAuthOptions {
/**
* Correction C3.2: the caller's OWN Spotify Developer app client_id. There is
* no librespot-public-client fallback — an empty clientId disables OAuth.
*/
clientId?: string;
/**
* Loopback redirect registered on the caller's Spotify app, supplied by the
* bot's web layer (e.g. its `/api/spotify/callback`). Must exactly match the
* value used at both the authorize and token steps.
*/
redirectUri?: string;
store: OAuthTokenStore;
deps?: { http?: AxiosInstance; now?: () => number };
}
/** 64 random chars from the PKCE unreserved set (43-128 allowed by the spec). */
export function generateCodeVerifier(): string {
const bytes = randomBytes(64);
let out = "";
for (let i = 0; i < 64; i++) out += PKCE_CHARS[bytes[i] % PKCE_CHARS.length];
return out;
}
/** base64url(SHA256(verifier)) with no padding — the S256 code challenge. */
export function codeChallengeS256(verifier: string): string {
return createHash("sha256").update(verifier).digest("base64url");
}
/** Persist OAuth tokens as a 0600 JSON file (used by the controller). */
export function createFileOAuthTokenStore(filePath: string): OAuthTokenStore {
return {
load() {
try {
if (!existsSync(filePath)) return null;
const parsed = JSON.parse(readFileSync(filePath, "utf8"));
return parsed?.refreshToken ? (parsed as OAuthTokens) : null;
} catch {
return null; // missing/corrupt -> treat as unauthorized
}
},
save(t: OAuthTokens) {
mkdirSync(dirname(filePath), { recursive: true });
// Atomic write: serialize to a sibling temp file in the SAME directory,
// then rename it onto the final path. rename is an atomic replace on POSIX
// and modern Windows, so a crash / power loss / ENOSPC mid-write can never
// leave the token file truncated — a reader always sees either the previous
// file or the fully-written new one, never a partial. This matters because
// refresh() persists a ROTATED refresh token here: Spotify invalidates the
// OLD one the instant it responds, so a torn write would silently
// de-authenticate the operator (next load() JSON.parse-fails -> null ->
// full PKCE re-login). Mirrors config.ts saveConfig. The temp lives in the
// same dir so the rename stays on one filesystem; pid + timestamp keep
// concurrent writers from colliding on the temp name. 0600 is preserved.
const tmp = `${filePath}.${process.pid}.${Date.now()}.tmp`;
try {
writeFileSync(tmp, JSON.stringify(t, null, 2), { mode: 0o600 });
renameSync(tmp, filePath);
} catch (err) {
// Never leave a partial temp file behind on failure.
try {
rmSync(tmp, { force: true });
} catch {
/* best-effort cleanup */
}
throw err;
}
},
clear() {
try {
rmSync(filePath, { force: true });
} catch {
/* already gone */
}
},
};
}
/**
* Authorization Code + PKCE flow for the USER player-control token. Public
* client (no secret).
*
* Correction C3.2: this REQUIRES the operator's own registered Spotify app —
* there is NO reuse of librespot's first-party keymaster client / fixed
* :5588 redirect. Without a clientId, `isAuthorized()` is false and
* `buildAuthorizeUrl()` throws.
*
* Refresh rotates the refresh token, so the newest is always persisted;
* invalid_grant clears the store (re-login required). Access/refresh tokens
* are never logged.
*/
export class SpotifyOAuth {
private clientId: string;
private redirectUri: string;
private store: OAuthTokenStore;
private http: AxiosInstance;
// Injectable clock (tests drive a mutable now); defaults to Date.now.
private now: () => number;
// Pending PKCE verifiers keyed by state, awaiting the loopback redirect back.
private pendingVerifiers = new Map<
string,
{ verifier: string; expiresAt: number }
>();
// A verifier is abandoned if the redirect never returns; drop it after TTL and
// cap the map so parallel logins can't grow it without bound.
private static readonly VERIFIER_TTL_MS = 10 * 60 * 1000;
private static readonly VERIFIER_MAX = 32;
// Collapse concurrent refreshes into one POST (rotation invalidates the token
// a second in-flight refresh would send); cleared in .finally().
private refreshInFlight: Promise<string | null> | null = null;
constructor(o: SpotifyOAuthOptions) {
this.clientId = o.clientId ?? "";
this.redirectUri = o.redirectUri ?? "";
this.store = o.store;
this.http =
o.deps?.http ?? axios.create({ baseURL: ACCOUNTS_BASE, timeout: 15_000 });
this.now = o.deps?.now ?? (() => Date.now());
}
private evictStaleVerifiers(): void {
const t = this.now();
for (const [state, e] of this.pendingVerifiers) {
if (e.expiresAt < t) this.pendingVerifiers.delete(state);
}
// Bound memory even if all are unexpired: drop oldest (insertion order).
while (this.pendingVerifiers.size >= SpotifyOAuth.VERIFIER_MAX) {
const oldest = this.pendingVerifiers.keys().next().value;
if (oldest === undefined) break;
this.pendingVerifiers.delete(oldest);
}
}
/** Update the operator's app credentials at runtime (from Settings save) so
* a UI-entered Client ID takes effect without a process restart. Empty
* clientId disables OAuth (isAuthorized()/buildAuthorizeUrl() gate on it). */
configure(clientId?: string, redirectUri?: string): void {
this.clientId = clientId?.trim() || "";
this.redirectUri = redirectUri || "";
}
getClientId(): string {
return this.clientId;
}
getRedirectUri(): string {
return this.redirectUri;
}
isAuthorized(): boolean {
// C3.2: no client_id means we could never refresh, so treat as unauthorized.
return !!this.clientId && !!this.store.load()?.refreshToken;
}
buildAuthorizeUrl(): { url: string; state: string } {
if (!this.clientId) {
// C3.2: cannot start OAuth against nobody's app.
throw new Error("Set your Spotify Client ID in settings first");
}
const state = randomBytes(16).toString("hex");
const verifier = generateCodeVerifier();
this.evictStaleVerifiers();
this.pendingVerifiers.set(state, {
verifier,
expiresAt: this.now() + SpotifyOAuth.VERIFIER_TTL_MS,
});
const params = new URLSearchParams({
client_id: this.clientId,
response_type: "code",
redirect_uri: this.redirectUri,
code_challenge: codeChallengeS256(verifier),
code_challenge_method: "S256",
scope: SPOTIFY_CONTROL_SCOPES,
state,
});
return { url: `${ACCOUNTS_BASE}/authorize?${params.toString()}`, state };
}
async handleCallback(code: string, state: string): Promise<boolean> {
const entry = this.pendingVerifiers.get(state);
if (!entry || entry.expiresAt < this.now()) {
// unknown/expired state -> CSRF guard (drop any stale entry too)
this.pendingVerifiers.delete(state);
return false;
}
const verifier = entry.verifier;
// C3.7: drop the state->verifier entry on EVERY terminal path (success,
// rejected token exchange, or throw) so a failed login can't leak/replay it.
try {
const body = new URLSearchParams({
grant_type: "authorization_code",
code,
redirect_uri: this.redirectUri,
client_id: this.clientId,
code_verifier: verifier,
});
const { data } = await this.http.post("/api/token", body.toString(), {
headers: FORM_HEADERS,
});
if (!data?.access_token || !data?.refresh_token) return false;
this.store.save(this.toTokens(data, data.refresh_token, data.scope));
return true;
} catch {
return false;
} finally {
this.pendingVerifiers.delete(state);
}
}
async getAccessToken(): Promise<string | null> {
if (!this.clientId) return null; // C3.2: no app => nothing to mint against
const tokens = this.store.load();
if (!tokens?.refreshToken) return null; // unauthorized
if (tokens.accessToken && this.now() < tokens.expiresAt) {
return tokens.accessToken;
}
// Collapse concurrent refreshes: rotation makes a second in-flight refresh
// use a refresh token the first one already invalidated.
if (this.refreshInFlight) return this.refreshInFlight;
this.refreshInFlight = this.refresh(tokens).finally(() => {
this.refreshInFlight = null;
});
return this.refreshInFlight;
}
private async refresh(current: OAuthTokens): Promise<string | null> {
const body = new URLSearchParams({
grant_type: "refresh_token",
refresh_token: current.refreshToken,
client_id: this.clientId,
});
try {
const { data } = await this.http.post("/api/token", body.toString(), {
headers: FORM_HEADERS,
});
if (!data?.access_token) return null;
// PKCE rotates the refresh token; fall back to the current one if omitted.
const rotated = data.refresh_token || current.refreshToken;
const saved = this.toTokens(data, rotated, data.scope ?? current.scope);
this.store.save(saved);
return saved.accessToken;
} catch (err: any) {
// invalid_grant => refresh token revoked/expired: discard, force re-login.
if (err?.response?.data?.error === "invalid_grant") this.store.clear();
return null;
}
}
private toTokens(data: any, refreshToken: string, scope: string): OAuthTokens {
return {
accessToken: data.access_token,
refreshToken,
expiresAt: this.now() + (data.expires_in ?? 3600) * 1000 - EXPIRY_SKEW_MS,
scope: scope ?? SPOTIFY_CONTROL_SCOPES,
};
}
}
+577
View File
@@ -0,0 +1,577 @@
import { describe, it, expect, vi } from "vitest";
import {
mapSpotifyTrack,
mapSpotifyTracks,
mapSpotifyAlbum,
mapSpotifyPlaylist,
isSpotifyUri,
SpotifyWebApi,
} from "./webapi.js";
describe("mapSpotifyTrack", () => {
// Shape trimmed from GET /v1/search?type=track.
const raw = {
id: "4iV5W9uYEdYUVa79Axb7Rh",
name: "Bohemian Rhapsody",
artists: [{ name: "Queen" }],
album: { name: "A Night at the Opera", images: [{ url: "https://i.scdn.co/x.jpg" }] },
duration_ms: 354320,
};
it("maps a track to a Song with platform 'spotify' and seconds duration", () => {
const s = mapSpotifyTrack(raw);
expect(s.platform).toBe("spotify");
expect(s.id).toBe("4iV5W9uYEdYUVa79Axb7Rh");
expect(s.name).toBe("Bohemian Rhapsody");
expect(s.artist).toBe("Queen");
expect(s.album).toBe("A Night at the Opera");
expect(s.duration).toBe(354); // 354320ms → 354s
expect(s.coverUrl).toBe("https://i.scdn.co/x.jpg");
});
it("joins multiple artists with ', '", () => {
const s = mapSpotifyTrack({ ...raw, artists: [{ name: "A" }, { name: "B" }] });
expect(s.artist).toBe("A, B");
});
it("tolerates missing fields", () => {
const s = mapSpotifyTrack({});
expect(s.id).toBe("");
expect(s.name).toBe("Unknown");
expect(s.artist).toBe("");
expect(s.duration).toBe(0);
expect(s.coverUrl).toBe("");
expect(s.platform).toBe("spotify");
});
it("mapSpotifyTracks returns [] for non-array input", () => {
expect(mapSpotifyTracks(undefined as any)).toEqual([]);
});
});
describe("mapSpotifyAlbum", () => {
it("maps an album with total_tracks → songCount", () => {
const a = mapSpotifyAlbum({
id: "1abc",
name: "A Night at the Opera",
artists: [{ name: "Queen" }],
images: [{ url: "https://i.scdn.co/a.jpg" }],
total_tracks: 12,
});
expect(a).toEqual({
id: "1abc",
name: "A Night at the Opera",
artist: "Queen",
coverUrl: "https://i.scdn.co/a.jpg",
songCount: 12,
platform: "spotify",
});
});
});
describe("mapSpotifyPlaylist", () => {
it("maps a playlist with tracks.total → songCount", () => {
const p = mapSpotifyPlaylist({
id: "37i9",
name: "Today's Top Hits",
images: [{ url: "https://i.scdn.co/p.jpg" }],
tracks: { total: 50 },
});
expect(p).toEqual({
id: "37i9",
name: "Today's Top Hits",
coverUrl: "https://i.scdn.co/p.jpg",
songCount: 50,
platform: "spotify",
});
});
});
describe("isSpotifyUri", () => {
it("recognizes the sentinel URI", () => {
expect(isSpotifyUri("spotify:track:4iV5W9uYEdYUVa79Axb7Rh")).toBe(true);
expect(isSpotifyUri("https://music.126.net/x.mp3")).toBe(false);
expect(isSpotifyUri("")).toBe(false);
});
});
describe("SpotifyWebApi rate-limit handling", () => {
it("retries once on 429 (honoring Retry-After) then returns data", async () => {
const auth = {
post: vi.fn().mockResolvedValue({ data: { access_token: "t", expires_in: 3600 } }),
} as any;
let call = 0;
const http = {
get: vi.fn().mockImplementation(() => {
call += 1;
if (call === 1) {
return Promise.reject({ response: { status: 429, headers: { "retry-after": "0" } } });
}
return Promise.resolve({
data: { tracks: { items: [{ id: "t1", name: "n", artists: [], duration_ms: 1000 }] } },
});
}),
} as any;
const api = new SpotifyWebApi(() => ({ clientId: "a", clientSecret: "b" }), { http, auth });
const out = await api.search("queen");
expect(http.get).toHaveBeenCalledTimes(2); // one 429, one success
expect(out.songs[0].id).toBe("t1");
});
// I5: the retry is BOUNDED — get() passes `false` on the recursive call so a
// second 429 is NOT retried. Without that bound arg this recurses forever;
// this test must FAIL (time out) if the `false` in `this.get(path, params, false)`
// is removed. retry-after "0" keeps the single permitted wait instant.
it("gives up after exactly ONE 429 retry when every call 429s (bounded, no infinite loop)", async () => {
const auth = {
post: vi.fn().mockResolvedValue({ data: { access_token: "t", expires_in: 3600 } }),
} as any;
const http = {
get: vi.fn().mockRejectedValue({
response: { status: 429, headers: { "retry-after": "0" } },
}),
} as any;
const api = new SpotifyWebApi(() => ({ clientId: "a", clientSecret: "b" }), { http, auth });
const out = await api.search("queen");
// Exactly two attempts: the original + one bounded retry, then it stops.
expect(http.get).toHaveBeenCalledTimes(2);
// Giving up returns null from get() → search yields an empty (non-throwing) result.
expect(out).toEqual({ songs: [], playlists: [], albums: [] });
});
// I5: the advised wait is capped by Math.min(retryAfter, 10). A large Retry-After
// (999s) must still wait only 10s, proving the cap. Fake timers keep it instant
// while letting us assert the retry fires at 10s, not before.
it("caps the Retry-After wait at 10s (Math.min(retryAfter, 10))", async () => {
vi.useFakeTimers();
try {
const auth = {
post: vi.fn().mockResolvedValue({ data: { access_token: "t", expires_in: 3600 } }),
} as any;
let call = 0;
const http = {
get: vi.fn().mockImplementation(() => {
call += 1;
if (call === 1) {
return Promise.reject({ response: { status: 429, headers: { "retry-after": "999" } } });
}
return Promise.resolve({
data: { tracks: { items: [{ id: "t1", name: "n", artists: [], duration_ms: 1000 }] } },
});
}),
} as any;
const api = new SpotifyWebApi(() => ({ clientId: "a", clientSecret: "b" }), { http, auth });
const p = api.search("queen");
// Let the token fetch + first (429) call settle and schedule the wait.
await vi.advanceTimersByTimeAsync(9_000); // 9s < cap → retry has NOT fired yet
expect(http.get).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(1_000); // now 10s total → cap reached, retry fires
const out = await p;
expect(http.get).toHaveBeenCalledTimes(2);
expect(out.songs[0].id).toBe("t1");
} finally {
vi.useRealTimers();
}
});
// Corner-case: a non-numeric Retry-After (HTTP-date) must NOT coerce to NaN and
// fire the retry at 0ms. `Number("Wed, 21 Oct 2025 07:28:00 GMT")` is NaN, so the
// pre-fix `Math.min(NaN,10)*1000` schedules an immediate (or never-firing) retry
// that ignores the advised backoff. Post-fix falls back to a finite 1s wait: the
// retry stays scheduled at exactly the fallback, still bounded to ONE retry.
it("falls back to a finite 1s wait when Retry-After is an HTTP-date (not NaN/immediate)", async () => {
vi.useFakeTimers();
try {
const auth = {
post: vi.fn().mockResolvedValue({ data: { access_token: "t", expires_in: 3600 } }),
} as any;
let call = 0;
const http = {
get: vi.fn().mockImplementation(() => {
call += 1;
if (call === 1) {
return Promise.reject({
response: {
status: 429,
headers: { "retry-after": "Wed, 21 Oct 2025 07:28:00 GMT" },
},
});
}
return Promise.resolve({
data: { tracks: { items: [{ id: "t1", name: "n", artists: [], duration_ms: 1000 }] } },
});
}),
} as any;
const api = new SpotifyWebApi(() => ({ clientId: "a", clientSecret: "b" }), { http, auth });
const p = api.search("queen");
// Let the token fetch + first (429) call settle and schedule the wait.
await vi.advanceTimersByTimeAsync(999); // < 1s fallback → retry has NOT fired yet
expect(http.get).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(1); // now 1s total → finite fallback reached, retry fires
const out = await p;
expect(http.get).toHaveBeenCalledTimes(2); // exactly one bounded retry
expect(out.songs[0].id).toBe("t1");
} finally {
vi.useRealTimers();
}
});
// Corner-case sibling: an empty Retry-After coerces to 0 (`Number("")` === 0), so
// pre-fix `Math.min(0,10)*1000` fires the retry immediately at 0ms. Post-fix guards
// `raw > 0`, so it falls back to the same finite 1s wait rather than firing at 0.
it("falls back to a finite 1s wait when Retry-After is empty (not 0/immediate)", async () => {
vi.useFakeTimers();
try {
const auth = {
post: vi.fn().mockResolvedValue({ data: { access_token: "t", expires_in: 3600 } }),
} as any;
let call = 0;
const http = {
get: vi.fn().mockImplementation(() => {
call += 1;
if (call === 1) {
return Promise.reject({ response: { status: 429, headers: { "retry-after": "" } } });
}
return Promise.resolve({
data: { tracks: { items: [{ id: "t1", name: "n", artists: [], duration_ms: 1000 }] } },
});
}),
} as any;
const api = new SpotifyWebApi(() => ({ clientId: "a", clientSecret: "b" }), { http, auth });
const p = api.search("queen");
await vi.advanceTimersByTimeAsync(999); // < 1s fallback → retry must NOT have fired at 0ms
expect(http.get).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(1); // 1s total → fallback reached, retry fires
const out = await p;
expect(http.get).toHaveBeenCalledTimes(2);
expect(out.songs[0].id).toBe("t1");
} finally {
vi.useRealTimers();
}
});
// Regression guard: a NORMAL numeric Retry-After ("3") is finite/positive, so the
// guard is transparent — the advised ~3s wait (below the 10s cap) is honored intact.
it("still honors a normal numeric Retry-After (~3s, below the 10s cap)", async () => {
vi.useFakeTimers();
try {
const auth = {
post: vi.fn().mockResolvedValue({ data: { access_token: "t", expires_in: 3600 } }),
} as any;
let call = 0;
const http = {
get: vi.fn().mockImplementation(() => {
call += 1;
if (call === 1) {
return Promise.reject({ response: { status: 429, headers: { "retry-after": "3" } } });
}
return Promise.resolve({
data: { tracks: { items: [{ id: "t1", name: "n", artists: [], duration_ms: 1000 }] } },
});
}),
} as any;
const api = new SpotifyWebApi(() => ({ clientId: "a", clientSecret: "b" }), { http, auth });
const p = api.search("queen");
await vi.advanceTimersByTimeAsync(2_999); // < 3s advised → retry has NOT fired yet
expect(http.get).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(1); // 3s total → advised wait reached, retry fires
const out = await p;
expect(http.get).toHaveBeenCalledTimes(2);
expect(out.songs[0].id).toBe("t1");
} finally {
vi.useRealTimers();
}
});
it("returns empty results when unconfigured (no creds → no token)", async () => {
const api = new SpotifyWebApi(() => ({ clientId: "", clientSecret: "" }));
expect(await api.search("queen")).toEqual({ songs: [], playlists: [], albums: [] });
});
});
// m4: the catalog fetch mappers (getTrack / getAlbumTracks / getPlaylistTracks)
// were untested. They shape raw Spotify payloads into Songs, inject album cover
// context, and drop malformed playlist rows.
describe("SpotifyWebApi catalog mappers", () => {
/** Builds an api whose single http.get resolves the supplied payload. */
function makeApi(payload: unknown) {
const auth = {
post: vi.fn().mockResolvedValue({ data: { access_token: "t", expires_in: 3600 } }),
} as any;
const http = { get: vi.fn().mockResolvedValue({ data: payload }) } as any;
const api = new SpotifyWebApi(() => ({ clientId: "a", clientSecret: "b" }), { http, auth });
return { api, http };
}
describe("getTrack", () => {
it("maps a single track payload to a Song", async () => {
const { api } = makeApi({
id: "trk1",
name: "Song One",
artists: [{ name: "Alice" }, { name: "Bob" }],
album: { name: "Album X", images: [{ url: "https://i.scdn.co/t.jpg" }] },
duration_ms: 210000,
});
const s = await api.getTrack("trk1");
expect(s).toEqual({
id: "trk1",
name: "Song One",
artist: "Alice, Bob",
album: "Album X",
duration: 210,
coverUrl: "https://i.scdn.co/t.jpg",
platform: "spotify",
});
});
it("returns null when the track fetch yields no data", async () => {
const auth = {
post: vi.fn().mockResolvedValue({ data: { access_token: "t", expires_in: 3600 } }),
} as any;
const http = { get: vi.fn().mockResolvedValue({ data: null }) } as any;
const api = new SpotifyWebApi(() => ({ clientId: "a", clientSecret: "b" }), { http, auth });
expect(await api.getTrack("nope")).toBeNull();
});
});
describe("getAlbumTracks", () => {
it("injects the album name + cover into every returned track", async () => {
// Album-track objects omit their own album block; the album cover/name must
// be back-filled from the album payload.
const { api } = makeApi({
name: "A Night at the Opera",
images: [{ url: "https://i.scdn.co/album.jpg" }],
tracks: {
items: [
{ id: "a1", name: "Death on Two Legs", artists: [{ name: "Queen" }], duration_ms: 223000 },
{ id: "a2", name: "Lazing on a Sunday", artists: [{ name: "Queen" }], duration_ms: 68000 },
],
},
});
const out = await api.getAlbumTracks("alb1");
expect(out).toHaveLength(2);
for (const t of out) {
expect(t.album).toBe("A Night at the Opera");
expect(t.coverUrl).toBe("https://i.scdn.co/album.jpg");
expect(t.platform).toBe("spotify");
}
expect(out[0].id).toBe("a1");
expect(out[0].name).toBe("Death on Two Legs");
expect(out[1].id).toBe("a2");
});
it("drops null track entries and returns [] when tracks.items is absent", async () => {
const { api: withNull } = makeApi({
name: "Alb",
images: [{ url: "https://i.scdn.co/c.jpg" }],
tracks: { items: [null, { id: "ok", name: "Keep", artists: [], duration_ms: 1000 }] },
});
const out = await withNull.getAlbumTracks("alb1");
expect(out).toHaveLength(1);
expect(out[0].id).toBe("ok");
expect(out[0].coverUrl).toBe("https://i.scdn.co/c.jpg");
const { api: noItems } = makeApi({ name: "Alb", images: [], tracks: {} });
expect(await noItems.getAlbumTracks("alb1")).toEqual([]);
});
});
describe("getPlaylistTracks", () => {
it("filters null items, {track:null}, and id-less tracks (never emits an id:'' Song)", async () => {
const { api } = makeApi({
items: [
null,
{ track: null },
{ track: { name: "No Id Here", artists: [], duration_ms: 1000 } }, // track present but no id
{ track: { id: "p1", name: "Real Song", artists: [{ name: "X" }], duration_ms: 1000 } },
],
});
const out = await api.getPlaylistTracks("pl1");
expect(out).toHaveLength(1);
expect(out[0].id).toBe("p1");
expect(out[0].name).toBe("Real Song");
// Guard the specific defect: no malformed empty-id Song leaks through.
expect(out.every((s) => s.id !== "")).toBe(true);
});
it("returns [] when items is absent", async () => {
const { api } = makeApi({});
expect(await api.getPlaylistTracks("pl1")).toEqual([]);
});
});
});
// R2-3: getPlaylistTracks must paginate (follow data.next) rather than silently
// truncating to the first 100 tracks, and must stop at a bounded cap so a
// pathological 10k-track playlist can't fan out into dozens of API calls.
describe("getPlaylistTracks pagination (R2-3)", () => {
const trackItem = (i: number) => ({
track: { id: `p${i}`, name: `Song ${i}`, artists: [{ name: "X" }], duration_ms: 1000 },
});
it("follows data.next across pages, returning all tracks in order with nulls filtered", async () => {
const auth = {
post: vi.fn().mockResolvedValue({ data: { access_token: "t", expires_in: 3600 } }),
} as any;
const page1 = {
items: Array.from({ length: 100 }, (_, i) => trackItem(i)),
next: "https://api.spotify.com/v1/playlists/pl1/tracks?offset=100&limit=100",
};
// Nulls / {track:null} / id-less rows on page 2 prove cross-page filtering.
const page2 = {
items: [null, { track: null }, { track: { name: "NoId", artists: [], duration_ms: 1 } }, trackItem(100), trackItem(101)],
next: null,
};
const http = {
get: vi.fn().mockResolvedValueOnce({ data: page1 }).mockResolvedValueOnce({ data: page2 }),
} as any;
const api = new SpotifyWebApi(() => ({ clientId: "a", clientSecret: "b" }), { http, auth });
const out = await api.getPlaylistTracks("pl1");
expect(http.get).toHaveBeenCalledTimes(2);
expect(out).toHaveLength(102); // 100 (page 1) + 2 real (page 2, three junk rows dropped)
expect(out[0].id).toBe("p0");
expect(out[99].id).toBe("p99");
expect(out[100].id).toBe("p100"); // page 2 appended in order
expect(out[101].id).toBe("p101");
expect(out.every((s) => s.id !== "")).toBe(true);
});
it("stops at the bounded cap when pages never end (call count bounded)", async () => {
const auth = {
post: vi.fn().mockResolvedValue({ data: { access_token: "t", expires_in: 3600 } }),
} as any;
// Every page is full (100) and always advertises a further next page.
const http = {
get: vi.fn().mockImplementation((_path: string, cfg: any) => {
const offset = Number(cfg?.params?.offset ?? 0);
return Promise.resolve({
data: {
items: Array.from({ length: 100 }, (_, i) => trackItem(offset + i)),
next: `https://api.spotify.com/v1/playlists/pl1/tracks?offset=${offset + 100}`,
},
});
}),
} as any;
const api = new SpotifyWebApi(() => ({ clientId: "a", clientSecret: "b" }), { http, auth });
const out = await api.getPlaylistTracks("pl1");
expect(out.length).toBeLessThanOrEqual(500); // never exceeds the cap
expect(out).toHaveLength(500); // and reaches it exactly
expect(http.get.mock.calls.length).toBeLessThanOrEqual(5); // 500 cap / 100 per page
});
});
// R2-6: getAlbumTracks must page beyond the 50-track embedded cap via the
// dedicated /albums/{id}/tracks endpoint, still injecting album name + cover.
describe("getAlbumTracks pagination (R2-6)", () => {
const albTrack = (i: number) => ({ id: `a${i}`, name: `T${i}`, artists: [{ name: "Queen" }], duration_ms: 1000 });
it("pages beyond the embedded 50-track cap, injecting album name/cover", async () => {
const auth = {
post: vi.fn().mockResolvedValue({ data: { access_token: "t", expires_in: 3600 } }),
} as any;
const albumPayload = {
name: "Big Compilation",
images: [{ url: "https://i.scdn.co/big.jpg" }],
tracks: {
items: Array.from({ length: 50 }, (_, i) => albTrack(i)),
next: "https://api.spotify.com/v1/albums/alb1/tracks?offset=50&limit=50",
},
};
const page2 = { items: [albTrack(50), albTrack(51), null, albTrack(52)], next: null };
const http = {
get: vi
.fn()
.mockResolvedValueOnce({ data: albumPayload }) // GET /v1/albums/alb1
.mockResolvedValueOnce({ data: page2 }), // GET /v1/albums/alb1/tracks
} as any;
const api = new SpotifyWebApi(() => ({ clientId: "a", clientSecret: "b" }), { http, auth });
const out = await api.getAlbumTracks("alb1");
expect(http.get).toHaveBeenCalledTimes(2);
expect(out).toHaveLength(53); // 50 + 3 real (one null dropped)
for (const t of out) {
expect(t.album).toBe("Big Compilation");
expect(t.coverUrl).toBe("https://i.scdn.co/big.jpg");
expect(t.platform).toBe("spotify");
}
expect(out[0].id).toBe("a0");
expect(out[49].id).toBe("a49");
expect(out[50].id).toBe("a50"); // page 2 appended in order
expect(out[52].id).toBe("a52");
});
// Robustness: a malformed/proxy response of { items: [], next: <non-null> } never
// grows songs.length nor nulls `next`, so a while-loop bounded ONLY by
// songs.length >= cap spins forever. getAlbumTracks must have a hard offset/page
// bound (mirroring the playlist loop) so it TERMINATES regardless of items/next.
it("terminates on a { items: [], next: <non-null> } response (bounded call count, no hang)", async () => {
const auth = {
post: vi.fn().mockResolvedValue({ data: { access_token: "t", expires_in: 3600 } }),
} as any;
const http = {
get: vi.fn().mockImplementation((path: string) => {
if (path === "/v1/albums/alb1") {
// Even the embedded first page is malformed: empty items, non-null next.
return Promise.resolve({
data: {
name: "Broken",
images: [{ url: "https://i.scdn.co/broken.jpg" }],
tracks: { items: [], next: "http://x/next" },
},
});
}
// Every /albums/{id}/tracks page keeps advertising a further page forever.
return Promise.resolve({ data: { items: [], next: "http://x/next" } });
}),
} as any;
const api = new SpotifyWebApi(() => ({ clientId: "a", clientSecret: "b" }), { http, auth });
const out = await api.getAlbumTracks("alb1");
// Returns what it has (nothing) rather than hanging.
expect(out).toEqual([]);
// Bounded: 1 album GET + at most MAX_ALBUM_TRACKS/ALBUM_PAGE_SIZE (=10) page fetches.
expect(http.get.mock.calls.length).toBeLessThanOrEqual(12);
});
});
// R2-7: search() must null-filter tracks.items like its album/playlist siblings so
// an unavailable/relinked null track never becomes a bogus id:'' "Unknown" Song.
describe("search track null-filtering (R2-7)", () => {
it("drops null and id-less track entries (never emits an empty-id 'Unknown' song)", async () => {
const auth = {
post: vi.fn().mockResolvedValue({ data: { access_token: "t", expires_in: 3600 } }),
} as any;
const http = {
get: vi.fn().mockResolvedValue({
data: {
tracks: {
items: [
null, // unavailable/relinked track
{ id: "t1", name: "Real", artists: [{ name: "X" }], duration_ms: 1000 },
{}, // id-less → maps to {id:'', name:'Unknown'}, must also be dropped
],
},
albums: { items: [] },
playlists: { items: [] },
},
}),
} as any;
const api = new SpotifyWebApi(() => ({ clientId: "a", clientSecret: "b" }), { http, auth });
const out = await api.search("x");
expect(out.songs).toHaveLength(1);
expect(out.songs[0].id).toBe("t1");
expect(out.songs.some((s) => s.id === "")).toBe(false);
expect(out.songs.some((s) => s.name === "Unknown")).toBe(false);
});
});
+232
View File
@@ -0,0 +1,232 @@
import axios, { type AxiosInstance } from "axios";
import type { Song, Album, Playlist, SearchResult } from "../provider.js";
export interface SpotifyCreds {
clientId: string;
clientSecret: string;
}
const ACCOUNTS_BASE = "https://accounts.spotify.com";
const API_BASE = "https://api.spotify.com";
// Spotify pages collection tracks at 100 (playlists) / 50 (albums). Follow the
// paging links, but bound the fan-out so a pathological 10k-track collection
// can't explode into dozens of API calls; stop once the cap is reached.
const PLAYLIST_PAGE_SIZE = 100;
const ALBUM_PAGE_SIZE = 50;
const MAX_PLAYLIST_TRACKS = 500;
const MAX_ALBUM_TRACKS = 500;
function artistsToString(artists: unknown): string {
return Array.isArray(artists)
? artists.map((a: any) => a?.name).filter(Boolean).join(", ")
: "";
}
/** Map a Spotify track object (search / tracks / playlist item .track) to a Song. */
export function mapSpotifyTrack(raw: any): Song {
return {
id: raw?.id ?? "",
name: raw?.name ?? "Unknown",
artist: artistsToString(raw?.artists),
album: raw?.album?.name ?? "",
duration: Math.round((raw?.duration_ms ?? 0) / 1000),
coverUrl: raw?.album?.images?.[0]?.url ?? "",
platform: "spotify",
};
}
export function mapSpotifyTracks(raw: any): Song[] {
return Array.isArray(raw) ? raw.map(mapSpotifyTrack) : [];
}
export function mapSpotifyAlbum(raw: any): Album {
return {
id: raw?.id ?? "",
name: raw?.name ?? "Unknown",
artist: artistsToString(raw?.artists),
coverUrl: raw?.images?.[0]?.url ?? "",
songCount: raw?.total_tracks ?? 0,
platform: "spotify",
};
}
export function mapSpotifyPlaylist(raw: any): Playlist {
return {
id: raw?.id ?? "",
name: raw?.name ?? "Unknown",
coverUrl: raw?.images?.[0]?.url ?? "",
songCount: raw?.tracks?.total ?? 0,
platform: "spotify",
};
}
/** True for the getSongUrl sentinel (spotify:track:<id>); real audio lands in Stage 2/3. */
export function isSpotifyUri(url: string): boolean {
return typeof url === "string" && url.startsWith("spotify:");
}
export class SpotifyWebApi {
private getCreds: () => SpotifyCreds;
private http: AxiosInstance;
private auth: AxiosInstance;
private token = "";
private tokenExpiresAt = 0;
constructor(
getCreds: () => SpotifyCreds,
deps?: { http?: AxiosInstance; auth?: AxiosInstance }
) {
this.getCreds = getCreds;
this.http = deps?.http ?? axios.create({ baseURL: API_BASE, timeout: 15_000 });
this.auth = deps?.auth ?? axios.create({ baseURL: ACCOUNTS_BASE, timeout: 15_000 });
}
setCreds(_c: SpotifyCreds): void {
// Creds are read live via getCreds(); force a token refresh on next call.
this.token = "";
this.tokenExpiresAt = 0;
}
hasCreds(): boolean {
const c = this.getCreds();
return !!c.clientId && !!c.clientSecret;
}
/** Client-Credentials app token, cached until ~30s before expiry. */
private async getToken(): Promise<string | null> {
if (!this.hasCreds()) return null;
if (this.token && Date.now() < this.tokenExpiresAt) return this.token;
const { clientId, clientSecret } = this.getCreds();
const basic = Buffer.from(`${clientId}:${clientSecret}`).toString("base64");
try {
const { data } = await this.auth.post(
"/api/token",
"grant_type=client_credentials",
{
headers: {
Authorization: `Basic ${basic}`,
"Content-Type": "application/x-www-form-urlencoded",
},
}
);
this.token = data?.access_token ?? "";
this.tokenExpiresAt = Date.now() + ((data?.expires_in ?? 3600) - 30) * 1000;
return this.token || null;
} catch {
return null;
}
}
private async get(
path: string,
params?: Record<string, unknown>,
retryOn429 = true
): Promise<any | null> {
const token = await this.getToken();
if (!token) return null;
try {
const { data } = await this.http.get(path, {
params,
headers: { Authorization: `Bearer ${token}` },
});
return data;
} catch (err: any) {
// Spotify rate-limits on a rolling 30s window (429 + Retry-After seconds).
// Retry once after the advised delay before giving up.
if (retryOn429 && err?.response?.status === 429) {
// Retry-After may be a non-numeric HTTP-date or empty string; Number(...)
// then yields NaN/0 and fires the single retry at 0ms, ignoring the advised
// backoff. Guard for a finite positive value (mirrors connect-api.ts).
const raw = Number(err.response?.headers?.["retry-after"]);
const retryAfter = Number.isFinite(raw) && raw > 0 ? raw : 1;
await new Promise((r) => setTimeout(r, Math.min(retryAfter, 10) * 1000));
return this.get(path, params, false);
}
return null;
}
}
async search(query: string, limit = 20): Promise<SearchResult> {
const data = await this.get("/v1/search", {
q: query,
type: "track,album,playlist",
limit,
});
if (!data) return { songs: [], playlists: [], albums: [] };
return {
// Mirror the album/playlist filtering: a null entry (unavailable/relinked
// track) must not become a bogus id:'' "Unknown" Song. Drop empty ids too.
songs: Array.isArray(data?.tracks?.items)
? data.tracks.items
.filter(Boolean)
.map(mapSpotifyTrack)
.filter((s: Song) => s.id !== "")
: [],
albums: Array.isArray(data?.albums?.items)
? data.albums.items.filter(Boolean).map(mapSpotifyAlbum)
: [],
playlists: Array.isArray(data?.playlists?.items)
? data.playlists.items.filter(Boolean).map(mapSpotifyPlaylist)
: [],
};
}
async getTrack(id: string): Promise<Song | null> {
const data = await this.get(`/v1/tracks/${id}`);
return data ? mapSpotifyTrack(data) : null;
}
async getAlbumTracks(albumId: string): Promise<Song[]> {
// Album-track objects omit the album block; fetch the album cover once and inject it.
const album = await this.get(`/v1/albums/${albumId}`);
const cover = album?.images?.[0]?.url ?? "";
const albumName = album?.name ?? "";
// Page 1 (up to 50 tracks) is embedded in the album payload; the embedded
// paging object caps at 50, so follow its `next` via the dedicated
// /albums/{id}/tracks endpoint until exhausted or the cap is reached. The
// offset bound is a HARD termination guarantee (mirrors the playlist loop):
// a malformed/proxy response of { items: [], next: <non-null> } never grows
// songs.length nor nulls `next`, so a loop bounded only by songs.length would
// spin forever — the offset cap stops it regardless of items/next.
const songs: Song[] = [];
let page = album?.tracks;
let offset = ALBUM_PAGE_SIZE;
while (page && Array.isArray(page.items)) {
for (const t of page.items.filter(Boolean)) {
songs.push({ ...mapSpotifyTrack(t), album: albumName, coverUrl: cover });
}
if (!page.next || songs.length >= MAX_ALBUM_TRACKS || offset >= MAX_ALBUM_TRACKS) break;
page = await this.get(`/v1/albums/${albumId}/tracks`, {
limit: ALBUM_PAGE_SIZE,
offset,
});
offset += ALBUM_PAGE_SIZE;
}
return songs.slice(0, MAX_ALBUM_TRACKS);
}
async getPlaylistTracks(playlistId: string): Promise<Song[]> {
// A single limit:100 page silently truncates 100+ track playlists. Follow
// `data.next` (advancing offset) until exhausted or the cap is reached, so a
// 10k-track playlist can't fan out into 100 API calls.
const songs: Song[] = [];
for (let offset = 0; offset < MAX_PLAYLIST_TRACKS; offset += PLAYLIST_PAGE_SIZE) {
const data = await this.get(`/v1/playlists/${playlistId}/tracks`, {
limit: PLAYLIST_PAGE_SIZE,
offset,
});
const items = data?.items;
if (!Array.isArray(items)) break;
// Keep the null / {track:null} / id-less filtering across ALL pages.
const mapped = items
.map((it: any) => it?.track)
.filter((t: any) => t && t.id)
.map(mapSpotifyTrack);
songs.push(...mapped);
if (!data.next) break;
}
return songs.slice(0, MAX_PLAYLIST_TRACKS);
}
}
+3 -1
View File
@@ -12,7 +12,8 @@ export function createAuthRouter(
bilibiliProvider: MusicProvider,
logger: Logger,
cookieStore?: CookieStore,
kugouProvider?: MusicProvider
kugouProvider?: MusicProvider,
spotifyProvider?: MusicProvider
): Router {
const router = Router();
// YouTube is auth-less; we only use this instance so /auth/status can
@@ -23,6 +24,7 @@ export function createAuthRouter(
if (platform === "bilibili") return bilibiliProvider;
if (platform === "youtube") return youtubeProvider;
if (platform === "kugou" && kugouProvider) return kugouProvider;
if (platform === "spotify" && spotifyProvider) return spotifyProvider;
return platform === "qq" ? qqProvider : neteaseProvider;
}
+310 -1
View File
@@ -3,7 +3,7 @@ import express from "express";
import cookieParser from "cookie-parser";
import request from "supertest";
import pino from "pino";
import { mkdtempSync, rmSync } from "node:fs";
import { mkdtempSync, rmSync, readFileSync, existsSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createDatabase, type BotDatabase } from "../../data/database.js";
@@ -196,6 +196,304 @@ describe("bot router /settings", () => {
expect(res.status).toBe(200);
expect(config.adminGroups).toEqual([6]);
});
it("GET /settings includes a masked spotify block (hasClientSecret, never a raw secret)", async () => {
config.spotify.enabled = true;
config.spotify.backend = "librespot";
config.spotify.clientId = "cid-1";
config.spotify.deviceName = "MyDevice";
config.spotify.bitrate = 160;
config.spotify.clientSecret = "supersecret";
const withSecret = await request(app).get("/api/bot/settings").set("Cookie", cookie);
expect(withSecret.status).toBe(200);
expect(withSecret.body.spotify).toEqual({
enabled: true,
backend: "librespot",
clientId: "cid-1",
deviceName: "MyDevice",
bitrate: 160,
hasClientSecret: true,
});
// The raw secret is never serialized to the client.
expect(withSecret.body.spotify).not.toHaveProperty("clientSecret");
config.spotify.clientSecret = "";
const noSecret = await request(app).get("/api/bot/settings").set("Cookie", cookie);
expect(noSecret.body.spotify.hasClientSecret).toBe(false);
expect(noSecret.body.spotify).not.toHaveProperty("clientSecret");
});
it("POST /settings updates the spotify block, echoes the masked view, and persists", async () => {
const res = await request(app)
.post("/api/bot/settings")
.set("Cookie", cookie)
.send({ spotify: { enabled: true, backend: "librespot", clientId: "cid", deviceName: "Dev", bitrate: 160 } });
expect(res.status).toBe(200);
expect(config.spotify.enabled).toBe(true);
expect(config.spotify.backend).toBe("librespot");
expect(config.spotify.clientId).toBe("cid");
expect(config.spotify.deviceName).toBe("Dev");
expect(config.spotify.bitrate).toBe(160);
expect(res.body.spotify).toEqual({
enabled: true,
backend: "librespot",
clientId: "cid",
deviceName: "Dev",
bitrate: 160,
hasClientSecret: false,
});
expect(res.body.spotify).not.toHaveProperty("clientSecret");
// saveConfig persisted the block to disk.
expect(existsSync(configPath)).toBe(true);
const persisted = JSON.parse(readFileSync(configPath, "utf-8"));
expect(persisted.spotify.clientId).toBe("cid");
expect(persisted.spotify.backend).toBe("librespot");
});
it("POST /settings ignores an invalid spotify backend/bitrate (partial-merge, no 400)", async () => {
config.spotify.backend = "auto";
config.spotify.bitrate = 320;
const res = await request(app)
.post("/api/bot/settings")
.set("Cookie", cookie)
.send({ spotify: { backend: "bogus", bitrate: 999 } });
expect(res.status).toBe(200);
expect(config.spotify.backend).toBe("auto");
expect(config.spotify.bitrate).toBe(320);
});
it("POST /settings sets a non-empty clientSecret but a blank one never wipes it", async () => {
const set = await request(app)
.post("/api/bot/settings")
.set("Cookie", cookie)
.send({ spotify: { clientSecret: "newsecret" } });
expect(set.status).toBe(200);
expect(config.spotify.clientSecret).toBe("newsecret");
expect(set.body.spotify.hasClientSecret).toBe(true);
expect(set.body.spotify).not.toHaveProperty("clientSecret");
const blank = await request(app)
.post("/api/bot/settings")
.set("Cookie", cookie)
.send({ spotify: { clientSecret: "" } });
expect(blank.status).toBe(200);
expect(config.spotify.clientSecret).toBe("newsecret");
expect(blank.body.spotify.hasClientSecret).toBe(true);
});
it("POST /settings ignores a blank/whitespace deviceName but stores a trimmed non-empty one", async () => {
config.spotify.deviceName = "OldDevice";
// Empty string leaves the prior deviceName untouched.
const empty = await request(app)
.post("/api/bot/settings")
.set("Cookie", cookie)
.send({ spotify: { deviceName: "" } });
expect(empty.status).toBe(200);
expect(config.spotify.deviceName).toBe("OldDevice");
expect(empty.body.spotify.deviceName).toBe("OldDevice");
// Whitespace-only is likewise ignored (trim().length === 0).
const ws = await request(app)
.post("/api/bot/settings")
.set("Cookie", cookie)
.send({ spotify: { deviceName: " " } });
expect(ws.status).toBe(200);
expect(config.spotify.deviceName).toBe("OldDevice");
// A non-empty value is stored TRIMMED.
const set = await request(app)
.post("/api/bot/settings")
.set("Cookie", cookie)
.send({ spotify: { deviceName: " Dev " } });
expect(set.status).toBe(200);
expect(config.spotify.deviceName).toBe("Dev");
expect(set.body.spotify.deviceName).toBe("Dev");
});
it("POST /settings that omits spotify leaves config.spotify untouched (no regression)", async () => {
config.spotify.clientId = "keep-me";
config.spotify.clientSecret = "keep-secret";
const before = { ...config.spotify };
const res = await request(app)
.post("/api/bot/settings")
.set("Cookie", cookie)
.send({ autoPauseOnEmpty: false });
expect(res.status).toBe(200);
expect(config.spotify).toEqual(before);
});
});
// Whole-branch I2: saving a Client ID in Settings must re-configure the single
// live SpotifyOAuth so the operator can Connect without a process restart.
describe("bot router /settings applies spotify creds to the live OAuth (I2)", () => {
let botDb: BotDatabase;
let app: express.Express;
let cookie: string;
let config: BotConfig;
let configPath: string;
let tmpDir: string;
let configureCalls: Array<[string | undefined, string | undefined]>;
beforeEach(async () => {
botDb = createDatabase(":memory:");
const users = createUserStore(botDb.db);
const sessions = createSessionStore(botDb.db);
const alice = await users.createUser("alice", "pw-alice", "admin");
cookie = `${SESSION_COOKIE_NAME}=${sessions.createSession(alice.id).token}`;
tmpDir = mkdtempSync(join(tmpdir(), "botsettings-oauth-"));
configPath = join(tmpDir, "config.json");
config = getDefaultConfig(); // webPort defaults to 3000
const fakeManager = { getAllBots: () => [] } as unknown as BotManager;
const avatarStore = createAvatarStore(tmpDir);
// Fake OAuth recording every configure(clientId, redirectUri) call.
configureCalls = [];
const fakeOAuth = {
configure(clientId?: string, redirectUri?: string) {
configureCalls.push([clientId, redirectUri]);
},
};
app = express();
app.use(express.json());
app.use(cookieParser());
app.use("/api", createRequireAuth(sessions, createPermissionStore(botDb.db), () => getDefaultConfig().guestMode));
app.use(
"/api/bot",
createBotRouter(fakeManager, config, configPath, pino({ level: "silent" }), botDb, avatarStore, undefined, fakeOAuth),
);
});
afterEach(() => {
botDb.close();
rmSync(tmpDir, { recursive: true, force: true });
});
it("configures the live OAuth once with the derived callback redirectUri when a Client ID is saved", async () => {
const res = await request(app)
.post("/api/bot/settings")
.set("Cookie", cookie)
.send({ spotify: { clientId: "cid", enabled: true } });
expect(res.status).toBe(200);
expect(configureCalls).toEqual([
["cid", `http://127.0.0.1:${config.webPort}/api/spotify/callback`],
]);
});
it("does NOT touch the OAuth when the request has no spotify block", async () => {
const res = await request(app)
.post("/api/bot/settings")
.set("Cookie", cookie)
.send({ autoPauseOnEmpty: false });
expect(res.status).toBe(200);
expect(configureCalls).toEqual([]);
});
it("configures with ('', undefined) when a spotify block clears the Client ID (disables OAuth)", async () => {
const res = await request(app)
.post("/api/bot/settings")
.set("Cookie", cookie)
.send({ spotify: { clientId: "" } });
expect(res.status).toBe(200);
expect(configureCalls).toEqual([["", undefined]]);
});
});
// R2-4: saving Spotify creds in Settings must also refresh the Web API SEARCH
// provider (spotifyProvider.setCreds), not only the OAuth playback path. Without
// this, a fresh install (enabled defaults false) keeps empty search creds until a
// full process restart even after an admin enters Client ID + Secret.
describe("bot router /settings refreshes the Web API search provider creds (R2-4)", () => {
let botDb: BotDatabase;
let app: express.Express;
let cookie: string;
let config: BotConfig;
let configPath: string;
let tmpDir: string;
let setCredsCalls: Array<[string, string]>;
let configureCalls: Array<[string | undefined, string | undefined]>;
beforeEach(async () => {
botDb = createDatabase(":memory:");
const users = createUserStore(botDb.db);
const sessions = createSessionStore(botDb.db);
const alice = await users.createUser("alice", "pw-alice", "admin");
cookie = `${SESSION_COOKIE_NAME}=${sessions.createSession(alice.id).token}`;
tmpDir = mkdtempSync(join(tmpdir(), "botsettings-provider-"));
configPath = join(tmpDir, "config.json");
config = getDefaultConfig();
const fakeManager = { getAllBots: () => [] } as unknown as BotManager;
const avatarStore = createAvatarStore(tmpDir);
// Fake search provider recording every setCreds(clientId, clientSecret) call.
setCredsCalls = [];
const fakeProvider = {
setCreds(clientId: string, clientSecret: string) {
setCredsCalls.push([clientId, clientSecret]);
},
};
// Fake OAuth so we can assert the playback path is still wired alongside.
configureCalls = [];
const fakeOAuth = {
configure(clientId?: string, redirectUri?: string) {
configureCalls.push([clientId, redirectUri]);
},
};
app = express();
app.use(express.json());
app.use(cookieParser());
app.use("/api", createRequireAuth(sessions, createPermissionStore(botDb.db), () => getDefaultConfig().guestMode));
app.use(
"/api/bot",
createBotRouter(fakeManager, config, configPath, pino({ level: "silent" }), botDb, avatarStore, undefined, fakeOAuth, fakeProvider),
);
});
afterEach(() => {
botDb.close();
rmSync(tmpDir, { recursive: true, force: true });
});
it("calls setCreds once with (clientId, clientSecret) when a spotify block is saved (and OAuth is still configured)", async () => {
const res = await request(app)
.post("/api/bot/settings")
.set("Cookie", cookie)
.send({ spotify: { clientId: "cid", clientSecret: "sec", enabled: true } });
expect(res.status).toBe(200);
expect(setCredsCalls).toEqual([["cid", "sec"]]);
// The OAuth playback path is still wired on the same save.
expect(configureCalls.length).toBe(1);
});
it("does NOT call setCreds when the request has no spotify block", async () => {
const res = await request(app)
.post("/api/bot/settings")
.set("Cookie", cookie)
.send({ autoPauseOnEmpty: false });
expect(res.status).toBe(200);
expect(setCredsCalls).toEqual([]);
});
it("calls setCreds with the PRESERVED stored secret when the spotify block omits/blanks clientSecret", async () => {
config.spotify.clientId = "cid0";
config.spotify.clientSecret = "stored-secret";
const res = await request(app)
.post("/api/bot/settings")
.set("Cookie", cookie)
.send({ spotify: { clientId: "cid0", clientSecret: "", enabled: true } });
expect(res.status).toBe(200);
// Post-merge values: masked/blank secret must keep the stored one, never "".
expect(setCredsCalls).toEqual([["cid0", "stored-secret"]]);
});
});
describe("bot router /settings guest-mode gating + persistence", () => {
@@ -251,4 +549,15 @@ describe("bot router /settings guest-mode gating + persistence", () => {
expect(res.body.guestMode.permissions.playNext).toBe(true);
expect(res.body.guestMode.permissions.addToQueue).toBe(true); // untouched default
});
it("POST /settings spotify write is 403 for a member lacking bot.manage", async () => {
const memberApp = mountBot(() => ({ role: "member", capabilities: new Set([]) }));
const res = await request(memberApp)
.post("/api/bot/settings")
.send({ spotify: { enabled: true, clientId: "cid" } });
expect(res.status).toBe(403);
// Gate rejected before any mutation.
expect(config.spotify.enabled).toBe(false);
expect(config.spotify.clientId).toBe("");
});
});
+61 -1
View File
@@ -1,6 +1,6 @@
import { Router } from "express";
import type { BotManager } from "../../bot/manager.js";
import type { BotConfig, GuestModeConfig } from "../../data/config.js";
import type { BotConfig, GuestModeConfig, SpotifyConfig } from "../../data/config.js";
import { saveConfig } from "../../data/config.js";
import type { Logger } from "../../logger.js";
import type { BotDatabase } from "../../data/database.js";
@@ -17,9 +17,28 @@ export function createBotRouter(
botDb: BotDatabase,
avatarStore: AvatarStore,
onGuestPolicyChanged?: (cfg: GuestModeConfig) => void,
// I2: the single process-wide OAuth, so a UI-entered Client ID reaches the
// live instance on save (no restart). Structural type = SpotifyOAuth.configure.
spotifyOAuth?: { configure(clientId?: string, redirectUri?: string): void },
// R2-4: the process-wide Web API SEARCH provider. Boot only wires creds when
// spotify.enabled is already true, so a fresh install that enables Spotify via
// Settings must push creds here too, or search stays empty until a restart.
// Structural type = SpotifyProvider.setCreds.
spotifyProvider?: { setCreds(clientId: string, clientSecret: string): void },
): Router {
const router = Router();
// Masked spotify view shared by the GET response and the POST echo. The raw
// clientSecret is write-only and NEVER serialized — only whether one is stored.
const maskedSpotify = () => ({
enabled: config.spotify.enabled,
backend: config.spotify.backend,
clientId: config.spotify.clientId,
deviceName: config.spotify.deviceName,
bitrate: config.spotify.bitrate,
hasClientSecret: config.spotify.clientSecret.length > 0,
});
router.get("/", (req, res) => {
const all = botManager.getAllBots().map((b) => b.getStatus());
const u = req.user!;
@@ -39,6 +58,7 @@ export function createBotRouter(
localAudioEnabled: config.localAudioEnabled,
adminGroups: config.adminGroups ?? [],
guestMode: config.guestMode,
spotify: maskedSpotify(),
});
});
@@ -85,8 +105,47 @@ export function createBotRouter(
);
}
// Partial-merge the spotify block (mirrors config.ts validation). Invalid
// sub-fields are ignored rather than 400-ing the whole request. Omitting
// `spotify` entirely leaves config.spotify untouched.
const VALID_BACKENDS = ["auto", "go-librespot", "librespot"] as const;
const VALID_BITRATES = [96, 160, 320];
const sp = req.body?.spotify;
if (sp && typeof sp === "object") {
const t = config.spotify;
if (typeof sp.enabled === "boolean") t.enabled = sp.enabled;
if (typeof sp.backend === "string" && (VALID_BACKENDS as readonly string[]).includes(sp.backend)) {
t.backend = sp.backend as SpotifyConfig["backend"];
}
if (typeof sp.clientId === "string") t.clientId = sp.clientId;
// Secret is write-only + set-on-non-empty so a blank field never wipes it.
if (typeof sp.clientSecret === "string" && sp.clientSecret.length > 0) {
t.clientSecret = sp.clientSecret;
}
if (typeof sp.deviceName === "string" && sp.deviceName.trim().length > 0) {
t.deviceName = sp.deviceName.trim();
}
if (typeof sp.bitrate === "number" && VALID_BITRATES.includes(sp.bitrate)) {
t.bitrate = sp.bitrate;
}
}
saveConfig(configPath, config);
// I2: only when the spotify block was present, push the (possibly UI-entered)
// Client ID into the live process-wide OAuth so Connect works without a
// restart. Empty clientId => undefined redirect => configure() disables OAuth.
if (sp && typeof sp === "object") {
const redirectUri = config.spotify.clientId
? `http://127.0.0.1:${config.webPort}/api/spotify/callback`
: undefined;
spotifyOAuth?.configure(config.spotify.clientId, redirectUri);
// R2-4: also refresh the live Web API search provider so search/getAuthStatus
// work without a restart. Uses the post-merge values so a masked/omitted
// secret keeps the stored one. The secret is never logged.
spotifyProvider?.setCreds(config.spotify.clientId, config.spotify.clientSecret);
}
// Guest-mode changed: tear down / re-scope in-flight guest WS sockets so a
// disabled or narrowed scope takes effect immediately (matches requireAuth's
// "disabling immediately invalidates in-flight guest sessions" invariant).
@@ -106,6 +165,7 @@ export function createBotRouter(
localAudioEnabled: config.localAudioEnabled,
adminGroups: config.adminGroups ?? [],
guestMode: config.guestMode,
spotify: maskedSpotify(),
});
});
+12 -1
View File
@@ -14,7 +14,8 @@ export function createMusicRouter(
logger: Logger,
localProvider?: MusicProvider,
config?: BotConfig,
kugouProvider?: MusicProvider
kugouProvider?: MusicProvider,
spotifyProvider?: MusicProvider
): Router {
const router = Router();
const youtubeProvider: MusicProvider = new YouTubeProvider();
@@ -28,6 +29,7 @@ export function createMusicRouter(
if (platform === "youtube") return youtubeProvider;
if (platform === "local" && localProvider) return localProvider;
if (platform === "kugou" && kugouProvider) return kugouProvider;
if (platform === "spotify" && spotifyProvider) return spotifyProvider;
return platform === "qq" ? qqProvider : neteaseProvider;
}
@@ -117,6 +119,11 @@ export function createMusicRouter(
return;
}
const parsedLimit = parseInt(limit as string) || 20;
// Spotify is intentionally EXCLUDED from unified /search/all in Stage 1:
// its tracks are metadata-only (not yet playable) until the librespot audio
// backend lands (Stage 2/3), so surfacing them in the default all-sources
// view would only yield results that get skipped. Spotify search remains
// available from its own tab via /search?platform=spotify.
const [neteaseResult, qqResult, bilibiliResult, localResult, kugouResult] = await Promise.allSettled([
neteaseProvider.search(q as string, parsedLimit),
qqProvider.search(q as string, parsedLimit),
@@ -295,6 +302,7 @@ export function createMusicRouter(
bilibili: bilibiliProvider.getQuality(),
local: localProvider?.getQuality() ?? "original",
kugou: kugouProvider?.getQuality() ?? "128",
spotify: spotifyProvider?.getQuality() ?? "320",
});
});
@@ -317,6 +325,9 @@ export function createMusicRouter(
if ((!platform || platform === "kugou") && kugouProvider) {
kugouProvider.setQuality(quality);
}
if ((!platform || platform === "spotify") && spotifyProvider) {
spotifyProvider.setQuality(quality);
}
logger.info({ quality, platform }, "Audio quality changed");
res.json({ success: true, quality });
});
+18 -12
View File
@@ -53,6 +53,11 @@ export function createPlayerRouter(
res.status(403).json({ error: "本地音频播放已关闭" });
}
function requesterName(req: any): string {
const name = req.user?.username;
return typeof name === "string" && name.trim() ? name.trim() : "游客";
}
router.post("/:botId/play", authorize({ capability: "player.control" }), async (req, res) => {
try {
const bot = (req as any).bot;
@@ -66,7 +71,7 @@ export function createPlayerRouter(
res.status(400).json({ error: "Invalid command" });
return;
}
const response = await bot.executeCommand(cmd);
const response = await bot.executeCommand(cmd, undefined, requesterName(req));
res.json({ message: response });
} catch (err) {
res.status(500).json({ error: (err as Error).message });
@@ -82,7 +87,7 @@ export function createPlayerRouter(
res.status(400).json({ error: "Invalid command" });
return;
}
const response = await bot.executeCommand(cmd);
const response = await bot.executeCommand(cmd, undefined, requesterName(req));
res.json({ message: response });
} catch (err) {
res.status(500).json({ error: (err as Error).message });
@@ -120,7 +125,7 @@ export function createPlayerRouter(
? platform
: "netease"
);
const message = await bot.startFm(provider);
const message = await bot.startFm(provider, requesterName(req));
res.json({
ok:
!message.startsWith("No FM songs") &&
@@ -198,7 +203,7 @@ export function createPlayerRouter(
.json({ error: "position must be a finite non-negative number" });
return;
}
bot.getPlayer().seek(position);
bot.seek(position);
res.json({ message: `Seeked to ${Math.floor(position)}s`, seekOffset: position });
} catch (err) {
res.status(500).json({ error: (err as Error).message });
@@ -271,7 +276,7 @@ export function createPlayerRouter(
`!playlist ${platformFlag(platform)} ${playlistId}`.trim(),
"!"
)!;
const response = await bot.executeCommand(cmd);
const response = await bot.executeCommand(cmd, undefined, requesterName(req));
res.json({ message: response });
} catch (err) {
res.status(500).json({ error: (err as Error).message });
@@ -332,7 +337,7 @@ export function createPlayerRouter(
const queue = bot.getQueueManager();
queue.clear();
for (const song of queueable) {
queue.add({ ...song, platform: provider.platform });
queue.add({ ...song, platform: provider.platform, requestedBy: requesterName(req) });
}
// Sweep AFTER the queue is rebuilt: the previous queue's local uploads are
// released and deleted, but an empty/failed playlist (early return above)
@@ -418,7 +423,7 @@ export function createPlayerRouter(
const queue = bot.getQueueManager();
queue.clear();
for (const song of queueable) {
queue.add({ ...song, platform: provider.platform });
queue.add({ ...song, platform: provider.platform, requestedBy: requesterName(req) });
}
// Sweep AFTER the queue is rebuilt (see play-playlist).
bot.cleanupQueuedLocalSongs?.("queue_replaced");
@@ -468,7 +473,7 @@ export function createPlayerRouter(
const queue = bot.getQueueManager();
bot.getPlayer().stop();
queue.clear();
queue.add(song);
queue.add({ ...song, requestedBy: requesterName(req) });
queue.play();
bot.getPlayer().resetFailures();
@@ -513,7 +518,7 @@ export function createPlayerRouter(
// after natural track end without queue.clear()).
const insertedAt =
queue.getCurrentIndex() < 0 ? queue.size() : queue.getCurrentIndex() + 1;
queue.addNext(song);
queue.addNext({ ...song, requestedBy: requesterName(req) });
if (wasIdle) {
// Promote the just-added song to current and start it.
@@ -555,7 +560,7 @@ export function createPlayerRouter(
const queue = bot.getQueueManager();
const insertedAt =
queue.getCurrentIndex() < 0 ? queue.size() : queue.getCurrentIndex() + 1;
queue.addNext(song);
queue.addNext({ ...song, requestedBy: requesterName(req) });
queue.playAt(insertedAt);
bot.getPlayer().resetFailures();
const ok = await bot.resolveAndPlay(queue.current()!);
@@ -587,7 +592,7 @@ export function createPlayerRouter(
const body = await bot.runExclusive(async () => {
const queue = bot.getQueueManager();
const wasIdle = bot.getPlayer().getState() === "idle";
queue.add(song);
queue.add({ ...song, requestedBy: requesterName(req) });
// If nothing was playing, start this newly-added song immediately.
if (wasIdle) {
@@ -627,7 +632,7 @@ export function createPlayerRouter(
}
const queue = bot.getQueueManager();
queue.add({ ...song, platform: provider.platform });
queue.add({ ...song, platform: provider.platform, requestedBy: requesterName(req) });
// If nothing is playing, start the first song
if (bot.getPlayer().getState() === "idle") {
@@ -678,6 +683,7 @@ export function createPlayerRouter(
coverUrl: r.coverUrl,
platform: r.platform,
playedAt: r.playedAt,
requestedBy: r.requestedBy,
}));
res.json({ history });
});
+95
View File
@@ -0,0 +1,95 @@
import { describe, it, expect, vi } from "vitest";
import express from "express";
import request from "supertest";
import pino from "pino";
import { createSpotifyRouter, type SpotifyOAuthLike } from "./spotify.js";
type Role = "admin" | "member" | "guest";
function makeApp(oauth: SpotifyOAuthLike, role: Role = "admin", caps: string[] = []) {
const app = express();
app.use(express.json());
// Stand in for the global requireAuth that populates req.user.
app.use((req, _res, next) => {
(req as any).user = { role, capabilities: new Set(caps) };
next();
});
app.use(
"/api/spotify",
createSpotifyRouter({
oauth,
logger: pino({ level: "silent" }),
getBackendInfo: () => ({ backend: "librespot", deviceName: "TS-Bot", binaryAvailable: true }),
webUiRedirect: "/",
}),
);
return app;
}
function fakeOauth(over: Partial<SpotifyOAuthLike> = {}): SpotifyOAuthLike {
return {
buildAuthorizeUrl: () => ({ url: "https://accounts.spotify.com/authorize?x=1", state: "st" }),
handleCallback: async () => true,
isAuthorized: () => false,
...over,
};
}
describe("spotify OAuth router", () => {
it("GET /login returns the authorize url for a permitted user", async () => {
const app = makeApp(fakeOauth());
const res = await request(app).get("/api/spotify/login");
expect(res.status).toBe(200);
expect(res.body.url).toContain("accounts.spotify.com/authorize");
});
it("GET /login is 403 for a member lacking platform.auth", async () => {
const app = makeApp(fakeOauth(), "member", []);
const res = await request(app).get("/api/spotify/login");
expect(res.status).toBe(403);
});
it("GET /callback with a good code+state redirects to success", async () => {
const handleCallback = vi.fn(async () => true);
const app = makeApp(fakeOauth({ handleCallback }));
const res = await request(app).get("/api/spotify/callback?code=abc&state=st");
expect(res.status).toBe(302);
expect(res.headers.location).toBe("/?spotify=success");
expect(handleCallback).toHaveBeenCalledWith("abc", "st");
});
it("GET /callback with a bad state (handleCallback false) redirects to error", async () => {
const app = makeApp(fakeOauth({ handleCallback: async () => false }));
const res = await request(app).get("/api/spotify/callback?code=abc&state=WRONG");
expect(res.status).toBe(302);
expect(res.headers.location).toBe("/?spotify=error");
});
it("GET /callback with missing code does not call oauth and redirects to error", async () => {
const handleCallback = vi.fn(async () => true);
const app = makeApp(fakeOauth({ handleCallback }));
const res = await request(app).get("/api/spotify/callback?state=st");
expect(res.status).toBe(302);
expect(res.headers.location).toBe("/?spotify=error");
expect(handleCallback).not.toHaveBeenCalled();
});
it("GET /callback swallows a throwing handleCallback and redirects to error", async () => {
const app = makeApp(fakeOauth({ handleCallback: async () => { throw new Error("boom"); } }));
const res = await request(app).get("/api/spotify/callback?code=abc&state=st");
expect(res.status).toBe(302);
expect(res.headers.location).toBe("/?spotify=error");
});
it("GET /status reflects authorized + backend + deviceName", async () => {
const app = makeApp(fakeOauth({ isAuthorized: () => true }));
const res = await request(app).get("/api/spotify/status");
expect(res.status).toBe(200);
expect(res.body).toEqual({ authorized: true, backend: "librespot", deviceName: "TS-Bot", binaryAvailable: true });
});
it("GET /status is 403 for a guest", async () => {
const app = makeApp(fakeOauth(), "guest");
const res = await request(app).get("/api/spotify/status");
expect(res.status).toBe(403);
});
});
+79
View File
@@ -0,0 +1,79 @@
import { Router } from "express";
import type { Logger } from "pino";
import { requirePermission } from "../middleware/requirePermission.js";
import { requireNotGuest } from "../middleware/requireNotGuest.js";
/** Minimal structural seam over SpotifyOAuth so this router needs no real
* network/crypto in tests. The concrete SpotifyOAuth satisfies it verbatim. */
export interface SpotifyOAuthLike {
buildAuthorizeUrl(): { url: string; state: string };
handleCallback(code: string, state: string): Promise<boolean>;
isAuthorized(): boolean;
}
export interface SpotifyRouterOptions {
oauth: SpotifyOAuthLike;
logger: Logger;
/** Process-wide backend info for /status (single Premium account, Stage 3). */
getBackendInfo: () => {
backend: string;
deviceName: string;
binaryAvailable: boolean;
};
/** Web UI page to bounce the browser back to after the OAuth callback. */
webUiRedirect?: string;
}
export function createSpotifyRouter(opts: SpotifyRouterOptions): Router {
const { oauth, logger } = opts;
const redirectBase = opts.webUiRedirect ?? "/";
const sep = redirectBase.includes("?") ? "&" : "?";
const router = Router();
// Start the Authorization Code + PKCE flow: hand the WebUI the accounts.spotify.com
// authorize URL (verifier is stashed by state inside SpotifyOAuth). Gated like the
// other platform logins in auth.ts.
router.get("/login", requirePermission("platform.auth"), (_req, res) => {
try {
const { url } = oauth.buildAuthorizeUrl();
res.json({ url });
} catch (err) {
logger.error({ err }, "Spotify authorize URL build failed");
res.status(500).json({ error: (err as Error).message });
}
});
// OAuth redirect target (own-app clientId => redirect_uri points here). This is a
// top-level browser navigation carrying the SameSite=Lax session cookie, so the
// global requireAuth passes; state is the CSRF guard for the flow itself. Always
// redirect (never JSON) so the user lands back in the UI.
router.get("/callback", async (req, res) => {
const code = typeof req.query.code === "string" ? req.query.code : "";
const state = typeof req.query.state === "string" ? req.query.state : "";
if (!code || !state) {
res.redirect(`${redirectBase}${sep}spotify=error`);
return;
}
try {
const ok = await oauth.handleCallback(code, state);
res.redirect(`${redirectBase}${sep}spotify=${ok ? "success" : "error"}`);
} catch (err) {
logger.error({ err }, "Spotify OAuth callback failed");
res.redirect(`${redirectBase}${sep}spotify=error`);
}
});
// Whether the (single, process-wide) account is authorized, plus which backend
// + device name are configured — used by the WebUI to show login-needed state.
router.get("/status", requireNotGuest, (_req, res) => {
const info = opts.getBackendInfo();
res.json({
authorized: oauth.isAuthorized(),
backend: info.backend,
deviceName: info.deviceName,
binaryAvailable: info.binaryAvailable,
});
});
return router;
}
+48 -2
View File
@@ -19,6 +19,14 @@ import { createUsersRouter } from "./api/users.js";
import { createAuditStore } from "../data/audit.js";
import { createAuditRouter } from "./api/audit.js";
import { createFavoritesRouter } from "./api/favorites.js";
import { createSpotifyRouter } from "./api/spotify.js";
import type { SpotifyOAuth } from "../music/spotify/spotify-oauth.js";
import type { SpotifyProvider } from "../music/spotify/provider.js";
import { resolveSpotifyBackendKind } from "../music/spotify/backend-select.js";
import {
isGoLibrespotPresent,
isLibrespotPresent,
} from "../music/spotify/binary.js";
import { setupWebSocket } from "./websocket.js";
import { createUserStore } from "../data/users.js";
import { createSessionStore } from "../data/sessions.js";
@@ -40,6 +48,7 @@ export interface WebServerOptions {
bilibiliProvider: MusicProvider;
localProvider: MusicProvider;
kugouProvider: MusicProvider;
spotifyProvider: MusicProvider;
database: BotDatabase;
config: BotConfig;
configPath: string;
@@ -47,6 +56,9 @@ export interface WebServerOptions {
cookieStore?: CookieStore;
avatarStore: AvatarStore;
staticDir?: string;
/** Process-wide shared Spotify OAuth (single account, Stage 3). When set, the
* /api/spotify {login,callback,status} router is mounted. */
spotifyOAuth?: SpotifyOAuth;
}
export interface WebServer {
@@ -121,11 +133,19 @@ export function createWebServer(options: WebServerOptions): WebServer {
options.database,
options.avatarStore,
(cfg) => onGuestPolicyChanged(cfg),
// I2: so saving a Client ID in Settings re-configures the live OAuth
// (no restart needed for the UI-entered-creds -> Connect flow).
options.spotifyOAuth,
// R2-4: so saving Spotify creds in Settings also refreshes the live Web API
// search provider (search + getAuthStatus) without a process restart. The
// runtime object is a SpotifyProvider (see index.ts); WebServerOptions types
// it as the wider MusicProvider, so narrow it here for the setCreds contract.
options.spotifyProvider as SpotifyProvider,
)
);
app.use(
"/api/music",
createMusicRouter(options.neteaseProvider, options.qqProvider, options.bilibiliProvider, logger, options.localProvider, options.config, options.kugouProvider)
createMusicRouter(options.neteaseProvider, options.qqProvider, options.bilibiliProvider, logger, options.localProvider, options.config, options.kugouProvider, options.spotifyProvider)
);
app.use("/api/player", createPlayerRouter(
options.botManager, logger, options.database,
@@ -133,8 +153,34 @@ export function createWebServer(options: WebServerOptions): WebServer {
));
app.use(
"/api/auth",
createAuthRouter(options.neteaseProvider, options.qqProvider, options.bilibiliProvider, logger, options.cookieStore, options.kugouProvider)
createAuthRouter(options.neteaseProvider, options.qqProvider, options.bilibiliProvider, logger, options.cookieStore, options.kugouProvider, options.spotifyProvider)
);
if (options.spotifyOAuth) {
app.use(
"/api/spotify",
createSpotifyRouter({
oauth: options.spotifyOAuth,
logger,
getBackendInfo: () => {
// PATH-aware presence (Bug m1): a PATH-installed binary (bare name)
// is resolved against $PATH, not existsSync()'d against cwd.
const goPresent = isGoLibrespotPresent();
const rustPresent = isLibrespotPresent();
const resolved = resolveSpotifyBackendKind(
options.config.spotify.backend,
goPresent,
rustPresent,
);
return {
backend: resolved ?? "none",
deviceName: options.config.spotify.deviceName,
binaryAvailable: resolved !== null,
};
},
webUiRedirect: "/",
}),
);
}
app.use("/api/favorites", requireNotGuest, createFavoritesRouter(options.database, logger));
// admin-only routes
+32 -2
View File
@@ -7,8 +7,13 @@
<span class="song-name">{{ song.name }}</span>
<span
class="platform-badge"
:class="song.platform === 'bilibili' ? 'badge-bilibili' : song.platform === 'qq' ? 'badge-qq' : song.platform === 'youtube' ? 'badge-youtube' : song.platform === 'local' ? 'badge-local' : song.platform === 'kugou' ? 'badge-kugou' : 'badge-netease'"
>{{ song.platform === 'bilibili' ? 'B站' : song.platform === 'qq' ? 'QQ' : song.platform === 'youtube' ? 'YouTube' : song.platform === 'local' ? '本地' : song.platform === 'kugou' ? '酷狗' : '网易云' }}</span>
:class="song.platform === 'bilibili' ? 'badge-bilibili' : song.platform === 'qq' ? 'badge-qq' : song.platform === 'youtube' ? 'badge-youtube' : song.platform === 'local' ? 'badge-local' : song.platform === 'kugou' ? 'badge-kugou' : song.platform === 'spotify' ? 'badge-spotify' : 'badge-netease'"
>{{ song.platform === 'bilibili' ? 'B站' : song.platform === 'qq' ? 'QQ' : song.platform === 'youtube' ? 'YouTube' : song.platform === 'local' ? '本地' : song.platform === 'kugou' ? '酷狗' : song.platform === 'spotify' ? 'Spotify' : '网易云' }}</span>
<span
v-if="song.requestedBy"
class="requester-badge"
:class="{ 'requester-badge-guest': song.requestedBy === '游客' }"
>{{ song.requestedBy }}</span>
</div>
<div class="song-artist">{{ song.artist }}</div>
</div>
@@ -115,6 +120,26 @@ function formatDuration(seconds: number): string {
line-height: 1.4;
}
.requester-badge {
flex-shrink: 0;
max-width: 96px;
font-size: var(--fs-micro);
font-weight: var(--fw-semi);
padding: 1px 5px;
border-radius: var(--radius-xs);
line-height: 1.4;
background: var(--color-online-15);
color: var(--color-online);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.requester-badge-guest {
background: rgba(156, 163, 175, 0.18);
color: rgba(156, 163, 175, 0.95);
}
.badge-netease {
background: var(--brand-netease-15);
color: var(--brand-netease);
@@ -145,6 +170,11 @@ function formatDuration(seconds: number): string {
color: var(--brand-kugou);
}
.badge-spotify {
background: var(--brand-spotify-12);
color: var(--brand-spotify);
}
.song-artist {
font-size: 12px;
color: var(--text-secondary);
+1
View File
@@ -25,6 +25,7 @@ const LABELS: Record<Source, string> = {
netease: '网易云',
qq: 'QQ',
kugou: '酷狗',
spotify: 'Spotify',
};
defineProps<{
@@ -0,0 +1,119 @@
import { describe, it, expect } from "vitest";
import {
buildSpotifyPayload,
parseSpotifyRedirect,
statusSummary,
SPOTIFY_DISCLAIMER,
type SpotifyConfigForm,
type SpotifyStatus,
} from "./useSpotifySettings.js";
function form(overrides: Partial<SpotifyConfigForm> = {}): SpotifyConfigForm {
return {
enabled: true,
backend: "auto",
clientId: "abc",
clientSecret: "",
deviceName: "TS-Bot",
bitrate: 320,
...overrides,
};
}
describe("buildSpotifyPayload", () => {
it("always includes enabled/backend/clientId/deviceName/bitrate under a spotify key", () => {
const { spotify } = buildSpotifyPayload(form());
expect(spotify).toMatchObject({
enabled: true,
backend: "auto",
clientId: "abc",
deviceName: "TS-Bot",
bitrate: 320,
});
});
it("omits clientSecret when blank (means unchanged, never wipes)", () => {
const { spotify } = buildSpotifyPayload(form({ clientSecret: "" }));
expect("clientSecret" in spotify).toBe(false);
});
it("includes clientSecret only when non-blank", () => {
const { spotify } = buildSpotifyPayload(form({ clientSecret: "s3cr3t" }));
expect(spotify.clientSecret).toBe("s3cr3t");
});
it("carries a non-default backend through unchanged", () => {
const { spotify } = buildSpotifyPayload(form({ backend: "librespot" }));
expect(spotify.backend).toBe("librespot");
});
});
describe("parseSpotifyRedirect", () => {
it("maps ?spotify=success to 'success'", () => {
expect(parseSpotifyRedirect("?spotify=success")).toBe("success");
});
it("maps ?spotify=error to 'error'", () => {
expect(parseSpotifyRedirect("?spotify=error")).toBe("error");
});
it("returns null for unrelated params", () => {
expect(parseSpotifyRedirect("?x=1")).toBeNull();
});
it("returns null for an empty search string", () => {
expect(parseSpotifyRedirect("")).toBeNull();
});
it("returns null for an unexpected spotify value", () => {
expect(parseSpotifyRedirect("?spotify=maybe")).toBeNull();
});
});
describe("statusSummary", () => {
const ok: SpotifyStatus = { authorized: true, backend: "go-librespot", deviceName: "d", binaryAvailable: true };
it("is 'off' tone when disabled, regardless of status", () => {
expect(statusSummary(null, false).tone).toBe("off");
expect(statusSummary(ok, false).tone).toBe("off");
});
it("is 'warn' tone when enabled but status is unknown (null)", () => {
expect(statusSummary(null, true).tone).toBe("warn");
});
it("is 'warn' tone when the binary is unavailable", () => {
const s: SpotifyStatus = { authorized: false, backend: "auto", deviceName: "d", binaryAvailable: false };
expect(statusSummary(s, true).tone).toBe("warn");
});
it("is 'warn' tone when the binary exists but not authorized", () => {
const s: SpotifyStatus = { authorized: false, backend: "auto", deviceName: "d", binaryAvailable: true };
expect(statusSummary(s, true).tone).toBe("warn");
});
it("is 'ok' tone when enabled, authorized and the binary is available", () => {
const summary = statusSummary(ok, true);
expect(summary.tone).toBe("ok");
expect(summary.label).toContain("go-librespot");
});
});
describe("SPOTIFY_DISCLAIMER", () => {
it("mentions the Premium + grey-area risk copy", () => {
expect(SPOTIFY_DISCLAIMER).toContain("Premium");
expect(SPOTIFY_DISCLAIMER.length).toBeGreaterThan(20);
});
// The disclaimer is compliance-critical: it must keep the Premium requirement,
// the ToS grey-area / at-your-own-risk warning, the default-off promise, and the
// "use your own developer credentials" notion. A refactor that drops any of these
// must fail here rather than silently shipping weakened copy.
it("retains every compliance-critical element (Premium, ToS/risk, default-off, own credentials)", () => {
expect(SPOTIFY_DISCLAIMER).toContain("Premium");
expect(SPOTIFY_DISCLAIMER).toContain("灰色地带"); // grey area of Spotify's ToS
expect(SPOTIFY_DISCLAIMER).toContain("风险自负"); // at your own risk
expect(SPOTIFY_DISCLAIMER).toContain("默认关闭"); // disabled by default
expect(SPOTIFY_DISCLAIMER).toContain("凭据"); // your own developer app credentials
});
});
+48
View File
@@ -0,0 +1,48 @@
export interface SpotifyConfigForm {
enabled: boolean;
backend: "auto" | "go-librespot" | "librespot";
clientId: string;
clientSecret: string; // blank means "unchanged"
deviceName: string;
bitrate: number;
}
export interface SpotifyStatus {
authorized: boolean;
backend: string;
deviceName: string;
binaryAvailable: boolean;
}
// 实验性 · 灰色地带 · 需要 Premium · 使用你自己的开发者应用凭据
export const SPOTIFY_DISCLAIMER =
"实验性功能:通过 librespot 播放 Spotify 需要 Spotify Premium 账号,并使用你自己注册的 Spotify 开发者应用凭据。" +
"该方式处于 Spotify 服务条款的灰色地带,风险自负;默认关闭,不会内置任何共享凭据。";
export function buildSpotifyPayload(f: SpotifyConfigForm): { spotify: Record<string, unknown> } {
const spotify: Record<string, unknown> = {
enabled: f.enabled,
backend: f.backend,
clientId: f.clientId,
deviceName: f.deviceName,
bitrate: f.bitrate,
};
if (f.clientSecret && f.clientSecret.length > 0) spotify.clientSecret = f.clientSecret;
return { spotify };
}
export function parseSpotifyRedirect(search: string): "success" | "error" | null {
const v = new URLSearchParams(search).get("spotify");
return v === "success" || v === "error" ? v : null;
}
export function statusSummary(
s: SpotifyStatus | null,
enabled: boolean,
): { label: string; tone: "ok" | "warn" | "off" } {
if (!enabled) return { label: "已关闭", tone: "off" };
if (!s) return { label: "未知", tone: "warn" };
if (!s.binaryAvailable) return { label: "未检测到 librespot 可执行文件", tone: "warn" };
if (!s.authorized) return { label: "未授权(点击“连接 Spotify”登录)", tone: "warn" };
return { label: `已就绪 · 后端 ${s.backend}`, tone: "ok" };
}
+15 -8
View File
@@ -10,10 +10,12 @@ export interface Song {
album: string;
duration: number;
coverUrl: string;
platform: 'netease' | 'qq' | 'bilibili' | 'youtube' | 'local' | 'kugou';
platform: 'netease' | 'qq' | 'bilibili' | 'youtube' | 'local' | 'kugou' | 'spotify';
requestedBy?: string;
playedAt?: string;
}
export type Source = 'netease' | 'qq' | 'kugou';
export type Source = 'netease' | 'qq' | 'kugou' | 'spotify';
export interface BotStatus {
id: string;
@@ -100,11 +102,11 @@ export const usePlayerStore = defineStore('player', {
theme: 'dark' as 'dark' | 'light',
// Home page cache, split by source
recommendPlaylists: { netease: [] as PlaylistItem[], qq: [] as PlaylistItem[], kugou: [] as PlaylistItem[] },
dailySongs: { netease: [] as Song[], qq: [] as Song[], kugou: [] as Song[] },
userPlaylists: { netease: [] as PlaylistItem[], qq: [] as PlaylistItem[], kugou: [] as PlaylistItem[] },
recommendPlaylists: { netease: [] as PlaylistItem[], qq: [] as PlaylistItem[], kugou: [] as PlaylistItem[], spotify: [] as PlaylistItem[] },
dailySongs: { netease: [] as Song[], qq: [] as Song[], kugou: [] as Song[], spotify: [] as Song[] },
userPlaylists: { netease: [] as PlaylistItem[], qq: [] as PlaylistItem[], kugou: [] as PlaylistItem[], spotify: [] as PlaylistItem[] },
bilibiliPopular: [] as Song[],
authStatus: { netease: false, qq: false, kugou: false },
authStatus: { netease: false, qq: false, kugou: false, spotify: false },
lastFetchTime: 0,
// Favorited playlists (fetched from server, isolated per WebUI user)
@@ -158,6 +160,7 @@ export const usePlayerStore = defineStore('player', {
if (this.authStatus.netease) s.push('netease');
if (this.authStatus.qq) s.push('qq');
if (this.authStatus.kugou) s.push('kugou');
if (this.authStatus.spotify) s.push('spotify');
return s;
},
},
@@ -572,23 +575,27 @@ export const usePlayerStore = defineStore('player', {
// Always check auth status first — if it changed since the cached
// fetch (e.g., user logged in/out as a different account), the
// cached playlists belong to a different user and we MUST refetch.
const [neAuthRes, qqAuthRes, kugouAuthRes] = await Promise.allSettled([
const [neAuthRes, qqAuthRes, kugouAuthRes, spAuthRes] = await Promise.allSettled([
axios.get('/api/auth/status', { params: { platform: 'netease' } }),
axios.get('/api/auth/status', { params: { platform: 'qq' } }),
axios.get('/api/auth/status', { params: { platform: 'kugou' } }),
axios.get('/api/auth/status', { params: { platform: 'spotify' } }),
]);
const newAuth = {
netease: neAuthRes.status === 'fulfilled' && !!neAuthRes.value.data?.loggedIn,
qq: qqAuthRes.status === 'fulfilled' && !!qqAuthRes.value.data?.loggedIn,
kugou: kugouAuthRes.status === 'fulfilled' && !!kugouAuthRes.value.data?.loggedIn,
spotify: spAuthRes.status === 'fulfilled' && !!spAuthRes.value.data?.loggedIn,
};
const authChanged =
newAuth.netease !== this.authStatus.netease ||
newAuth.qq !== this.authStatus.qq ||
newAuth.kugou !== this.authStatus.kugou;
newAuth.kugou !== this.authStatus.kugou ||
newAuth.spotify !== this.authStatus.spotify;
this.authStatus.netease = newAuth.netease;
this.authStatus.qq = newAuth.qq;
this.authStatus.kugou = newAuth.kugou;
this.authStatus.spotify = newAuth.spotify;
// Favorites are user-local and cheap; always refresh them, even on a
// home-data cache hit, so hearts stay correct across tabs/sessions.
+1 -1
View File
@@ -28,7 +28,7 @@ function readAll(): Partial<Record<TabKey, Source>> {
export function loadTabSource(key: TabKey, fallback: Source = 'netease'): Source {
const all = readAll();
const v = all[key];
return v === 'netease' || v === 'qq' || v === 'kugou' ? v : fallback;
return v === 'netease' || v === 'qq' || v === 'kugou' || v === 'spotify' ? v : fallback;
}
export function saveTabSource(key: TabKey, value: Source): void {
+2
View File
@@ -82,6 +82,8 @@
--brand-youtube-12: rgba(255, 0, 0, 0.12);
--brand-kugou: #2ca2f9;
--brand-kugou-12: rgba(44, 162, 249, 0.12);
--brand-spotify: #1DB954;
--brand-spotify-12: rgba(29, 185, 84, 0.12);
}
// Dark theme (default)
+277 -2
View File
@@ -449,6 +449,106 @@
</div>
</section>
<!-- Spotify (Connect) playback via librespot — requires platform.auth -->
<section v-if="can('platform.auth')" class="settings-section">
<h2 class="section-title">Spotify 播放(实验性)</h2>
<p class="spotify-disclaimer">{{ SPOTIFY_DISCLAIMER }}</p>
<div class="account-card">
<div class="account-header">
<Icon icon="mdi:spotify" class="account-icon spotify-icon" />
<div class="account-info">
<div class="account-name">Spotify (librespot)</div>
<div class="account-status" :class="`tone-${spotifyStatusSummary.tone}`">
{{ spotifyStatusSummary.label }}
</div>
</div>
</div>
<!-- Enable -->
<label class="profile-toggle behavior-toggle spotify-toggle">
<div class="profile-toggle-text">
<div class="profile-toggle-label">启用 Spotify 播放</div>
<div class="profile-toggle-hint">开启后可通过 librespot 后端播放 Spotify(需 Premium 账号 + 你自己注册的开发者应用凭据)。默认关闭。</div>
</div>
<input v-model="spotifyForm.enabled" type="checkbox" class="profile-toggle-switch" />
</label>
<!-- Backend -->
<div class="setting-row spotify-row">
<div class="setting-label">播放后端</div>
<div class="spotify-btn-group">
<button
v-for="b in SPOTIFY_BACKENDS"
:key="b.value"
class="spotify-choice"
:class="{ active: spotifyForm.backend === b.value }"
@click="spotifyForm.backend = b.value"
>
{{ b.label }}
</button>
</div>
</div>
<!-- Bitrate -->
<div class="setting-row spotify-row">
<div class="setting-label">比特率 (kbps)</div>
<div class="spotify-btn-group">
<button
v-for="rate in SPOTIFY_BITRATES"
:key="rate"
class="spotify-choice"
:class="{ active: spotifyForm.bitrate === rate }"
@click="spotifyForm.bitrate = rate"
>
{{ rate }}
</button>
</div>
</div>
<!-- Client ID -->
<div class="form-group">
<label>Client ID</label>
<input v-model="spotifyForm.clientId" class="input" autocomplete="off" placeholder="Spotify 开发者应用 Client ID" />
</div>
<!-- Client Secret (write-only) -->
<div class="form-group">
<label>
Client Secret
<span class="spotify-secret-hint" :class="{ set: spotifyHasSecret }">
{{ spotifyHasSecret ? '已设置' : '未设置' }}
</span>
</label>
<input
v-model="spotifyForm.clientSecret"
class="input"
type="password"
autocomplete="off"
placeholder="留空表示不修改已保存的 Secret"
/>
</div>
<!-- Device name -->
<div class="form-group">
<label>设备名称</label>
<input v-model="spotifyForm.deviceName" class="input" autocomplete="off" placeholder="TSMusicBot" />
</div>
<div class="spotify-actions">
<button class="btn-primary" :disabled="spotifySaving" @click="saveSpotify">
{{ spotifySaving ? '保存中…' : '保存' }}
</button>
<button class="btn-secondary spotify-connect" :disabled="spotifyConnecting" @click="connectSpotify">
<Icon icon="mdi:spotify" />
{{ spotifyConnecting ? '跳转中…' : '连接 Spotify' }}
</button>
</div>
<p v-if="spotifyMessage" class="spotify-message" :class="`tone-${spotifyMessageTone}`">{{ spotifyMessage }}</p>
</div>
</section>
<!-- Audio Quality requires quality -->
<section v-if="can('quality')" class="settings-section">
<h2 class="section-title">音质设置</h2>
@@ -812,7 +912,7 @@
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, onUnmounted } from 'vue';
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue';
import { Icon } from '@iconify/vue';
import axios from 'axios';
import AvatarUpload from '../components/AvatarUpload.vue';
@@ -820,6 +920,14 @@ import CustomAvatarRow from '../components/CustomAvatarRow.vue';
import QRCode from 'qrcode';
import { usePlayerStore } from '../stores/player.js';
import { useSession } from '../composables/useSession.js';
import {
buildSpotifyPayload,
parseSpotifyRedirect,
statusSummary,
SPOTIFY_DISCLAIMER,
type SpotifyConfigForm,
type SpotifyStatus,
} from '../composables/useSpotifySettings.js';
const store = usePlayerStore();
@@ -1144,6 +1252,7 @@ async function loadIdleTimeout() {
localAudioEnabled.value = res.data.localAudioEnabled ?? true;
applyGuestModeFromServer(res.data.guestMode);
applyAdminGroupsFromServer(res.data.adminGroups);
applySpotifyConfig(res.data.spotify);
} catch { /* ignore */ }
}
@@ -1166,6 +1275,97 @@ async function saveLocalAudioEnabled() {
} catch { /* ignore */ }
}
// --- Spotify (Connect) settings (§8) ---
// NOTE: This card's status comes ONLY from /api/spotify/status (playback OAuth
// `authorized`). It is deliberately NOT wired to the player store's
// authStatus.spotify (which reflects /api/auth/status?platform=spotify — the
// separate metadata Web-API login). Keep the two concepts distinct.
const spotifyForm = reactive<SpotifyConfigForm>({
enabled: false,
backend: 'auto',
clientId: '',
clientSecret: '',
deviceName: 'TSMusicBot',
bitrate: 320,
});
const spotifyHasSecret = ref(false);
const spotifyStatus = ref<SpotifyStatus | null>(null);
const spotifySaving = ref(false);
const spotifyConnecting = ref(false);
const spotifyMessage = ref('');
const spotifyMessageTone = ref<'ok' | 'warn'>('ok');
const SPOTIFY_BACKENDS: { value: SpotifyConfigForm['backend']; label: string }[] = [
{ value: 'auto', label: '自动' },
{ value: 'go-librespot', label: 'go-librespot' },
{ value: 'librespot', label: 'librespot' },
];
const SPOTIFY_BITRATES = [96, 160, 320];
const spotifyStatusSummary = computed(() => statusSummary(spotifyStatus.value, spotifyForm.enabled));
// Populate the form from the masked GET /api/bot/settings response. The raw
// clientSecret is write-only and never returned — only `hasClientSecret`.
function applySpotifyConfig(sp: any) {
if (!sp || typeof sp !== 'object') return;
spotifyForm.enabled = Boolean(sp.enabled);
if (sp.backend === 'auto' || sp.backend === 'go-librespot' || sp.backend === 'librespot') {
spotifyForm.backend = sp.backend;
}
spotifyForm.clientId = typeof sp.clientId === 'string' ? sp.clientId : '';
spotifyForm.deviceName = typeof sp.deviceName === 'string' ? sp.deviceName : '';
if (typeof sp.bitrate === 'number') spotifyForm.bitrate = sp.bitrate;
spotifyForm.clientSecret = ''; // always blank on load; blank on save = unchanged
spotifyHasSecret.value = Boolean(sp.hasClientSecret);
}
async function loadSpotifyStatus() {
try {
const res = await axios.get('/api/spotify/status');
spotifyStatus.value = res.data;
} catch {
spotifyStatus.value = null;
}
}
async function saveSpotify() {
spotifySaving.value = true;
try {
const res = await axios.post('/api/bot/settings', buildSpotifyPayload(spotifyForm));
applySpotifyConfig(res.data?.spotify);
await loadSpotifyStatus();
} catch { /* ignore */ } finally {
spotifySaving.value = false;
}
}
async function connectSpotify() {
spotifyConnecting.value = true;
spotifyMessage.value = '';
try {
const { data } = await axios.get('/api/spotify/login');
window.location.href = data.url;
} catch (err: any) {
spotifyConnecting.value = false;
spotifyMessageTone.value = 'warn';
spotifyMessage.value = err?.response?.status === 403
? '没有权限执行平台登录(需要 platform.auth)'
: '无法开始 Spotify 授权,请稍后重试';
}
}
// On mount, surface the ?spotify=success|error redirect result, then strip the
// param so a refresh doesn't re-show it.
function handleSpotifyRedirect() {
const r = parseSpotifyRedirect(window.location.search);
if (!r) return;
spotifyMessageTone.value = r === 'success' ? 'ok' : 'warn';
spotifyMessage.value = r === 'success' ? 'Spotify 授权成功' : 'Spotify 授权失败或被取消';
const url = new URL(window.location.href);
url.searchParams.delete('spotify');
history.replaceState(null, '', url.pathname + url.search + url.hash);
}
// --- Guest mode (admin only) ---
const GUEST_FLAGS: { token: string; label: string }[] = [
{ token: 'addToQueue', label: '添加到队列末尾' },
@@ -1630,7 +1830,9 @@ onMounted(() => {
store.fetchBots(); // Refresh bot status on page visit
checkAuthStatus();
loadQuality();
loadIdleTimeout();
loadIdleTimeout(); // also populates the Spotify config form (same endpoint)
loadSpotifyStatus();
handleSpotifyRedirect();
if (session.isAdmin.value) {
loadUsers();
loadAudit();
@@ -1891,6 +2093,79 @@ onUnmounted(() => {
align-self: flex-end;
}
// --- Spotify (Connect) card ---
.spotify-disclaimer {
font-size: 13px;
line-height: 1.6;
color: var(--color-paused);
background: var(--color-paused-15);
border-radius: var(--radius-sm);
padding: 12px 14px;
margin: -4px 0 16px;
}
.spotify-icon { color: #1db954; }
.account-status {
&.tone-ok { color: var(--color-online); }
&.tone-warn { color: var(--color-paused); }
&.tone-off { color: var(--text-tertiary); }
}
.spotify-toggle { padding-top: 0; margin-bottom: 4px; }
.spotify-row { margin-top: 4px; }
.spotify-btn-group {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.spotify-choice {
padding: 8px 16px;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all var(--transition-fast);
&:hover { border-color: var(--color-primary); color: var(--color-primary); }
&.active {
background: var(--color-primary-10);
border-color: var(--color-primary);
color: var(--color-primary);
}
}
.spotify-secret-hint {
margin-left: 8px;
font-size: 11px;
font-weight: 500;
color: var(--text-tertiary);
&.set { color: var(--color-online); }
}
.spotify-actions {
display: flex;
gap: 10px;
margin-top: 8px;
}
.spotify-connect {
display: inline-flex;
align-items: center;
gap: 6px;
}
.spotify-message {
margin: 10px 0 0;
font-size: 13px;
&.tone-ok { color: var(--color-online); }
&.tone-warn { color: #e26a6a; }
}
// Shared
.form-row {
display: flex;