diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte
index 83417fa8..de76c01f 100644
--- a/web/src/routes/+page.svelte
+++ b/web/src/routes/+page.svelte
@@ -1,83 +1,112 @@
-
-
-
+
{#if query.isError}
- {:else if showSkeleton.value && artists.length === 0}
-
- {:else if !query.isPending && total === 0}
-
- No artists yet — scan a library folder via the server's config.
-
- {:else}
-
- {#each artists as a (a.id)}
-
- {/each}
-
+ {:else if showSkeleton.value && !data}
+
Loading…
+ {:else if data}
+
+
+
+ {#if data.recently_added_albums.length === 0}
+ Nothing added yet. Scan a folder via the server's config.
+ {:else}
+ {#each chunk(data.recently_added_albums, 25) as row, i (i)}
+
+ {#snippet item(album: import('$lib/api/types').AlbumRef)}
+
+ {/snippet}
+
+ {/each}
+ {/if}
+
- {#if query.hasNextPage}
-
- {:else if artists.length > 0}
-
End of library
- {/if}
+
+
+
+ {#if data.rediscover_albums.length === 0 && data.rediscover_artists.length === 0}
+ No forgotten favourites yet. Like some albums or artists to fill this in.
+ {:else}
+ {#if data.rediscover_albums.length > 0}
+
+ {#snippet item(album: import('$lib/api/types').AlbumRef)}
+
+ {/snippet}
+
+ {/if}
+ {#if data.rediscover_artists.length > 0}
+
+ {#snippet item(artist: import('$lib/api/types').ArtistRef)}
+
+ {/snippet}
+
+ {/if}
+ {/if}
+
+
+
+
+
+ {#if data.most_played_tracks.length === 0}
+ No plays to draw from. Listen to something.
+ {:else}
+ {#each chunk(data.most_played_tracks, 25) as row, i (i)}
+
+ {#snippet item(track: import('$lib/api/types').TrackRef, idx: number)}
+
+ {/snippet}
+
+ {/each}
+ {/if}
+
+
+
+
+
+ {#if data.last_played_artists.length === 0}
+ No recent plays.
+ {:else}
+
+ {#snippet item(artist: import('$lib/api/types').ArtistRef)}
+
+ {/snippet}
+
+ {/if}
+
{/if}
diff --git a/web/src/routes/artists.test.ts b/web/src/routes/artists.test.ts
deleted file mode 100644
index 2ce6c39c..00000000
--- a/web/src/routes/artists.test.ts
+++ /dev/null
@@ -1,140 +0,0 @@
-import { afterEach, describe, expect, test, vi } from 'vitest';
-import { render, screen, fireEvent } from '@testing-library/svelte';
-import { flushSync } from 'svelte';
-import { readable } from 'svelte/store';
-import { mockInfiniteQuery } from '../test-utils/query';
-import type { ArtistRef, Page } from '$lib/api/types';
-
-const state = vi.hoisted(() => ({ pageUrl: new URL('http://localhost/') }));
-
-vi.mock('$app/state', () => ({
- page: { get url() { return state.pageUrl; } }
-}));
-
-vi.mock('$app/navigation', () => ({
- goto: vi.fn()
-}));
-
-vi.mock('$lib/api/queries', () => ({
- qk: { artists: (sort: string) => ['artists', { sort }] },
- createArtistsQuery: vi.fn()
-}));
-
-vi.mock('$lib/api/likes', () => ({
- createLikedIdsQuery: () => readable({
- data: { track_ids: [], album_ids: [], artist_ids: [] },
- isPending: false,
- isError: false
- }),
- likeEntity: vi.fn(),
- unlikeEntity: vi.fn()
-}));
-
-vi.mock('@tanstack/svelte-query', async (orig) => {
- const actual = (await orig()) as Record
;
- return { ...actual, useQueryClient: () => ({}) };
-});
-
-import ArtistsPage from './+page.svelte';
-import { createArtistsQuery } from '$lib/api/queries';
-import { goto } from '$app/navigation';
-
-function page(items: T[], total: number, offset = 0, limit = 50): Page {
- return { items, total, offset, limit };
-}
-
-afterEach(() => {
- vi.clearAllMocks();
- state.pageUrl = new URL('http://localhost/');
-});
-
-describe('artists list page', () => {
- test('renders a row per artist', () => {
- const alice: ArtistRef = { id: 'a', name: 'Alice', sort_name: 'Alice', album_count: 3, cover_url: '' };
- const bob: ArtistRef = { id: 'b', name: 'Bob', sort_name: 'Bob', album_count: 1, cover_url: '' };
- (createArtistsQuery as ReturnType).mockReturnValue(
- mockInfiniteQuery({ pages: [page([alice, bob], 2)] })
- );
- render(ArtistsPage);
- expect(screen.getByRole('link', { name: /Alice/ })).toHaveAttribute('href', '/artists/a');
- expect(screen.getByRole('link', { name: /Bob/ })).toHaveAttribute('href', '/artists/b');
- });
-
- test('renders the total count from the first page', () => {
- (createArtistsQuery as ReturnType).mockReturnValue(
- mockInfiniteQuery({ pages: [page([], 1247)] })
- );
- render(ArtistsPage);
- expect(screen.getByText(/1247 artists/i)).toBeInTheDocument();
- });
-
- test('Load more button calls fetchNextPage when hasNextPage', async () => {
- const fetchNextPage = vi.fn();
- (createArtistsQuery as ReturnType).mockReturnValue(
- mockInfiniteQuery({
- pages: [page([], 100)],
- hasNextPage: true,
- fetchNextPage
- })
- );
- render(ArtistsPage);
- await fireEvent.click(screen.getByRole('button', { name: /load more/i }));
- expect(fetchNextPage).toHaveBeenCalledTimes(1);
- });
-
- test('"End of library" when hasNextPage is false and at least one page loaded', () => {
- (createArtistsQuery as ReturnType).mockReturnValue(
- mockInfiniteQuery({ pages: [page([{ id: 'a', name: 'A', sort_name: 'A', album_count: 1, cover_url: '' }], 1)] })
- );
- render(ArtistsPage);
- expect(screen.getByText(/end of library/i)).toBeInTheDocument();
- });
-
- test('empty total renders empty-library message', () => {
- (createArtistsQuery as ReturnType).mockReturnValue(
- mockInfiniteQuery({ pages: [page([], 0)] })
- );
- render(ArtistsPage);
- expect(screen.getByText(/no artists yet/i)).toBeInTheDocument();
- });
-
- test('changing the sort dropdown calls goto with ?sort=newest', async () => {
- (createArtistsQuery as ReturnType).mockReturnValue(
- mockInfiniteQuery({ pages: [page([], 0)] })
- );
- render(ArtistsPage);
- const select = screen.getByLabelText(/sort/i) as HTMLSelectElement;
- await fireEvent.change(select, { target: { value: 'newest' } });
- expect(goto).toHaveBeenCalledWith('?sort=newest', { replaceState: true });
- });
-
- test('pending state renders the list skeleton after the useDelayed window', () => {
- (createArtistsQuery as ReturnType).mockReturnValue(
- mockInfiniteQuery({ isPending: true })
- );
- vi.useFakeTimers();
- try {
- render(ArtistsPage);
- vi.advanceTimersByTime(200);
- flushSync(); // flush the $effect that reads delayed.value
- expect(screen.getByTestId('skeleton-list')).toBeInTheDocument();
- } finally {
- vi.useRealTimers();
- }
- });
-
- test('error state renders the error banner with retry', async () => {
- const refetch = vi.fn();
- (createArtistsQuery as ReturnType).mockReturnValue(
- mockInfiniteQuery({
- isError: true,
- error: { code: 'server_error', message: 'db down', status: 500 },
- refetch
- })
- );
- render(ArtistsPage);
- expect(screen.getByRole('alert')).toHaveTextContent('db down');
- await fireEvent.click(screen.getByRole('button', { name: /try again/i }));
- expect(refetch).toHaveBeenCalledTimes(1);
- });
-});