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 @@
+
+
+
+
+
+ {#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();
+ });
+});