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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-19 20:37:03 -04:00
co-authored by Claude Opus 5.5
parent 453a977e48
commit edcb3a57ef
25 changed files with 1102 additions and 57 deletions
+29
View File
@@ -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 = {
+3 -1
View File
@@ -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();
});
});
+1
View File
@@ -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.",
+8 -9
View File
@@ -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'}
+11 -8
View File
@@ -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']);
});