import { useEffect, useState } from 'react'; import { useParams, useNavigate } from 'react-router'; import { useTranslation } from 'react-i18next'; import { Badge, Button, Callout, Card, IconButton, ScrollArea, Spinner, TextField } from '@olly/modern-sk'; import { useApplyMetadataMutation, useEnrichTrackMutation, useGetTrackQuery, useLazyGetMetadataMatchesQuery, } from '../../api/endpoints/library'; import type { MetadataMatch } from '../../api/types'; import { LoadingSkeleton } from '../../components/common/LoadingSkeleton'; import { ErrorState } from '../../components/common/ErrorState'; import { Placeholder } from '../../components/common/Placeholder'; interface Props { /** Single-track editor vs. batch editor — both A7, same scaffold. */ batch?: boolean; } /** Editable fields, kept as strings while in the form — parsed on save. */ interface FormState { title: string; artistName: string; albumTitle: string; year: string; genre: string; trackNumber: string; } const EMPTY_FORM: FormState = { title: '', artistName: '', albumTitle: '', year: '', genre: '', trackNumber: '', }; const labelStyle: React.CSSProperties = { display: 'block', fontSize: '0.8125rem', fontWeight: 500, marginBottom: '0.375rem', color: 'var(--color-text-2)', }; function fieldStyle(): React.CSSProperties { return { width: '100%' }; } /** * `/tracks/:trackId/metadata` — A7 metadata editor: manual edits + AcoustID * match picker with a current-vs-proposed diff. `/metadata/batch` is deferred. */ export function MetadataEditorPage({ batch = false }: Props) { const { t } = useTranslation(); if (batch) { return ; } return ; } function SingleTrackEditor() { const { t } = useTranslation(); const navigate = useNavigate(); const { trackId } = useParams<{ trackId: string }>(); const trackQuery = useGetTrackQuery(trackId ?? '', { skip: !trackId }); const [findMatches, matchesResult] = useLazyGetMetadataMatchesQuery(); const [applyMetadata, applyResult] = useApplyMetadataMutation(); const [enrichTrack, enrichResult] = useEnrichTrackMutation(); const [form, setForm] = useState(EMPTY_FORM); const [initialized, setInitialized] = useState(false); const [selectedMatch, setSelectedMatch] = useState(null); // Seed the form from the loaded track exactly once — afterwards it's the // user's edit buffer and shouldn't be clobbered by refetches. useEffect(() => { if (initialized || !trackQuery.data) return; const track = trackQuery.data; setForm({ title: track.title, artistName: track.artistName, albumTitle: track.albumTitle, year: track.year != null ? String(track.year) : '', genre: track.genre ?? '', trackNumber: track.trackNumber != null ? String(track.trackNumber) : '', }); setInitialized(true); }, [initialized, trackQuery.data]); if (!trackId) { return ; } if (trackQuery.isLoading || !initialized) { return ( ); } if (trackQuery.isError || !trackQuery.data) { return ( trackQuery.refetch()} /> ); } const track = trackQuery.data; const updateField = (key: keyof FormState) => (value: string) => setForm((prev) => ({ ...prev, [key]: value })); const applyMatch = (match: MetadataMatch) => { setForm((prev) => ({ ...prev, title: match.title ?? prev.title, artistName: match.artist ?? prev.artistName, albumTitle: match.album ?? prev.albumTitle, year: match.year != null ? String(match.year) : prev.year, })); setSelectedMatch(null); }; const handleSave = async () => { await applyMetadata({ trackId, edit: { title: form.title.trim() || undefined, artistName: form.artistName.trim() || undefined, albumTitle: form.albumTitle.trim() || undefined, year: form.year.trim() ? Number(form.year) : undefined, genre: form.genre.trim() || undefined, trackNumber: form.trackNumber.trim() ? Number(form.trackNumber) : undefined, }, }).unwrap(); }; return ( navigate(-1)} aria-label={t('common.back')} > ← {t('pages.metadata')} {track.artistName} · {track.title} {applyResult.isSuccess && ( {t('metadataEditor.saved')} )} {applyResult.isError && ( {t('metadataEditor.saveError')} )} {t('metadataEditor.fields.title')} updateField('title')(e.target.value)} /> {t('metadataEditor.fields.artist')} updateField('artistName')(e.target.value)} /> {t('metadataEditor.fields.album')} updateField('albumTitle')(e.target.value)} /> {t('metadataEditor.fields.year')} updateField('year')(e.target.value)} /> {t('metadataEditor.fields.trackNumber')} updateField('trackNumber')(e.target.value)} /> {t('metadataEditor.fields.genre')} updateField('genre')(e.target.value)} /> void handleSave()} disabled={applyResult.isLoading} > {applyResult.isLoading ? : t('metadataEditor.save')} {t('metadataEditor.autoEnrich.title')} {t('metadataEditor.autoEnrich.hint')} void enrichTrack(trackId)} disabled={enrichResult.isLoading} > {enrichResult.isLoading ? ( ) : ( t('metadataEditor.autoEnrich.reEnrich') )} void findMatches(trackId)} disabled={matchesResult.isFetching} > {matchesResult.isFetching ? ( ) : ( t('metadataEditor.autoEnrich.findMatches') )} {enrichResult.isSuccess && ( {t('metadataEditor.autoEnrich.enqueued')} )} {matchesResult.isError && ( {t('metadataEditor.autoEnrich.error')} )} {matchesResult.isSuccess && matchesResult.data && ( matchesResult.data.length === 0 ? ( {t('metadataEditor.autoEnrich.noMatches')} ) : ( {matchesResult.data.map((match) => ( setSelectedMatch(match)} /> ))} ) )} {selectedMatch && ( applyMatch(selectedMatch)} onCancel={() => setSelectedMatch(null)} /> )} ); } function MatchRow({ match, onUse }: { match: MetadataMatch; onUse: () => void }) { const { t } = useTranslation(); const pct = Math.round(match.score * 100); return ( = 80 ? 'lime' : pct >= 50 ? 'outline' : 'neutral'}> {pct}% {match.title ?? t('metadataEditor.matches.unknownTitle')} {[match.artist, match.album, match.year] .filter((v) => v !== undefined && v !== null && v !== '') .join(' · ')} {t('metadataEditor.matches.use')} ); } interface DiffRowDef { key: 'title' | 'artistName' | 'albumTitle' | 'year'; label: string; current: string; proposed?: string; } function DiffView({ current, proposed, onApply, onCancel, }: { current: FormState; proposed: MetadataMatch; onApply: () => void; onCancel: () => void; }) { const { t } = useTranslation(); const rows: DiffRowDef[] = [ { key: 'title', label: t('metadataEditor.fields.title'), current: current.title, proposed: proposed.title, }, { key: 'artistName', label: t('metadataEditor.fields.artist'), current: current.artistName, proposed: proposed.artist, }, { key: 'albumTitle', label: t('metadataEditor.fields.album'), current: current.albumTitle, proposed: proposed.album, }, { key: 'year', label: t('metadataEditor.fields.year'), current: current.year, proposed: proposed.year != null ? String(proposed.year) : undefined, }, ]; const changed = rows.filter( (row) => row.proposed !== undefined && row.proposed !== row.current, ); return ( {t('metadataEditor.diff.title')} {changed.length === 0 ? ( {t('metadataEditor.diff.noChanges')} ) : ( {changed.map((row) => ( {row.label}: {row.current || '—'} {' → '} {row.proposed || '—'} ))} )} {t('metadataEditor.diff.cancel')} {t('metadataEditor.diff.apply')} ); }
{track.artistName} · {track.title}