feat(web): a playlist entry whose file is missing greys out and is skipped — #2527
test-web / test (push) Successful in 33s

The row treatment for a dead playlist entry already existed -- muted
text, no play on click, no drag, no kebab, never "now playing" -- but it
only fired for track_id === null, the track-deleted case. A missing file
kept a live-looking row that failed on click.

The behavioural gate now covers both, and the presentation distinguishes
them, because they mean different things to the person reading the list.
A removed track is gone for good and keeps the strikethrough. A missing
file is a track we still have -- history, likes, the lot -- whose bytes
aren't on disk right now, so it gets an explicit "File missing" and a
title explaining it stays in the playlist and comes back on its own if
the file does. A strikethrough there would claim it was deleted, which
is a lie about a file the scanner may well adopt back tomorrow (#2528).

Skipping routes through playlistTrackToRef, which already returned null
for removed tracks and whose callers already filter nulls. Adding the
unavailable check there means every queue builder -- PlaylistCard,
systemRefetch, the detail page -- skips a missing file without any of
them learning what missing_since is.

Remove stays available on a dead row: the owner must still be able to
take it out of their own list.
This commit is contained in:
2026-08-16 11:55:36 -04:00
parent 4dd0a58d63
commit 4c49ee2cc6
7 changed files with 126 additions and 10 deletions
@@ -0,0 +1,51 @@
import { describe, expect, test } from 'vitest';
import type { PlaylistTrack } from '$lib/api/types';
import { playlistTrackToRef } from './playlistTrackToRef';
const row: PlaylistTrack = {
position: 0,
track_id: 't-1',
album_id: 'a-1',
artist_id: 'ar-1',
title: 'Roygbiv',
artist_name: 'Boards of Canada',
album_title: 'MHTRTC',
duration_sec: 137,
stream_url: '/api/tracks/t-1/stream',
added_at: '',
unavailable: false
};
describe('playlistTrackToRef', () => {
test('maps a playable row onto a TrackRef', () => {
expect(playlistTrackToRef(row)).toEqual({
id: 't-1',
title: 'Roygbiv',
album_id: 'a-1',
album_title: 'MHTRTC',
artist_id: 'ar-1',
artist_name: 'Boards of Canada',
duration_sec: 137,
stream_url: '/api/tracks/t-1/stream'
});
});
test('returns null when the upstream track was removed', () => {
expect(playlistTrackToRef({ ...row, track_id: null, stream_url: null })).toBeNull();
});
// This is what makes a missing file "get skipped": every queue builder
// filters the nulls out of this mapper, so nothing downstream has to know
// about missing_since. If this stops returning null, the player will try
// to stream a file that is not there.
test('returns null when the file is missing, even with ids intact', () => {
expect(playlistTrackToRef({ ...row, unavailable: true, stream_url: null })).toBeNull();
});
// Belt and braces: the server withholds stream_url for a missing file, but
// the flag alone must be enough — a client that trusted only the URL would
// happily queue a stale one from cache.
test('the flag alone disqualifies a row', () => {
expect(playlistTrackToRef({ ...row, unavailable: true })).toBeNull();
});
});
+9 -3
View File
@@ -2,13 +2,19 @@
// by play queues + track menus. Centralizes the mapping so server
// additions (new TrackRef fields) only update one site.
//
// Returns null when the upstream track has been removed (track_id
// is null on the playlist row); callers should filter nulls.
// Returns null when the row cannot be played, for either of the two
// reasons a playlist entry can outlive its track:
// - track_id is null — the track was removed from the library and only
// the playlist's text snapshot remains.
// - unavailable — the track is still there, with its history intact,
// but its file is missing from disk (#2527).
// Callers filter nulls, which is what makes a missing file "get skipped"
// everywhere a queue is built rather than at each call site.
import type { PlaylistTrack, TrackRef } from '$lib/api/types';
export function playlistTrackToRef(row: PlaylistTrack): TrackRef | null {
if (!row.track_id) return null;
if (!row.track_id || row.unavailable) return null;
return {
id: row.track_id,
title: row.title,