From 0d7a65cffbb626c0205ba309f6af3c5997f4514b Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 30 Apr 2026 20:35:11 -0400 Subject: [PATCH] feat(web): /library/hidden user-facing quarantine view Co-Authored-By: Claude Opus 4.7 --- web/src/lib/components/Shell.svelte | 15 +-- web/src/lib/components/Shell.test.ts | 15 +++ web/src/routes/library/hidden/+page.svelte | 111 +++++++++++++++++++ web/src/routes/library/hidden/hidden.test.ts | 64 +++++++++++ 4 files changed, 198 insertions(+), 7 deletions(-) create mode 100644 web/src/routes/library/hidden/+page.svelte create mode 100644 web/src/routes/library/hidden/hidden.test.ts diff --git a/web/src/lib/components/Shell.svelte b/web/src/lib/components/Shell.svelte index 9fc18724..80d7af57 100644 --- a/web/src/lib/components/Shell.svelte +++ b/web/src/lib/components/Shell.svelte @@ -22,13 +22,14 @@ } const navItems = [ - { href: '/', label: 'Library' }, - { href: '/library/liked', label: 'Liked' }, - { href: '/search', label: 'Search' }, - { href: '/discover', label: 'Discover' }, - { href: '/requests', label: 'Requests' }, - { href: '/playlists', label: 'Playlists' }, - { href: '/settings', label: 'Settings' } + { href: '/', label: 'Library' }, + { href: '/library/liked', label: 'Liked' }, + { href: '/library/hidden', label: 'Hidden' }, + { href: '/search', label: 'Search' }, + { href: '/discover', label: 'Discover' }, + { href: '/requests', label: 'Requests' }, + { href: '/playlists', label: 'Playlists' }, + { href: '/settings', label: 'Settings' } ]; // Admin link sits between Playlists and Settings, only visible to admins. diff --git a/web/src/lib/components/Shell.test.ts b/web/src/lib/components/Shell.test.ts index dc7b445a..b3f24b90 100644 --- a/web/src/lib/components/Shell.test.ts +++ b/web/src/lib/components/Shell.test.ts @@ -48,6 +48,21 @@ describe('Shell', () => { expect(screen.getByRole('link', { name: 'Playlists' })).toHaveAttribute('href', '/playlists'); }); + test('Hidden nav link sits between Liked and Search', () => { + render(Shell); + const hidden = screen.getByRole('link', { name: 'Hidden' }); + expect(hidden).toHaveAttribute('href', '/library/hidden'); + const labels = screen + .getAllByRole('link') + .map((el) => el.textContent?.trim()) + .filter(Boolean); + const idxLiked = labels.indexOf('Liked'); + const idxHidden = labels.indexOf('Hidden'); + const idxSearch = labels.indexOf('Search'); + expect(idxLiked).toBeLessThan(idxHidden); + expect(idxHidden).toBeLessThan(idxSearch); + }); + test('non-admin users do not see the Admin nav link', () => { userState.current = { id: '1', username: 'alice', is_admin: false }; render(Shell); diff --git a/web/src/routes/library/hidden/+page.svelte b/web/src/routes/library/hidden/+page.svelte new file mode 100644 index 00000000..35c8e9f5 --- /dev/null +++ b/web/src/routes/library/hidden/+page.svelte @@ -0,0 +1,111 @@ + + +
+
+

Hidden

+

Tracks you've flagged as broken.

+
+ + {#if query.isError} + + {:else if !query.isPending && rows.length === 0} +

Nothing hidden yet.

+ {:else if rows.length > 0} +
    + {#each rows as row (row.track_id)} +
  • + +
    + {#if row.album_cover_art_path} + + {:else} + + {/if} +
    + +
    +
    + Track + {REASON_LABELS[row.reason]} +
    +
    {row.track_title}
    +
    + by {row.artist_name} · {row.album_title} · flagged {relativeTime(row.created_at)} +
    + {#if row.notes} +
    {row.notes}
    + {/if} +
    + + +
  • + {/each} +
+ {/if} +
+ + diff --git a/web/src/routes/library/hidden/hidden.test.ts b/web/src/routes/library/hidden/hidden.test.ts new file mode 100644 index 00000000..1630d241 --- /dev/null +++ b/web/src/routes/library/hidden/hidden.test.ts @@ -0,0 +1,64 @@ +import { afterEach, describe, expect, test, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/svelte'; +import { mockQuery } from '../../../test-utils/query'; + +vi.mock('@tanstack/svelte-query', async (orig) => { + const actual = (await orig()) as Record; + return { ...actual, useQueryClient: () => ({ invalidateQueries: vi.fn() }) }; +}); + +vi.mock('$lib/api/quarantine', () => ({ + createMyQuarantineQuery: vi.fn(), + unflagTrack: vi.fn().mockResolvedValue(undefined) +})); + +import HiddenPage from './+page.svelte'; +import { createMyQuarantineQuery, unflagTrack } from '$lib/api/quarantine'; +import type { LidarrQuarantineMineRow } from '$lib/api/types'; + +const baseRow: LidarrQuarantineMineRow = { + track_id: 't1', + reason: 'bad_rip', + notes: 'crackles', + created_at: new Date(Date.now() - 2 * 24 * 3_600_000).toISOString(), // 2 days ago + track_title: 'Roygbiv', + track_duration_ms: 200000, + album_id: 'a1', + album_title: 'Geogaddi', + album_cover_art_path: null, + artist_id: 'ar1', + artist_name: 'Boards of Canada' +}; + +afterEach(() => vi.clearAllMocks()); + +describe('/library/hidden', () => { + test('renders one row per quarantine with title + meta + reason pill + notes', () => { + (createMyQuarantineQuery as ReturnType).mockReturnValue(mockQuery({ data: [baseRow] })); + render(HiddenPage); + expect(screen.getByText('Roygbiv')).toBeInTheDocument(); + expect(screen.getByText(/by boards of canada · geogaddi/i)).toBeInTheDocument(); + expect(screen.getByText('Bad rip')).toBeInTheDocument(); + expect(screen.getByText('crackles')).toBeInTheDocument(); + }); + + test('un-hide button calls unflagTrack', async () => { + (createMyQuarantineQuery as ReturnType).mockReturnValue(mockQuery({ data: [baseRow] })); + render(HiddenPage); + await fireEvent.click(screen.getByRole('button', { name: /un-hide roygbiv/i })); + expect(unflagTrack).toHaveBeenCalledWith('t1'); + }); + + test('empty state shows "Nothing hidden yet."', () => { + (createMyQuarantineQuery as ReturnType).mockReturnValue(mockQuery({ data: [] })); + render(HiddenPage); + expect(screen.getByText(/nothing hidden yet/i)).toBeInTheDocument(); + }); + + test('notes are absent when row.notes is null', () => { + const row = { ...baseRow, notes: null }; + (createMyQuarantineQuery as ReturnType).mockReturnValue(mockQuery({ data: [row] })); + render(HiddenPage); + expect(screen.queryByText('crackles')).not.toBeInTheDocument(); + }); +});