feat: the library's folders are set in Admin › Library, and changing a folder moves its tracks with it, keeping their plays, likes and playlists (#6129)
release / govulncheck (push) Successful in 15s
release / web (push) Successful in 1m20s
release / go (push) Failing after 1m37s
release / integration (push) Failing after 4m56s
release / android (push) Successful in 5m1s
release / Build signed APK (releases and dev) (push) Successful in 5m13s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
release / govulncheck (push) Successful in 15s
release / web (push) Successful in 1m20s
release / go (push) Failing after 1m37s
release / integration (push) Failing after 4m56s
release / android (push) Successful in 5m1s
release / Build signed APK (releases and dev) (push) Successful in 5m13s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -365,6 +365,35 @@ export async function updateLoudnessSettings(s: LoudnessSettings): Promise<Loudn
|
||||
return api.put<LoudnessSettings>('/api/admin/library/loudness-settings', s);
|
||||
}
|
||||
|
||||
// The library's folders (#6129) -----------------------------------------------
|
||||
|
||||
export type LibraryFolder = {
|
||||
path: string;
|
||||
/** Whether the server can see the folder right now. */
|
||||
present: boolean;
|
||||
tracks: number;
|
||||
};
|
||||
|
||||
export type LibraryFolders = {
|
||||
folders: LibraryFolder[];
|
||||
/** False while the folders still come from MINSTREL_LIBRARY_SCAN_PATHS. */
|
||||
saved: boolean;
|
||||
/** How many tracks a save moved to a new folder. */
|
||||
moved?: number;
|
||||
};
|
||||
|
||||
/** One folder of a save. `was` is the folder it replaces, whose files are now
|
||||
* at `path`; '' is a folder added. */
|
||||
export type LibraryFolderChange = { path: string; was: string };
|
||||
|
||||
export async function getLibraryFolders(): Promise<LibraryFolders> {
|
||||
return api.get<LibraryFolders>('/api/admin/library/folders');
|
||||
}
|
||||
|
||||
export async function updateLibraryFolders(folders: LibraryFolderChange[]): Promise<LibraryFolders> {
|
||||
return api.put<LibraryFolders>('/api/admin/library/folders', { folders });
|
||||
}
|
||||
|
||||
// AcoustID recording-id lookup (M401) ---------------------------------------
|
||||
|
||||
export type AcoustIDSettings = {
|
||||
|
||||
@@ -21,7 +21,9 @@ const DETAIL_CODES: ReadonlySet<string> = new Set([
|
||||
// The server names the field and its range (#3913).
|
||||
'invalid_setting',
|
||||
// The server says what shape of address it wants (#4981).
|
||||
'invalid_public_url'
|
||||
'invalid_public_url',
|
||||
// The server names the folder and what is wrong with it (#6129).
|
||||
'bad_folders'
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import {
|
||||
getLibraryFolders,
|
||||
updateLibraryFolders,
|
||||
type LibraryFolders
|
||||
} from '#lib/api/admin.js';
|
||||
import { errMessage } from '#lib/api/errors.js';
|
||||
import { pushToast } from '#lib/stores/toast.svelte.js';
|
||||
import { plural } from '#lib/utils/plural.js';
|
||||
import SettingRow from '#lib/components/settings/SettingRow.svelte';
|
||||
|
||||
// The library's folders (#6129), set here rather than only by
|
||||
// MINSTREL_LIBRARY_SCAN_PATHS. Editing a folder's path says its files moved
|
||||
// there: the server rewrites its tracks' paths, so plays, likes and
|
||||
// playlists stay with them. A folder removed is no longer read.
|
||||
|
||||
let { onSaved }: { onSaved?: () => void } = $props();
|
||||
|
||||
let current = $state<LibraryFolders | null>(null);
|
||||
let loadError = $state(false);
|
||||
let editing = $state(false);
|
||||
let saving = $state(false);
|
||||
// was: the folder a row started as ('' for one added), so the server can
|
||||
// tell a move from an addition.
|
||||
let rows = $state<{ path: string; was: string }[]>([]);
|
||||
|
||||
const removed = $derived(
|
||||
editing && current ? current.folders.filter((f) => !rows.some((r) => r.was === f.path)) : []
|
||||
);
|
||||
const moved = $derived(rows.filter((r) => r.was && r.path.trim() && r.path.trim() !== r.was));
|
||||
const blank = $derived(rows.some((r) => !r.path.trim()));
|
||||
const valid = $derived(rows.length > 0 && !blank);
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
current = await getLibraryFolders();
|
||||
loadError = false;
|
||||
} catch {
|
||||
loadError = true;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(load);
|
||||
|
||||
function edit() {
|
||||
if (!current) return;
|
||||
rows = current.folders.map((f) => ({ path: f.path, was: f.path }));
|
||||
if (rows.length === 0) rows = [{ path: '', was: '' }];
|
||||
editing = true;
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!valid) return;
|
||||
saving = true;
|
||||
try {
|
||||
current = await updateLibraryFolders(rows.map((r) => ({ path: r.path.trim(), was: r.was })));
|
||||
editing = false;
|
||||
const n = current.moved ?? 0;
|
||||
pushToast(
|
||||
n > 0
|
||||
? `Library folders saved. ${plural(n, 'track', 'tracks')} moved with their folder; a scan has started.`
|
||||
: 'Library folders saved; a scan has started.'
|
||||
);
|
||||
onSaved?.();
|
||||
} catch (e) {
|
||||
pushToast(errMessage(e), 'error');
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
const button =
|
||||
'rounded-md border border-border px-3 py-1.5 text-sm text-text-secondary hover:bg-surface-hover hover:text-text-primary disabled:opacity-50';
|
||||
const field = 'w-64 max-w-full rounded-md border border-border bg-background px-2 py-1.5 font-mono text-xs';
|
||||
</script>
|
||||
|
||||
{#if loadError}
|
||||
<p class="px-4 py-3 text-sm text-error-fg">
|
||||
Couldn't load the library folders.
|
||||
<button type="button" class="underline hover:no-underline" onclick={load}>Try again</button>
|
||||
</p>
|
||||
{:else if current === null}
|
||||
<p class="px-4 py-3 text-sm text-text-secondary">Loading…</p>
|
||||
{:else if !editing}
|
||||
<SettingRow
|
||||
label="Folders"
|
||||
hint={current.saved ? 'Where Minstrel looks for music.' : 'Set by MINSTREL_LIBRARY_SCAN_PATHS until changed here.'}
|
||||
>
|
||||
<span class="flex flex-col items-end font-mono text-xs text-text-secondary" data-testid="library-folders">
|
||||
{#each current.folders as f (f.path)}<span>{f.path}</span>{:else}<span>—</span>{/each}
|
||||
</span>
|
||||
<button type="button" class={button} onclick={edit}>Change</button>
|
||||
</SettingRow>
|
||||
{#each current.folders.filter((f) => !f.present) as f (f.path)}
|
||||
<p class="px-4 pb-3 text-xs text-error-fg" data-testid="folder-missing">
|
||||
The server can't see {f.path}{f.tracks > 0 ? `, which holds ${plural(f.tracks, 'track', 'tracks')}` : ''}.
|
||||
If its files are now somewhere else, change the folder to where they are.
|
||||
</p>
|
||||
{/each}
|
||||
{:else}
|
||||
<div class="flex flex-col gap-2 px-4 py-3" data-testid="library-folders-form">
|
||||
<span class="text-sm text-text-primary">Folders</span>
|
||||
<p class="text-xs text-text-secondary">
|
||||
Change a folder's path when its files have moved; the tracks move with it and keep their plays, likes and
|
||||
playlists. Each path is as the server sees it, inside its container.
|
||||
</p>
|
||||
{#each rows as row, i (i)}
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
aria-label="Folder {i + 1}"
|
||||
placeholder="/data/music"
|
||||
bind:value={row.path}
|
||||
class={field}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class={button}
|
||||
aria-label="Remove folder {i + 1}"
|
||||
onclick={() => (rows = rows.filter((_, j) => j !== i))}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
<div>
|
||||
<button type="button" class={button} onclick={() => (rows = [...rows, { path: '', was: '' }])}>Add folder</button>
|
||||
</div>
|
||||
<div class="text-xs text-text-secondary" data-testid="folder-changes">
|
||||
{#each moved as m (m.was)}
|
||||
<p>{m.was} → {m.path.trim()}: its tracks move with it.</p>
|
||||
{/each}
|
||||
{#each removed as f (f.path)}
|
||||
<p class="text-error-fg">
|
||||
{f.path} will no longer be read{f.tracks > 0
|
||||
? `; its ${plural(f.tracks, 'track is', 'tracks are')} marked missing at the next scan`
|
||||
: ''}.
|
||||
</p>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center justify-end gap-2">
|
||||
{#if blank}
|
||||
<p class="mr-auto text-xs text-error-fg">Fill in or remove the empty folder.</p>
|
||||
{:else if rows.length === 0}
|
||||
<p class="mr-auto text-xs text-error-fg">The library needs at least one folder.</p>
|
||||
{/if}
|
||||
<button type="button" class={button} disabled={saving} onclick={() => (editing = false)}>Cancel</button>
|
||||
<button type="button" class={button} disabled={!valid || saving} onclick={save}>
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
|
||||
import type { LibraryFolders } from '#lib/api/admin.js';
|
||||
|
||||
vi.mock('#lib/api/admin.js', () => ({
|
||||
getLibraryFolders: vi.fn(),
|
||||
updateLibraryFolders: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock('#lib/stores/toast.svelte.js', () => ({ pushToast: vi.fn() }));
|
||||
|
||||
import LibraryFoldersSetting from './LibraryFoldersSetting.svelte';
|
||||
import { getLibraryFolders, updateLibraryFolders } from '#lib/api/admin.js';
|
||||
import { pushToast } from '#lib/stores/toast.svelte.js';
|
||||
|
||||
// The mount moved: the server can no longer see /music.
|
||||
const before: LibraryFolders = {
|
||||
saved: false,
|
||||
folders: [{ path: '/music', present: false, tracks: 12000 }]
|
||||
};
|
||||
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
async function renderSetting(f: LibraryFolders = before) {
|
||||
vi.mocked(getLibraryFolders).mockResolvedValue(f);
|
||||
const onSaved = vi.fn();
|
||||
render(LibraryFoldersSetting, { onSaved });
|
||||
await screen.findByTestId('library-folders');
|
||||
return onSaved;
|
||||
}
|
||||
|
||||
describe('LibraryFoldersSetting', () => {
|
||||
test('lists the folders, says where they come from, and flags one the server cannot see', async () => {
|
||||
await renderSetting();
|
||||
expect(screen.getByTestId('library-folders').textContent).toBe('/music');
|
||||
expect(screen.getByText(/MINSTREL_LIBRARY_SCAN_PATHS/)).toBeInTheDocument();
|
||||
expect(screen.getByTestId('folder-missing').textContent).toMatch(/can't see \/music, which holds 12,000 tracks/);
|
||||
});
|
||||
|
||||
test('changing a folder moves its tracks there', async () => {
|
||||
const onSaved = await renderSetting();
|
||||
vi.mocked(updateLibraryFolders).mockResolvedValue({
|
||||
saved: true,
|
||||
moved: 12000,
|
||||
folders: [{ path: '/data/music', present: true, tracks: 12000 }]
|
||||
});
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Change' }));
|
||||
await fireEvent.input(screen.getByRole('textbox', { name: 'Folder 1' }), { target: { value: '/data/music' } });
|
||||
expect(screen.getByTestId('folder-changes').textContent).toMatch(/\/music → \/data\/music: its tracks move with it/);
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||
await waitFor(() =>
|
||||
expect(updateLibraryFolders).toHaveBeenCalledWith([{ path: '/data/music', was: '/music' }])
|
||||
);
|
||||
await waitFor(() => expect(onSaved).toHaveBeenCalled());
|
||||
expect(pushToast).toHaveBeenCalledWith(expect.stringMatching(/12,000 tracks moved/));
|
||||
expect((await screen.findByTestId('library-folders')).textContent).toBe('/data/music');
|
||||
});
|
||||
|
||||
test('a folder removed warns that its tracks will be marked missing', async () => {
|
||||
await renderSetting({
|
||||
saved: true,
|
||||
folders: [
|
||||
{ path: '/music', present: true, tracks: 5 },
|
||||
{ path: '/more', present: true, tracks: 1 }
|
||||
]
|
||||
});
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Change' }));
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Remove folder 2' }));
|
||||
expect(screen.getByTestId('folder-changes').textContent).toMatch(
|
||||
/\/more will no longer be read; its 1 track is marked missing/
|
||||
);
|
||||
});
|
||||
|
||||
test('a blank folder cannot be saved', async () => {
|
||||
await renderSetting();
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Change' }));
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Add folder' }));
|
||||
expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled();
|
||||
expect(screen.getByText(/Fill in or remove the empty folder/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -29,6 +29,7 @@
|
||||
"invalid_paging": "Invalid page size or offset.",
|
||||
"not_decidable": "That download isn't waiting on a decision any more. Refresh the list.",
|
||||
"bad_import_root": "New music has to be filed into one of the library's folders.",
|
||||
"bad_folders": "The library folders weren't saved.",
|
||||
"bad_reason": "Invalid quarantine reason.",
|
||||
"mbid_required": "An MBID is required for this lookup.",
|
||||
"system_playlist_readonly": "System playlists can't be edited directly.",
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import StageBadge from '#lib/components/StageBadge.svelte';
|
||||
import LoudnessSettingsCard from '#lib/components/LoudnessSettingsCard.svelte';
|
||||
import FingerprintSettingsCard from '#lib/components/FingerprintSettingsCard.svelte';
|
||||
import LibraryFoldersSetting from '#lib/components/LibraryFoldersSetting.svelte';
|
||||
import SettingsColumns from '#lib/components/settings/SettingsColumns.svelte';
|
||||
import SettingsGroup from '#lib/components/settings/SettingsGroup.svelte';
|
||||
import SettingRow from '#lib/components/settings/SettingRow.svelte';
|
||||
@@ -15,7 +16,6 @@
|
||||
triggerScan,
|
||||
researchMissingArt
|
||||
} from '#lib/api/admin.js';
|
||||
import { createSearchSettingsQuery } from '#lib/api/wanted.js';
|
||||
import { qk } from '#lib/api/queries.js';
|
||||
import { errMessage } from '#lib/api/errors.js';
|
||||
import { pushToast } from '#lib/stores/toast.svelte.js';
|
||||
@@ -31,9 +31,6 @@
|
||||
const button =
|
||||
'rounded-md border border-border px-3 py-1.5 text-sm text-text-secondary hover:bg-surface-hover hover:text-text-primary disabled:opacity-50';
|
||||
|
||||
const searchStore = createSearchSettingsQuery();
|
||||
const folders = $derived($searchStore.data?.scan_paths ?? []);
|
||||
|
||||
const scanStore = createScanStatusQuery();
|
||||
const scan = $derived($scanStore.data);
|
||||
const scanInFlight = $derived(scan?.in_flight === true);
|
||||
@@ -150,11 +147,13 @@
|
||||
<SettingsColumns>
|
||||
{#snippet main()}
|
||||
<SettingsGroup title="Library" id="scan" hint="The folders Minstrel reads, and the scan that reads them.">
|
||||
<SettingRow label="Folders" hint="Set where Minstrel is installed.">
|
||||
<span class="flex flex-col items-end font-mono text-xs text-text-secondary" data-testid="library-folders">
|
||||
{#each folders as f (f)}<span>{f}</span>{:else}<span>—</span>{/each}
|
||||
</span>
|
||||
</SettingRow>
|
||||
<!-- The import's folder picker reads the folders through search settings. -->
|
||||
<LibraryFoldersSetting
|
||||
onSaved={() => {
|
||||
client.invalidateQueries({ queryKey: qk.scanStatus() });
|
||||
client.invalidateQueries({ queryKey: qk.searchSettings() });
|
||||
}}
|
||||
/>
|
||||
<SettingRow label="Scan" hint={scanLine}>
|
||||
<button type="button" onclick={onRunScan} disabled={triggering || scanInFlight} class={button}>
|
||||
{scanInFlight ? 'Scanning…' : triggering ? 'Starting…' : 'Run scan'}
|
||||
|
||||
@@ -21,14 +21,17 @@ vi.mock('#lib/api/admin.js', () => ({
|
||||
updateLoudnessSettings: vi.fn(),
|
||||
getLoudnessCoverage: vi.fn(() => new Promise(() => {})),
|
||||
getFingerprintSettings: vi.fn(() => new Promise(() => {})),
|
||||
updateFingerprintSettings: vi.fn()
|
||||
updateFingerprintSettings: vi.fn(),
|
||||
getLibraryFolders: vi.fn().mockResolvedValue({
|
||||
saved: false,
|
||||
folders: [
|
||||
{ path: '/music', present: true, tracks: 1200 },
|
||||
{ path: '/more-music', present: true, tracks: 3 }
|
||||
]
|
||||
}),
|
||||
updateLibraryFolders: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock('#lib/api/wanted.js', async () => {
|
||||
const { mockQuery } = await import('../../../test-utils/query');
|
||||
return { createSearchSettingsQuery: vi.fn(() => mockQuery({ data: { scan_paths: ['/music', '/more-music'] } })) };
|
||||
});
|
||||
|
||||
import LibraryPage from './+page.svelte';
|
||||
import {
|
||||
createScanStatusQuery,
|
||||
@@ -57,9 +60,9 @@ function setup(scan: Partial<ScanStatus> = { id: '', started_at: '', finished_at
|
||||
const squash = (el: Element) => el.textContent?.replace(/\s+/g, ' ').trim();
|
||||
|
||||
describe('Admin › Library', () => {
|
||||
test('lists the folders Minstrel reads', () => {
|
||||
test('lists the folders Minstrel reads', async () => {
|
||||
setup();
|
||||
const folders = [...screen.getByTestId('library-folders').children].map((el) => el.textContent);
|
||||
const folders = [...(await screen.findByTestId('library-folders')).children].map((el) => el.textContent);
|
||||
expect(folders).toEqual(['/music', '/more-music']);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user