From 13e12d97ae3a39e3881df01e4b91723780c1030a Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 28 Apr 2026 15:45:42 -0400 Subject: [PATCH] feat(web): add /settings page with ListenBrainz section Adds api.put helper, ListenBrainz API module with query/mutation helpers, a /settings route with token + enable/disable UI, and 5 tests. Adds Settings to the Shell nav. Co-Authored-By: Claude Sonnet 4.6 --- web/src/lib/api/client.ts | 2 + web/src/lib/api/listenbrainz.ts | 44 ++++++++++ web/src/lib/components/Shell.svelte | 3 +- web/src/routes/settings/+page.svelte | 100 ++++++++++++++++++++++ web/src/routes/settings/settings.test.ts | 102 +++++++++++++++++++++++ 5 files changed, 250 insertions(+), 1 deletion(-) create mode 100644 web/src/lib/api/listenbrainz.ts create mode 100644 web/src/routes/settings/+page.svelte create mode 100644 web/src/routes/settings/settings.test.ts diff --git a/web/src/lib/api/client.ts b/web/src/lib/api/client.ts index 9c4f9244..632e6ba7 100644 --- a/web/src/lib/api/client.ts +++ b/web/src/lib/api/client.ts @@ -45,6 +45,8 @@ export const api = { get: (path: string): Promise => apiFetch(path) as Promise, post: (path: string, body: unknown): Promise => apiFetch(path, { method: 'POST', body: JSON.stringify(body) }) as Promise, + put: (path: string, body: unknown): Promise => + apiFetch(path, { method: 'PUT', body: JSON.stringify(body) }) as Promise, del: (path: string): Promise => apiFetch(path, { method: 'DELETE' }) as Promise }; diff --git a/web/src/lib/api/listenbrainz.ts b/web/src/lib/api/listenbrainz.ts new file mode 100644 index 00000000..515f450b --- /dev/null +++ b/web/src/lib/api/listenbrainz.ts @@ -0,0 +1,44 @@ +import { createQuery, createMutation } from '@tanstack/svelte-query'; +import type { QueryClient } from '@tanstack/svelte-query'; +import { api } from './client'; + +export type LBStatus = { + enabled: boolean; + token_set: boolean; + last_scrobbled_at: string | null; +}; + +export function getListenBrainzStatus(): Promise { + return api.get('/api/me/listenbrainz'); +} + +export function setListenBrainzToken(token: string): Promise { + return api.put('/api/me/listenbrainz', { token }); +} + +export function setListenBrainzEnabled(enabled: boolean): Promise { + return api.put('/api/me/listenbrainz', { enabled }); +} + +export const LB_QUERY_KEY = ['settings', 'listenbrainz'] as const; + +export function createLBStatusQuery() { + return createQuery({ + queryKey: LB_QUERY_KEY, + queryFn: getListenBrainzStatus + }); +} + +export function createTokenMutation(queryClient: QueryClient) { + return createMutation({ + mutationFn: (token: string) => setListenBrainzToken(token), + onSuccess: () => queryClient.invalidateQueries({ queryKey: LB_QUERY_KEY }) + }); +} + +export function createEnabledMutation(queryClient: QueryClient) { + return createMutation({ + mutationFn: (enabled: boolean) => setListenBrainzEnabled(enabled), + onSuccess: () => queryClient.invalidateQueries({ queryKey: LB_QUERY_KEY }) + }); +} diff --git a/web/src/lib/components/Shell.svelte b/web/src/lib/components/Shell.svelte index 8a81e8a8..fb9a0a90 100644 --- a/web/src/lib/components/Shell.svelte +++ b/web/src/lib/components/Shell.svelte @@ -25,7 +25,8 @@ { href: '/', label: 'Library' }, { href: '/library/liked', label: 'Liked' }, { href: '/search', label: 'Search' }, - { href: '/playlists', label: 'Playlists' } + { href: '/playlists', label: 'Playlists' }, + { href: '/settings', label: 'Settings' } ]; diff --git a/web/src/routes/settings/+page.svelte b/web/src/routes/settings/+page.svelte new file mode 100644 index 00000000..bd16b88c --- /dev/null +++ b/web/src/routes/settings/+page.svelte @@ -0,0 +1,100 @@ + + +
+

Settings

+ +
+

ListenBrainz

+ + {#if $status.isPending} +

Loading…

+ {:else if $status.isError} +

Couldn't load status.

+ {:else if $status.data} +
+ + + + + {#if $status.data.last_scrobbled_at} +

+ Last scrobbled: {new Date($status.data.last_scrobbled_at).toLocaleString()} +

+ {/if} + +

+ Get a token at listenbrainz.org/profile. + Tokens are stored unencrypted in this server's database — treat as sensitive. +

+
+ {/if} +
+
diff --git a/web/src/routes/settings/settings.test.ts b/web/src/routes/settings/settings.test.ts new file mode 100644 index 00000000..f31a6296 --- /dev/null +++ b/web/src/routes/settings/settings.test.ts @@ -0,0 +1,102 @@ +import { afterEach, describe, expect, test, vi } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'; +import { readable, writable } from 'svelte/store'; +import type { LBStatus } from '$lib/api/listenbrainz'; + +vi.mock('$lib/api/listenbrainz', () => ({ + createLBStatusQuery: vi.fn(), + createTokenMutation: vi.fn(), + createEnabledMutation: vi.fn(), + setListenBrainzToken: vi.fn(), + setListenBrainzEnabled: vi.fn() +})); + +vi.mock('@tanstack/svelte-query', async (orig) => { + const actual = (await orig()) as Record; + return { + ...actual, + useQueryClient: () => ({ invalidateQueries: vi.fn() }) + }; +}); + +import SettingsPage from './+page.svelte'; +import { + createLBStatusQuery, + createTokenMutation, + createEnabledMutation, + setListenBrainzToken, + setListenBrainzEnabled +} from '$lib/api/listenbrainz'; + +function mockStatusStore(data: LBStatus) { + return readable({ data, isPending: false, isError: false }); +} + +function mockMutationStore(mutateFn: (vars: unknown) => void = vi.fn()) { + return readable({ isPending: false, mutate: mutateFn, mutateAsync: vi.fn() }); +} + +afterEach(() => vi.clearAllMocks()); + +describe('Settings page — ListenBrainz', () => { + test('token-not-set state renders input + Save button', async () => { + (createLBStatusQuery as ReturnType).mockReturnValue( + mockStatusStore({ enabled: false, token_set: false, last_scrobbled_at: null }) + ); + (createTokenMutation as ReturnType).mockReturnValue(mockMutationStore()); + (createEnabledMutation as ReturnType).mockReturnValue(mockMutationStore()); + render(SettingsPage); + await waitFor(() => + expect(screen.getByPlaceholderText(/paste your lb token/i)).toBeInTheDocument() + ); + expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument(); + }); + + test('token-set state renders masked + Clear button', async () => { + (createLBStatusQuery as ReturnType).mockReturnValue( + mockStatusStore({ enabled: false, token_set: true, last_scrobbled_at: null }) + ); + (createTokenMutation as ReturnType).mockReturnValue(mockMutationStore()); + (createEnabledMutation as ReturnType).mockReturnValue(mockMutationStore()); + render(SettingsPage); + await waitFor(() => expect(screen.getByText(/\(set\)/)).toBeInTheDocument()); + expect(screen.getByText('clear')).toBeInTheDocument(); + }); + + test('Save button calls setListenBrainzToken with typed value', async () => { + (createLBStatusQuery as ReturnType).mockReturnValue( + mockStatusStore({ enabled: false, token_set: false, last_scrobbled_at: null }) + ); + const mutateFn = vi.fn(); + (createTokenMutation as ReturnType).mockReturnValue(mockMutationStore(mutateFn)); + (createEnabledMutation as ReturnType).mockReturnValue(mockMutationStore()); + render(SettingsPage); + const input = await screen.findByPlaceholderText(/paste your lb token/i); + await fireEvent.input(input, { target: { value: 'mytoken' } }); + await fireEvent.click(screen.getByRole('button', { name: /save/i })); + await waitFor(() => expect(mutateFn).toHaveBeenCalledWith('mytoken', expect.anything())); + }); + + test('Enabled checkbox is disabled when no token', async () => { + (createLBStatusQuery as ReturnType).mockReturnValue( + mockStatusStore({ enabled: false, token_set: false, last_scrobbled_at: null }) + ); + (createTokenMutation as ReturnType).mockReturnValue(mockMutationStore()); + (createEnabledMutation as ReturnType).mockReturnValue(mockMutationStore()); + render(SettingsPage); + const checkbox = await screen.findByRole('checkbox'); + expect(checkbox).toBeDisabled(); + }); + + test('Last-scrobbled-at renders when present', async () => { + (createLBStatusQuery as ReturnType).mockReturnValue( + mockStatusStore({ enabled: true, token_set: true, last_scrobbled_at: '2026-04-28T03:00:00Z' }) + ); + (createTokenMutation as ReturnType).mockReturnValue(mockMutationStore()); + (createEnabledMutation as ReturnType).mockReturnValue(mockMutationStore()); + render(SettingsPage); + await waitFor(() => + expect(screen.getByText(/last scrobbled:/i)).toBeInTheDocument() + ); + }); +});