From 0f9ddbca97175c7d3ddb9717d8fcea2931a05c24 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 8 Oct 2026 16:55:33 -0400 Subject: [PATCH 1/4] feat(web): every paged list loads as you scroll; Liked becomes tabs (#5416) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Preference 172: no "Load more" buttons. Eight remained, on Liked (three sections), the three Search overflow pages, Genres and Years. - ListContinuation: the one bottom-of-list block. It loads the next page ahead of the reader, announces "Loading more…" through aria-live, shows an optional end line, and on a failed page shows the error with Try again, dropping the sentinel so a dead endpoint isn't re-hit on every scroll. Used on all ten paged lists, including the four that already autoloaded. - A failed next page no longer replaces the list. TanStack sets isError for it, so the page-level error branches now apply only to a failed first load. Genres and Years did the same with their own loader; a later page's failure now keeps the grid. - Liked: Artists | Albums | Tracks tabs (operator's choice). Stacked, a long Artists list loading as you scroll would bury the other two. It opens on the first tab that has likes. - TabStrip: the in-page tab strip, now shared by Liked, Playback errors and Requests. - test-utils/intersectionObserver: a stand-in so tests can scroll to the end. Co-Authored-By: Claude Opus 5.5 --- .../lib/components/ListContinuation.svelte | 49 +++++ .../lib/components/ListContinuation.test.ts | 43 +++++ web/src/lib/components/TabStrip.svelte | 47 +++++ web/src/lib/components/TabStrip.test.ts | 25 +++ .../routes/admin/playback-errors/+page.svelte | 29 +-- web/src/routes/admin/requests/+page.svelte | 38 +--- .../routes/admin/suspect-sources/+page.svelte | 19 +- web/src/routes/library/albums/+page.svelte | 24 +-- web/src/routes/library/artists/+page.svelte | 24 +-- web/src/routes/library/genres/+page.svelte | 34 ++-- web/src/routes/library/genres/genres.test.ts | 42 ++++- web/src/routes/library/history/+page.svelte | 22 +-- web/src/routes/library/liked/+page.svelte | 174 +++++++++--------- web/src/routes/library/liked/liked.test.ts | 137 ++++++++------ web/src/routes/library/years/+page.svelte | 36 ++-- web/src/routes/library/years/years.test.ts | 41 ++++- web/src/routes/search/albums/+page.svelte | 21 +-- web/src/routes/search/albums/albums.test.ts | 14 +- web/src/routes/search/artists/+page.svelte | 21 +-- web/src/routes/search/artists/artists.test.ts | 20 +- web/src/routes/search/tracks/+page.svelte | 21 +-- web/src/routes/search/tracks/tracks.test.ts | 14 +- web/src/test-utils/intersectionObserver.ts | 40 ++++ web/src/test-utils/query.ts | 2 + 24 files changed, 593 insertions(+), 344 deletions(-) create mode 100644 web/src/lib/components/ListContinuation.svelte create mode 100644 web/src/lib/components/ListContinuation.test.ts create mode 100644 web/src/lib/components/TabStrip.svelte create mode 100644 web/src/lib/components/TabStrip.test.ts create mode 100644 web/src/test-utils/intersectionObserver.ts diff --git a/web/src/lib/components/ListContinuation.svelte b/web/src/lib/components/ListContinuation.svelte new file mode 100644 index 00000000..9c0b6d4c --- /dev/null +++ b/web/src/lib/components/ListContinuation.svelte @@ -0,0 +1,49 @@ + + +{#if failed} + +{:else if hasMore} + +{/if} + +

+ {#if !failed && hasMore && loading}Loading more…{:else if !hasMore && endLabel}{endLabel}{/if} +

diff --git a/web/src/lib/components/ListContinuation.test.ts b/web/src/lib/components/ListContinuation.test.ts new file mode 100644 index 00000000..45d2b9d4 --- /dev/null +++ b/web/src/lib/components/ListContinuation.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, test, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/svelte'; +import ListContinuation from './ListContinuation.svelte'; + +// InfiniteScrollSentinel renders diff --git a/web/src/routes/library/artists/+page.svelte b/web/src/routes/library/artists/+page.svelte index 0d88e61b..e38808d1 100644 --- a/web/src/routes/library/artists/+page.svelte +++ b/web/src/routes/library/artists/+page.svelte @@ -4,7 +4,7 @@ import ArtistCard from '#lib/components/ArtistCard.svelte'; import AlphabeticalGrid from '#lib/components/AlphabeticalGrid.svelte'; import ApiErrorBanner from '#lib/components/ApiErrorBanner.svelte'; - import InfiniteScrollSentinel from '#lib/components/InfiniteScrollSentinel.svelte'; + import ListContinuation from '#lib/components/ListContinuation.svelte'; import QuickFilter from '#lib/components/QuickFilter.svelte'; import EmptyState from '#lib/components/EmptyState.svelte'; import { useDelayed } from '#lib/utils/useDelayed.svelte.js'; @@ -30,7 +30,7 @@

Artists

- {#if !query.isPending && !query.isError} + {#if !query.isPending && !(query.isError && !query.isFetchNextPageError)}

{total} {total === 1 ? 'artist' : 'artists'}

@@ -41,7 +41,7 @@ {/if}
- {#if query.isError} + {#if query.isError && !query.isFetchNextPageError} {:else if showSkeleton.value && artists.length === 0}

Loading…

@@ -79,16 +79,12 @@ {/snippet} - {#if query.hasNextPage} - query.fetchNextPage()} - /> - {#if query.isFetchingNextPage} -

Loading more…

- {/if} - {:else if artists.length > 0} -

End of library

- {/if} + query.fetchNextPage()} + endLabel="End of library" + /> {/if} diff --git a/web/src/routes/library/genres/+page.svelte b/web/src/routes/library/genres/+page.svelte index 3a4ccfdf..e8adffb1 100644 --- a/web/src/routes/library/genres/+page.svelte +++ b/web/src/routes/library/genres/+page.svelte @@ -11,6 +11,7 @@ import AlbumCard from '#lib/components/AlbumCard.svelte'; import ApiErrorBanner from '#lib/components/ApiErrorBanner.svelte'; import EmptyState from '#lib/components/EmptyState.svelte'; + import ListContinuation from '#lib/components/ListContinuation.svelte'; import QuickFilter from '#lib/components/QuickFilter.svelte'; import type { AlbumRef } from '#lib/api/types.js'; @@ -59,6 +60,9 @@ let total = $state(0); let loading = $state(false); let failed = $state(false); + // A later page failed: the albums already shown stay, and the list offers a + // retry at its end instead of loading again on every scroll. + let moreFailed = $state(false); // Plain `let`, deliberately not $state: it's read inside the fetch path, and // as reactive state that read would make this effect depend on its own @@ -76,6 +80,7 @@ albums = []; total = 0; failed = false; + moreFailed = false; if (!genre) return; await fetchPage(genre, 0, requestToken); } @@ -88,13 +93,18 @@ albums = offset === 0 ? p.items : [...albums, ...p.items]; total = p.total; } catch { - if (token === requestToken) failed = true; + if (token === requestToken) { + if (offset === 0) failed = true; + else moreFailed = true; + } } finally { if (token === requestToken) loading = false; } } function loadMore() { + if (loading) return; + moreFailed = false; void fetchPage(selected, albums.length, requestToken); } @@ -154,21 +164,13 @@ {/each} - {#if albums.length < total} -
- -
- {:else} -

End of genre

- {/if} + {/if} {:else} diff --git a/web/src/routes/library/genres/genres.test.ts b/web/src/routes/library/genres/genres.test.ts index f24be753..5e3b81ac 100644 --- a/web/src/routes/library/genres/genres.test.ts +++ b/web/src/routes/library/genres/genres.test.ts @@ -1,6 +1,7 @@ -import { afterEach, describe, expect, test, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import { render, screen, waitFor, fireEvent } from '@testing-library/svelte'; import { mockQuery } from '#test-utils/query.js'; +import { installIntersectionObserverMock } from '#test-utils/intersectionObserver.js'; import { pageUrlModule } from '#test-utils/mocks/appState.js'; import { apiClientMock } from '#test-utils/mocks/client.js'; import { emptyLikesMock } from '#test-utils/mocks/likes.js'; @@ -33,6 +34,11 @@ import { createGenresQuery, listAlbumsByGenre } from '#lib/api/browse.js'; const asMock = (fn: unknown) => fn as ReturnType; +let io: ReturnType; +beforeEach(() => { + io = installIntersectionObserverMock(); +}); + function album(id: string, title: string): AlbumRef { return { id, @@ -187,7 +193,7 @@ describe('/library/genres drill-down', () => { expect(screen.getByRole('heading', { name: 'Rock/Pop' })).toBeInTheDocument(); }); - test('load more appends the next page and then reports the end', async () => { + test('scrolling to the end loads the next page, then reports the end', async () => { pageState.pageUrl = new URL('http://localhost/library/genres?g=Rock'); asMock(createGenresQuery).mockReturnValue(mockQuery({ data: [] })); asMock(listAlbumsByGenre) @@ -201,10 +207,13 @@ describe('/library/genres drill-down', () => { render(GenresPage); await screen.findByText('One'); - const more = await screen.findByRole('button', { name: /Load more \(1 left\)/ }); - await fireEvent.click(more); + // No button (#5416): reaching the bottom of the grid loads the next page. + expect(screen.queryByRole('button', { name: /load more/i })).not.toBeInTheDocument(); + await waitFor(() => { + io.scrollToEnd(); + expect(listAlbumsByGenre).toHaveBeenLastCalledWith('Rock', 2, 2); + }); - await waitFor(() => expect(listAlbumsByGenre).toHaveBeenLastCalledWith('Rock', 2, 2)); expect(await screen.findByText('Three')).toBeInTheDocument(); // Earlier pages are appended, not replaced. expect(screen.getByText('One')).toBeInTheDocument(); @@ -219,4 +228,27 @@ describe('/library/genres drill-down', () => { expect(await screen.findByText(/Couldn't load albums for this genre/i)).toBeInTheDocument(); }); + + // A failed later page keeps what is shown and offers a retry at the end, + // rather than replacing the grid or re-requesting on every scroll. + test('a failed later page keeps the albums and offers Try again', async () => { + pageState.pageUrl = new URL('http://localhost/library/genres?g=Rock'); + asMock(createGenresQuery).mockReturnValue(mockQuery({ data: [] })); + asMock(listAlbumsByGenre) + .mockResolvedValueOnce({ items: [album('a1', 'One'), album('a2', 'Two')], total: 3, limit: 2, offset: 0 }) + .mockRejectedValueOnce(new Error('nope')) + .mockResolvedValueOnce({ items: [album('a3', 'Three')], total: 3, limit: 2, offset: 2 }); + render(GenresPage); + + await screen.findByText('One'); + await waitFor(() => { + io.scrollToEnd(); + expect(screen.getByRole('alert')).toHaveTextContent("Couldn't load more."); + }); + expect(screen.getByText('One')).toBeInTheDocument(); + expect(screen.queryByText(/Couldn't load albums for this genre/i)).not.toBeInTheDocument(); + + await fireEvent.click(screen.getByRole('button', { name: 'Try again' })); + expect(await screen.findByText('Three')).toBeInTheDocument(); + }); }); diff --git a/web/src/routes/library/history/+page.svelte b/web/src/routes/library/history/+page.svelte index 44893c19..be509f10 100644 --- a/web/src/routes/library/history/+page.svelte +++ b/web/src/routes/library/history/+page.svelte @@ -5,7 +5,7 @@ import { groupByDay } from '#lib/utils/dayGroup.js'; import HistoryRow from '#lib/components/HistoryRow.svelte'; import TrackList from '#lib/components/TrackList.svelte'; - import InfiniteScrollSentinel from '#lib/components/InfiniteScrollSentinel.svelte'; + import ListContinuation from '#lib/components/ListContinuation.svelte'; import LibrarySkeleton from '#lib/components/LibrarySkeleton.svelte'; import ApiErrorBanner from '#lib/components/ApiErrorBanner.svelte'; import QuickFilter from '#lib/components/QuickFilter.svelte'; @@ -40,7 +40,7 @@ {#if query.isPending} - {:else if query.isError} + {:else if query.isError && !query.isFetchNextPageError} {:else if flatEvents.length === 0} {/each} - {#if query.hasNextPage} - query.fetchNextPage()} - /> - {#if query.isFetchingNextPage} -

Loading more…

- {/if} - {:else if flatEvents.length > 0} -

End of history

- {/if} + query.fetchNextPage()} + endLabel="End of history" + /> {/if} diff --git a/web/src/routes/library/liked/+page.svelte b/web/src/routes/library/liked/+page.svelte index 29a67cd1..f71cee22 100644 --- a/web/src/routes/library/liked/+page.svelte +++ b/web/src/routes/library/liked/+page.svelte @@ -12,6 +12,8 @@ import TrackList from '#lib/components/TrackList.svelte'; import QuickFilter from '#lib/components/QuickFilter.svelte'; import EmptyState from '#lib/components/EmptyState.svelte'; + import TabStrip from '#lib/components/TabStrip.svelte'; + import ListContinuation from '#lib/components/ListContinuation.svelte'; const artistsStore = $derived(createLikedArtistsInfiniteQuery()); const albumsStore = $derived(createLikedAlbumsInfiniteQuery()); @@ -29,6 +31,14 @@ const albumsTotal = $derived(albumsQuery?.data?.pages?.[0]?.total ?? 0); const tracksTotal = $derived(tracksQuery?.data?.pages?.[0]?.total ?? 0); + type Tab = 'artists' | 'albums' | 'tracks'; + // Opens on the first tab with anything in it; once the reader picks a tab, + // that choice sticks. + let chosenTab = $state(null); + const tab = $derived( + chosenTab ?? (artistsTotal > 0 ? 'artists' : albumsTotal > 0 ? 'albums' : tracksTotal > 0 ? 'tracks' : 'artists') + ); + let filter = $state(''); const q = $derived(filter.trim().toLowerCase()); const fArtists = $derived(q ? artists.filter((a) => a.name.toLowerCase().includes(q)) : artists); @@ -41,8 +51,10 @@ t.artist_name.toLowerCase().includes(q) || (t.album_title ?? '').toLowerCase().includes(q)) : tracks); + // The filter applies to the open tab's loaded rows. const filterMatchesNothing = $derived( - !!q && fArtists.length === 0 && fAlbums.length === 0 && fTracks.length === 0 + !!q && + (tab === 'artists' ? fArtists : tab === 'albums' ? fAlbums : fTracks).length === 0 ); const allEmpty = $derived( !q && artistsTotal === 0 && albumsTotal === 0 && tracksTotal === 0 @@ -82,99 +94,79 @@
{/if} - {#if !allEmpty && (!q || fArtists.length > 0)} -
-

Artists

- {#if artistsTotal === 0} - - {#snippet actions()} - Browse artists → - {/snippet} - - {:else} -
- {#each fArtists as a (a.id)} - - {/each} -
- {#if artistsQuery?.hasNextPage} - - {/if} - {/if} -
- {/if} + {#if !allEmpty} + + tab, (v) => (chosenTab = v)} + ariaLabel="Liked" + items={[ + { id: 'artists', label: 'Artists', count: artistsTotal }, + { id: 'albums', label: 'Albums', count: albumsTotal }, + { id: 'tracks', label: 'Tracks', count: tracksTotal } + ]} + /> - {#if !allEmpty && (!q || fAlbums.length > 0)} -
-

Albums

- {#if albumsTotal === 0} - - {#snippet actions()} - Browse albums → - {/snippet} - + {#if tab === 'artists'} + {#if artistsTotal === 0} + + {#snippet actions()} + Browse artists → + {/snippet} + + {:else} +
+ {#each fArtists as a (a.id)} + + {/each} +
+ artistsQuery.fetchNextPage()} + /> + {/if} + {:else if tab === 'albums'} + {#if albumsTotal === 0} + + {#snippet actions()} + Browse albums → + {/snippet} + + {:else} +
+ {#each fAlbums as al (al.id)} + + {/each} +
+ albumsQuery.fetchNextPage()} + /> + {/if} {:else} -
- {#each fAlbums as al (al.id)} - - {/each} -
- {#if albumsQuery?.hasNextPage} - + {#if tracksTotal === 0} + + {#snippet actions()} + Find something new → + {/snippet} + + {:else} + + {#each fTracks as t, i (t.id)} + + {/each} + + tracksQuery.fetchNextPage()} + /> {/if} {/if} -
- {/if} - - {#if !allEmpty && (!q || fTracks.length > 0)} -
-

Tracks

- {#if tracksTotal === 0} - - {#snippet actions()} - Find something new → - {/snippet} - - {:else} - - {#each fTracks as t, i (t.id)} - - {/each} - - {#if tracksQuery?.hasNextPage} - - {/if} - {/if} -
{/if} diff --git a/web/src/routes/library/liked/liked.test.ts b/web/src/routes/library/liked/liked.test.ts index 19616208..242f8849 100644 --- a/web/src/routes/library/liked/liked.test.ts +++ b/web/src/routes/library/liked/liked.test.ts @@ -38,82 +38,99 @@ function page(items: T[], total: number, offset = 0, limit = 50): Page { afterEach(() => vi.clearAllMocks()); +const ar: ArtistRef = { id: 'a1', name: 'Miles', sort_name: 'Miles', album_count: 1, cover_url: '' }; +const al: AlbumRef = { + id: 'al1', title: 'Kind of Blue', sort_title: 'Kind of Blue', artist_id: 'a1', artist_name: 'Miles', + year: 1959, track_count: 1, duration_sec: 100, cover_url: '/x', cover_art_source: null +}; +const tr: TrackRef = makeTrack({ + title: 'So What', album_id: 'al1', album_title: 'Kind of Blue', + artist_id: 'a1', artist_name: 'Miles', duration_sec: 100 +}); + +function setup(opts: { + artists?: Page; + albums?: Page; + tracks?: Page; + tracksQuery?: Parameters[0]; +}) { + (createLikedArtistsInfiniteQuery as ReturnType).mockReturnValue( + mockInfiniteQuery({ pages: [opts.artists ?? page([], 0)] }) + ); + (createLikedAlbumsInfiniteQuery as ReturnType).mockReturnValue( + mockInfiniteQuery({ pages: [opts.albums ?? page([], 0)] }) + ); + (createLikedTracksInfiniteQuery as ReturnType).mockReturnValue( + mockInfiniteQuery({ pages: [opts.tracks ?? page([], 0)], ...opts.tracksQuery }) + ); +} + describe('liked library page', () => { - test('renders three sections when each has items', () => { - const ar: ArtistRef = { id: 'a1', name: 'X', sort_name: 'X', album_count: 1, cover_url: '' }; - const al: AlbumRef = { - id: 'al1', title: 'Y', sort_title: 'Y', artist_id: 'a1', artist_name: 'X', - year: 2020, track_count: 1, duration_sec: 100, cover_url: '/x', cover_art_source: null - }; - const tr: TrackRef = makeTrack({ - title: 'Z', album_id: 'al1', album_title: 'Y', - artist_id: 'a1', artist_name: 'X', duration_sec: 100 - }); - (createLikedArtistsInfiniteQuery as ReturnType).mockReturnValue( - mockInfiniteQuery({ pages: [page([ar], 1)] }) - ); - (createLikedAlbumsInfiniteQuery as ReturnType).mockReturnValue( - mockInfiniteQuery({ pages: [page([al], 1)] }) - ); - (createLikedTracksInfiniteQuery as ReturnType).mockReturnValue( - mockInfiniteQuery({ pages: [page([tr], 1)] }) - ); + // #5416: one list at a time. Stacked, a long Artists list that loads as you + // scroll would bury Albums and Tracks. + test('tabs carry each count and show one list at a time', async () => { + setup({ artists: page([ar], 1), albums: page([al], 1), tracks: page([tr], 1) }); render(LikedPage); - expect(screen.getByRole('heading', { name: /artists/i })).toBeInTheDocument(); - expect(screen.getByRole('heading', { name: /albums/i })).toBeInTheDocument(); - expect(screen.getByRole('heading', { name: /tracks/i })).toBeInTheDocument(); + + const tabs = screen.getAllByRole('tab'); + expect(tabs.map((t) => t.textContent?.replace(/\s+/g, ' ').trim())).toEqual([ + 'Artists 1', + 'Albums 1', + 'Tracks 1' + ]); + expect(screen.getByText('Miles')).toBeInTheDocument(); + expect(screen.queryByText('So What')).not.toBeInTheDocument(); + + await fireEvent.click(screen.getByRole('tab', { name: /albums/i })); + expect(screen.getByText('Kind of Blue')).toBeInTheDocument(); + + await fireEvent.click(screen.getByRole('tab', { name: /tracks/i })); + expect(screen.getByText('So What')).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: /tracks/i })).toHaveAttribute('aria-selected', 'true'); }); - test('all three sections empty renders the whole-tab onboarding card', () => { - (createLikedArtistsInfiniteQuery as ReturnType).mockReturnValue( - mockInfiniteQuery({ pages: [page([], 0)] }) - ); - (createLikedAlbumsInfiniteQuery as ReturnType).mockReturnValue( - mockInfiniteQuery({ pages: [page([], 0)] }) - ); - (createLikedTracksInfiniteQuery as ReturnType).mockReturnValue( - mockInfiniteQuery({ pages: [page([], 0)] }) - ); + test('opens on the first tab that has likes', () => { + setup({ tracks: page([tr], 1) }); + render(LikedPage); + expect(screen.getByRole('tab', { name: /tracks/i })).toHaveAttribute('aria-selected', 'true'); + expect(screen.getByText('So What')).toBeInTheDocument(); + }); + + test('all three empty renders the whole-page onboarding card and no tabs', () => { + setup({}); render(LikedPage); - // The whole-tab EmptyState replaces the three per-section hints - // when every section is empty — single heading + action affordances. expect(screen.getByRole('heading', { name: /no likes yet/i })).toBeInTheDocument(); expect(screen.getByRole('link', { name: /explore home/i })).toBeInTheDocument(); expect(screen.getByRole('link', { name: /browse albums/i })).toBeInTheDocument(); + expect(screen.queryByRole('tablist')).not.toBeInTheDocument(); }); - test('one populated section + two empty subsections renders inline hints', () => { - (createLikedArtistsInfiniteQuery as ReturnType).mockReturnValue( - mockInfiniteQuery({ pages: [page([{ id: 'a1', name: 'Miles' } as ArtistRef], 1)] }) - ); - (createLikedAlbumsInfiniteQuery as ReturnType).mockReturnValue( - mockInfiniteQuery({ pages: [page([], 0)] }) - ); - (createLikedTracksInfiniteQuery as ReturnType).mockReturnValue( - mockInfiniteQuery({ pages: [page([], 0)] }) - ); + test('an empty tab shows its inline hint', async () => { + setup({ artists: page([ar], 1) }); render(LikedPage); + await fireEvent.click(screen.getByRole('tab', { name: /albums/i })); expect(screen.getByText(/no liked albums yet/i)).toBeInTheDocument(); + await fireEvent.click(screen.getByRole('tab', { name: /tracks/i })); expect(screen.getByText(/no liked tracks yet/i)).toBeInTheDocument(); - // Inline variant uses link affordances, not the big card heading. expect(screen.queryByRole('heading', { name: /no likes yet/i })).not.toBeInTheDocument(); }); - test('Load more calls fetchNextPage on each section that has more', async () => { - const fetchTracks = vi.fn(); - (createLikedTracksInfiniteQuery as ReturnType).mockReturnValue( - mockInfiniteQuery({ pages: [page([], 100)], hasNextPage: true, fetchNextPage: fetchTracks }) - ); - (createLikedAlbumsInfiniteQuery as ReturnType).mockReturnValue( - mockInfiniteQuery({ pages: [page([], 0)] }) - ); - (createLikedArtistsInfiniteQuery as ReturnType).mockReturnValue( - mockInfiniteQuery({ pages: [page([], 0)] }) - ); + // Preference 172: the list continues itself; there is no button to press. + test('more tracks load as you scroll, with no Load more button', () => { + setup({ tracks: page([tr], 100), tracksQuery: { hasNextPage: true } }); + const { container } = render(LikedPage); + expect(screen.queryByRole('button', { name: /load more/i })).not.toBeInTheDocument(); + expect(container.querySelector('div[aria-hidden="true"].h-px')).not.toBeNull(); + }); + + test('a failed page offers Try again, which fetches it', async () => { + const fetchNextPage = vi.fn(); + setup({ + tracks: page([tr], 100), + tracksQuery: { hasNextPage: true, isFetchNextPageError: true, fetchNextPage } + }); render(LikedPage); - const buttons = screen.getAllByRole('button', { name: /load more/i }); - expect(buttons).toHaveLength(1); - await fireEvent.click(buttons[0]); - expect(fetchTracks).toHaveBeenCalledTimes(1); + await fireEvent.click(screen.getByRole('button', { name: 'Try again' })); + expect(fetchNextPage).toHaveBeenCalledTimes(1); }); }); diff --git a/web/src/routes/library/years/+page.svelte b/web/src/routes/library/years/+page.svelte index 5d1469f7..fb5ab4fe 100644 --- a/web/src/routes/library/years/+page.svelte +++ b/web/src/routes/library/years/+page.svelte @@ -11,6 +11,7 @@ import AlbumCard from '#lib/components/AlbumCard.svelte'; import ApiErrorBanner from '#lib/components/ApiErrorBanner.svelte'; import EmptyState from '#lib/components/EmptyState.svelte'; + import ListContinuation from '#lib/components/ListContinuation.svelte'; import type { AlbumRef } from '#lib/api/types.js'; const indexStore = createAlbumYearsQuery(); @@ -48,6 +49,9 @@ let total = $state(0); let loading = $state(false); let failed = $state(false); + // A later page failed: the albums already shown stay, and the list offers a + // retry at its end instead of loading again on every scroll. + let moreFailed = $state(false); // Plain `let`, not $state — see the note in the genres page: as reactive // state, reading it in the fetch path would make the effect below depend on @@ -65,6 +69,7 @@ albums = []; total = 0; failed = false; + moreFailed = false; if (year === null) return; await fetchPage(year, 0, requestToken); } @@ -77,14 +82,19 @@ albums = offset === 0 ? p.items : [...albums, ...p.items]; total = p.total; } catch { - if (token === requestToken) failed = true; + if (token === requestToken) { + if (offset === 0) failed = true; + else moreFailed = true; + } } finally { if (token === requestToken) loading = false; } } function loadMore() { - if (selected !== null) void fetchPage(selected, albums.length, requestToken); + if (selected === null || loading) return; + moreFailed = false; + void fetchPage(selected, albums.length, requestToken); } @@ -136,21 +146,13 @@ {/each} - {#if albums.length < total} -
- -
- {:else} -

End of year

- {/if} + {/if} {:else} diff --git a/web/src/routes/library/years/years.test.ts b/web/src/routes/library/years/years.test.ts index cd0f2436..eedfab16 100644 --- a/web/src/routes/library/years/years.test.ts +++ b/web/src/routes/library/years/years.test.ts @@ -1,6 +1,7 @@ -import { afterEach, describe, expect, test, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import { render, screen, waitFor, fireEvent } from '@testing-library/svelte'; import { mockQuery } from '#test-utils/query.js'; +import { installIntersectionObserverMock } from '#test-utils/intersectionObserver.js'; import { pageUrlModule } from '#test-utils/mocks/appState.js'; import { apiClientMock } from '#test-utils/mocks/client.js'; import { emptyLikesMock } from '#test-utils/mocks/likes.js'; @@ -33,6 +34,11 @@ import { createAlbumYearsQuery, listAlbumsByYear } from '#lib/api/browse.js'; const asMock = (fn: unknown) => fn as ReturnType; +let io: ReturnType; +beforeEach(() => { + io = installIntersectionObserverMock(); +}); + function album(id: string, title: string): AlbumRef { return { id, @@ -146,7 +152,7 @@ describe('/library/years drill-down', () => { expect(screen.getByRole('heading', { level: 1, name: 'Years' })).toBeInTheDocument(); }); - test('load more appends and then reports the end', async () => { + test('scrolling to the end loads the next page, then reports the end', async () => { pageState.pageUrl = new URL('http://localhost/library/years?y=1995'); asMock(createAlbumYearsQuery).mockReturnValue(mockQuery({ data: [] })); asMock(listAlbumsByYear) @@ -160,9 +166,13 @@ describe('/library/years drill-down', () => { render(YearsPage); await screen.findByText('One'); - await fireEvent.click(await screen.findByRole('button', { name: /Load more \(1 left\)/ })); + // No button (#5416): reaching the bottom of the grid loads the next page. + expect(screen.queryByRole('button', { name: /load more/i })).not.toBeInTheDocument(); + await waitFor(() => { + io.scrollToEnd(); + expect(listAlbumsByYear).toHaveBeenLastCalledWith(1995, 2, 2); + }); - await waitFor(() => expect(listAlbumsByYear).toHaveBeenLastCalledWith(1995, 2, 2)); expect(await screen.findByText('Three')).toBeInTheDocument(); expect(screen.getByText('One')).toBeInTheDocument(); expect(await screen.findByText('End of year')).toBeInTheDocument(); @@ -176,4 +186,27 @@ describe('/library/years drill-down', () => { expect(await screen.findByText(/Couldn't load albums for 1995/i)).toBeInTheDocument(); }); + + // A failed later page keeps what is shown and offers a retry at the end, + // rather than replacing the grid or re-requesting on every scroll. + test('a failed later page keeps the albums and offers Try again', async () => { + pageState.pageUrl = new URL('http://localhost/library/years?y=1995'); + asMock(createAlbumYearsQuery).mockReturnValue(mockQuery({ data: [] })); + asMock(listAlbumsByYear) + .mockResolvedValueOnce({ items: [album('a1', 'One'), album('a2', 'Two')], total: 3, limit: 2, offset: 0 }) + .mockRejectedValueOnce(new Error('nope')) + .mockResolvedValueOnce({ items: [album('a3', 'Three')], total: 3, limit: 2, offset: 2 }); + render(YearsPage); + + await screen.findByText('One'); + await waitFor(() => { + io.scrollToEnd(); + expect(screen.getByRole('alert')).toHaveTextContent("Couldn't load more."); + }); + expect(screen.getByText('One')).toBeInTheDocument(); + expect(screen.queryByText(/Couldn't load albums for 1995/i)).not.toBeInTheDocument(); + + await fireEvent.click(screen.getByRole('button', { name: 'Try again' })); + expect(await screen.findByText('Three')).toBeInTheDocument(); + }); }); diff --git a/web/src/routes/search/albums/+page.svelte b/web/src/routes/search/albums/+page.svelte index 97287a25..35a28baa 100644 --- a/web/src/routes/search/albums/+page.svelte +++ b/web/src/routes/search/albums/+page.svelte @@ -5,6 +5,7 @@ import AlbumCard from '#lib/components/AlbumCard.svelte'; import LibrarySkeleton from '#lib/components/LibrarySkeleton.svelte'; import ApiErrorBanner from '#lib/components/ApiErrorBanner.svelte'; + import ListContinuation from '#lib/components/ListContinuation.svelte'; import { useDelayed } from '#lib/utils/useDelayed.svelte.js'; const q = $derived((page.url.searchParams.get('q') ?? '').trim()); @@ -24,14 +25,14 @@ ← Back to search

Albums matching '{q}'

- {#if !query?.isPending && !query?.isError && q} + {#if q && !query?.isPending && !(query?.isError && albums.length === 0)}

{total} {total === 1 ? 'album' : 'albums'}

{/if} {#if !q}

No query — return to search to start typing.

- {:else if query?.isError} + {:else if query?.isError && albums.length === 0} {:else if showSkeleton.value && albums.length === 0} @@ -43,15 +44,11 @@ {/each} - {#if query?.hasNextPage} - - {/if} + query?.fetchNextPage()} + /> {/if} diff --git a/web/src/routes/search/albums/albums.test.ts b/web/src/routes/search/albums/albums.test.ts index 2ad8d960..516ceb46 100644 --- a/web/src/routes/search/albums/albums.test.ts +++ b/web/src/routes/search/albums/albums.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, test, vi } from 'vitest'; -import { render, screen, fireEvent } from '@testing-library/svelte'; +import { render, screen, waitFor } from '@testing-library/svelte'; import { mockInfiniteQuery } from '../../../test-utils/query'; +import { installIntersectionObserverMock } from '#test-utils/intersectionObserver.js'; import { emptyLikesMock } from '../../../test-utils/mocks/likes'; import { apiClientMock } from '../../../test-utils/mocks/client'; import { pageUrlModule } from '#test-utils/mocks/appState.js'; @@ -47,7 +48,9 @@ describe('search albums overflow', () => { expect(screen.getByRole('link', { name: /Kind of Blue/ })).toHaveAttribute('href', '/albums/al1'); }); - test('Load more calls fetchNextPage', async () => { + // #5416: no button; reaching the end of the list loads the next page. + test('scrolling to the end loads the next page', async () => { + const io = installIntersectionObserverMock(); const fetchNextPage = vi.fn(); (createSearchAlbumsInfiniteQuery as ReturnType).mockReturnValue( mockInfiniteQuery({ @@ -57,8 +60,11 @@ describe('search albums overflow', () => { }) ); render(AlbumsOverflow); - await fireEvent.click(screen.getByRole('button', { name: /load more/i })); - expect(fetchNextPage).toHaveBeenCalledTimes(1); + expect(screen.queryByRole('button', { name: /load more/i })).not.toBeInTheDocument(); + await waitFor(() => { + io.scrollToEnd(); + expect(fetchNextPage).toHaveBeenCalled(); + }); }); test('empty q shows a prompt', () => { diff --git a/web/src/routes/search/artists/+page.svelte b/web/src/routes/search/artists/+page.svelte index d6503cc0..9e0d7967 100644 --- a/web/src/routes/search/artists/+page.svelte +++ b/web/src/routes/search/artists/+page.svelte @@ -5,6 +5,7 @@ import ArtistCard from '#lib/components/ArtistCard.svelte'; import LibrarySkeleton from '#lib/components/LibrarySkeleton.svelte'; import ApiErrorBanner from '#lib/components/ApiErrorBanner.svelte'; + import ListContinuation from '#lib/components/ListContinuation.svelte'; import { useDelayed } from '#lib/utils/useDelayed.svelte.js'; const q = $derived((page.url.searchParams.get('q') ?? '').trim()); @@ -24,14 +25,14 @@ ← Back to search

Artists matching '{q}'

- {#if !query?.isPending && !query?.isError && q} + {#if q && !query?.isPending && !(query?.isError && artists.length === 0)}

{total} {total === 1 ? 'artist' : 'artists'}

{/if} {#if !q}

No query — return to search to start typing.

- {:else if query?.isError} + {:else if query?.isError && artists.length === 0} {:else if showSkeleton.value && artists.length === 0} @@ -43,15 +44,11 @@ {/each} - {#if query?.hasNextPage} - - {/if} + query?.fetchNextPage()} + /> {/if} diff --git a/web/src/routes/search/artists/artists.test.ts b/web/src/routes/search/artists/artists.test.ts index d604f734..7f8bb42f 100644 --- a/web/src/routes/search/artists/artists.test.ts +++ b/web/src/routes/search/artists/artists.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, test, vi } from 'vitest'; -import { render, screen, fireEvent } from '@testing-library/svelte'; +import { render, screen, waitFor } from '@testing-library/svelte'; import { mockInfiniteQuery } from '../../../test-utils/query'; +import { installIntersectionObserverMock } from '#test-utils/intersectionObserver.js'; import { emptyLikesMock } from '../../../test-utils/mocks/likes'; import { pageUrlModule } from '#test-utils/mocks/appState.js'; import type { ArtistRef, Page } from '#lib/api/types.js'; @@ -39,7 +40,9 @@ describe('search artists overflow', () => { expect(screen.getByRole('link', { name: /Miles Mosley/ })).toBeInTheDocument(); }); - test('Load more calls fetchNextPage when hasNextPage', async () => { + // #5416: no button; reaching the end of the list loads the next page. + test('scrolling to the end loads the next page', async () => { + const io = installIntersectionObserverMock(); const fetchNextPage = vi.fn(); (createSearchArtistsInfiniteQuery as ReturnType).mockReturnValue( mockInfiniteQuery({ @@ -49,16 +52,19 @@ describe('search artists overflow', () => { }) ); render(ArtistsOverflow); - await fireEvent.click(screen.getByRole('button', { name: /load more/i })); - expect(fetchNextPage).toHaveBeenCalledTimes(1); + expect(screen.queryByRole('button', { name: /load more/i })).not.toBeInTheDocument(); + await waitFor(() => { + io.scrollToEnd(); + expect(fetchNextPage).toHaveBeenCalled(); + }); }); - test('Load more is hidden when hasNextPage is false', () => { + test('nothing more to load: no sentinel', () => { (createSearchArtistsInfiniteQuery as ReturnType).mockReturnValue( mockInfiniteQuery({ pages: [page([{ id: 'a', name: 'A', sort_name: 'A', album_count: 1, cover_url: '' }], 1)] }) ); - render(ArtistsOverflow); - expect(screen.queryByRole('button', { name: /load more/i })).not.toBeInTheDocument(); + const { container } = render(ArtistsOverflow); + expect(container.querySelector('div[aria-hidden="true"].h-px')).toBeNull(); }); test('empty q shows a prompt and fires no query', () => { diff --git a/web/src/routes/search/tracks/+page.svelte b/web/src/routes/search/tracks/+page.svelte index 95ef8014..2be2f018 100644 --- a/web/src/routes/search/tracks/+page.svelte +++ b/web/src/routes/search/tracks/+page.svelte @@ -7,6 +7,7 @@ import TrackList from '#lib/components/TrackList.svelte'; import LibrarySkeleton from '#lib/components/LibrarySkeleton.svelte'; import ApiErrorBanner from '#lib/components/ApiErrorBanner.svelte'; + import ListContinuation from '#lib/components/ListContinuation.svelte'; import { useDelayed } from '#lib/utils/useDelayed.svelte.js'; import { playRadio } from '#lib/player/store.svelte.js'; import type { TrackRef } from '#lib/api/types.js'; @@ -32,14 +33,14 @@ ← Back to search

Tracks matching '{q}'

- {#if !query?.isPending && !query?.isError && q} + {#if q && !query?.isPending && !(query?.isError && tracks.length === 0)}

{total} {total === 1 ? 'track' : 'tracks'}

{/if} {#if !q}

No query — return to search to start typing.

- {:else if query?.isError} + {:else if query?.isError && tracks.length === 0} {:else if showSkeleton.value && tracks.length === 0} @@ -51,15 +52,11 @@ {/each} - {#if query?.hasNextPage} - - {/if} + query?.fetchNextPage()} + /> {/if} diff --git a/web/src/routes/search/tracks/tracks.test.ts b/web/src/routes/search/tracks/tracks.test.ts index c624b5f4..6b081352 100644 --- a/web/src/routes/search/tracks/tracks.test.ts +++ b/web/src/routes/search/tracks/tracks.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, test, vi } from 'vitest'; -import { render, screen, fireEvent } from '@testing-library/svelte'; +import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'; import { mockInfiniteQuery } from '../../../test-utils/query'; +import { installIntersectionObserverMock } from '#test-utils/intersectionObserver.js'; import { emptyLikesMock } from '../../../test-utils/mocks/likes'; import { emptyQuarantineMock } from '../../../test-utils/mocks/quarantine'; import { pageUrlModule } from '#test-utils/mocks/appState.js'; @@ -59,7 +60,9 @@ describe('search tracks overflow', () => { expect(playRadio).toHaveBeenCalledWith('t1'); }); - test('Load more calls fetchNextPage', async () => { + // #5416: no button; reaching the end of the list loads the next page. + test('scrolling to the end loads the next page', async () => { + const io = installIntersectionObserverMock(); const fetchNextPage = vi.fn(); (createSearchTracksInfiniteQuery as ReturnType).mockReturnValue( mockInfiniteQuery({ @@ -69,8 +72,11 @@ describe('search tracks overflow', () => { }) ); render(TracksOverflow); - await fireEvent.click(screen.getByRole('button', { name: /load more/i })); - expect(fetchNextPage).toHaveBeenCalledTimes(1); + expect(screen.queryByRole('button', { name: /load more/i })).not.toBeInTheDocument(); + await waitFor(() => { + io.scrollToEnd(); + expect(fetchNextPage).toHaveBeenCalled(); + }); }); test('empty q shows a prompt', () => { diff --git a/web/src/test-utils/intersectionObserver.ts b/web/src/test-utils/intersectionObserver.ts new file mode 100644 index 00000000..57450dbb --- /dev/null +++ b/web/src/test-utils/intersectionObserver.ts @@ -0,0 +1,40 @@ +import { vi } from 'vitest'; + +// jsdom doesn't ship IntersectionObserver, so a list that loads as you scroll +// (InfiniteScrollSentinel, ListContinuation) never fires in a test. This +// installs a stand-in that records each observer, so a test can play +// "the reader scrolled to the bottom" with scrollToEnd(). +type Observer = { cb: IntersectionObserverCallback; self: IntersectionObserver; connected: boolean }; + +export function installIntersectionObserverMock() { + const observers: Observer[] = []; + + class MockIntersectionObserver { + observe = vi.fn(); + unobserve = vi.fn(); + disconnect = vi.fn(() => { + const o = observers.find((x) => x.self === (this as unknown as IntersectionObserver)); + if (o) o.connected = false; + }); + takeRecords = vi.fn(() => []); + constructor(cb: IntersectionObserverCallback) { + observers.push({ cb, self: this as unknown as IntersectionObserver, connected: true }); + } + } + + vi.stubGlobal('IntersectionObserver', MockIntersectionObserver); + + return { + /** Reports every connected observer's target as on screen. */ + scrollToEnd() { + for (const o of observers) { + if (!o.connected) continue; + o.cb([{ isIntersecting: true } as IntersectionObserverEntry], o.self); + } + }, + /** How many observers are currently watching. */ + connectedCount() { + return observers.filter((o) => o.connected).length; + } + }; +} diff --git a/web/src/test-utils/query.ts b/web/src/test-utils/query.ts index 6e24f72e..39f2e50c 100644 --- a/web/src/test-utils/query.ts +++ b/web/src/test-utils/query.ts @@ -14,6 +14,7 @@ export function mockInfiniteQuery(opts: { error?: unknown; hasNextPage?: boolean; isFetchingNextPage?: boolean; + isFetchNextPageError?: boolean; fetchNextPage?: () => void; refetch?: () => void; } = {}) { @@ -24,6 +25,7 @@ export function mockInfiniteQuery(opts: { error: opts.error, hasNextPage: opts.hasNextPage ?? false, isFetchingNextPage: opts.isFetchingNextPage ?? false, + isFetchNextPageError: opts.isFetchNextPageError ?? false, fetchNextPage: opts.fetchNextPage ?? (() => {}), refetch: opts.refetch ?? (() => {}) }); -- 2.54.0 From 015fe1d3b25e67b99b2d3b0aa59947d02abe4935 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 8 Oct 2026 17:17:34 -0400 Subject: [PATCH 2/4] fix(web): admin error lines use text-error-fg, not the untokened text-oxblood (#5417) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit text-oxblood is not a theme token, so Tailwind emits nothing and the scan error on Admin → Overview and "It stopped early" on Duplicates rendered as body text. Same fix as the album page in #5392; no uses remain. Co-Authored-By: Claude Opus 5.5 --- web/src/routes/admin/+page.svelte | 2 +- web/src/routes/admin/duplicates/+page.svelte | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/web/src/routes/admin/+page.svelte b/web/src/routes/admin/+page.svelte index eedd4efd..68dd3836 100644 --- a/web/src/routes/admin/+page.svelte +++ b/web/src/routes/admin/+page.svelte @@ -428,7 +428,7 @@
Started {formatTime(scan.started_at)} {#if scan.finished_at}· Finished {formatTime(scan.finished_at)}{/if} - {#if scan.error_message}· Error: {scan.error_message}{/if} + {#if scan.error_message}· Error: {scan.error_message}{/if}
{/if} diff --git a/web/src/routes/admin/duplicates/+page.svelte b/web/src/routes/admin/duplicates/+page.svelte index 89df56bd..d474ae53 100644 --- a/web/src/routes/admin/duplicates/+page.svelte +++ b/web/src/routes/admin/duplicates/+page.svelte @@ -191,7 +191,7 @@ Last swept {relativeTime(sweep.finished_at ?? '')}, comparing {(sweep.candidates ?? 0).toLocaleString()} tracks. {#if sweep.error_message} - It stopped early: {sweep.error_message} + It stopped early: {sweep.error_message} {/if} {/if} {#if prints && prints.pending > 0} -- 2.54.0 From 8bd2f66416d7e4e23bfa7ba2dd354ab485049657 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 8 Oct 2026 17:28:36 -0400 Subject: [PATCH 3/4] fix(web): missing files show greyed in track lists and never enter the queue (#5418) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TrackRef gains the unavailable flag the server already sends on album detail and search. TrackRow greys such a row with '· File missing' and hides like, radio, add and the menu, as playlist rows already do. Every player-store entry point (playQueue, enqueue, play next, and a refetched mix) drops missing files; playQueue keeps the chosen track as the start. Co-Authored-By: Claude Opus 5.5 --- web/src/lib/api/types.ts | 4 +++ web/src/lib/components/TrackRow.svelte | 15 +++++++++- web/src/lib/components/TrackRow.test.ts | 14 ++++++++++ web/src/lib/player/store.svelte.ts | 20 ++++++++++++- web/src/lib/player/store.test.ts | 37 +++++++++++++++++++++++++ 5 files changed, 88 insertions(+), 2 deletions(-) diff --git a/web/src/lib/api/types.ts b/web/src/lib/api/types.ts index 5db26e8a..10887d41 100644 --- a/web/src/lib/api/types.ts +++ b/web/src/lib/api/types.ts @@ -41,6 +41,10 @@ export type TrackRef = { disc_number?: number; duration_sec: number; stream_url: string; + // #5418: the file is gone from the library (missing_since is set). Sent on + // direct lookups such as album detail and search; lists that pick tracks + // for playback leave missing files out instead. + unavailable?: boolean; }; export type ArtistDetail = ArtistRef & { diff --git a/web/src/lib/components/TrackRow.svelte b/web/src/lib/components/TrackRow.svelte index 1caccb62..8629a3cb 100644 --- a/web/src/lib/components/TrackRow.svelte +++ b/web/src/lib/components/TrackRow.svelte @@ -25,6 +25,9 @@ } = $props(); const track = $derived(tracks[index]); + // #5418: the file is gone. The row stays, greyed, so an album still reads + // in order, but there is nothing to play, like or queue. + const missing = $derived(!!track.unavailable); const isSelected = $derived(selection.isSelected(track.id)); const selectMode = $derived(selection.active); @@ -61,9 +64,13 @@ title={track.title} {subtitle} durationSec={track.duration_sec} - {track} + track={missing ? null : track} ariaLabel={track.title} + rowTitle={missing + ? 'This track’s file is missing from the library, so it can’t be played. It returns automatically if the file comes back.' + : undefined} {isSelected} + disabled={missing} onActivate={activate} > {#snippet lead()} @@ -88,4 +95,10 @@ {/snippet} + + {#snippet note()} + {#if missing} + · File missing + {/if} + {/snippet} diff --git a/web/src/lib/components/TrackRow.test.ts b/web/src/lib/components/TrackRow.test.ts index f0049c72..14f60079 100644 --- a/web/src/lib/components/TrackRow.test.ts +++ b/web/src/lib/components/TrackRow.test.ts @@ -89,6 +89,20 @@ describe('TrackRow', () => { ).toBeInTheDocument(); }); + // #5418: a missing file stays in the list, greyed, with nothing to act on. + test('a missing file is marked, does not play, and offers no actions', async () => { + const withMissing = [{ ...tracks[0], unavailable: true }, tracks[1]]; + render(TrackRow, { props: { tracks: withMissing, index: 0 } }); + const row = screen.getByRole('button', { name: 'So What' }); + expect(row).toHaveAttribute('aria-disabled', 'true'); + expect(screen.getByText(/file missing/i)).toBeInTheDocument(); + await fireEvent.click(row); + expect(playQueue).not.toHaveBeenCalled(); + expect(screen.queryByRole('button', { name: /add .* to queue/i })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /play radio/i })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /track actions for/i })).not.toBeInTheDocument(); + }); + describe('selection mode', () => { test('clicking the checkbox toggles selection without playing', async () => { render(TrackRow, { props: { tracks, index: 0 } }); diff --git a/web/src/lib/player/store.svelte.ts b/web/src/lib/player/store.svelte.ts index a2bfb8a0..01e12442 100644 --- a/web/src/lib/player/store.svelte.ts +++ b/web/src/lib/player/store.svelte.ts @@ -123,6 +123,12 @@ export function registerAudioEl(el: HTMLAudioElement | null): void { _audioEl = el; } +// #5418: a missing file can't stream, so it never enters the queue. Returns +// the same array when nothing is missing. +function playable(tracks: TrackRef[]): TrackRef[] { + return tracks.some((t) => t.unavailable) ? tracks.filter((t) => !t.unavailable) : tracks; +} + export function playQueue( tracks: TrackRef[], startIndex = 0, @@ -138,8 +144,16 @@ export function playQueue( // 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; + const refetch = opts.refetch; + _queueRefetch = refetch ? () => refetch().then(playable) : null; _selfHealAttempts = 0; + // Start on the chosen track's place in the playable list; if the chosen + // one is missing, that place holds the next playable track after it. + const all = tracks; + tracks = playable(all); + if (tracks !== all) { + startIndex = all.slice(0, startIndex).filter((t) => !t.unavailable).length; + } 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 @@ -391,6 +405,7 @@ function trySelfHeal(): boolean { export function enqueueTrack(t: TrackRef): void { _radioSeedId = null; // M4c + if (t.unavailable) return; _queue = [..._queue, t]; if (_state === 'idle') _index = 0; } @@ -402,6 +417,7 @@ export function enqueueTrack(t: TrackRef): void { */ export function playNext(t: TrackRef): void { _radioSeedId = null; // M4c: any user-driven enqueue clears the radio refresh state + if (t.unavailable) return; if (_queue.length === 0) { _queue = [t]; _index = 0; @@ -413,6 +429,7 @@ export function playNext(t: TrackRef): void { export function enqueueTracks(ts: TrackRef[]): void { _radioSeedId = null; // M4c + ts = playable(ts); if (ts.length === 0) return; _queue = [..._queue, ...ts]; if (_state === 'idle') _index = 0; @@ -423,6 +440,7 @@ export function enqueueTracks(ts: TrackRef[]): void { // with the batch at index 0 (mirrors playNext's single-track shape). export function playNextMany(ts: TrackRef[]): void { _radioSeedId = null; + ts = playable(ts); if (ts.length === 0) return; if (_queue.length === 0) { _queue = [...ts]; diff --git a/web/src/lib/player/store.test.ts b/web/src/lib/player/store.test.ts index 51edbbcb..4a89aa71 100644 --- a/web/src/lib/player/store.test.ts +++ b/web/src/lib/player/store.test.ts @@ -338,6 +338,7 @@ import { enqueueTrack, enqueueTracks, playNext, + playNextMany, playRadio, moveQueueItem, removeFromQueue, @@ -398,6 +399,42 @@ describe('player store — enqueue + playRadio', () => { }); }); +// #5418: a missing file can't stream, so no entry point lets one into the queue. +describe('missing files stay out of the queue', () => { + const gone = (id: string) => ({ ...track(id), unavailable: true }); + + test('playQueue drops them and starts on the chosen track', () => { + playQueue([track('1'), gone('2'), track('3'), track('4')], 2); + expect(player.queue.map((t) => t.id)).toEqual(['1', '3', '4']); + expect(player.current?.id).toBe('3'); + }); + + test('playQueue on a missing track starts on the next playable one', () => { + playQueue([track('1'), gone('2'), track('3')], 1); + expect(player.current?.id).toBe('3'); + }); + + test('playQueue with a missing tail starts on the last playable one', () => { + playQueue([track('1'), gone('2')], 1); + expect(player.current?.id).toBe('1'); + }); + + test('playQueue of only missing files leaves the player idle', () => { + playQueue([gone('1'), gone('2')], 0); + expect(player.queue).toEqual([]); + expect(player.state).toBe('idle'); + }); + + test('enqueue and play-next skip them', () => { + playQueue([track('a')], 0); + enqueueTrack(gone('b')); + playNext(gone('c')); + enqueueTracks([gone('d'), track('e')]); + playNextMany([track('f'), gone('g')]); + expect(player.queue.map((t) => t.id)).toEqual(['a', 'f', 'e']); + }); +}); + describe('playNext', () => { test('splices at _index + 1, leaving _index unchanged', () => { playQueue([track('a'), track('b')], 0); -- 2.54.0 From bf0bc45c4b691df84624aec5b8904418d960105d Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 8 Oct 2026 17:33:10 -0400 Subject: [PATCH 4/4] fix(web): filter a refetched mix inside the self-heal step, not by wrapping the closure (#5418) The wrapper added a promise hop, so the self-heal test's single microtask wait saw 'loading' instead of 'error'. Co-Authored-By: Claude Opus 5.5 --- web/src/lib/player/store.svelte.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/web/src/lib/player/store.svelte.ts b/web/src/lib/player/store.svelte.ts index 01e12442..bce1c43a 100644 --- a/web/src/lib/player/store.svelte.ts +++ b/web/src/lib/player/store.svelte.ts @@ -144,8 +144,7 @@ export function playQueue( // 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. - const refetch = opts.refetch; - _queueRefetch = refetch ? () => refetch().then(playable) : null; + _queueRefetch = opts.refetch ?? null; _selfHealAttempts = 0; // Start on the chosen track's place in the playable list; if the chosen // one is missing, that place holds the next playable track after it. @@ -376,7 +375,8 @@ function trySelfHeal(): boolean { _error = null; const refetch = _queueRefetch; void refetch() - .then((refs) => { + .then((fetched) => { + const refs = playable(fetched); if (refs.length > 0) { // Re-seed in place — keep _queueSource / _queueRefetch so the new // snapshot can itself self-heal, and don't reset _selfHealAttempts -- 2.54.0