diff --git a/web/src/lib/components/PlaylistCard.svelte b/web/src/lib/components/PlaylistCard.svelte index 84e358c0..95642a6b 100644 --- a/web/src/lib/components/PlaylistCard.svelte +++ b/web/src/lib/components/PlaylistCard.svelte @@ -5,6 +5,7 @@ import { user } from '$lib/auth/store.svelte'; import { getPlaylist, systemShuffle, refreshSystem } from '$lib/api/playlists'; import { playlistTrackToRef } from '$lib/playlists/playlistTrackToRef'; + import { systemPlaylistRefetch } from '$lib/playlists/systemRefetch'; import { errCode } from '$lib/api/errors'; import { qk } from '$lib/api/queries'; import { playQueue } from '$lib/player/store.svelte'; @@ -80,7 +81,14 @@ const detail = await systemShuffle(variant); const refs = toTrackRefs(detail.tracks); if (refs.length > 0) { - playQueue(refs, 0, { source: variant }); + playQueue(refs, 0, { + source: variant, + refetch: systemPlaylistRefetch({ + variant, + playlistId: playlist.id, + perArtist: false, + }), + }); } } else { const detail = await getPlaylist(playlist.id); @@ -93,7 +101,14 @@ // so source attribution stays absent — the PlaylistCard // test pins this contract. if (variant != null) { - playQueue(refs, 0, { source: variant }); + playQueue(refs, 0, { + source: variant, + refetch: systemPlaylistRefetch({ + variant, + playlistId: playlist.id, + perArtist: true, + }), + }); } else { playQueue(refs, 0); } diff --git a/web/src/lib/player/store.svelte.ts b/web/src/lib/player/store.svelte.ts index ef8f9c1d..1e41432b 100644 --- a/web/src/lib/player/store.svelte.ts +++ b/web/src/lib/player/store.svelte.ts @@ -89,6 +89,17 @@ let _queueSource = $state(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) | 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; + } = {}, ): 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 { +async function fetchRadioTracks(seedTrackId: string): Promise { const resp = await api.get( `/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 { + 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) } diff --git a/web/src/lib/player/store.test.ts b/web/src/lib/player/store.test.ts index 2b5228f1..3d2044d2 100644 --- a/web/src/lib/player/store.test.ts +++ b/web/src/lib/player/store.test.ts @@ -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 { diff --git a/web/src/lib/playlists/systemRefetch.ts b/web/src/lib/playlists/systemRefetch.ts new file mode 100644 index 00000000..ef5a19ba --- /dev/null +++ b/web/src/lib/playlists/systemRefetch.ts @@ -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 { + return async () => { + const detail = opts.perArtist + ? await getPlaylist(opts.playlistId) + : await systemShuffle(opts.variant); + return toRefs(detail); + }; +} diff --git a/web/src/routes/playlists/[id]/+page.svelte b/web/src/routes/playlists/[id]/+page.svelte index ee23199d..fd80bcf9 100644 --- a/web/src/routes/playlists/[id]/+page.svelte +++ b/web/src/routes/playlists/[id]/+page.svelte @@ -18,6 +18,7 @@ import { user } from '$lib/auth/store.svelte'; import { errCode, errMessage } from '$lib/api/errors'; import { playQueue } from '$lib/player/store.svelte'; + import { systemPlaylistRefetch } from '$lib/playlists/systemRefetch'; import { pushToast } from '$lib/stores/toast.svelte'; import type { TrackRef } from '$lib/api/types'; @@ -61,16 +62,30 @@ function onPlay(position: number) { if (!playlistQuery?.data) return; + const data = playlistQuery.data; // Skip rows where track_id is null (track was removed from library). - const live = playlistQuery.data.tracks - .filter((t) => t.track_id !== null) - .map(toTrackRef); + const live = data.tracks.filter((t) => t.track_id !== null).map(toTrackRef); // Find which `live` index corresponds to the clicked position. Some // positions in the original list may be unavailable (filtered out), // 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); - playQueue(live, Math.max(0, startIdx)); + // #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)); + } } function toTrackRef(t: {