mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 04:52:50 +08:00
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>
This commit is contained in:
1 parent
35bdd2a168
commit
efe47c5bbc
2 files changed
+62
-1
No files matched your search
@@ -189,6 +189,8 @@ describe("RustLibrespotBackend track-end poll loop", () => {
|
|||||||
const meta = vi.fn();
|
const meta = vi.fn();
|
||||||
h.backend.on("trackEnded", ended);
|
h.backend.on("trackEnded", ended);
|
||||||
h.backend.on("metadata", meta);
|
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
|
h.connect.getPlaybackState
|
||||||
.mockResolvedValueOnce({ isPlaying: true, progressMs: 1000, trackUri: "spotify:track:A", durationMs: 200000 })
|
.mockResolvedValueOnce({ isPlaying: true, progressMs: 1000, trackUri: "spotify:track:A", durationMs: 200000 })
|
||||||
.mockResolvedValueOnce({ isPlaying: true, progressMs: 199000, 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 h = makeHarness();
|
||||||
const ended = vi.fn();
|
const ended = vi.fn();
|
||||||
h.backend.on("trackEnded", ended);
|
h.backend.on("trackEnded", ended);
|
||||||
|
await h.backend.playTrack("spotify:track:A");
|
||||||
h.connect.getPlaybackState
|
h.connect.getPlaybackState
|
||||||
.mockResolvedValueOnce({ isPlaying: true, progressMs: 5000, trackUri: "spotify:track:A", durationMs: 200000 })
|
.mockResolvedValueOnce({ isPlaying: true, progressMs: 5000, trackUri: "spotify:track:A", durationMs: 200000 })
|
||||||
.mockResolvedValueOnce({ isPlaying: false, 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 h = makeHarness();
|
||||||
const ended = vi.fn();
|
const ended = vi.fn();
|
||||||
h.backend.on("trackEnded", ended);
|
h.backend.on("trackEnded", ended);
|
||||||
|
await h.backend.playTrack("spotify:track:A");
|
||||||
h.connect.getPlaybackState
|
h.connect.getPlaybackState
|
||||||
.mockResolvedValueOnce({ isPlaying: true, progressMs: 1000, trackUri: "spotify:track:A", durationMs: 200000 })
|
.mockResolvedValueOnce({ isPlaying: true, progressMs: 1000, trackUri: "spotify:track:A", durationMs: 200000 })
|
||||||
.mockResolvedValueOnce({ isPlaying: true, progressMs: 0, trackUri: null, durationMs: 0 });
|
.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();
|
await (h.backend as any).pollState();
|
||||||
expect(ended).not.toHaveBeenCalled();
|
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", () => {
|
describe("RustLibrespotBackend.stop", () => {
|
||||||
|
|||||||
@@ -67,6 +67,11 @@ export class RustLibrespotBackend extends EventEmitter implements SpotifyAudioBa
|
|||||||
private currentUri: string | null = null;
|
private currentUri: string | null = null;
|
||||||
private hasPlayed = false;
|
private hasPlayed = false;
|
||||||
private endedForCurrent = 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) {
|
constructor(o: RustLibrespotBackendOptions) {
|
||||||
super();
|
super();
|
||||||
@@ -205,6 +210,15 @@ export class RustLibrespotBackend extends EventEmitter implements SpotifyAudioBa
|
|||||||
return;
|
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) {
|
if (!state) {
|
||||||
// C3.5: a 204 / no-active-device response. AFTER our own track has been
|
// 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
|
// 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;
|
this.endedForCurrent = true;
|
||||||
const endedUri = this.currentUri;
|
const endedUri = this.currentUri;
|
||||||
this.currentUri = null;
|
this.currentUri = null;
|
||||||
|
this.positionMs = 0;
|
||||||
const e: SpotifyTrackEndedEvent = { uri: endedUri, reason: "ended" };
|
const e: SpotifyTrackEndedEvent = { uri: endedUri, reason: "ended" };
|
||||||
this.emit("trackEnded", e);
|
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
|
this.endedForCurrent = true; // latch: emit at most once per track
|
||||||
const endedUri = this.currentUri;
|
const endedUri = this.currentUri;
|
||||||
this.currentUri = null;
|
this.currentUri = null;
|
||||||
|
this.positionMs = 0;
|
||||||
const e: SpotifyTrackEndedEvent = { uri: endedUri, reason: "ended" };
|
const e: SpotifyTrackEndedEvent = { uri: endedUri, reason: "ended" };
|
||||||
this.emit("trackEnded", e);
|
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
|
// 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
|
// 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
|
// 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.currentUri = null;
|
||||||
this.hasPlayed = false;
|
this.hasPlayed = false;
|
||||||
this.endedForCurrent = false;
|
this.endedForCurrent = false;
|
||||||
|
this.armed = true;
|
||||||
// transfer(false) activates our device WITHOUT starting audio; play() then
|
// transfer(false) activates our device WITHOUT starting audio; play() then
|
||||||
// actually starts the uri. The two-step is required — transfer alone won't
|
// actually starts the uri. The two-step is required — transfer alone won't
|
||||||
// begin playback.
|
// begin playback.
|
||||||
|
|||||||
Reference in new issue
Block a user