mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-01 20:42:50 +08:00
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>
This commit is contained in:
1 parent
ec0a027a1e
commit
1a3ccd0e38
5 files changed
+176
-13
No files matched your search
@@ -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,不受此账号级抢占影响。
|
||||
|
||||
## 配置文件
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<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
|
||||
@@ -509,20 +556,25 @@ export class RustLibrespotBackend extends EventEmitter implements SpotifyAudioBa
|
||||
}
|
||||
|
||||
async pause(): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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.
|
||||
|
||||
Reference in new issue
Block a user