mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 04:52:50 +08:00
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>
This commit is contained in:
1 parent
7e58e8a611
commit
952bd26d2d
2 files changed
+96
-22
No files matched your search
@@ -367,19 +367,71 @@ describe("RustLibrespotBackend track-end poll loop", () => {
|
|||||||
expect(ended).toHaveBeenCalledWith({ uri: "spotify:track:A", reason: "ended" });
|
expect(ended).toHaveBeenCalledWith({ uri: "spotify:track:A", reason: "ended" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("emits trackEnded when the track uri transitions to null after playing", async () => {
|
// 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 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");
|
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 });
|
.mockResolvedValue({ isPlaying: true, progressMs: 0, trackUri: null, durationMs: 0 });
|
||||||
await (h.backend as any).pollState();
|
await (h.backend as any).pollState(); // observed playing our uri
|
||||||
await (h.backend as any).pollState();
|
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" });
|
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 () => {
|
it("ignores a null playback state (no active device) without emitting", async () => {
|
||||||
const h = makeHarness();
|
const h = makeHarness();
|
||||||
const ended = vi.fn();
|
const ended = vi.fn();
|
||||||
|
|||||||
@@ -97,10 +97,17 @@ export class RustLibrespotBackend extends EventEmitter implements SpotifyAudioBa
|
|||||||
// pause command and occupancy auto-pause-when-alone on the Rust backend). Set
|
// pause command and occupancy auto-pause-when-alone on the Rust backend). Set
|
||||||
// by pause(), cleared by resume() and playTrack().
|
// by pause(), cleared by resume() and playTrack().
|
||||||
private paused = false;
|
private paused = false;
|
||||||
// Robustness: a genuine external stop must be confirmed across TWO consecutive
|
// Robustness (finishedByStop + finishedByNull / R3-1): a "not clearly playing
|
||||||
// non-paused is_playing:false polls before we emit trackEnded, so a momentary
|
// our track" signal — either a non-paused is_playing:false (external stop) OR
|
||||||
// mid-track is_playing:false (buffering) can't false-skip. Set on the first
|
// a null item (trackUri===null) observed while our track was playing — must be
|
||||||
// such poll; reset whenever the track is playing again or the track changes.
|
// 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;
|
private stopSeen = false;
|
||||||
// I(pipe): one teardown per broken librespot->ffmpeg pipe. Both stream ends
|
// 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;
|
// (ffmpeg.stdin write side, proc.stdout read side) can report the same EPIPE;
|
||||||
@@ -329,11 +336,18 @@ export class RustLibrespotBackend extends EventEmitter implements SpotifyAudioBa
|
|||||||
|
|
||||||
if (state.isPlaying) {
|
if (state.isPlaying) {
|
||||||
this.hasPlayed = true;
|
this.hasPlayed = true;
|
||||||
// Playing again -> any earlier is_playing:false was transient, not a stop.
|
|
||||||
this.stopSeen = false;
|
|
||||||
// Real playback observed -> the I4 degrade-to-skip watchdog is moot.
|
// Real playback observed -> the I4 degrade-to-skip watchdog is moot.
|
||||||
this.clearPlaybackWatchdog();
|
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 (state.isPlaying && state.trackUri !== null) {
|
||||||
|
this.stopSeen = false;
|
||||||
|
}
|
||||||
if (!this.currentUri || this.endedForCurrent) return;
|
if (!this.currentUri || this.endedForCurrent) return;
|
||||||
|
|
||||||
// C3.4: EVERY end condition is gated on hasPlayed so no end can fire until
|
// C3.4: EVERY end condition is gated on hasPlayed so no end can fire until
|
||||||
@@ -355,23 +369,31 @@ export class RustLibrespotBackend extends EventEmitter implements SpotifyAudioBa
|
|||||||
!this.paused &&
|
!this.paused &&
|
||||||
state.durationMs > END_OF_TRACK_WINDOW_MS &&
|
state.durationMs > END_OF_TRACK_WINDOW_MS &&
|
||||||
state.progressMs >= state.durationMs - END_OF_TRACK_WINDOW_MS;
|
state.progressMs >= state.durationMs - END_OF_TRACK_WINDOW_MS;
|
||||||
// C1(pause-skip): a self-initiated pause (this.paused) reports is_playing:false
|
// C1(pause-skip) + R3-1(null-item): a self-initiated pause (this.paused)
|
||||||
// with the SAME uri — that is NOT a track end, so never finish while paused.
|
// reports is_playing:false (and can idle to a null item) with the SAME uri —
|
||||||
// And even for a genuine external stop, require it to persist across two
|
// that is NOT a track end, so never finish while paused. Beyond pause, the
|
||||||
// consecutive polls (stopSeen) so a momentary mid-track is_playing:false
|
// two "not clearly playing our track" signals — an external stop
|
||||||
// (buffering) doesn't false-skip. Confirmed stops still emit within ~one
|
// (is_playing:false) and a null item (trackUri===null) — are BOTH momentary
|
||||||
// extra poll interval.
|
// at the boundaries (buffering; Connect handoff / market relink), so they
|
||||||
let finishedByStop = false;
|
// share ONE two-poll confirmation (stopSeen): require the signal to persist
|
||||||
if (this.hasPlayed && !state.isPlaying && !this.paused) {
|
// 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.
|
||||||
|
let finishedByStopOrNull = false;
|
||||||
|
if (
|
||||||
|
this.hasPlayed &&
|
||||||
|
!this.paused &&
|
||||||
|
(!state.isPlaying || state.trackUri === null)
|
||||||
|
) {
|
||||||
if (this.stopSeen) {
|
if (this.stopSeen) {
|
||||||
finishedByStop = true;
|
finishedByStopOrNull = true;
|
||||||
} else {
|
} else {
|
||||||
this.stopSeen = true; // first non-paused stop poll — await confirmation
|
this.stopSeen = true; // first such poll — await confirmation
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const finishedByNull = this.hasPlayed && state.trackUri === null;
|
|
||||||
|
|
||||||
if (finishedByProgress || finishedByStop || finishedByNull) {
|
if (finishedByProgress || finishedByStopOrNull) {
|
||||||
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;
|
||||||
|
|||||||
Reference in new issue
Block a user