Silent self-update, active sessions with real client IPs, genre/year browsing, handoff fix #119

Merged
bvandeusen merged 15 commits from dev into main 2026-08-05 15:14:49 -04:00
7 changed files with 860 additions and 0 deletions
Showing only changes of commit feb1c2eca8 - Show all commits
+82
View File
@@ -0,0 +1,82 @@
import { createQuery } from '@tanstack/svelte-query';
import { api } from './client';
import { qk } from './queries';
import type { AlbumRef, Page } from './types';
export const BROWSE_PAGE_SIZE = 50;
// Genres are the raw ID3 strings, split on [;,] server-side but otherwise
// untouched — no case folding, no synonym mapping. So "Rock" and "rock" can
// both appear, as can "Rock/Pop" beside "Rock" and "Pop". Deliberate for v1:
// the raw spread has to be visible before anyone can judge whether it needs
// normalising.
export type GenreCount = { genre: string; track_count: number };
export type YearCount = { year: number; album_count: number };
export async function listGenres(): Promise<GenreCount[]> {
return api.get<GenreCount[]>('/api/library/genres');
}
export async function listAlbumYears(): Promise<YearCount[]> {
return api.get<YearCount[]>('/api/library/years');
}
// The indexes use svelte-query: they're fetched once per page mount, so static
// options are enough, and the cache means bouncing between browse and a
// drill-down doesn't refetch. The drill-down lists below deliberately do NOT —
// see the note on listAlbumsByGenre.
export function createGenresQuery() {
return createQuery({
queryKey: qk.genres(),
queryFn: listGenres,
// Genres only change when the library is rescanned.
staleTime: 5 * 60_000
});
}
export function createAlbumYearsQuery() {
return createQuery({
queryKey: qk.albumYears(),
queryFn: listAlbumYears,
staleTime: 5 * 60_000
});
}
// Genre travels as a QUERY parameter, never a path segment. Raw ID3 genres
// contain slashes ("Rock/Pop" is a real tag), which a path segment cannot
// carry — the server would see two segments, and a hard reload of such a URL
// would not survive the SPA fallback either.
//
// Called directly rather than through createInfiniteQuery because the selected
// genre comes from the URL and changes without remounting the page. This
// codebase has no reactive-query-options pattern, and introducing one here
// would be a larger change than the feature warrants.
export async function listAlbumsByGenre(
genre: string,
limit: number,
offset: number
): Promise<Page<AlbumRef>> {
const params = new URLSearchParams({
genre,
limit: String(limit),
offset: String(offset)
});
return api.get<Page<AlbumRef>>(`/api/library/albums?${params}`);
}
// Single-year drill-down. The server takes an inclusive range, so one year is
// expressed as its own degenerate range rather than needing a separate shape.
export async function listAlbumsByYear(
year: number,
limit: number,
offset: number
): Promise<Page<AlbumRef>> {
const params = new URLSearchParams({
year_from: String(year),
year_to: String(year),
limit: String(limit),
offset: String(offset)
});
return api.get<Page<AlbumRef>>(`/api/library/albums?${params}`);
}
+5
View File
@@ -68,6 +68,11 @@ export const qk = {
['playlists', { kind: kind ?? 'user' }] as const, ['playlists', { kind: kind ?? 'user' }] as const,
playlist: (id: string) => ['playlist', id] as const, playlist: (id: string) => ['playlist', id] as const,
systemPlaylistsStatus: () => ['systemPlaylistsStatus'] as const, systemPlaylistsStatus: () => ['systemPlaylistsStatus'] as const,
// Browse indexes (#367). Keys carry no arguments — both are whole-library
// indexes, and the per-genre / per-year album lists are fetched outside
// svelte-query because their selection comes from the URL.
genres: () => ['genres'] as const,
albumYears: () => ['albumYears'] as const,
}; };
export function createArtistsQuery(sort: ArtistSort) { export function createArtistsQuery(sort: ArtistSort) {
+7
View File
@@ -7,9 +7,16 @@
// (artists / albums / liked / history / playlists) — Playlists lives // (artists / albums / liked / history / playlists) — Playlists lives
// here so the operator can find their personal collection in one // here so the operator can find their personal collection in one
// place rather than tracking a separate top-level route. // place rather than tracking a separate top-level route.
// Genres and Years sit next to Albums — all three are ways of walking the
// same collection — rather than at the end beside the personal tabs (Liked,
// History, Playlists). NOTE: these two are web-only for now; Android's
// LibraryScreen has no equivalent, so the "mirrors Android" claim above is
// currently aspirational for this pair. Parity is an open call (#367).
const tabs = [ const tabs = [
{ href: '/library/artists', label: 'Artists' }, { href: '/library/artists', label: 'Artists' },
{ href: '/library/albums', label: 'Albums' }, { href: '/library/albums', label: 'Albums' },
{ href: '/library/genres', label: 'Genres' },
{ href: '/library/years', label: 'Years' },
{ href: '/library/liked', label: 'Liked' }, { href: '/library/liked', label: 'Liked' },
{ href: '/library/history', label: 'History' }, { href: '/library/history', label: 'History' },
{ href: '/library/playlists', label: 'Playlists' } { href: '/library/playlists', label: 'Playlists' }
+210
View File
@@ -0,0 +1,210 @@
<script lang="ts">
import { page } from '$app/state';
import { pageTitle } from '$lib/branding';
import { ChevronLeft } from 'lucide-svelte';
import {
createGenresQuery,
listAlbumsByGenre,
BROWSE_PAGE_SIZE,
type GenreCount
} from '$lib/api/browse';
import AlbumCard from '$lib/components/AlbumCard.svelte';
import ApiErrorBanner from '$lib/components/ApiErrorBanner.svelte';
import EmptyState from '$lib/components/EmptyState.svelte';
import QuickFilter from '$lib/components/QuickFilter.svelte';
import type { AlbumRef } from '$lib/api/types';
const indexStore = createGenresQuery();
const index = $derived($indexStore);
const genres = $derived(index.data ?? []);
// Selection rides a query parameter rather than a route segment: "Rock/Pop"
// is a real ID3 tag and a slash cannot survive a path — neither the server's
// router nor an SPA-fallback reload would reconstruct it.
const selected = $derived(page.url.searchParams.get('g') ?? '');
let filter = $state('');
const filteredGenres = $derived.by(() => {
const q = filter.trim().toLowerCase();
if (!q) return genres;
return genres.filter((g: GenreCount) => g.genre.toLowerCase().includes(q));
});
let albums = $state<AlbumRef[]>([]);
let total = $state(0);
let loading = $state(false);
let failed = $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
// writes. Its only job is to let a late response for a previous genre be
// discarded rather than painted over the current one.
let requestToken = 0;
$effect(() => {
const g = selected; // the only tracked read — reload when selection moves
void reload(g);
});
async function reload(genre: string) {
requestToken += 1;
albums = [];
total = 0;
failed = false;
if (!genre) return;
await fetchPage(genre, 0, requestToken);
}
async function fetchPage(genre: string, offset: number, token: number) {
loading = true;
try {
const p = await listAlbumsByGenre(genre, BROWSE_PAGE_SIZE, offset);
if (token !== requestToken) return; // selection moved on; drop it
albums = offset === 0 ? p.items : [...albums, ...p.items];
total = p.total;
} catch {
if (token === requestToken) failed = true;
} finally {
if (token === requestToken) loading = false;
}
}
function loadMore() {
void fetchPage(selected, albums.length, requestToken);
}
function genreHref(genre: string): string {
return `/library/genres?g=${encodeURIComponent(genre)}`;
}
</script>
<svelte:head>
<title>{pageTitle(selected ? `Library · ${selected}` : 'Library · Genres')}</title>
</svelte:head>
{#if selected}
<div class="space-y-4">
<header class="space-y-2">
<a
href="/library/genres"
class="inline-flex items-center gap-1 text-sm text-accent hover:underline"
>
<ChevronLeft size={14} aria-hidden="true" />
All genres
</a>
<div>
<h1 class="font-display text-2xl font-medium text-text-primary">{selected}</h1>
{#if !loading || albums.length > 0}
<p class="text-sm text-text-secondary">
{total} {total === 1 ? 'album' : 'albums'}
</p>
{/if}
</div>
</header>
{#if failed}
<p class="text-sm text-action-destructive">
Couldn't load albums for this genre.
<button
type="button"
class="underline hover:no-underline"
onclick={() => reload(selected)}>Try again</button
>
</p>
{:else if loading && albums.length === 0}
<p class="text-text-secondary">Loading…</p>
{:else if albums.length === 0}
<!-- Reachable when a genre exists in the index but its albums have since
been rescanned away. Not the multi-genre bug that made this whole
surface worth care — the server splits on both sides now. -->
<EmptyState
title="No albums for this genre"
hint="The library may have been rescanned since this list was built."
/>
{:else}
<div
class="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6"
>
{#each albums as album (album.id)}
<AlbumCard {album} />
{/each}
</div>
{#if albums.length < total}
<div class="flex justify-center py-2">
<button
type="button"
class="rounded-md border border-border px-4 py-2 text-sm hover:bg-surface-hover
focus-visible:ring-2 focus-visible:ring-accent disabled:opacity-50"
disabled={loading}
onclick={loadMore}
>
{loading ? 'Loading…' : `Load more (${total - albums.length} left)`}
</button>
</div>
{:else}
<p class="py-2 text-center text-sm text-text-secondary">End of genre</p>
{/if}
{/if}
</div>
{:else}
<div class="space-y-4">
<header class="flex flex-wrap items-end justify-between gap-3">
<div>
<h1 class="font-display text-2xl font-medium text-text-primary">Genres</h1>
{#if !index.isPending && !index.isError}
<p class="text-sm text-text-secondary">
{genres.length} {genres.length === 1 ? 'genre' : 'genres'}, straight from your file tags
</p>
{/if}
</div>
{#if genres.length > 0}
<QuickFilter bind:value={filter} placeholder="Filter genres" />
{/if}
</header>
{#if index.isError}
<ApiErrorBanner error={index.error} onRetry={index.refetch} />
{:else if index.isPending}
<p class="text-text-secondary">Loading…</p>
{:else if genres.length === 0}
<EmptyState
title="No genres found"
hint="Genres come from the genre tag on your audio files. If your library is tagged but this is empty, try a rescan."
>
{#snippet actions()}
<a
href="/admin"
class="inline-flex items-center rounded-md bg-action-secondary px-4 py-2 text-sm text-action-fg hover:opacity-90"
>
Open admin
</a>
{/snippet}
</EmptyState>
{:else if filter.trim() && filteredGenres.length === 0}
<p class="text-text-secondary">
No genres match <span class="font-medium">'{filter.trim()}'</span>.
</p>
{:else}
<!-- Ordered by track count, not alphabetically: raw tags carry a long
tail of one-offs, so alphabetical would bury the handful of genres
you actually have a library's worth of. -->
<ul class="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
{#each filteredGenres as g (g.genre)}
<li>
<a
href={genreHref(g.genre)}
class="flex items-center justify-between gap-3 rounded-md border border-border
bg-surface px-3 py-2 hover:bg-surface-hover
focus-visible:ring-2 focus-visible:ring-accent"
>
<span class="truncate text-text-primary">{g.genre}</span>
<span class="flex-shrink-0 text-sm text-text-secondary">
{g.track_count}
</span>
</a>
</li>
{/each}
</ul>
{/if}
</div>
{/if}
@@ -0,0 +1,171 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
import { render, screen, waitFor, fireEvent } from '@testing-library/svelte';
import { mockQuery } from '$test-utils/query';
import { pageUrlModule } from '$test-utils/mocks/appState';
import { apiClientMock } from '$test-utils/mocks/client';
import { emptyLikesMock } from '$test-utils/mocks/likes';
import type { AlbumRef } from '$lib/api/types';
const pageState = vi.hoisted(() => ({
pageUrl: new URL('http://localhost/library/genres')
}));
vi.mock('$app/state', () => pageUrlModule(pageState));
vi.mock('$lib/api/browse', () => ({
BROWSE_PAGE_SIZE: 2,
createGenresQuery: vi.fn(),
listAlbumsByGenre: vi.fn()
}));
vi.mock('$lib/api/client', () => apiClientMock());
vi.mock('$lib/api/likes', () => emptyLikesMock());
vi.mock('$lib/player/store.svelte', () => ({
playQueue: vi.fn(),
playRadio: vi.fn(),
enqueueTrack: vi.fn(),
enqueueTracks: vi.fn(),
player: { current: undefined }
}));
import GenresPage from './+page.svelte';
import { createGenresQuery, listAlbumsByGenre } from '$lib/api/browse';
const asMock = (fn: unknown) => fn as ReturnType<typeof vi.fn>;
function album(id: string, title: string): AlbumRef {
return {
id,
title,
sort_title: title,
artist_id: 'ar1',
artist_name: 'Someone',
year: 1999,
track_count: 1,
duration_sec: 100,
cover_url: '',
cover_art_source: null
};
}
afterEach(() => {
pageState.pageUrl = new URL('http://localhost/library/genres');
vi.clearAllMocks();
});
describe('/library/genres index', () => {
test('lists genres with track counts in server order', () => {
asMock(createGenresQuery).mockReturnValue(
mockQuery({
data: [
{ genre: 'Rock', track_count: 120 },
{ genre: 'Jazz', track_count: 8 }
]
})
);
render(GenresPage);
expect(screen.getByText('Rock')).toBeInTheDocument();
expect(screen.getByText('120')).toBeInTheDocument();
expect(screen.getByText('Jazz')).toBeInTheDocument();
expect(screen.getByText('8')).toBeInTheDocument();
});
// The reason genre is a query parameter and not a route segment. If this
// regresses to a path, "Rock/Pop" silently becomes two segments.
test('encodes a slash-bearing genre into the query string', () => {
asMock(createGenresQuery).mockReturnValue(
mockQuery({ data: [{ genre: 'Rock/Pop', track_count: 3 }] })
);
render(GenresPage);
const link = screen.getByRole('link', { name: /Rock\/Pop/ });
expect(link.getAttribute('href')).toBe('/library/genres?g=Rock%2FPop');
});
test('case variants appear separately — genres are exposed as-is', () => {
asMock(createGenresQuery).mockReturnValue(
mockQuery({
data: [
{ genre: 'Rock', track_count: 10 },
{ genre: 'rock', track_count: 2 }
]
})
);
render(GenresPage);
expect(screen.getByRole('link', { name: /^Rock 10$/ })).toBeInTheDocument();
expect(screen.getByRole('link', { name: /^rock 2$/ })).toBeInTheDocument();
});
test('empty library explains where genres come from', () => {
asMock(createGenresQuery).mockReturnValue(mockQuery({ data: [] }));
render(GenresPage);
expect(screen.getByText('No genres found')).toBeInTheDocument();
expect(screen.getByText(/genre tag on your audio files/i)).toBeInTheDocument();
});
test('surfaces an index error with a retry', () => {
const refetch = vi.fn();
asMock(createGenresQuery).mockReturnValue(
mockQuery({ isError: true, error: { message: 'boom' }, refetch })
);
render(GenresPage);
fireEvent.click(screen.getByRole('button', { name: /Try again/i }));
expect(refetch).toHaveBeenCalled();
});
});
describe('/library/genres drill-down', () => {
test('fetches and renders albums for the selected genre', async () => {
pageState.pageUrl = new URL('http://localhost/library/genres?g=Rock%2FPop');
asMock(createGenresQuery).mockReturnValue(mockQuery({ data: [] }));
asMock(listAlbumsByGenre).mockResolvedValue({
items: [album('a1', 'First Album')],
total: 1,
limit: 2,
offset: 0
});
render(GenresPage);
// The decoded genre must reach the API, not the percent-encoded form.
await waitFor(() => expect(listAlbumsByGenre).toHaveBeenCalledWith('Rock/Pop', 2, 0));
expect(await screen.findByText('First Album')).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'Rock/Pop' })).toBeInTheDocument();
});
test('load more appends the next page and then reports the end', 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
})
.mockResolvedValueOnce({ items: [album('a3', 'Three')], total: 3, limit: 2, offset: 2 });
render(GenresPage);
await screen.findByText('One');
const more = await screen.findByRole('button', { name: /Load more \(1 left\)/ });
await fireEvent.click(more);
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();
expect(await screen.findByText('End of genre')).toBeInTheDocument();
});
test('a failed drill-down offers a retry rather than an empty grid', async () => {
pageState.pageUrl = new URL('http://localhost/library/genres?g=Rock');
asMock(createGenresQuery).mockReturnValue(mockQuery({ data: [] }));
asMock(listAlbumsByGenre).mockRejectedValue(new Error('nope'));
render(GenresPage);
expect(await screen.findByText(/Couldn't load albums for this genre/i)).toBeInTheDocument();
});
});
+206
View File
@@ -0,0 +1,206 @@
<script lang="ts">
import { page } from '$app/state';
import { pageTitle } from '$lib/branding';
import { ChevronLeft } from 'lucide-svelte';
import {
createAlbumYearsQuery,
listAlbumsByYear,
BROWSE_PAGE_SIZE,
type YearCount
} from '$lib/api/browse';
import AlbumCard from '$lib/components/AlbumCard.svelte';
import ApiErrorBanner from '$lib/components/ApiErrorBanner.svelte';
import EmptyState from '$lib/components/EmptyState.svelte';
import type { AlbumRef } from '$lib/api/types';
const indexStore = createAlbumYearsQuery();
const index = $derived($indexStore);
const years = $derived(index.data ?? []);
const selected = $derived.by(() => {
const raw = page.url.searchParams.get('y');
if (!raw) return null;
const n = Number.parseInt(raw, 10);
return Number.isFinite(n) ? n : null;
});
// Grouped by decade so the index stays scannable — a flat list of every year
// in a decades-deep library is a wall of numbers, and the decade is usually
// how someone actually thinks about it.
const decades = $derived.by(() => {
const buckets = new Map<number, YearCount[]>();
for (const y of years) {
const decade = Math.floor(y.year / 10) * 10;
const list = buckets.get(decade);
if (list) list.push(y);
else buckets.set(decade, [y]);
}
return [...buckets.entries()]
.sort((a, b) => b[0] - a[0])
.map(([decade, entries]) => ({
decade,
entries: entries.slice().sort((a, b) => b.year - a.year),
albumCount: entries.reduce((sum, e) => sum + e.album_count, 0)
}));
});
let albums = $state<AlbumRef[]>([]);
let total = $state(0);
let loading = $state(false);
let failed = $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
// its own writes. It exists so a late response for a previously selected
// year is discarded instead of painted over the current one.
let requestToken = 0;
$effect(() => {
const y = selected; // only tracked read
void reload(y);
});
async function reload(year: number | null) {
requestToken += 1;
albums = [];
total = 0;
failed = false;
if (year === null) return;
await fetchPage(year, 0, requestToken);
}
async function fetchPage(year: number, offset: number, token: number) {
loading = true;
try {
const p = await listAlbumsByYear(year, BROWSE_PAGE_SIZE, offset);
if (token !== requestToken) return;
albums = offset === 0 ? p.items : [...albums, ...p.items];
total = p.total;
} catch {
if (token === requestToken) failed = true;
} finally {
if (token === requestToken) loading = false;
}
}
function loadMore() {
if (selected !== null) void fetchPage(selected, albums.length, requestToken);
}
</script>
<svelte:head>
<title>{pageTitle(selected !== null ? `Library · ${selected}` : 'Library · Years')}</title>
</svelte:head>
{#if selected !== null}
<div class="space-y-4">
<header class="space-y-2">
<a
href="/library/years"
class="inline-flex items-center gap-1 text-sm text-accent hover:underline"
>
<ChevronLeft size={14} aria-hidden="true" />
All years
</a>
<div>
<h1 class="font-display text-2xl font-medium text-text-primary">{selected}</h1>
{#if !loading || albums.length > 0}
<p class="text-sm text-text-secondary">
{total} {total === 1 ? 'album' : 'albums'}
</p>
{/if}
</div>
</header>
{#if failed}
<p class="text-sm text-action-destructive">
Couldn't load albums for {selected}.
<button
type="button"
class="underline hover:no-underline"
onclick={() => reload(selected)}>Try again</button
>
</p>
{:else if loading && albums.length === 0}
<p class="text-text-secondary">Loading…</p>
{:else if albums.length === 0}
<EmptyState
title="No albums from {selected}"
hint="The library may have been rescanned since this list was built."
/>
{:else}
<div
class="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6"
>
{#each albums as album (album.id)}
<AlbumCard {album} />
{/each}
</div>
{#if albums.length < total}
<div class="flex justify-center py-2">
<button
type="button"
class="rounded-md border border-border px-4 py-2 text-sm hover:bg-surface-hover
focus-visible:ring-2 focus-visible:ring-accent disabled:opacity-50"
disabled={loading}
onclick={loadMore}
>
{loading ? 'Loading…' : `Load more (${total - albums.length} left)`}
</button>
</div>
{:else}
<p class="py-2 text-center text-sm text-text-secondary">End of year</p>
{/if}
{/if}
</div>
{:else}
<div class="space-y-4">
<header>
<h1 class="font-display text-2xl font-medium text-text-primary">Years</h1>
{#if !index.isPending && !index.isError}
<p class="text-sm text-text-secondary">
{years.length} {years.length === 1 ? 'year' : 'years'} with dated releases
</p>
{/if}
</header>
{#if index.isError}
<ApiErrorBanner error={index.error} onRetry={index.refetch} />
{:else if index.isPending}
<p class="text-text-secondary">Loading…</p>
{:else if years.length === 0}
<!-- Albums with no release date are absent by design rather than bucketed
under a fake year, so an untagged library lands here legitimately. -->
<EmptyState
title="No release years found"
hint="Years come from the release date on your albums. Albums without one don't appear on this axis."
/>
{:else}
<ul class="space-y-4">
{#each decades as d (d.decade)}
<li>
<h2 class="mb-2 text-sm font-medium text-text-secondary">
{d.decade}s
<span class="font-normal">· {d.albumCount}</span>
</h2>
<ul class="flex flex-wrap gap-2">
{#each d.entries as y (y.year)}
<li>
<a
href={`/library/years?y=${y.year}`}
class="inline-flex items-baseline gap-1.5 rounded-md border border-border
bg-surface px-3 py-1.5 hover:bg-surface-hover
focus-visible:ring-2 focus-visible:ring-accent"
>
<span class="text-text-primary">{y.year}</span>
<span class="text-xs text-text-secondary">{y.album_count}</span>
</a>
</li>
{/each}
</ul>
</li>
{/each}
</ul>
{/if}
</div>
{/if}
+179
View File
@@ -0,0 +1,179 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
import { render, screen, waitFor, fireEvent } from '@testing-library/svelte';
import { mockQuery } from '$test-utils/query';
import { pageUrlModule } from '$test-utils/mocks/appState';
import { apiClientMock } from '$test-utils/mocks/client';
import { emptyLikesMock } from '$test-utils/mocks/likes';
import type { AlbumRef } from '$lib/api/types';
const pageState = vi.hoisted(() => ({
pageUrl: new URL('http://localhost/library/years')
}));
vi.mock('$app/state', () => pageUrlModule(pageState));
vi.mock('$lib/api/browse', () => ({
BROWSE_PAGE_SIZE: 2,
createAlbumYearsQuery: vi.fn(),
listAlbumsByYear: vi.fn()
}));
vi.mock('$lib/api/client', () => apiClientMock());
vi.mock('$lib/api/likes', () => emptyLikesMock());
vi.mock('$lib/player/store.svelte', () => ({
playQueue: vi.fn(),
playRadio: vi.fn(),
enqueueTrack: vi.fn(),
enqueueTracks: vi.fn(),
player: { current: undefined }
}));
import YearsPage from './+page.svelte';
import { createAlbumYearsQuery, listAlbumsByYear } from '$lib/api/browse';
const asMock = (fn: unknown) => fn as ReturnType<typeof vi.fn>;
function album(id: string, title: string): AlbumRef {
return {
id,
title,
sort_title: title,
artist_id: 'ar1',
artist_name: 'Someone',
year: 1999,
track_count: 1,
duration_sec: 100,
cover_url: '',
cover_art_source: null
};
}
afterEach(() => {
pageState.pageUrl = new URL('http://localhost/library/years');
vi.clearAllMocks();
});
describe('/library/years index', () => {
test('groups years into decades, newest decade first', () => {
asMock(createAlbumYearsQuery).mockReturnValue(
mockQuery({
data: [
{ year: 2020, album_count: 3 },
{ year: 1995, album_count: 2 },
{ year: 1991, album_count: 1 }
]
})
);
render(YearsPage);
const headings = screen.getAllByRole('heading', { level: 2 }).map((h) => h.textContent ?? '');
const decades = headings.map((t) => t.trim().split(/\s+/)[0]);
expect(decades).toEqual(['2020s', '1990s']);
});
test('sums album counts per decade', () => {
asMock(createAlbumYearsQuery).mockReturnValue(
mockQuery({
data: [
{ year: 1995, album_count: 2 },
{ year: 1991, album_count: 5 }
]
})
);
render(YearsPage);
// 2 + 5 across the decade, not per-year.
const heading = screen.getByRole('heading', { level: 2 });
expect(heading.textContent).toMatch(/1990s\s*·\s*7/);
});
test('years within a decade run newest first', () => {
asMock(createAlbumYearsQuery).mockReturnValue(
mockQuery({
data: [
{ year: 1991, album_count: 1 },
{ year: 1997, album_count: 1 },
{ year: 1994, album_count: 1 }
]
})
);
render(YearsPage);
const links = screen.getAllByRole('link').map((a) => a.getAttribute('href'));
expect(links).toEqual([
'/library/years?y=1997',
'/library/years?y=1994',
'/library/years?y=1991'
]);
});
// Undated albums are excluded server-side rather than bucketed under a fake
// year, so an entirely undated library legitimately lands on the empty state.
test('empty index explains that undated albums are absent from this axis', () => {
asMock(createAlbumYearsQuery).mockReturnValue(mockQuery({ data: [] }));
render(YearsPage);
expect(screen.getByText('No release years found')).toBeInTheDocument();
expect(screen.getByText(/without one don't appear/i)).toBeInTheDocument();
});
});
describe('/library/years drill-down', () => {
test('requests the selected year as a degenerate range', async () => {
pageState.pageUrl = new URL('http://localhost/library/years?y=1995');
asMock(createAlbumYearsQuery).mockReturnValue(mockQuery({ data: [] }));
asMock(listAlbumsByYear).mockResolvedValue({
items: [album('a1', 'Mid Nineties')],
total: 1,
limit: 2,
offset: 0
});
render(YearsPage);
await waitFor(() => expect(listAlbumsByYear).toHaveBeenCalledWith(1995, 2, 0));
expect(await screen.findByText('Mid Nineties')).toBeInTheDocument();
});
test('a non-numeric year is treated as no selection', () => {
pageState.pageUrl = new URL('http://localhost/library/years?y=nineteen');
asMock(createAlbumYearsQuery).mockReturnValue(
mockQuery({ data: [{ year: 1999, album_count: 1 }] })
);
render(YearsPage);
// Falls back to the index rather than fetching NaN.
expect(listAlbumsByYear).not.toHaveBeenCalled();
expect(screen.getByRole('heading', { level: 1, name: 'Years' })).toBeInTheDocument();
});
test('load more appends and then reports the end', 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
})
.mockResolvedValueOnce({ items: [album('a3', 'Three')], total: 3, limit: 2, offset: 2 });
render(YearsPage);
await screen.findByText('One');
await fireEvent.click(await screen.findByRole('button', { name: /Load more \(1 left\)/ }));
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();
});
test('a failed drill-down offers a retry', async () => {
pageState.pageUrl = new URL('http://localhost/library/years?y=1995');
asMock(createAlbumYearsQuery).mockReturnValue(mockQuery({ data: [] }));
asMock(listAlbumsByYear).mockRejectedValue(new Error('nope'));
render(YearsPage);
expect(await screen.findByText(/Couldn't load albums for 1995/i)).toBeInTheDocument();
});
});