Silent self-update, active sessions with real client IPs, genre/year browsing, handoff fix #119
@@ -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}`);
|
||||
}
|
||||
@@ -68,6 +68,11 @@ export const qk = {
|
||||
['playlists', { kind: kind ?? 'user' }] as const,
|
||||
playlist: (id: string) => ['playlist', id] 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) {
|
||||
|
||||
@@ -7,9 +7,16 @@
|
||||
// (artists / albums / liked / history / playlists) — Playlists lives
|
||||
// here so the operator can find their personal collection in one
|
||||
// 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 = [
|
||||
{ href: '/library/artists', label: 'Artists' },
|
||||
{ href: '/library/albums', label: 'Albums' },
|
||||
{ href: '/library/genres', label: 'Genres' },
|
||||
{ href: '/library/years', label: 'Years' },
|
||||
{ href: '/library/liked', label: 'Liked' },
|
||||
{ href: '/library/history', label: 'History' },
|
||||
{ href: '/library/playlists', label: 'Playlists' }
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user