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 { useParams, useNavigate } from 'react-router';
|
||||
import { useParams, useNavigate, useSearchParams } from 'react-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Badge,
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Card,
|
||||
IconButton,
|
||||
ScrollArea,
|
||||
SegmentedControl,
|
||||
Spinner,
|
||||
TextField,
|
||||
} from '@olly/modern-sk';
|
||||
@@ -17,10 +18,15 @@ import {
|
||||
useGetTrackQuery,
|
||||
useLazyGetMetadataMatchesQuery,
|
||||
} 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 { EmptyState } from '../../components/common/EmptyState';
|
||||
import { ErrorState } from '../../components/common/ErrorState';
|
||||
import { Placeholder } from '../../components/common/Placeholder';
|
||||
import { TrackRow } from '../../components/track/TrackRow';
|
||||
|
||||
interface Props {
|
||||
/** 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
|
||||
* 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) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (batch) {
|
||||
return <Placeholder title={t('pages.metadataBatch')} />;
|
||||
return <BatchEditor />;
|
||||
}
|
||||
|
||||
return <SingleTrackEditor />;
|
||||
@@ -589,3 +594,319 @@ function DiffView({
|
||||
</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 to = offset + data.items.length;
|
||||
// Broken files map to the `failed` metadata bucket; missing → `pending`.
|
||||
const batchStatus = emptyKey === 'broken' ? 'failed' : 'pending';
|
||||
|
||||
return (
|
||||
<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) => (
|
||||
<TrackRow
|
||||
key={track.id}
|
||||
|
||||
@@ -314,6 +314,7 @@ const en = {
|
||||
pagination: '{{from}}–{{to}} of {{total}}',
|
||||
prev: 'Previous',
|
||||
next: 'Next',
|
||||
editBatch: 'Edit in batch',
|
||||
},
|
||||
device: 'On this device',
|
||||
server: 'On the server',
|
||||
@@ -594,6 +595,25 @@ const en = {
|
||||
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: {
|
||||
error: 'Failed to load track',
|
||||
saved: 'Metadata saved.',
|
||||
|
||||
@@ -315,6 +315,7 @@ const ru: Translations = {
|
||||
pagination: '{{from}}–{{to}} из {{total}}',
|
||||
prev: 'Назад',
|
||||
next: 'Вперёд',
|
||||
editBatch: 'Редактировать массово',
|
||||
},
|
||||
device: 'На этом устройстве',
|
||||
server: 'На сервере',
|
||||
@@ -596,6 +597,25 @@ const ru: Translations = {
|
||||
manual: 'Изменено вручную — не обновляется автоматически',
|
||||
},
|
||||
},
|
||||
metadataBatch: {
|
||||
selectedCount: 'Выбрано {{selected}} из {{total}}',
|
||||
status: {
|
||||
pending: 'Без метаданных',
|
||||
failed: 'С ошибкой',
|
||||
},
|
||||
result: 'Обновлено {{ok}}, ошибок {{fail}}',
|
||||
reEnrich: {
|
||||
title: 'Повторное авто-обогащение',
|
||||
hint: 'Поставить в очередь идентификацию AcoustID/MusicBrainz для выбранных треков.',
|
||||
button: 'Переобогатить выбранные',
|
||||
},
|
||||
common: {
|
||||
title: 'Применить общие поля',
|
||||
hint: 'Заполненные поля запишутся всем выбранным трекам; пустые останутся без изменений.',
|
||||
apply: 'Применить к выбранным',
|
||||
},
|
||||
empty: 'В этой категории пусто.',
|
||||
},
|
||||
metadataEditor: {
|
||||
error: 'Не удалось загрузить трек',
|
||||
saved: 'Метаданные сохранены.',
|
||||
|
||||
Reference in New Issue
Block a user