feat(web/player): self-heal a stale system-playlist / radio queue on total failure
test-web / test (push) Successful in 39s

When the whole queue proves unplayable (e.g. a tab left open across the
daily system-playlist rebuild — the exact stale-snapshot case), the player
now re-pulls the fresh snapshot and resumes instead of dead-ending on
"Try again". The seeder hands the store an opaque refetch closure so the
store stays decoupled from the playlist API and the per-artist
(songs_like_artist) identity problem: single-instance variants re-pull via
systemShuffle, per-artist mixes via getPlaylist(id), radio re-seeds from
its track. Bounded to one self-heal per exhaustion (reset on the next
successful play) so a still-broken refresh can't loop; "Try again" stays
the genuine last resort. Wired from PlaylistCard, the playlist detail page,
and playRadio. Issue #968.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-20 12:51:47 -04:00
parent 335d782215
commit 27766ae063
5 changed files with 169 additions and 11 deletions
+26
View File
@@ -306,6 +306,32 @@ describe('player store — shuffle + repeat + audio reports', () => {
expect(player.state).toBe('error');
expect(player.error).toBe('boom');
});
test('reportStateFromAudio("error") self-heals a refreshable queue on total failure', async () => {
const fresh = [track('a'), track('b')];
const refetch = vi.fn().mockResolvedValue(fresh);
playQueue([track('1')], 0, { source: 'for_you', refetch });
reportStateFromAudio('error', 'boom'); // sole track fails → exhausted → self-heal
expect(refetch).toHaveBeenCalledOnce();
expect(player.state).toBe('loading');
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
expect(player.queue.map((t) => t.id)).toEqual(['a', 'b']);
expect(player.index).toBe(0);
});
test('self-heal fires at most once per exhaustion, then dead-ends', async () => {
const refetch = vi.fn().mockResolvedValue([track('x')]);
playQueue([track('1')], 0, { source: 'for_you', refetch });
reportStateFromAudio('error', 'boom'); // exhausted → self-heal #1
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
reportStateFromAudio('error', 'boom'); // re-pulled track also fails; budget spent → error
expect(player.state).toBe('error');
expect(refetch).toHaveBeenCalledOnce();
});
});
import {