From b653e834784415588ae4520ead68ab936e6ca19b Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 30 Apr 2026 20:26:07 -0400 Subject: [PATCH] feat(web): API client modules for quarantine + admin quarantine --- web/src/lib/api/admin.test.ts | 119 ++++++++++++++++++++++++++++- web/src/lib/api/admin.ts | 45 +++++++++++ web/src/lib/api/quarantine.test.ts | 116 ++++++++++++++++++++++++++++ web/src/lib/api/quarantine.ts | 37 +++++++++ web/src/lib/api/queries.ts | 4 + web/src/lib/api/types.ts | 75 ++++++++++++++++++ 6 files changed, 395 insertions(+), 1 deletion(-) create mode 100644 web/src/lib/api/quarantine.test.ts create mode 100644 web/src/lib/api/quarantine.ts diff --git a/web/src/lib/api/admin.test.ts b/web/src/lib/api/admin.test.ts index 5ccd6329..dcbed704 100644 --- a/web/src/lib/api/admin.test.ts +++ b/web/src/lib/api/admin.test.ts @@ -17,13 +17,21 @@ import { listRootFolders, listAdminRequests, approveRequest, - rejectRequest + rejectRequest, + listAdminQuarantine, + resolveQuarantine, + deleteQuarantineFile, + deleteQuarantineViaLidarr, + listQuarantineActions } from './admin'; import { api } from './client'; import { qk } from './queries'; import type { + ActionResult, + AdminQuarantineRow, LidarrConfig, LidarrQualityProfile, + LidarrQuarantineActionRow, LidarrRequest, LidarrRootFolder } from './types'; @@ -228,3 +236,112 @@ describe('qk admin keys', () => { ]); }); }); + +const baseQuarantineRow: AdminQuarantineRow = { + track_id: 't1', + track_title: 'Avril 14th', + artist_name: 'Aphex Twin', + album_title: 'Drukqs', + album_id: 'alb1', + lidarr_album_mbid: null, + report_count: 1, + latest_at: '2026-01-01T00:00:00Z', + reason_counts: { bad_rip: 1 }, + reports: [] +}; + +const baseAction: ActionResult = { + action_id: 'a1', + affected_users: 1 +}; + +const baseActionRow: LidarrQuarantineActionRow = { + id: 'a1', + track_id: 't1', + track_title: 'Avril 14th', + artist_name: 'Aphex Twin', + album_title: 'Drukqs', + action: 'resolved', + admin_id: 'admin1', + lidarr_album_mbid: null, + affected_users: 1, + created_at: '2026-01-01T00:00:00Z' +}; + +describe('listAdminQuarantine', () => { + test('GETs /api/admin/quarantine', async () => { + (api.get as ReturnType).mockResolvedValueOnce([baseQuarantineRow]); + const out = await listAdminQuarantine(); + expect(api.get).toHaveBeenCalledWith('/api/admin/quarantine'); + expect(out).toEqual([baseQuarantineRow]); + }); +}); + +describe('resolveQuarantine', () => { + test('POSTs /api/admin/quarantine/:trackID/resolve with empty body', async () => { + (api.post as ReturnType).mockResolvedValueOnce(baseAction); + const out = await resolveQuarantine('t1'); + expect(api.post).toHaveBeenCalledWith('/api/admin/quarantine/t1/resolve', {}); + expect(out).toBe(baseAction); + }); +}); + +describe('deleteQuarantineFile', () => { + test('POSTs /api/admin/quarantine/:trackID/delete-file with empty body', async () => { + (api.post as ReturnType).mockResolvedValueOnce(baseAction); + const out = await deleteQuarantineFile('t1'); + expect(api.post).toHaveBeenCalledWith( + '/api/admin/quarantine/t1/delete-file', + {} + ); + expect(out).toBe(baseAction); + }); +}); + +describe('deleteQuarantineViaLidarr', () => { + test('POSTs /api/admin/quarantine/:trackID/delete-via-lidarr with empty body', async () => { + (api.post as ReturnType).mockResolvedValueOnce(baseAction); + const out = await deleteQuarantineViaLidarr('t1'); + expect(api.post).toHaveBeenCalledWith( + '/api/admin/quarantine/t1/delete-via-lidarr', + {} + ); + expect(out).toBe(baseAction); + }); +}); + +describe('listQuarantineActions', () => { + test('defaults to limit=50 when no argument', async () => { + (api.get as ReturnType).mockResolvedValueOnce([baseActionRow]); + await listQuarantineActions(); + expect(api.get).toHaveBeenCalledWith( + '/api/admin/quarantine/actions?limit=50' + ); + }); + + test('forwards explicit limit', async () => { + (api.get as ReturnType).mockResolvedValueOnce([baseActionRow]); + await listQuarantineActions(10); + expect(api.get).toHaveBeenCalledWith( + '/api/admin/quarantine/actions?limit=10' + ); + }); +}); + +describe('qk admin quarantine keys', () => { + test('adminQuarantine key', () => { + expect(qk.adminQuarantine()).toEqual(['adminQuarantine']); + }); + test('adminQuarantineActions key defaults to limit 50', () => { + expect(qk.adminQuarantineActions()).toEqual([ + 'adminQuarantineActions', + { limit: 50 } + ]); + }); + test('adminQuarantineActions key reflects custom limit', () => { + expect(qk.adminQuarantineActions(25)).toEqual([ + 'adminQuarantineActions', + { limit: 25 } + ]); + }); +}); diff --git a/web/src/lib/api/admin.ts b/web/src/lib/api/admin.ts index 9ec0fc0e..fe6b7d83 100644 --- a/web/src/lib/api/admin.ts +++ b/web/src/lib/api/admin.ts @@ -2,8 +2,11 @@ import { createQuery } from '@tanstack/svelte-query'; import { api } from './client'; import { qk } from './queries'; import type { + ActionResult, + AdminQuarantineRow, LidarrConfig, LidarrQualityProfile, + LidarrQuarantineActionRow, LidarrRequest, LidarrRequestStatus, LidarrRootFolder, @@ -103,3 +106,45 @@ export function createAdminRequestsQuery(status?: LidarrRequestStatus) { queryFn: () => listAdminRequests(status) }); } + +// Admin quarantine -------------------------------------------------------- + +export async function listAdminQuarantine(): Promise { + return api.get('/api/admin/quarantine'); +} + +export async function resolveQuarantine(trackID: string): Promise { + return api.post(`/api/admin/quarantine/${trackID}/resolve`, {}); +} + +export async function deleteQuarantineFile(trackID: string): Promise { + return api.post(`/api/admin/quarantine/${trackID}/delete-file`, {}); +} + +export async function deleteQuarantineViaLidarr(trackID: string): Promise { + return api.post(`/api/admin/quarantine/${trackID}/delete-via-lidarr`, {}); +} + +export async function listQuarantineActions( + limit: number = 50 +): Promise { + return api.get( + `/api/admin/quarantine/actions?limit=${limit}` + ); +} + +export function createAdminQuarantineQuery() { + return createQuery({ + queryKey: qk.adminQuarantine(), + queryFn: listAdminQuarantine, + staleTime: 30_000 + }); +} + +export function createQuarantineActionsQuery(limit: number = 50) { + return createQuery({ + queryKey: qk.adminQuarantineActions(limit), + queryFn: () => listQuarantineActions(limit), + staleTime: 60_000 + }); +} diff --git a/web/src/lib/api/quarantine.test.ts b/web/src/lib/api/quarantine.test.ts new file mode 100644 index 00000000..a8843f44 --- /dev/null +++ b/web/src/lib/api/quarantine.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, test, vi, beforeEach, afterEach } from 'vitest'; + +vi.mock('./client', () => ({ + api: { + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), + del: vi.fn() + }, + apiFetch: vi.fn() +})); + +import { flagTrack, unflagTrack, listMyQuarantine } from './quarantine'; +import { api, apiFetch } from './client'; +import { qk } from './queries'; +import type { + LidarrQuarantineRow, + LidarrQuarantineMineRow +} from './types'; + +const mockRow: LidarrQuarantineRow = { + user_id: 'u1', + track_id: 't1', + reason: 'bad_rip', + notes: null, + created_at: '2026-01-01T00:00:00Z' +}; + +const mockMineRow: LidarrQuarantineMineRow = { + track_id: 't1', + reason: 'bad_rip', + notes: null, + created_at: '2026-01-01T00:00:00Z', + track_title: 'Avril 14th', + track_duration_ms: 120000, + album_id: 'alb1', + album_title: 'Drukqs', + album_cover_art_path: null, + artist_id: 'art1', + artist_name: 'Aphex Twin' +}; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe('flagTrack', () => { + test('POSTs /api/quarantine without notes when none provided', async () => { + (api.post as ReturnType).mockResolvedValueOnce(mockRow); + const out = await flagTrack({ track_id: 't1', reason: 'bad_rip' }); + expect(api.post).toHaveBeenCalledWith('/api/quarantine', { + track_id: 't1', + reason: 'bad_rip' + }); + const body = (api.post as ReturnType).mock.calls[0][1] as Record< + string, + unknown + >; + expect(body).not.toHaveProperty('notes'); + expect(out).toBe(mockRow); + }); + + test('omits empty-string notes from the body', async () => { + (api.post as ReturnType).mockResolvedValueOnce(mockRow); + await flagTrack({ track_id: 't1', reason: 'wrong_tags', notes: '' }); + const body = (api.post as ReturnType).mock.calls[0][1] as Record< + string, + unknown + >; + expect(body).toEqual({ track_id: 't1', reason: 'wrong_tags' }); + expect(body).not.toHaveProperty('notes'); + }); + + test('includes notes when a non-empty string is provided', async () => { + (api.post as ReturnType).mockResolvedValueOnce(mockRow); + await flagTrack({ + track_id: 't1', + reason: 'duplicate', + notes: 'same as t0' + }); + expect(api.post).toHaveBeenCalledWith('/api/quarantine', { + track_id: 't1', + reason: 'duplicate', + notes: 'same as t0' + }); + }); +}); + +describe('unflagTrack', () => { + test('DELETEs /api/quarantine/:trackID', async () => { + (apiFetch as ReturnType).mockResolvedValueOnce(null); + await unflagTrack('t1'); + expect(apiFetch).toHaveBeenCalledWith('/api/quarantine/t1', { + method: 'DELETE' + }); + }); +}); + +describe('listMyQuarantine', () => { + test('GETs /api/quarantine/mine', async () => { + (api.get as ReturnType).mockResolvedValueOnce([mockMineRow]); + const out = await listMyQuarantine(); + expect(api.get).toHaveBeenCalledWith('/api/quarantine/mine'); + expect(out).toEqual([mockMineRow]); + }); +}); + +describe('qk.myQuarantine', () => { + test('returns the expected key tuple', () => { + expect(qk.myQuarantine()).toEqual(['myQuarantine']); + }); +}); diff --git a/web/src/lib/api/quarantine.ts b/web/src/lib/api/quarantine.ts new file mode 100644 index 00000000..db14dbec --- /dev/null +++ b/web/src/lib/api/quarantine.ts @@ -0,0 +1,37 @@ +import { createQuery } from '@tanstack/svelte-query'; +import { api, apiFetch } from './client'; +import { qk } from './queries'; +import type { + LidarrQuarantineRow, + LidarrQuarantineMineRow, + LidarrQuarantineReason +} from './types'; + +export type FlagParams = { + track_id: string; + reason: LidarrQuarantineReason; + notes?: string; +}; + +export async function flagTrack(params: FlagParams): Promise { + const body: FlagParams = { track_id: params.track_id, reason: params.reason }; + if (params.notes && params.notes.length > 0) body.notes = params.notes; + return api.post('/api/quarantine', body); +} + +// Server returns 204 (no body) for DELETE; apiFetch handles it. +export async function unflagTrack(trackID: string): Promise { + await apiFetch(`/api/quarantine/${trackID}`, { method: 'DELETE' }); +} + +export async function listMyQuarantine(): Promise { + return api.get('/api/quarantine/mine'); +} + +export function createMyQuarantineQuery() { + return createQuery({ + queryKey: qk.myQuarantine(), + queryFn: listMyQuarantine, + staleTime: 60_000 + }); +} diff --git a/web/src/lib/api/queries.ts b/web/src/lib/api/queries.ts index 3efeb35c..1d684959 100644 --- a/web/src/lib/api/queries.ts +++ b/web/src/lib/api/queries.ts @@ -28,6 +28,10 @@ export const qk = { lidarrRootFolders: () => ['lidarrRootFolders'] as const, adminRequests: (status?: string) => ['adminRequests', { status: status ?? 'all' }] as const, + myQuarantine: () => ['myQuarantine'] as const, + adminQuarantine: () => ['adminQuarantine'] as const, + adminQuarantineActions: (limit?: number) => + ['adminQuarantineActions', { limit: limit ?? 50 }] as const, }; export function createArtistsQuery(sort: ArtistSort) { diff --git a/web/src/lib/api/types.ts b/web/src/lib/api/types.ts index 0eb5ed43..6b8ce1fc 100644 --- a/web/src/lib/api/types.ts +++ b/web/src/lib/api/types.ts @@ -146,3 +146,78 @@ export type LidarrRootFolder = { export type LidarrTestResult = | { ok: true; version: string } | { ok: false; error: string }; + +// Lidarr quarantine ------------------------------------------------------ + +export type LidarrQuarantineReason = + | 'bad_rip' + | 'wrong_file' + | 'wrong_tags' + | 'duplicate' + | 'other'; + +export type LidarrQuarantineRow = { + user_id: string; + track_id: string; + reason: LidarrQuarantineReason; + notes?: string | null; + created_at: string; +}; + +// What GET /api/quarantine/mine returns per row +export type LidarrQuarantineMineRow = { + track_id: string; + reason: LidarrQuarantineReason; + notes?: string | null; + created_at: string; + track_title: string; + track_duration_ms: number; + album_id: string; + album_title: string; + album_cover_art_path?: string | null; + artist_id: string; + artist_name: string; +}; + +// What GET /api/admin/quarantine returns per row +export type AdminQuarantineRow = { + track_id: string; + track_title: string; + artist_name: string; + album_title: string; + album_id: string; + lidarr_album_mbid?: string | null; + report_count: number; + latest_at: string; + reason_counts: Partial>; + reports: AdminQuarantineReport[]; +}; + +export type AdminQuarantineReport = { + user_id: string; + username: string; + reason: LidarrQuarantineReason; + notes?: string | null; + created_at: string; +}; + +export type LidarrQuarantineAction = 'resolved' | 'deleted_file' | 'deleted_via_lidarr'; + +export type LidarrQuarantineActionRow = { + id: string; + track_id: string; + track_title: string; + artist_name: string; + album_title?: string | null; + action: LidarrQuarantineAction; + admin_id?: string | null; + lidarr_album_mbid?: string | null; + affected_users: number; + created_at: string; +}; + +export type ActionResult = { + action_id: string; + affected_users: number; + deleted_track_count?: number; +};