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
+4 -2
View File
@@ -35,7 +35,8 @@ vi.mock('$lib/api/playlists', () => ({
album_title: 'Test Album',
duration_sec: 60,
stream_url: '/s/t-1',
added_at: '2026-01-01T00:00:00Z'
added_at: '2026-01-01T00:00:00Z',
unavailable: false
}
]
} as PlaylistDetail),
@@ -66,7 +67,8 @@ vi.mock('$lib/api/playlists', () => ({
album_title: 'Test Album',
duration_sec: 60,
stream_url: '/s/t-9',
added_at: '2026-01-01T00:00:00Z'
added_at: '2026-01-01T00:00:00Z',
unavailable: false
}
]
} as PlaylistDetail),
+16 -2
View File
@@ -22,7 +22,15 @@
onMove?: (fromPos: number, toPos: number) => void;
} = $props();
const isUnavailable = $derived(row.track_id === null);
// Two different ways a row can be dead, with the same behaviour and
// deliberately different copy. A removed track is gone for good; a
// missing file is a track we still have — history, likes and all —
// whose bytes aren't on disk right now (#2527). Telling the user
// "file missing" invites them to fix it; a strikethrough would say
// "deleted", which is a lie about a file that may well come back.
const isMissingFile = $derived(row.unavailable);
const isRemoved = $derived(row.track_id === null);
const isUnavailable = $derived(isRemoved || isMissingFile);
// Reconstruct a minimal TrackRef for the kebab menu when the
// upstream track still exists. When unavailable, the menu is hidden.
@@ -108,10 +116,16 @@
class="min-w-0 flex-1 text-left"
onclick={() => !isUnavailable && onPlay(row.position)}
disabled={isUnavailable}
title={isMissingFile
? 'This track’s file is missing from the library, so it can’t be played. It stays in the playlist, and returns automatically if the file comes back.'
: undefined}
>
<div class="truncate {isUnavailable ? 'line-through' : ''}">{row.title}</div>
<div class="truncate {isRemoved ? 'line-through' : ''}">{row.title}</div>
<div class="truncate text-xs text-text-muted">
{row.artist_name} · {row.album_title}
{#if isMissingFile}
<span class="text-action-destructive"> · File missing</span>
{/if}
</div>
</button>
@@ -41,7 +41,8 @@ const live: PlaylistTrack = {
album_title: 'MHTRTC',
duration_sec: 137,
stream_url: '/api/tracks/t-1/stream',
added_at: ''
added_at: '',
unavailable: false
};
const removed: PlaylistTrack = {
@@ -52,6 +53,15 @@ const removed: PlaylistTrack = {
stream_url: null
};
// Missing file (#2527): the track row still exists — ids intact, history
// intact — but the server withholds the stream URL because the bytes are
// not on disk. Deliberately distinct from `removed` above.
const missingFile: PlaylistTrack = {
...live,
stream_url: null,
unavailable: true
};
afterEach(() => vi.clearAllMocks());
describe('PlaylistTrackRow', () => {
@@ -119,4 +129,31 @@ describe('PlaylistTrackRow', () => {
await fireEvent.click(screen.getByText('Roygbiv'));
expect(onPlay).toHaveBeenCalledWith(2);
});
// A missing file is NOT a removed track: the row says so, and says it
// without the strikethrough that would imply the track is gone for good.
test('missing file is labelled and not struck through', () => {
render(PlaylistTrackRow, {
props: { row: missingFile, isOwner: true, onRemove: vi.fn(), onPlay: vi.fn() }
});
expect(screen.getByText(/file missing/i)).toBeTruthy();
expect(screen.getByText('Roygbiv').className).not.toContain('line-through');
});
test('clicking a missing-file row does not play it', async () => {
const onPlay = vi.fn();
render(PlaylistTrackRow, {
props: { row: missingFile, isOwner: true, onRemove: vi.fn(), onPlay }
});
await fireEvent.click(screen.getByText('Roygbiv'));
expect(onPlay).not.toHaveBeenCalled();
});
// The owner must still be able to take the dead entry out of their list.
test('remove stays available on a missing-file row', () => {
render(PlaylistTrackRow, {
props: { row: missingFile, isOwner: true, onRemove: vi.fn(), onPlay: vi.fn() }
});
expect(screen.getByLabelText(/remove roygbiv from playlist/i)).toBeTruthy();
});
});