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:
Цвылев Александр Вадимович
2026-07-28 20:50:04 +03:00
parent 87fac7a5a1
commit 6efbf4b68b
4 changed files with 379 additions and 7 deletions
@@ -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}
+20
View File
@@ -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.',
+20
View File
@@ -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: 'Метаданные сохранены.',