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:
@@ -89,6 +89,17 @@ let _queueSource = $state<string | null>(null);
|
||||
// looping forever. Reset on a successful play and on a fresh playQueue.
|
||||
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;
|
||||
|
||||
export const player = {
|
||||
@@ -115,13 +126,20 @@ export function registerAudioEl(el: HTMLAudioElement | null): void {
|
||||
export function playQueue(
|
||||
tracks: TrackRef[],
|
||||
startIndex = 0,
|
||||
opts: { shuffle?: boolean; source?: string | null } = {},
|
||||
opts: {
|
||||
shuffle?: boolean;
|
||||
source?: string | null;
|
||||
refetch?: () => Promise<TrackRef[]>;
|
||||
} = {},
|
||||
): void {
|
||||
_radioSeedId = null; // M4c: non-radio enqueue clears the radio refresh state
|
||||
// #415: a fresh queue resets the system-playlist source. Set only
|
||||
// when seeded from a system playlist (server already returned the
|
||||
// rotation-aware order, so no client shuffle in that path).
|
||||
_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) {
|
||||
// Fisher-Yates over the whole list. startIndex is ignored — the
|
||||
// caller is asking for "random play from this pool," so the first
|
||||
@@ -271,6 +289,7 @@ export function reportStateFromAudio(
|
||||
_state = 'playing';
|
||||
_error = null;
|
||||
_failureStreak = 0;
|
||||
_selfHealAttempts = 0;
|
||||
return;
|
||||
case 'paused':
|
||||
_state = 'paused';
|
||||
@@ -323,10 +342,53 @@ function handleLoadFailure(detail?: string): void {
|
||||
_error = null;
|
||||
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';
|
||||
_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 {
|
||||
_radioSeedId = null; // M4c
|
||||
_queue = [..._queue, t];
|
||||
@@ -371,12 +433,19 @@ export function playNextMany(ts: TrackRef[]): void {
|
||||
_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>(
|
||||
`/api/radio?seed_track=${encodeURIComponent(seedTrackId)}`
|
||||
);
|
||||
if (resp.tracks.length === 0) return;
|
||||
playQueue(resp.tracks, 0);
|
||||
return resp.tracks;
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user