diff --git a/src/api/endpoints/library.ts b/src/api/endpoints/library.ts index 66e807a..3dfec9a 100644 --- a/src/api/endpoints/library.ts +++ b/src/api/endpoints/library.ts @@ -2,10 +2,12 @@ import { api } from '../index'; import { toAlbum, toArtist, + toMetadataMatch, toPage, toTrack, type RawAlbum, type RawArtist, + type RawMetadataMatch, type RawPaged, type RawTrack, } from '../mappers'; @@ -13,6 +15,8 @@ import type { Track, Album, Artist, + MetadataEdit, + MetadataMatch, PaginatedResponse, LibraryFilters, } from '../types'; @@ -161,6 +165,41 @@ export const libraryApi = api.injectEndpoints({ }), providesTags: ['Track', 'Album', 'Artist'], }), + getMetadataMatches: build.query({ + query: (trackId) => `/tracks/${trackId}/metadata/matches`, + transformResponse: (raw: { items: RawMetadataMatch[] }) => + raw.items.map(toMetadataMatch), + }), + applyMetadata: build.mutation< + Track, + { trackId: string; edit: MetadataEdit } + >({ + query: ({ trackId, edit }) => ({ + url: `/tracks/${trackId}/metadata`, + method: 'PUT', + body: { + title: edit.title, + artist_name: edit.artistName, + album_title: edit.albumTitle, + year: edit.year, + genre: edit.genre, + track_number: edit.trackNumber, + }, + }), + transformResponse: (raw: RawTrack) => toTrack(raw), + invalidatesTags: (_r, _e, { trackId }) => [ + { type: 'Track', id: trackId }, + 'Album', + 'Artist', + ], + }), + enrichTrack: build.mutation<{ track_id: string; job_id: string }, string>({ + query: (trackId) => ({ + url: `/tracks/${trackId}/metadata/enrich`, + method: 'POST', + }), + invalidatesTags: (_r, _e, trackId) => [{ type: 'Track', id: trackId }], + }), }), overrideExisting: false, }); @@ -176,4 +215,7 @@ export const { useGetArtistAlbumsQuery, useGetArtistTracksQuery, useSearchLibraryQuery, + useLazyGetMetadataMatchesQuery, + useApplyMetadataMutation, + useEnrichTrackMutation, } = libraryApi; diff --git a/src/api/mappers.ts b/src/api/mappers.ts index 22c879d..23d3580 100644 --- a/src/api/mappers.ts +++ b/src/api/mappers.ts @@ -14,6 +14,7 @@ import type { Album, Artist, + MetadataMatch, MetadataStatus, PaginatedResponse, Playlist, @@ -63,6 +64,9 @@ export interface RawTrack { duration_seconds: number | null; file_format: string; file_size: number; + genre: string | null; + year: number | null; + track_number: number | null; metadata_status: string; metadata_error: string | null; enriched_at: string | null; @@ -71,6 +75,18 @@ export interface RawTrack { created_at: string; } +/** One AcoustID candidate, as returned by `GET /tracks/{id}/metadata/matches`. */ +export interface RawMetadataMatch { + acoustid: string; + score: number; + recording_mbid: string | null; + release_group_mbid: string | null; + title: string | null; + artist: string | null; + album: string | null; + year: number | null; +} + export interface RawAlbum { id: string; title: string; @@ -127,6 +143,9 @@ export const toTrack = (r: RawTrack): Track => ({ availability: 'server', metadataStatus: toMetadataStatus(r.metadata_status), metadataError: r.metadata_error ?? undefined, + genre: r.genre ?? undefined, + year: r.year ?? undefined, + trackNumber: r.track_number ?? undefined, liked: false, format: r.file_format, fileSize: r.file_size, @@ -135,6 +154,17 @@ export const toTrack = (r: RawTrack): Track => ({ enrichedAt: r.enriched_at ?? undefined, }); +export const toMetadataMatch = (r: RawMetadataMatch): MetadataMatch => ({ + acoustid: r.acoustid, + score: r.score, + recordingMbid: r.recording_mbid ?? undefined, + releaseGroupMbid: r.release_group_mbid ?? undefined, + title: r.title ?? undefined, + artist: r.artist ?? undefined, + album: r.album ?? undefined, + year: r.year ?? undefined, +}); + export const toAlbum = (r: RawAlbum): Album => ({ id: r.id, title: r.title, diff --git a/src/api/types.ts b/src/api/types.ts index 9d6cb26..f6e2c4e 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -161,3 +161,26 @@ export interface ApiError { message: string; code?: string; } + +/** One AcoustID candidate from `GET /tracks/{id}/metadata/matches` (§A7). */ +export interface MetadataMatch { + acoustid: string; + /** Confidence 0..1. */ + score: number; + recordingMbid?: string; + releaseGroupMbid?: string; + title?: string; + artist?: string; + album?: string; + year?: number; +} + +/** Manual edits / an accepted match, sent to `PUT /tracks/{id}/metadata`. */ +export interface MetadataEdit { + title?: string; + artistName?: string; + albumTitle?: string; + year?: number; + genre?: string; + trackNumber?: number; +} diff --git a/src/features/metadata-editor/MetadataEditorPage.tsx b/src/features/metadata-editor/MetadataEditorPage.tsx index 3b70ef5..33c1582 100644 --- a/src/features/metadata-editor/MetadataEditorPage.tsx +++ b/src/features/metadata-editor/MetadataEditorPage.tsx @@ -1,4 +1,16 @@ +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 { @@ -6,13 +18,500 @@ interface Props { 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` (single) and `/metadata/batch` (bulk) — A7 - * metadata editor with auto-enrichment / diff view. Scaffold only. + * `/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')} + )} + + +
+
+ + updateField('title')(e.target.value)} + /> +
+
+ + updateField('artistName')(e.target.value)} + /> +
+
+ + updateField('albumTitle')(e.target.value)} + /> +
+
+
+ + updateField('year')(e.target.value)} + /> +
+
+ + updateField('trackNumber')(e.target.value)} + /> +
+
+
+ + updateField('genre')(e.target.value)} + /> +
+ +
+ +
+
+
+ + +
+
+
+
+ {t('metadataEditor.autoEnrich.title')} +
+
+ {t('metadataEditor.autoEnrich.hint')} +
+
+
+ + +
+
+ + {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(' · ')} +
+
+ +
+ ); +} + +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 || '—'} + +
+ ))} +
+ )} +
+ + +
+
); } diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 5f8c3d0..50e4427 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -281,6 +281,39 @@ const en = { manual: 'Edited manually — not auto-updated', }, }, + metadataEditor: { + error: 'Failed to load track', + saved: 'Metadata saved.', + saveError: 'Failed to save metadata.', + save: 'Save', + fields: { + title: 'Title', + artist: 'Artist', + album: 'Album', + year: 'Year', + genre: 'Genre', + trackNumber: 'Track number', + }, + autoEnrich: { + title: 'AcoustID lookup', + hint: 'Identify this track by audio fingerprint.', + findMatches: 'Find matches', + reEnrich: 'Re-run enrichment', + enqueued: 'Enrichment queued — refresh in a moment.', + error: 'Could not look up matches.', + noMatches: 'No matches found.', + }, + matches: { + use: 'Use', + unknownTitle: 'Unknown title', + }, + diff: { + title: 'Apply this match?', + noChanges: 'No changes from current values.', + cancel: 'Cancel', + apply: 'Apply', + }, + }, } as const; export default en; diff --git a/src/i18n/locales/ru.ts b/src/i18n/locales/ru.ts index 04a32b9..8a0e170 100644 --- a/src/i18n/locales/ru.ts +++ b/src/i18n/locales/ru.ts @@ -283,6 +283,39 @@ const ru: Translations = { manual: 'Изменено вручную — не обновляется автоматически', }, }, + metadataEditor: { + error: 'Не удалось загрузить трек', + saved: 'Метаданные сохранены.', + saveError: 'Не удалось сохранить метаданные.', + save: 'Сохранить', + fields: { + title: 'Название', + artist: 'Исполнитель', + album: 'Альбом', + year: 'Год', + genre: 'Жанр', + trackNumber: 'Номер трека', + }, + autoEnrich: { + title: 'Поиск по AcoustID', + hint: 'Определить трек по аудио-отпечатку.', + findMatches: 'Найти совпадения', + reEnrich: 'Повторить обогащение', + enqueued: 'Обогащение запущено — обновите через момент.', + error: 'Не удалось найти совпадения.', + noMatches: 'Совпадений не найдено.', + }, + matches: { + use: 'Использовать', + unknownTitle: 'Неизвестное название', + }, + diff: { + title: 'Применить это совпадение?', + noChanges: 'Нет изменений относительно текущих значений.', + cancel: 'Отмена', + apply: 'Применить', + }, + }, }; export default ru;