From 1a3ccd0e38d285a4676fcbb4ee4809d1cd0c9f2a Mon Sep 17 00:00:00 2001 From: saopig1 <4x7sw862st@gmail.com> Date: Sat, 4 Jul 2026 13:50:28 +0800 Subject: [PATCH] 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 --- README.md | 1 + src/music/spotify/connect-api.test.ts | 36 ++++++++++++ src/music/spotify/connect-api.ts | 11 ++++ src/music/spotify/rust-librespot.test.ts | 71 ++++++++++++++++++++++-- src/music/spotify/rust-librespot.ts | 70 ++++++++++++++++++++--- 5 files changed, 176 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index d6c94a5..42657b3 100644 --- a/README.md +++ b/README.md @@ -641,6 +641,7 @@ OAuth 相关端点:`/api/spotify/login`、`/api/spotify/callback`、`/api/spot - 在多租户/共享主机上,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,不受此账号级抢占影响。 ## 配置文件 diff --git a/src/music/spotify/connect-api.test.ts b/src/music/spotify/connect-api.test.ts index cf2c4b6..c9068b7 100644 --- a/src/music/spotify/connect-api.test.ts +++ b/src/music/spotify/connect-api.test.ts @@ -141,6 +141,16 @@ describe("SpotifyConnectApi mutating calls", () => { }); }); + 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 }); @@ -326,6 +336,32 @@ describe("SpotifyConnectApi.getPlaybackState", () => { }); }); + // 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 }); diff --git a/src/music/spotify/connect-api.ts b/src/music/spotify/connect-api.ts index 56cae27..8eb2b64 100644 --- a/src/music/spotify/connect-api.ts +++ b/src/music/spotify/connect-api.ts @@ -25,6 +25,14 @@ export interface PlaybackState { 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; } /** @@ -186,6 +194,9 @@ export class SpotifyConnectApi { 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; diff --git a/src/music/spotify/rust-librespot.test.ts b/src/music/spotify/rust-librespot.test.ts index a3c530b..34d1a78 100644 --- a/src/music/spotify/rust-librespot.test.ts +++ b/src/music/spotify/rust-librespot.test.ts @@ -173,14 +173,19 @@ describe("RustLibrespotBackend transport delegation (Connect API)", () => { await expect(h.backend.playTrack("spotify:track:x")).rejects.toThrow(/device/i); }); - it("pause/resume/seek delegate to the Connect API and seek updates position", async () => { + // 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).toHaveBeenCalled(); - expect(h.connect.resume).toHaveBeenCalled(); - expect(h.connect.seek).toHaveBeenCalledWith(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); }); }); @@ -481,6 +486,64 @@ describe("RustLibrespotBackend track-end poll loop", () => { }); }); +// 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 diff --git a/src/music/spotify/rust-librespot.ts b/src/music/spotify/rust-librespot.ts index f8f4efd..7358d2b 100644 --- a/src/music/spotify/rust-librespot.ts +++ b/src/music/spotify/rust-librespot.ts @@ -82,6 +82,16 @@ export class RustLibrespotBackend extends EventEmitter implements SpotifyAudioBa 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; @@ -332,10 +342,30 @@ export class RustLibrespotBackend extends EventEmitter implements SpotifyAudioBa return; } - this.positionMs = state.progressMs; + // 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; - // Track change -> reset the end-detection state and surface best-effort metadata. - if (state.trackUri && state.trackUri !== this.currentUri) { + // 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; @@ -351,7 +381,11 @@ export class RustLibrespotBackend extends EventEmitter implements SpotifyAudioBa this.emit("metadata", np); } - if (state.isPlaying) { + // 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(); @@ -362,7 +396,7 @@ export class RustLibrespotBackend extends EventEmitter implements SpotifyAudioBa // 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 (state.isPlaying && state.trackUri !== null) { + if (ourTrackPlaying && state.trackUri !== null) { this.stopSeen = false; } if (!this.currentUri || this.endedForCurrent) return; @@ -381,9 +415,13 @@ export class RustLibrespotBackend extends EventEmitter implements SpotifyAudioBa // 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) @@ -397,11 +435,16 @@ export class RustLibrespotBackend extends EventEmitter implements SpotifyAudioBa // 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 && - (!state.isPlaying || state.trackUri === null) + (foreignActive || !state.isPlaying || state.trackUri === null) ) { if (this.stopSeen) { finishedByStopOrNull = true; @@ -431,6 +474,10 @@ export class RustLibrespotBackend extends EventEmitter implements SpotifyAudioBa async playTrack(uri: string): Promise { 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 @@ -509,20 +556,25 @@ export class RustLibrespotBackend extends EventEmitter implements SpotifyAudioBa } async pause(): Promise { - await this.connect.pause(); + // 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 { - await this.connect.resume(); + // 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 { - await this.connect.seek(ms); + // 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.