diff --git a/web/src/lib/api/admin/tracks.test.ts b/web/src/lib/api/admin/tracks.test.ts deleted file mode 100644 index 7ac97aa7..00000000 --- a/web/src/lib/api/admin/tracks.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -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 deleted file mode 100644 index 05fab4b8..00000000 --- a/web/src/lib/api/admin/tracks.ts +++ /dev/null @@ -1,42 +0,0 @@ -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; -} diff --git a/web/src/lib/components/CompactTrackCard.test.ts b/web/src/lib/components/CompactTrackCard.test.ts index 0936ce2d..94b4a3d2 100644 --- a/web/src/lib/components/CompactTrackCard.test.ts +++ b/web/src/lib/components/CompactTrackCard.test.ts @@ -10,10 +10,6 @@ vi.mock('$lib/api/likes', () => emptyLikesMock()); vi.mock('$lib/api/quarantine', () => emptyQuarantineMock()); -vi.mock('$lib/api/admin/tracks', () => ({ - removeTrack: vi.fn().mockResolvedValue({ deleted_track_id: 't1' }) -})); - vi.mock('$lib/api/playlists', () => emptyPlaylistsMock()); vi.mock('$lib/auth/store.svelte', () => ({ diff --git a/web/src/lib/components/PlaylistTrackRow.test.ts b/web/src/lib/components/PlaylistTrackRow.test.ts index 57854625..5ea34d6b 100644 --- a/web/src/lib/components/PlaylistTrackRow.test.ts +++ b/web/src/lib/components/PlaylistTrackRow.test.ts @@ -21,10 +21,6 @@ vi.mock('$lib/api/likes', () => emptyLikesMock()); vi.mock('$lib/api/quarantine', () => emptyQuarantineMock()); -vi.mock('$lib/api/admin/tracks', () => ({ - removeTrack: vi.fn().mockResolvedValue({ deleted_track_id: 't-1' }) -})); - vi.mock('$lib/player/store.svelte', () => ({ playNext: vi.fn(), enqueueTrack: vi.fn(), diff --git a/web/src/lib/components/RemoveTrackPopover.svelte b/web/src/lib/components/RemoveTrackPopover.svelte deleted file mode 100644 index d3155dd7..00000000 --- a/web/src/lib/components/RemoveTrackPopover.svelte +++ /dev/null @@ -1,103 +0,0 @@ - - - diff --git a/web/src/lib/components/RemoveTrackPopover.test.ts b/web/src/lib/components/RemoveTrackPopover.test.ts deleted file mode 100644 index 93b2505a..00000000 --- a/web/src/lib/components/RemoveTrackPopover.test.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { afterEach, describe, expect, test, vi } from 'vitest'; -import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'; -import { makeTrack } from '$test-utils/fixtures/track'; - -const invalidateMock = vi.fn(); -vi.mock('@tanstack/svelte-query', async (orig) => { - const actual = (await orig()) as Record; - return { - ...actual, - useQueryClient: () => ({ invalidateQueries: invalidateMock }) - }; -}); - -vi.mock('$lib/api/admin/tracks', () => ({ - removeTrack: vi.fn() -})); - -import RemoveTrackPopover from './RemoveTrackPopover.svelte'; -import { removeTrack } from '$lib/api/admin/tracks'; - -const track = makeTrack({ title: 'Roygbiv' }); - -afterEach(() => vi.clearAllMocks()); - -describe('RemoveTrackPopover', () => { - test('renders the title and subtext', () => { - render(RemoveTrackPopover, { props: { track, onClose: vi.fn() } }); - expect(screen.getByText(/remove "roygbiv" from your library/i)).toBeInTheDocument(); - expect(screen.getByText(/this deletes the file from disk/i)).toBeInTheDocument(); - }); - - test('renders the Lidarr unmonitor checkbox', () => { - render(RemoveTrackPopover, { props: { track, onClose: vi.fn() } }); - const cb = screen.getByRole('checkbox', { name: /also stop lidarr/i }); - expect(cb).toBeInTheDocument(); - expect((cb as HTMLInputElement).checked).toBe(false); - }); - - test('Cancel button calls onClose without firing removeTrack', async () => { - const onClose = vi.fn(); - render(RemoveTrackPopover, { props: { track, onClose } }); - await fireEvent.click(screen.getByRole('button', { name: /cancel/i })); - expect(removeTrack).not.toHaveBeenCalled(); - expect(onClose).toHaveBeenCalled(); - }); - - test('Remove without checkbox calls removeTrack with unmonitor=false', async () => { - (removeTrack as ReturnType).mockResolvedValue({ deleted_track_id: 't1' }); - const onClose = vi.fn(); - render(RemoveTrackPopover, { props: { track, onClose } }); - await fireEvent.click(screen.getByRole('button', { name: /^remove$/i })); - // confirm() chains 3-5 awaited invalidateQueries calls before onClose, - // each a separate microtask. waitFor polls until the side effects land. - await waitFor(() => expect(onClose).toHaveBeenCalled()); - expect(removeTrack).toHaveBeenCalledWith('t1', { unmonitor: false }); - }); - - test('Remove with checkbox checked calls removeTrack with unmonitor=true', async () => { - (removeTrack as ReturnType).mockResolvedValue({ deleted_track_id: 't1' }); - const onClose = vi.fn(); - render(RemoveTrackPopover, { props: { track, onClose } }); - await fireEvent.click(screen.getByRole('checkbox', { name: /also stop lidarr/i })); - await fireEvent.click(screen.getByRole('button', { name: /^remove$/i })); - await waitFor(() => expect(onClose).toHaveBeenCalled()); - expect(removeTrack).toHaveBeenCalledWith('t1', { unmonitor: true }); - }); - - test('failed remove surfaces error via copyForCode and does not close', async () => { - (removeTrack as ReturnType).mockRejectedValue({ code: 'not_found' }); - const onClose = vi.fn(); - render(RemoveTrackPopover, { props: { track, onClose } }); - await fireEvent.click(screen.getByRole('button', { name: /^remove$/i })); - await waitFor(() => expect(removeTrack).toHaveBeenCalled()); - // The catch path runs synchronously after the rejected await; one more - // microtask boundary is enough. - await Promise.resolve(); - expect(onClose).not.toHaveBeenCalled(); - }); - - test('success invalidates album, artist and home queries', async () => { - (removeTrack as ReturnType).mockResolvedValue({ - deleted_track_id: 't1', - deleted_album_id: 'a1', - deleted_artist_id: 'ar1' - }); - render(RemoveTrackPopover, { props: { track, onClose: vi.fn() } }); - await fireEvent.click(screen.getByRole('button', { name: /^remove$/i })); - // Wait for all three baseline invalidations (album, artist, home) plus - // the two cascade ones (deleted_album_id, deleted_artist_id) to land. - await waitFor(() => expect(invalidateMock.mock.calls.length).toBeGreaterThanOrEqual(5)); - const keys = invalidateMock.mock.calls.map((c) => c[0].queryKey); - const flat = keys.map((k) => Array.isArray(k) ? k.join('|') : String(k)); - expect(flat.some((k) => k.startsWith('album|a1'))).toBe(true); - expect(flat.some((k) => k.startsWith('artist|ar1'))).toBe(true); - expect(flat.some((k) => k === 'home')).toBe(true); - }); -}); diff --git a/web/src/lib/components/TrackMenu.svelte b/web/src/lib/components/TrackMenu.svelte index ed0ae17c..74661f4f 100644 --- a/web/src/lib/components/TrackMenu.svelte +++ b/web/src/lib/components/TrackMenu.svelte @@ -10,17 +10,14 @@ Disc3, User, EyeOff, - Eye, - Trash2 + Eye } from 'lucide-svelte'; import { goto } from '$app/navigation'; import { useQueryClient } from '@tanstack/svelte-query'; import FlagPopover from './FlagPopover.svelte'; - import RemoveTrackPopover from './RemoveTrackPopover.svelte'; import AddToPlaylistMenu from './AddToPlaylistMenu.svelte'; import TrackMenuItem from './TrackMenuItem.svelte'; import TrackMenuDivider from './TrackMenuDivider.svelte'; - import { user } from '$lib/auth/store.svelte'; import { createLikedIdsQuery, likeEntity, unlikeEntity } from '$lib/api/likes'; import { createMyQuarantineQuery, unflagTrack } from '$lib/api/quarantine'; import { qk } from '$lib/api/queries'; @@ -44,7 +41,6 @@ let menuOpen = $state(false); let flagOpen = $state(false); - let removeOpen = $state(false); let addOpen = $state(false); const queryClient = useQueryClient(); @@ -63,8 +59,6 @@ return rows.some((r) => r.track_id === track.id); }); - const isAdmin = $derived(user.value?.is_admin === true); - function toggleMenu(e: MouseEvent) { e.stopPropagation(); menuOpen = !menuOpen; @@ -73,7 +67,6 @@ function closeAll() { menuOpen = false; flagOpen = false; - removeOpen = false; addOpen = false; } @@ -124,13 +117,8 @@ goto(`/artists/${track.artist_id}`); } - function onRemoveOpen() { - menuOpen = false; - removeOpen = true; - } - function onKeydown(e: KeyboardEvent) { - if (!menuOpen && !flagOpen && !removeOpen && !addOpen) return; + if (!menuOpen && !flagOpen && !addOpen) return; if (e.key === 'Escape') { e.preventDefault(); closeAll(); @@ -166,8 +154,8 @@ add-to-playlist / start-radio group → navigation group → hide. "Start radio" is shown even under hideQueueActions (reseeding a station from the current track is meaningful where play-next / - add-to-queue are not). The admin-only "Remove from library" is a - web superset Android has no surface for — kept past the divider. --> + add-to-queue are not). No single-click destructive action lives + here — track removal is intentionally kept out of the kebab. --> {#if !hideQueueActions} @@ -199,15 +187,6 @@ label={hidden ? 'Unhide' : 'Hide'} onclick={onToggleHide} /> - {#if isAdmin} - - - {/if} {/if} @@ -215,10 +194,6 @@ {/if} - {#if removeOpen} - - {/if} - {#if addOpen} (addOpen = false)} /> {/if} diff --git a/web/src/lib/components/TrackMenu.test.ts b/web/src/lib/components/TrackMenu.test.ts index 79c58e57..31213cb8 100644 --- a/web/src/lib/components/TrackMenu.test.ts +++ b/web/src/lib/components/TrackMenu.test.ts @@ -25,10 +25,6 @@ vi.mock('$lib/api/likes', () => emptyLikesMock()); vi.mock('$lib/api/quarantine', () => emptyQuarantineMock()); -vi.mock('$lib/api/admin/tracks', () => ({ - removeTrack: vi.fn().mockResolvedValue({ deleted_track_id: 't1' }) -})); - vi.mock('$lib/api/playlists', () => emptyPlaylistsMock()); vi.mock('$lib/player/store.svelte', () => ({ @@ -56,7 +52,7 @@ describe('TrackMenu', () => { expect(kebab.getAttribute('aria-expanded')).toBe('true'); }); - test('opening menu shows all 9 entries for an admin user', async () => { + test('opening menu shows all 8 entries', async () => { render(TrackMenu, { props: { track } }); await fireEvent.click(screen.getByRole('button', { name: /track actions/i })); @@ -68,15 +64,8 @@ describe('TrackMenu', () => { expect(screen.getByRole('menuitem', { name: /go to album/i })).toBeInTheDocument(); expect(screen.getByRole('menuitem', { name: /go to artist/i })).toBeInTheDocument(); expect(screen.getByRole('menuitem', { name: /^hide$/i })).toBeInTheDocument(); - expect(screen.getByRole('menuitem', { name: /remove from library/i })).toBeInTheDocument(); - expect(screen.getAllByRole('menuitem')).toHaveLength(9); - }); - - test('"Remove from library" hidden for non-admin', async () => { - userState.current = { id: 'u2', username: 'normal', is_admin: false }; - render(TrackMenu, { props: { track } }); - await fireEvent.click(screen.getByRole('button', { name: /track actions/i })); + // No "Remove from library" — destructive removal is intentionally absent. expect(screen.queryByRole('menuitem', { name: /remove from library/i })).not.toBeInTheDocument(); expect(screen.getAllByRole('menuitem')).toHaveLength(8); }); diff --git a/web/src/routes/playlists/[id]/playlist.test.ts b/web/src/routes/playlists/[id]/playlist.test.ts index 679e3790..fd1fbfd6 100644 --- a/web/src/routes/playlists/[id]/playlist.test.ts +++ b/web/src/routes/playlists/[id]/playlist.test.ts @@ -41,7 +41,6 @@ vi.mock('$lib/api/likes', () => ({ unlikeEntity: vi.fn() })); vi.mock('$lib/api/quarantine', () => emptyQuarantineMock()); -vi.mock('$lib/api/admin/tracks', () => ({ removeTrack: vi.fn() })); import PlaylistDetailPage from './+page.svelte'; import { createPlaylistQuery, deletePlaylist, refreshSystem } from '$lib/api/playlists';