feat(web): API client modules for quarantine + admin quarantine

This commit is contained in:
2026-04-30 20:26:07 -04:00
parent b7f8136502
commit b653e83478
6 changed files with 395 additions and 1 deletions
+118 -1
View File
@@ -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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValueOnce([baseActionRow]);
await listQuarantineActions();
expect(api.get).toHaveBeenCalledWith(
'/api/admin/quarantine/actions?limit=50'
);
});
test('forwards explicit limit', async () => {
(api.get as ReturnType<typeof vi.fn>).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 }
]);
});
});
+45
View File
@@ -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<AdminQuarantineRow[]> {
return api.get<AdminQuarantineRow[]>('/api/admin/quarantine');
}
export async function resolveQuarantine(trackID: string): Promise<ActionResult> {
return api.post<ActionResult>(`/api/admin/quarantine/${trackID}/resolve`, {});
}
export async function deleteQuarantineFile(trackID: string): Promise<ActionResult> {
return api.post<ActionResult>(`/api/admin/quarantine/${trackID}/delete-file`, {});
}
export async function deleteQuarantineViaLidarr(trackID: string): Promise<ActionResult> {
return api.post<ActionResult>(`/api/admin/quarantine/${trackID}/delete-via-lidarr`, {});
}
export async function listQuarantineActions(
limit: number = 50
): Promise<LidarrQuarantineActionRow[]> {
return api.get<LidarrQuarantineActionRow[]>(
`/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
});
}
+116
View File
@@ -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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValueOnce(mockRow);
await flagTrack({ track_id: 't1', reason: 'wrong_tags', notes: '' });
const body = (api.post as ReturnType<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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']);
});
});
+37
View File
@@ -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<LidarrQuarantineRow> {
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<LidarrQuarantineRow>('/api/quarantine', body);
}
// Server returns 204 (no body) for DELETE; apiFetch handles it.
export async function unflagTrack(trackID: string): Promise<void> {
await apiFetch(`/api/quarantine/${trackID}`, { method: 'DELETE' });
}
export async function listMyQuarantine(): Promise<LidarrQuarantineMineRow[]> {
return api.get<LidarrQuarantineMineRow[]>('/api/quarantine/mine');
}
export function createMyQuarantineQuery() {
return createQuery({
queryKey: qk.myQuarantine(),
queryFn: listMyQuarantine,
staleTime: 60_000
});
}
+4
View File
@@ -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) {
+75
View File
@@ -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<Record<LidarrQuarantineReason, number>>;
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;
};