feat(web): API client modules for quarantine + admin quarantine
This commit is contained in:
@@ -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 }
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user