diff --git a/src/music/spotify/rust-librespot.test.ts b/src/music/spotify/rust-librespot.test.ts index c155030..2e539c7 100644 --- a/src/music/spotify/rust-librespot.test.ts +++ b/src/music/spotify/rust-librespot.test.ts @@ -189,6 +189,8 @@ describe("RustLibrespotBackend track-end poll loop", () => { 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 }); @@ -204,6 +206,7 @@ describe("RustLibrespotBackend track-end poll loop", () => { 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 }) @@ -219,6 +222,7 @@ describe("RustLibrespotBackend track-end poll loop", () => { 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: 0, trackUri: null, durationMs: 0 }); @@ -235,6 +239,45 @@ describe("RustLibrespotBackend track-end poll loop", () => { 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" }); + }); }); describe("RustLibrespotBackend.stop", () => { diff --git a/src/music/spotify/rust-librespot.ts b/src/music/spotify/rust-librespot.ts index abbb544..7dc9f19 100644 --- a/src/music/spotify/rust-librespot.ts +++ b/src/music/spotify/rust-librespot.ts @@ -67,6 +67,11 @@ export class RustLibrespotBackend extends EventEmitter implements SpotifyAudioBa 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; constructor(o: RustLibrespotBackendOptions) { super(); @@ -205,6 +210,15 @@ export class RustLibrespotBackend extends EventEmitter implements SpotifyAudioBa 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; + 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 @@ -214,6 +228,7 @@ export class RustLibrespotBackend extends EventEmitter implements SpotifyAudioBa this.endedForCurrent = true; const endedUri = this.currentUri; this.currentUri = null; + this.positionMs = 0; const e: SpotifyTrackEndedEvent = { uri: endedUri, reason: "ended" }; this.emit("trackEnded", e); } @@ -256,6 +271,7 @@ export class RustLibrespotBackend extends EventEmitter implements SpotifyAudioBa 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); } @@ -271,10 +287,12 @@ export class RustLibrespotBackend extends EventEmitter implements SpotifyAudioBa // 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. + // (fresh metadata) rather than treating it as unchanged. Arming here is the + // primary guarantee that no end/metadata can fire before the bot plays. this.currentUri = null; this.hasPlayed = false; this.endedForCurrent = false; + this.armed = true; // 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.