diff --git a/src/features/metadata-editor/MetadataEditorPage.tsx b/src/features/metadata-editor/MetadataEditorPage.tsx index f3cb37c..108fd33 100644 --- a/src/features/metadata-editor/MetadataEditorPage.tsx +++ b/src/features/metadata-editor/MetadataEditorPage.tsx @@ -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 ; + return ; } return ; @@ -589,3 +594,319 @@ function DiffView({ ); } + +// ── 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>(new Set()); + const [seededFor, setSeededFor] = useState(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(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) => { + 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 ( +
+
+ navigate(-1)} + aria-label={t('common.back')} + > + ← + +
+

+ {t('pages.metadataBatch')} +

+

+ {t('metadataBatch.selectedCount', { + selected: selectedCount, + total: items.length, + })} +

+
+ setParams({ status: v })} + items={[ + { value: 'pending', label: t('metadataBatch.status.pending') }, + { value: 'failed', label: t('metadataBatch.status.failed') }, + ]} + /> +
+ + +
+ {result && ( + + {t('metadataBatch.result', { + ok: result.ok, + fail: result.fail, + })} + + )} + + +
+
+
+
+ {t('metadataBatch.reEnrich.title')} +
+
+ {t('metadataBatch.reEnrich.hint')} +
+
+ +
+ +
+ +
+ {t('metadataBatch.common.title')} +
+
+ {t('metadataBatch.common.hint')} +
+
+
+ + updateField('artistName')(e.target.value)} + /> +
+
+ + updateField('albumTitle')(e.target.value)} + /> +
+
+
+
+ + updateField('year')(e.target.value)} + /> +
+
+ + updateField('genre')(e.target.value)} + /> +
+
+
+ +
+
+ + + {active.isLoading && } + {active.isError && active.refetch()} />} + {!active.isLoading && !active.isError && items.length === 0 && ( + + )} + {items.length > 0 && ( +
+ {items.map((track) => ( +
+ toggle(track.id)} + aria-label={track.title} + /> +
+ +
+
+ ))} +
+ )} +
+ +
+ ); +} diff --git a/src/features/storage/StorageMaintenancePage.tsx b/src/features/storage/StorageMaintenancePage.tsx index c487e1e..3d12754 100644 --- a/src/features/storage/StorageMaintenancePage.tsx +++ b/src/features/storage/StorageMaintenancePage.tsx @@ -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 (
+
+ +
{data.items.map((track) => (