feat(metadata): wire batch editor over metadata-status buckets (A7)
/metadata/batch?status=pending|failed edits a whole bucket: bulk re-enrich (AcoustID/MusicBrainz) and stamp common fields onto the selection, with a select-list and per-track checkboxes. Storage Maintenance failed/missing tabs link straight into it, closing the A6→A7 flow. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useParams, useNavigate } from 'react-router';
|
import { useParams, useNavigate, useSearchParams } from 'react-router';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {
|
||||||
Badge,
|
Badge,
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
Card,
|
Card,
|
||||||
IconButton,
|
IconButton,
|
||||||
ScrollArea,
|
ScrollArea,
|
||||||
|
SegmentedControl,
|
||||||
Spinner,
|
Spinner,
|
||||||
TextField,
|
TextField,
|
||||||
} from '@olly/modern-sk';
|
} from '@olly/modern-sk';
|
||||||
@@ -17,10 +18,15 @@ import {
|
|||||||
useGetTrackQuery,
|
useGetTrackQuery,
|
||||||
useLazyGetMetadataMatchesQuery,
|
useLazyGetMetadataMatchesQuery,
|
||||||
} from '../../api/endpoints/library';
|
} from '../../api/endpoints/library';
|
||||||
import type { MetadataMatch } from '../../api/types';
|
import {
|
||||||
|
useGetBrokenFilesQuery,
|
||||||
|
useGetMissingMetadataQuery,
|
||||||
|
} from '../../api/endpoints/storage';
|
||||||
|
import type { MetadataEdit, MetadataMatch, Track } from '../../api/types';
|
||||||
import { LoadingSkeleton } from '../../components/common/LoadingSkeleton';
|
import { LoadingSkeleton } from '../../components/common/LoadingSkeleton';
|
||||||
|
import { EmptyState } from '../../components/common/EmptyState';
|
||||||
import { ErrorState } from '../../components/common/ErrorState';
|
import { ErrorState } from '../../components/common/ErrorState';
|
||||||
import { Placeholder } from '../../components/common/Placeholder';
|
import { TrackRow } from '../../components/track/TrackRow';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/** Single-track editor vs. batch editor — both A7, same scaffold. */
|
/** Single-track editor vs. batch editor — both A7, same scaffold. */
|
||||||
@@ -60,13 +66,12 @@ function fieldStyle(): React.CSSProperties {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* `/tracks/:trackId/metadata` — A7 metadata editor: manual edits + AcoustID
|
* `/tracks/:trackId/metadata` — A7 metadata editor: manual edits + AcoustID
|
||||||
* match picker with a current-vs-proposed diff. `/metadata/batch` is deferred.
|
* match picker with a current-vs-proposed diff. `/metadata/batch` operates on a
|
||||||
|
* metadata-status bucket (§A6 → A7 flow): bulk re-enrich + apply common fields.
|
||||||
*/
|
*/
|
||||||
export function MetadataEditorPage({ batch = false }: Props) {
|
export function MetadataEditorPage({ batch = false }: Props) {
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
if (batch) {
|
if (batch) {
|
||||||
return <Placeholder title={t('pages.metadataBatch')} />;
|
return <BatchEditor />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <SingleTrackEditor />;
|
return <SingleTrackEditor />;
|
||||||
@@ -589,3 +594,319 @@ function DiffView({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Batch editor (§A7 / §A6 cross-flow) ─────────────────────────────────────
|
||||||
|
|
||||||
|
interface BatchForm {
|
||||||
|
artistName: string;
|
||||||
|
albumTitle: string;
|
||||||
|
year: string;
|
||||||
|
genre: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_BATCH: BatchForm = {
|
||||||
|
artistName: '',
|
||||||
|
albumTitle: '',
|
||||||
|
year: '',
|
||||||
|
genre: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
type BatchStatus = 'pending' | 'failed';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `/metadata/batch?status=pending|failed` — bulk metadata over a status bucket
|
||||||
|
* (tracks awaiting enrichment, or ones whose enrichment failed). Two batch
|
||||||
|
* actions: re-run auto-enrichment, or stamp common fields onto the selection.
|
||||||
|
*/
|
||||||
|
function BatchEditor() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [params, setParams] = useSearchParams();
|
||||||
|
const status: BatchStatus =
|
||||||
|
params.get('status') === 'failed' ? 'failed' : 'pending';
|
||||||
|
|
||||||
|
const missing = useGetMissingMetadataQuery(
|
||||||
|
{ limit: 200 },
|
||||||
|
{ skip: status !== 'pending' },
|
||||||
|
);
|
||||||
|
const broken = useGetBrokenFilesQuery(
|
||||||
|
{ limit: 200 },
|
||||||
|
{ skip: status !== 'failed' },
|
||||||
|
);
|
||||||
|
const active = status === 'failed' ? broken : missing;
|
||||||
|
const items: Track[] = active.data?.items ?? [];
|
||||||
|
|
||||||
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||||
|
const [seededFor, setSeededFor] = useState<BatchStatus | null>(null);
|
||||||
|
|
||||||
|
// Seed the selection (all) the first time each bucket's data arrives.
|
||||||
|
useEffect(() => {
|
||||||
|
if (active.data && seededFor !== status) {
|
||||||
|
setSelected(new Set(active.data.items.map((tr) => tr.id)));
|
||||||
|
setSeededFor(status);
|
||||||
|
}
|
||||||
|
}, [active.data, seededFor, status]);
|
||||||
|
|
||||||
|
const [form, setForm] = useState<BatchForm>(EMPTY_BATCH);
|
||||||
|
const [enrichTrack] = useEnrichTrackMutation();
|
||||||
|
const [applyMetadata] = useApplyMetadataMutation();
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [result, setResult] = useState<{ ok: number; fail: number } | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const toggle = (id: string) =>
|
||||||
|
setSelected((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(id)) next.delete(id);
|
||||||
|
else next.add(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
const runBulk = async (op: (id: string) => Promise<unknown>) => {
|
||||||
|
setBusy(true);
|
||||||
|
setResult(null);
|
||||||
|
const ids = [...selected];
|
||||||
|
const outcomes = await Promise.allSettled(ids.map(op));
|
||||||
|
const ok = outcomes.filter((o) => o.status === 'fulfilled').length;
|
||||||
|
setResult({ ok, fail: ids.length - ok });
|
||||||
|
setBusy(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const reEnrich = () => void runBulk((id) => enrichTrack(id).unwrap());
|
||||||
|
const applyCommon = () => {
|
||||||
|
const edit: MetadataEdit = {
|
||||||
|
artistName: form.artistName.trim() || undefined,
|
||||||
|
albumTitle: form.albumTitle.trim() || undefined,
|
||||||
|
year: form.year.trim() ? Number(form.year) : undefined,
|
||||||
|
genre: form.genre.trim() || undefined,
|
||||||
|
};
|
||||||
|
void runBulk((id) => applyMetadata({ trackId: id, edit }).unwrap());
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateField = (key: keyof BatchForm) => (value: string) =>
|
||||||
|
setForm((prev) => ({ ...prev, [key]: value }));
|
||||||
|
const hasCommonEdits = Object.values(form).some((v) => v.trim() !== '');
|
||||||
|
const selectedCount = selected.size;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '1.25rem 1.5rem',
|
||||||
|
borderBottom: '1px solid var(--color-border)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '1rem',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => navigate(-1)}
|
||||||
|
aria-label={t('common.back')}
|
||||||
|
>
|
||||||
|
←
|
||||||
|
</IconButton>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<h2 style={{ margin: 0, fontSize: '1.125rem', fontWeight: 700 }}>
|
||||||
|
{t('pages.metadataBatch')}
|
||||||
|
</h2>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
margin: '0.125rem 0 0',
|
||||||
|
fontSize: '0.8125rem',
|
||||||
|
color: 'var(--color-text-3)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('metadataBatch.selectedCount', {
|
||||||
|
selected: selectedCount,
|
||||||
|
total: items.length,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<SegmentedControl
|
||||||
|
value={status}
|
||||||
|
onValueChange={(v) => setParams({ status: v })}
|
||||||
|
items={[
|
||||||
|
{ value: 'pending', label: t('metadataBatch.status.pending') },
|
||||||
|
{ value: 'failed', label: t('metadataBatch.status.failed') },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ScrollArea style={{ flex: 1 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '1.5rem',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '1.25rem',
|
||||||
|
maxWidth: 760,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{result && (
|
||||||
|
<Callout variant={result.fail === 0 ? 'success' : 'danger'}>
|
||||||
|
{t('metadataBatch.result', {
|
||||||
|
ok: result.ok,
|
||||||
|
fail: result.fail,
|
||||||
|
})}
|
||||||
|
</Callout>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '1.25rem',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '1rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: '0.75rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 600, fontSize: '0.9375rem' }}>
|
||||||
|
{t('metadataBatch.reEnrich.title')}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '0.8125rem',
|
||||||
|
color: 'var(--color-text-3)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('metadataBatch.reEnrich.hint')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
onClick={reEnrich}
|
||||||
|
disabled={busy || selectedCount === 0}
|
||||||
|
>
|
||||||
|
{busy ? (
|
||||||
|
<Spinner size="sm" />
|
||||||
|
) : (
|
||||||
|
t('metadataBatch.reEnrich.button')
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ borderTop: '1px solid var(--color-border)' }} />
|
||||||
|
|
||||||
|
<div style={{ fontWeight: 600, fontSize: '0.9375rem' }}>
|
||||||
|
{t('metadataBatch.common.title')}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{ fontSize: '0.8125rem', color: 'var(--color-text-3)' }}
|
||||||
|
>
|
||||||
|
{t('metadataBatch.common.hint')}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '1rem' }}>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<label style={labelStyle}>
|
||||||
|
{t('metadataEditor.fields.artist')}
|
||||||
|
</label>
|
||||||
|
<TextField
|
||||||
|
style={fieldStyle()}
|
||||||
|
value={form.artistName}
|
||||||
|
onChange={(e) => updateField('artistName')(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<label style={labelStyle}>
|
||||||
|
{t('metadataEditor.fields.album')}
|
||||||
|
</label>
|
||||||
|
<TextField
|
||||||
|
style={fieldStyle()}
|
||||||
|
value={form.albumTitle}
|
||||||
|
onChange={(e) => updateField('albumTitle')(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '1rem' }}>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<label style={labelStyle}>
|
||||||
|
{t('metadataEditor.fields.year')}
|
||||||
|
</label>
|
||||||
|
<TextField
|
||||||
|
style={fieldStyle()}
|
||||||
|
type="number"
|
||||||
|
value={form.year}
|
||||||
|
onChange={(e) => updateField('year')(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<label style={labelStyle}>
|
||||||
|
{t('metadataEditor.fields.genre')}
|
||||||
|
</label>
|
||||||
|
<TextField
|
||||||
|
style={fieldStyle()}
|
||||||
|
value={form.genre}
|
||||||
|
onChange={(e) => updateField('genre')(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={applyCommon}
|
||||||
|
disabled={busy || selectedCount === 0 || !hasCommonEdits}
|
||||||
|
>
|
||||||
|
{busy ? (
|
||||||
|
<Spinner size="sm" />
|
||||||
|
) : (
|
||||||
|
t('metadataBatch.common.apply')
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{active.isLoading && <LoadingSkeleton rows={6} height={56} />}
|
||||||
|
{active.isError && <ErrorState onRetry={() => active.refetch()} />}
|
||||||
|
{!active.isLoading && !active.isError && items.length === 0 && (
|
||||||
|
<EmptyState icon="✓" title={t('metadataBatch.empty')} />
|
||||||
|
)}
|
||||||
|
{items.length > 0 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '0.25rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{items.map((track) => (
|
||||||
|
<div
|
||||||
|
key={track.id}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.75rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selected.has(track.id)}
|
||||||
|
onChange={() => toggle(track.id)}
|
||||||
|
aria-label={track.title}
|
||||||
|
/>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<TrackRow track={track} showAlbum />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -181,9 +181,20 @@ function TrackListTab({
|
|||||||
|
|
||||||
const from = offset + 1;
|
const from = offset + 1;
|
||||||
const to = offset + data.items.length;
|
const to = offset + data.items.length;
|
||||||
|
// Broken files map to the `failed` metadata bucket; missing → `pending`.
|
||||||
|
const batchStatus = emptyKey === 'broken' ? 'failed' : 'pending';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.25rem' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.25rem' }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: '0.5rem' }}>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void navigate(`/metadata/batch?status=${batchStatus}`)}
|
||||||
|
>
|
||||||
|
{t('storage.maintenance.editBatch')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
{data.items.map((track) => (
|
{data.items.map((track) => (
|
||||||
<TrackRow
|
<TrackRow
|
||||||
key={track.id}
|
key={track.id}
|
||||||
|
|||||||
@@ -314,6 +314,7 @@ const en = {
|
|||||||
pagination: '{{from}}–{{to}} of {{total}}',
|
pagination: '{{from}}–{{to}} of {{total}}',
|
||||||
prev: 'Previous',
|
prev: 'Previous',
|
||||||
next: 'Next',
|
next: 'Next',
|
||||||
|
editBatch: 'Edit in batch',
|
||||||
},
|
},
|
||||||
device: 'On this device',
|
device: 'On this device',
|
||||||
server: 'On the server',
|
server: 'On the server',
|
||||||
@@ -594,6 +595,25 @@ const en = {
|
|||||||
manual: 'Edited manually — not auto-updated',
|
manual: 'Edited manually — not auto-updated',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
metadataBatch: {
|
||||||
|
selectedCount: '{{selected}} of {{total}} selected',
|
||||||
|
status: {
|
||||||
|
pending: 'No metadata',
|
||||||
|
failed: 'Failed',
|
||||||
|
},
|
||||||
|
result: '{{ok}} updated, {{fail}} failed',
|
||||||
|
reEnrich: {
|
||||||
|
title: 'Re-run auto-enrichment',
|
||||||
|
hint: 'Queue AcoustID/MusicBrainz identification for the selected tracks.',
|
||||||
|
button: 'Re-enrich selected',
|
||||||
|
},
|
||||||
|
common: {
|
||||||
|
title: 'Apply common fields',
|
||||||
|
hint: 'Filled fields are written to every selected track; blank fields are left unchanged.',
|
||||||
|
apply: 'Apply to selected',
|
||||||
|
},
|
||||||
|
empty: 'Nothing in this bucket.',
|
||||||
|
},
|
||||||
metadataEditor: {
|
metadataEditor: {
|
||||||
error: 'Failed to load track',
|
error: 'Failed to load track',
|
||||||
saved: 'Metadata saved.',
|
saved: 'Metadata saved.',
|
||||||
|
|||||||
@@ -315,6 +315,7 @@ const ru: Translations = {
|
|||||||
pagination: '{{from}}–{{to}} из {{total}}',
|
pagination: '{{from}}–{{to}} из {{total}}',
|
||||||
prev: 'Назад',
|
prev: 'Назад',
|
||||||
next: 'Вперёд',
|
next: 'Вперёд',
|
||||||
|
editBatch: 'Редактировать массово',
|
||||||
},
|
},
|
||||||
device: 'На этом устройстве',
|
device: 'На этом устройстве',
|
||||||
server: 'На сервере',
|
server: 'На сервере',
|
||||||
@@ -596,6 +597,25 @@ const ru: Translations = {
|
|||||||
manual: 'Изменено вручную — не обновляется автоматически',
|
manual: 'Изменено вручную — не обновляется автоматически',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
metadataBatch: {
|
||||||
|
selectedCount: 'Выбрано {{selected}} из {{total}}',
|
||||||
|
status: {
|
||||||
|
pending: 'Без метаданных',
|
||||||
|
failed: 'С ошибкой',
|
||||||
|
},
|
||||||
|
result: 'Обновлено {{ok}}, ошибок {{fail}}',
|
||||||
|
reEnrich: {
|
||||||
|
title: 'Повторное авто-обогащение',
|
||||||
|
hint: 'Поставить в очередь идентификацию AcoustID/MusicBrainz для выбранных треков.',
|
||||||
|
button: 'Переобогатить выбранные',
|
||||||
|
},
|
||||||
|
common: {
|
||||||
|
title: 'Применить общие поля',
|
||||||
|
hint: 'Заполненные поля запишутся всем выбранным трекам; пустые останутся без изменений.',
|
||||||
|
apply: 'Применить к выбранным',
|
||||||
|
},
|
||||||
|
empty: 'В этой категории пусто.',
|
||||||
|
},
|
||||||
metadataEditor: {
|
metadataEditor: {
|
||||||
error: 'Не удалось загрузить трек',
|
error: 'Не удалось загрузить трек',
|
||||||
saved: 'Метаданные сохранены.',
|
saved: 'Метаданные сохранены.',
|
||||||
|
|||||||
Reference in New Issue
Block a user