feat(web/player): self-heal a stale system-playlist / radio queue on total failure
test-web / test (push) Successful in 39s
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:
@@ -5,6 +5,7 @@
|
|||||||
import { user } from '$lib/auth/store.svelte';
|
import { user } from '$lib/auth/store.svelte';
|
||||||
import { getPlaylist, systemShuffle, refreshSystem } from '$lib/api/playlists';
|
import { getPlaylist, systemShuffle, refreshSystem } from '$lib/api/playlists';
|
||||||
import { playlistTrackToRef } from '$lib/playlists/playlistTrackToRef';
|
import { playlistTrackToRef } from '$lib/playlists/playlistTrackToRef';
|
||||||
|
import { systemPlaylistRefetch } from '$lib/playlists/systemRefetch';
|
||||||
import { errCode } from '$lib/api/errors';
|
import { errCode } from '$lib/api/errors';
|
||||||
import { qk } from '$lib/api/queries';
|
import { qk } from '$lib/api/queries';
|
||||||
import { playQueue } from '$lib/player/store.svelte';
|
import { playQueue } from '$lib/player/store.svelte';
|
||||||
@@ -80,7 +81,14 @@
|
|||||||
const detail = await systemShuffle(variant);
|
const detail = await systemShuffle(variant);
|
||||||
const refs = toTrackRefs(detail.tracks);
|
const refs = toTrackRefs(detail.tracks);
|
||||||
if (refs.length > 0) {
|
if (refs.length > 0) {
|
||||||
playQueue(refs, 0, { source: variant });
|
playQueue(refs, 0, {
|
||||||
|
source: variant,
|
||||||
|
refetch: systemPlaylistRefetch({
|
||||||
|
variant,
|
||||||
|
playlistId: playlist.id,
|
||||||
|
perArtist: false,
|
||||||
|
}),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const detail = await getPlaylist(playlist.id);
|
const detail = await getPlaylist(playlist.id);
|
||||||
@@ -93,7 +101,14 @@
|
|||||||
// so source attribution stays absent — the PlaylistCard
|
// so source attribution stays absent — the PlaylistCard
|
||||||
// test pins this contract.
|
// test pins this contract.
|
||||||
if (variant != null) {
|
if (variant != null) {
|
||||||
playQueue(refs, 0, { source: variant });
|
playQueue(refs, 0, {
|
||||||
|
source: variant,
|
||||||
|
refetch: systemPlaylistRefetch({
|
||||||
|
variant,
|
||||||
|
playlistId: playlist.id,
|
||||||
|
perArtist: true,
|
||||||
|
}),
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
playQueue(refs, 0);
|
playQueue(refs, 0);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,6 +89,17 @@ let _queueSource = $state<string | null>(null);
|
|||||||
// looping forever. Reset on a successful play and on a fresh playQueue.
|
// looping forever. Reset on a successful play and on a fresh playQueue.
|
||||||
let _failureStreak = 0;
|
let _failureStreak = 0;
|
||||||
|
|
||||||
|
// Track B (#968): when a queue is seeded from a refreshable source (a system
|
||||||
|
// playlist or radio), the seeder hands us a closure that re-pulls the fresh
|
||||||
|
// snapshot. On total failure (whole queue unplayable — likely a stale tab
|
||||||
|
// left open across the daily rebuild) we call it once to self-heal before
|
||||||
|
// surfacing the dead-end. Kept opaque so the store stays decoupled from the
|
||||||
|
// playlist API and the per-artist (songs_like_artist) identity problem.
|
||||||
|
let _queueRefetch: (() => Promise<TrackRef[]>) | null = null;
|
||||||
|
let _selfHealInFlight = false;
|
||||||
|
let _selfHealAttempts = 0;
|
||||||
|
const MAX_SELF_HEAL_ATTEMPTS = 1;
|
||||||
|
|
||||||
let _audioEl: HTMLAudioElement | null = null;
|
let _audioEl: HTMLAudioElement | null = null;
|
||||||
|
|
||||||
export const player = {
|
export const player = {
|
||||||
@@ -115,13 +126,20 @@ export function registerAudioEl(el: HTMLAudioElement | null): void {
|
|||||||
export function playQueue(
|
export function playQueue(
|
||||||
tracks: TrackRef[],
|
tracks: TrackRef[],
|
||||||
startIndex = 0,
|
startIndex = 0,
|
||||||
opts: { shuffle?: boolean; source?: string | null } = {},
|
opts: {
|
||||||
|
shuffle?: boolean;
|
||||||
|
source?: string | null;
|
||||||
|
refetch?: () => Promise<TrackRef[]>;
|
||||||
|
} = {},
|
||||||
): void {
|
): void {
|
||||||
_radioSeedId = null; // M4c: non-radio enqueue clears the radio refresh state
|
_radioSeedId = null; // M4c: non-radio enqueue clears the radio refresh state
|
||||||
// #415: a fresh queue resets the system-playlist source. Set only
|
// #415: a fresh queue resets the system-playlist source. Set only
|
||||||
// when seeded from a system playlist (server already returned the
|
// when seeded from a system playlist (server already returned the
|
||||||
// rotation-aware order, so no client shuffle in that path).
|
// rotation-aware order, so no client shuffle in that path).
|
||||||
_queueSource = opts.source ?? null;
|
_queueSource = opts.source ?? null;
|
||||||
|
// #968: a fresh play replaces the self-heal closure + resets its budget.
|
||||||
|
_queueRefetch = opts.refetch ?? null;
|
||||||
|
_selfHealAttempts = 0;
|
||||||
if (opts.shuffle && tracks.length > 1) {
|
if (opts.shuffle && tracks.length > 1) {
|
||||||
// Fisher-Yates over the whole list. startIndex is ignored — the
|
// Fisher-Yates over the whole list. startIndex is ignored — the
|
||||||
// caller is asking for "random play from this pool," so the first
|
// caller is asking for "random play from this pool," so the first
|
||||||
@@ -271,6 +289,7 @@ export function reportStateFromAudio(
|
|||||||
_state = 'playing';
|
_state = 'playing';
|
||||||
_error = null;
|
_error = null;
|
||||||
_failureStreak = 0;
|
_failureStreak = 0;
|
||||||
|
_selfHealAttempts = 0;
|
||||||
return;
|
return;
|
||||||
case 'paused':
|
case 'paused':
|
||||||
_state = 'paused';
|
_state = 'paused';
|
||||||
@@ -323,10 +342,53 @@ function handleLoadFailure(detail?: string): void {
|
|||||||
_error = null;
|
_error = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Whole queue is unplayable. If it came from a refreshable source, the
|
||||||
|
// snapshot is probably stale — re-pull it and resume before giving up.
|
||||||
|
if (trySelfHeal()) return;
|
||||||
_state = 'error';
|
_state = 'error';
|
||||||
_error = detail ?? 'Playback failed.';
|
_error = detail ?? 'Playback failed.';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// #968: re-pull a stale refreshable queue. Returns true if a self-heal was
|
||||||
|
// started (caller must not dead-end). Bounded to one attempt per exhaustion
|
||||||
|
// (reset on the next successful play) so a still-broken refresh can't loop.
|
||||||
|
function trySelfHeal(): boolean {
|
||||||
|
if (_selfHealInFlight) return true;
|
||||||
|
if (_queueRefetch === null) return false;
|
||||||
|
if (_selfHealAttempts >= MAX_SELF_HEAL_ATTEMPTS) return false;
|
||||||
|
_selfHealAttempts++;
|
||||||
|
_selfHealInFlight = true;
|
||||||
|
_state = 'loading';
|
||||||
|
_error = null;
|
||||||
|
const refetch = _queueRefetch;
|
||||||
|
void refetch()
|
||||||
|
.then((refs) => {
|
||||||
|
if (refs.length > 0) {
|
||||||
|
// Re-seed in place — keep _queueSource / _queueRefetch so the new
|
||||||
|
// snapshot can itself self-heal, and don't reset _selfHealAttempts
|
||||||
|
// (only a successful 'playing' clears the budget).
|
||||||
|
_queue = refs;
|
||||||
|
_index = 0;
|
||||||
|
_position = 0;
|
||||||
|
_duration = 0;
|
||||||
|
_failureStreak = 0;
|
||||||
|
_state = 'loading';
|
||||||
|
_error = null;
|
||||||
|
} else {
|
||||||
|
_state = 'error';
|
||||||
|
_error = 'Nothing playable in this mix right now.';
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
_state = 'error';
|
||||||
|
_error = 'Couldn’t refresh this mix. Try again.';
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
_selfHealInFlight = false;
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
export function enqueueTrack(t: TrackRef): void {
|
export function enqueueTrack(t: TrackRef): void {
|
||||||
_radioSeedId = null; // M4c
|
_radioSeedId = null; // M4c
|
||||||
_queue = [..._queue, t];
|
_queue = [..._queue, t];
|
||||||
@@ -371,12 +433,19 @@ export function playNextMany(ts: TrackRef[]): void {
|
|||||||
_queue = [..._queue.slice(0, next), ...ts, ..._queue.slice(next)];
|
_queue = [..._queue.slice(0, next), ...ts, ..._queue.slice(next)];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function playRadio(seedTrackId: string): Promise<void> {
|
async function fetchRadioTracks(seedTrackId: string): Promise<TrackRef[]> {
|
||||||
const resp = await api.get<RadioResponse>(
|
const resp = await api.get<RadioResponse>(
|
||||||
`/api/radio?seed_track=${encodeURIComponent(seedTrackId)}`
|
`/api/radio?seed_track=${encodeURIComponent(seedTrackId)}`
|
||||||
);
|
);
|
||||||
if (resp.tracks.length === 0) return;
|
return resp.tracks;
|
||||||
playQueue(resp.tracks, 0);
|
}
|
||||||
|
|
||||||
|
export async function playRadio(seedTrackId: string): Promise<void> {
|
||||||
|
const tracks = await fetchRadioTracks(seedTrackId);
|
||||||
|
if (tracks.length === 0) return;
|
||||||
|
// #968: hand the player a self-heal closure so a fully-stale radio queue
|
||||||
|
// re-seeds from the same track instead of dead-ending.
|
||||||
|
playQueue(tracks, 0, { refetch: () => fetchRadioTracks(seedTrackId) });
|
||||||
_radioSeedId = seedTrackId; // M4c: set AFTER playQueue (which clears it)
|
_radioSeedId = seedTrackId; // M4c: set AFTER playQueue (which clears it)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -306,6 +306,32 @@ describe('player store — shuffle + repeat + audio reports', () => {
|
|||||||
expect(player.state).toBe('error');
|
expect(player.state).toBe('error');
|
||||||
expect(player.error).toBe('boom');
|
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 {
|
import {
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import type { TrackRef, PlaylistDetail } from '$lib/api/types';
|
||||||
|
import { systemShuffle, getPlaylist } from '$lib/api/playlists';
|
||||||
|
import { playlistTrackToRef } from './playlistTrackToRef';
|
||||||
|
|
||||||
|
// #968: builds the self-heal closure for a system-playlist queue. On total
|
||||||
|
// playback failure (every queued track unplayable — typically a stale tab
|
||||||
|
// left open across the daily rebuild) the player calls this to re-pull the
|
||||||
|
// current snapshot and resume.
|
||||||
|
//
|
||||||
|
// Single-instance variants (for_you, discover, deep_cuts, …) re-pull by
|
||||||
|
// variant via the rotation-aware shuffle endpoint. Per-artist mixes
|
||||||
|
// (songs_like_artist — one playlist per seed artist) can't be addressed by
|
||||||
|
// variant alone, so they re-pull by playlist id. Mirrors the play routing
|
||||||
|
// in PlaylistCard.
|
||||||
|
|
||||||
|
function toRefs(detail: PlaylistDetail): TrackRef[] {
|
||||||
|
return detail.tracks
|
||||||
|
.map((r) => playlistTrackToRef(r))
|
||||||
|
.filter((t): t is TrackRef => t !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function systemPlaylistRefetch(opts: {
|
||||||
|
variant: string;
|
||||||
|
playlistId: string;
|
||||||
|
perArtist: boolean;
|
||||||
|
}): () => Promise<TrackRef[]> {
|
||||||
|
return async () => {
|
||||||
|
const detail = opts.perArtist
|
||||||
|
? await getPlaylist(opts.playlistId)
|
||||||
|
: await systemShuffle(opts.variant);
|
||||||
|
return toRefs(detail);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@
|
|||||||
import { user } from '$lib/auth/store.svelte';
|
import { user } from '$lib/auth/store.svelte';
|
||||||
import { errCode, errMessage } from '$lib/api/errors';
|
import { errCode, errMessage } from '$lib/api/errors';
|
||||||
import { playQueue } from '$lib/player/store.svelte';
|
import { playQueue } from '$lib/player/store.svelte';
|
||||||
|
import { systemPlaylistRefetch } from '$lib/playlists/systemRefetch';
|
||||||
import { pushToast } from '$lib/stores/toast.svelte';
|
import { pushToast } from '$lib/stores/toast.svelte';
|
||||||
import type { TrackRef } from '$lib/api/types';
|
import type { TrackRef } from '$lib/api/types';
|
||||||
|
|
||||||
@@ -61,17 +62,31 @@
|
|||||||
|
|
||||||
function onPlay(position: number) {
|
function onPlay(position: number) {
|
||||||
if (!playlistQuery?.data) return;
|
if (!playlistQuery?.data) return;
|
||||||
|
const data = playlistQuery.data;
|
||||||
// Skip rows where track_id is null (track was removed from library).
|
// Skip rows where track_id is null (track was removed from library).
|
||||||
const live = playlistQuery.data.tracks
|
const live = data.tracks.filter((t) => t.track_id !== null).map(toTrackRef);
|
||||||
.filter((t) => t.track_id !== null)
|
|
||||||
.map(toTrackRef);
|
|
||||||
// Find which `live` index corresponds to the clicked position. Some
|
// Find which `live` index corresponds to the clicked position. Some
|
||||||
// positions in the original list may be unavailable (filtered out),
|
// positions in the original list may be unavailable (filtered out),
|
||||||
// so the index in the playable list != the playlist position.
|
// so the index in the playable list != the playlist position.
|
||||||
const clickedTrackID = playlistQuery.data.tracks.find((t) => t.position === position)?.track_id;
|
const clickedTrackID = data.tracks.find((t) => t.position === position)?.track_id;
|
||||||
const startIdx = live.findIndex((t) => t.id === clickedTrackID);
|
const startIdx = live.findIndex((t) => t.id === clickedTrackID);
|
||||||
|
// #968: a system playlist played from its detail page gets the same
|
||||||
|
// self-heal closure as the home tile, so a stale snapshot re-pulls on
|
||||||
|
// total failure. (Source attribution is intentionally left to the home
|
||||||
|
// tile / shuffle path — this only adds the recovery closure.)
|
||||||
|
const variant = data.system_variant;
|
||||||
|
if (variant != null) {
|
||||||
|
playQueue(live, Math.max(0, startIdx), {
|
||||||
|
refetch: systemPlaylistRefetch({
|
||||||
|
variant,
|
||||||
|
playlistId: id,
|
||||||
|
perArtist: data.seed_artist_id != null
|
||||||
|
})
|
||||||
|
});
|
||||||
|
} else {
|
||||||
playQueue(live, Math.max(0, startIdx));
|
playQueue(live, Math.max(0, startIdx));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function toTrackRef(t: {
|
function toTrackRef(t: {
|
||||||
track_id: string | null;
|
track_id: string | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user