From 905b5f0da7eee5df615db87dc7479b2ed5987bbd Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 2 May 2026 22:44:01 -0400 Subject: [PATCH] feat(web): admin removeTrack API helper for M7 #372 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DELETE /api/admin/tracks/{id} with optional ?unmonitor=true. Returns the typed envelope with optional cascaded album/artist ids and the lidarr_unmonitor_failed flag (only set on Lidarr-side failure when unmonitor was requested — the file + DB delete still succeeded). Caller invalidates TanStack Query keys for any vanished entities. --- web/src/lib/api/admin/tracks.test.ts | 64 ++++++++++++++++++++++++++++ web/src/lib/api/admin/tracks.ts | 42 ++++++++++++++++++ 2 files changed, 106 insertions(+) create mode 100644 web/src/lib/api/admin/tracks.test.ts create mode 100644 web/src/lib/api/admin/tracks.ts diff --git a/web/src/lib/api/admin/tracks.test.ts b/web/src/lib/api/admin/tracks.test.ts new file mode 100644 index 00000000..7ac97aa7 --- /dev/null +++ b/web/src/lib/api/admin/tracks.test.ts @@ -0,0 +1,64 @@ +import { afterEach, describe, expect, test, vi } from 'vitest'; +import { removeTrack } from './tracks'; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +function stubFetch(status: number, body: unknown, init: Partial = {}) { + const res = new Response( + body === null ? null : JSON.stringify(body), + { status, headers: { 'Content-Type': 'application/json' }, ...init } + ); + const spy = vi.fn().mockResolvedValue(res); + vi.stubGlobal('fetch', spy); + return spy; +} + +describe('removeTrack', () => { + test('DELETEs /api/admin/tracks/:id with no query string when unmonitor unset', async () => { + const spy = stubFetch(200, { + deleted_track_id: 't-1', + deleted_album_id: 'al-1' + }); + + const r = await removeTrack('t-1'); + expect(r.deleted_track_id).toBe('t-1'); + expect(r.deleted_album_id).toBe('al-1'); + expect(r.deleted_artist_id).toBeUndefined(); + expect(r.lidarr_unmonitor_failed).toBeUndefined(); + + const call = spy.mock.calls[0]; + expect(call[0]).toBe('/api/admin/tracks/t-1'); + expect((call[1] as RequestInit).method).toBe('DELETE'); + }); + + test('appends ?unmonitor=true when option set', async () => { + const spy = stubFetch(200, { + deleted_track_id: 't-1', + lidarr_unmonitor_failed: true + }); + + const r = await removeTrack('t-1', { unmonitor: true }); + expect(r.lidarr_unmonitor_failed).toBe(true); + + const call = spy.mock.calls[0]; + expect(call[0]).toBe('/api/admin/tracks/t-1?unmonitor=true'); + }); + + test('omits the query string when unmonitor: false', async () => { + const spy = stubFetch(200, { deleted_track_id: 't-1' }); + + await removeTrack('t-1', { unmonitor: false }); + const call = spy.mock.calls[0]; + expect(call[0]).toBe('/api/admin/tracks/t-1'); + }); + + test('surfaces not_found as ApiError', async () => { + stubFetch(404, { error: 'not_found' }); + + await expect(removeTrack('t-1')).rejects.toMatchObject({ + code: 'not_found' + }); + }); +}); diff --git a/web/src/lib/api/admin/tracks.ts b/web/src/lib/api/admin/tracks.ts new file mode 100644 index 00000000..05fab4b8 --- /dev/null +++ b/web/src/lib/api/admin/tracks.ts @@ -0,0 +1,42 @@ +import { apiFetch } from '$lib/api/client'; + +export type RemoveTrackResult = { + deleted_track_id: string; + deleted_album_id?: string; + deleted_artist_id?: string; + /** Only present (always `true` when set) when the operator requested + * unmonitor=true AND the Lidarr unmonitor call failed. The destructive + * file + DB delete already succeeded — surface a follow-up toast so + * the operator knows to manually unmonitor in Lidarr. */ + lidarr_unmonitor_failed?: true; +}; + +export type RemoveTrackOptions = { + /** When true, after the file + DB delete the server calls + * Lidarr.UnmonitorTrack so Lidarr won't search for a replacement. + * When false / omitted, no Lidarr call — Lidarr's monitoring will + * re-import on next scan, which is the operator's "find a replacement" + * path. */ + unmonitor?: boolean; +}; + +/** + * DELETE /api/admin/tracks/{id} — admin-only. Deletes the file from + * disk and the DB row, cascading album/artist tidy-up. Optionally + * unmonitors the track in Lidarr. + * + * On success returns the deleted track id plus optional album/artist + * ids when their parent row was tidied up by the server-side cascade. + * Caller is responsible for invalidating any TanStack Query keys the + * deleted entities backed. + */ +export async function removeTrack( + id: string, + options: RemoveTrackOptions = {} +): Promise { + const params = new URLSearchParams(); + if (options.unmonitor) params.set('unmonitor', 'true'); + const qs = params.toString(); + const url = `/api/admin/tracks/${encodeURIComponent(id)}${qs ? '?' + qs : ''}`; + return (await apiFetch(url, { method: 'DELETE' })) as RemoveTrackResult; +}