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,
|
['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,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' }
|
||||||
|
|||||||
@@ -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