feat(web): API client modules for quarantine + admin quarantine
This commit is contained in:
@@ -17,13 +17,21 @@ import {
|
|||||||
listRootFolders,
|
listRootFolders,
|
||||||
listAdminRequests,
|
listAdminRequests,
|
||||||
approveRequest,
|
approveRequest,
|
||||||
rejectRequest
|
rejectRequest,
|
||||||
|
listAdminQuarantine,
|
||||||
|
resolveQuarantine,
|
||||||
|
deleteQuarantineFile,
|
||||||
|
deleteQuarantineViaLidarr,
|
||||||
|
listQuarantineActions
|
||||||
} from './admin';
|
} from './admin';
|
||||||
import { api } from './client';
|
import { api } from './client';
|
||||||
import { qk } from './queries';
|
import { qk } from './queries';
|
||||||
import type {
|
import type {
|
||||||
|
ActionResult,
|
||||||
|
AdminQuarantineRow,
|
||||||
LidarrConfig,
|
LidarrConfig,
|
||||||
LidarrQualityProfile,
|
LidarrQualityProfile,
|
||||||
|
LidarrQuarantineActionRow,
|
||||||
LidarrRequest,
|
LidarrRequest,
|
||||||
LidarrRootFolder
|
LidarrRootFolder
|
||||||
} from './types';
|
} 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 { api } from './client';
|
||||||
import { qk } from './queries';
|
import { qk } from './queries';
|
||||||
import type {
|
import type {
|
||||||
|
ActionResult,
|
||||||
|
AdminQuarantineRow,
|
||||||
LidarrConfig,
|
LidarrConfig,
|
||||||
LidarrQualityProfile,
|
LidarrQualityProfile,
|
||||||
|
LidarrQuarantineActionRow,
|
||||||
LidarrRequest,
|
LidarrRequest,
|
||||||
LidarrRequestStatus,
|
LidarrRequestStatus,
|
||||||
LidarrRootFolder,
|
LidarrRootFolder,
|
||||||
@@ -103,3 +106,45 @@ export function createAdminRequestsQuery(status?: LidarrRequestStatus) {
|
|||||||
queryFn: () => listAdminRequests(status)
|
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,
|
lidarrRootFolders: () => ['lidarrRootFolders'] as const,
|
||||||
adminRequests: (status?: string) =>
|
adminRequests: (status?: string) =>
|
||||||
['adminRequests', { status: status ?? 'all' }] as const,
|
['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) {
|
export function createArtistsQuery(sort: ArtistSort) {
|
||||||
|
|||||||
@@ -146,3 +146,78 @@ export type LidarrRootFolder = {
|
|||||||
export type LidarrTestResult =
|
export type LidarrTestResult =
|
||||||
| { ok: true; version: string }
|
| { ok: true; version: string }
|
||||||
| { ok: false; error: 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