feat(web): a playlist entry whose file is missing greys out and is skipped — #2527
test-web / test (push) Successful in 33s
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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user