feat(metadata): implement single-track metadata editor page (§A7)
Replace the placeholder with a controlled form for title/artist/album/ year/genre/track number, an AcoustID "find matches" action showing ranked candidates with confidence, a diff/apply picker, a re-enrich button, and save via PUT /metadata. Adds matches/apply API endpoints, mappers, types, and en/ru i18n strings. Batch editor remains a placeholder (deferred).
This commit is contained in:
@@ -2,10 +2,12 @@ import { api } from '../index';
|
|||||||
import {
|
import {
|
||||||
toAlbum,
|
toAlbum,
|
||||||
toArtist,
|
toArtist,
|
||||||
|
toMetadataMatch,
|
||||||
toPage,
|
toPage,
|
||||||
toTrack,
|
toTrack,
|
||||||
type RawAlbum,
|
type RawAlbum,
|
||||||
type RawArtist,
|
type RawArtist,
|
||||||
|
type RawMetadataMatch,
|
||||||
type RawPaged,
|
type RawPaged,
|
||||||
type RawTrack,
|
type RawTrack,
|
||||||
} from '../mappers';
|
} from '../mappers';
|
||||||
@@ -13,6 +15,8 @@ import type {
|
|||||||
Track,
|
Track,
|
||||||
Album,
|
Album,
|
||||||
Artist,
|
Artist,
|
||||||
|
MetadataEdit,
|
||||||
|
MetadataMatch,
|
||||||
PaginatedResponse,
|
PaginatedResponse,
|
||||||
LibraryFilters,
|
LibraryFilters,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
@@ -161,6 +165,41 @@ export const libraryApi = api.injectEndpoints({
|
|||||||
}),
|
}),
|
||||||
providesTags: ['Track', 'Album', 'Artist'],
|
providesTags: ['Track', 'Album', 'Artist'],
|
||||||
}),
|
}),
|
||||||
|
getMetadataMatches: build.query<MetadataMatch[], string>({
|
||||||
|
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,
|
overrideExisting: false,
|
||||||
});
|
});
|
||||||
@@ -176,4 +215,7 @@ export const {
|
|||||||
useGetArtistAlbumsQuery,
|
useGetArtistAlbumsQuery,
|
||||||
useGetArtistTracksQuery,
|
useGetArtistTracksQuery,
|
||||||
useSearchLibraryQuery,
|
useSearchLibraryQuery,
|
||||||
|
useLazyGetMetadataMatchesQuery,
|
||||||
|
useApplyMetadataMutation,
|
||||||
|
useEnrichTrackMutation,
|
||||||
} = libraryApi;
|
} = libraryApi;
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
import type {
|
import type {
|
||||||
Album,
|
Album,
|
||||||
Artist,
|
Artist,
|
||||||
|
MetadataMatch,
|
||||||
MetadataStatus,
|
MetadataStatus,
|
||||||
PaginatedResponse,
|
PaginatedResponse,
|
||||||
Playlist,
|
Playlist,
|
||||||
@@ -63,6 +64,9 @@ export interface RawTrack {
|
|||||||
duration_seconds: number | null;
|
duration_seconds: number | null;
|
||||||
file_format: string;
|
file_format: string;
|
||||||
file_size: number;
|
file_size: number;
|
||||||
|
genre: string | null;
|
||||||
|
year: number | null;
|
||||||
|
track_number: number | null;
|
||||||
metadata_status: string;
|
metadata_status: string;
|
||||||
metadata_error: string | null;
|
metadata_error: string | null;
|
||||||
enriched_at: string | null;
|
enriched_at: string | null;
|
||||||
@@ -71,6 +75,18 @@ export interface RawTrack {
|
|||||||
created_at: string;
|
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 {
|
export interface RawAlbum {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -127,6 +143,9 @@ export const toTrack = (r: RawTrack): Track => ({
|
|||||||
availability: 'server',
|
availability: 'server',
|
||||||
metadataStatus: toMetadataStatus(r.metadata_status),
|
metadataStatus: toMetadataStatus(r.metadata_status),
|
||||||
metadataError: r.metadata_error ?? undefined,
|
metadataError: r.metadata_error ?? undefined,
|
||||||
|
genre: r.genre ?? undefined,
|
||||||
|
year: r.year ?? undefined,
|
||||||
|
trackNumber: r.track_number ?? undefined,
|
||||||
liked: false,
|
liked: false,
|
||||||
format: r.file_format,
|
format: r.file_format,
|
||||||
fileSize: r.file_size,
|
fileSize: r.file_size,
|
||||||
@@ -135,6 +154,17 @@ export const toTrack = (r: RawTrack): Track => ({
|
|||||||
enrichedAt: r.enriched_at ?? undefined,
|
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 => ({
|
export const toAlbum = (r: RawAlbum): Album => ({
|
||||||
id: r.id,
|
id: r.id,
|
||||||
title: r.title,
|
title: r.title,
|
||||||
|
|||||||
@@ -161,3 +161,26 @@ export interface ApiError {
|
|||||||
message: string;
|
message: string;
|
||||||
code?: 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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,16 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useParams, useNavigate } from 'react-router';
|
||||||
import { useTranslation } from 'react-i18next';
|
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';
|
import { Placeholder } from '../../components/common/Placeholder';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -6,13 +18,500 @@ interface Props {
|
|||||||
batch?: boolean;
|
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
|
* `/tracks/:trackId/metadata` — A7 metadata editor: manual edits + AcoustID
|
||||||
* metadata editor with auto-enrichment / diff view. Scaffold only.
|
* match picker with a current-vs-proposed diff. `/metadata/batch` is deferred.
|
||||||
*/
|
*/
|
||||||
export function MetadataEditorPage({ batch = false }: Props) {
|
export function MetadataEditorPage({ batch = false }: Props) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
if (batch) {
|
||||||
|
return <Placeholder title={t('pages.metadataBatch')} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <SingleTrackEditor />;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<FormState>(EMPTY_FORM);
|
||||||
|
const [initialized, setInitialized] = useState(false);
|
||||||
|
const [selectedMatch, setSelectedMatch] = useState<MetadataMatch | null>(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 <ErrorState message={t('metadataEditor.error')} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trackQuery.isLoading || !initialized) {
|
||||||
return (
|
return (
|
||||||
<Placeholder title={batch ? t('pages.metadataBatch') : t('pages.metadata')} />
|
<div style={{ padding: '1.5rem' }}>
|
||||||
|
<LoadingSkeleton rows={6} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trackQuery.isError || !trackQuery.data) {
|
||||||
|
return (
|
||||||
|
<ErrorState
|
||||||
|
message={t('metadataEditor.error')}
|
||||||
|
onRetry={() => 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 (
|
||||||
|
<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.metadata')}
|
||||||
|
</h2>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
margin: '0.125rem 0 0',
|
||||||
|
fontSize: '0.8125rem',
|
||||||
|
color: 'var(--color-text-3)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{track.artistName} · {track.title}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ScrollArea style={{ flex: 1 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '1.5rem',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '1.25rem',
|
||||||
|
maxWidth: 640,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{applyResult.isSuccess && (
|
||||||
|
<Callout variant="success">{t('metadataEditor.saved')}</Callout>
|
||||||
|
)}
|
||||||
|
{applyResult.isError && (
|
||||||
|
<Callout variant="danger">{t('metadataEditor.saveError')}</Callout>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '1.25rem',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '1rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>{t('metadataEditor.fields.title')}</label>
|
||||||
|
<TextField
|
||||||
|
style={fieldStyle()}
|
||||||
|
value={form.title}
|
||||||
|
onChange={(e) => updateField('title')(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>{t('metadataEditor.fields.artist')}</label>
|
||||||
|
<TextField
|
||||||
|
style={fieldStyle()}
|
||||||
|
value={form.artistName}
|
||||||
|
onChange={(e) => updateField('artistName')(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>{t('metadataEditor.fields.album')}</label>
|
||||||
|
<TextField
|
||||||
|
style={fieldStyle()}
|
||||||
|
value={form.albumTitle}
|
||||||
|
onChange={(e) => updateField('albumTitle')(e.target.value)}
|
||||||
|
/>
|
||||||
|
</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.trackNumber')}</label>
|
||||||
|
<TextField
|
||||||
|
style={fieldStyle()}
|
||||||
|
type="number"
|
||||||
|
value={form.trackNumber}
|
||||||
|
onChange={(e) => updateField('trackNumber')(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>{t('metadataEditor.fields.genre')}</label>
|
||||||
|
<TextField
|
||||||
|
style={fieldStyle()}
|
||||||
|
value={form.genre}
|
||||||
|
onChange={(e) => updateField('genre')(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem', justifyContent: 'flex-end' }}>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={() => void handleSave()}
|
||||||
|
disabled={applyResult.isLoading}
|
||||||
|
>
|
||||||
|
{applyResult.isLoading ? <Spinner size="sm" /> : t('metadataEditor.save')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<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('metadataEditor.autoEnrich.title')}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '0.8125rem', color: 'var(--color-text-3)' }}>
|
||||||
|
{t('metadataEditor.autoEnrich.hint')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem', flexShrink: 0 }}>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void enrichTrack(trackId)}
|
||||||
|
disabled={enrichResult.isLoading}
|
||||||
|
>
|
||||||
|
{enrichResult.isLoading ? (
|
||||||
|
<Spinner size="sm" />
|
||||||
|
) : (
|
||||||
|
t('metadataEditor.autoEnrich.reEnrich')
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void findMatches(trackId)}
|
||||||
|
disabled={matchesResult.isFetching}
|
||||||
|
>
|
||||||
|
{matchesResult.isFetching ? (
|
||||||
|
<Spinner size="sm" />
|
||||||
|
) : (
|
||||||
|
t('metadataEditor.autoEnrich.findMatches')
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{enrichResult.isSuccess && (
|
||||||
|
<Callout variant="info">{t('metadataEditor.autoEnrich.enqueued')}</Callout>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{matchesResult.isError && (
|
||||||
|
<Callout variant="danger">{t('metadataEditor.autoEnrich.error')}</Callout>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{matchesResult.isSuccess && matchesResult.data && (
|
||||||
|
matchesResult.data.length === 0 ? (
|
||||||
|
<Callout variant="warning">{t('metadataEditor.autoEnrich.noMatches')}</Callout>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||||
|
{matchesResult.data.map((match) => (
|
||||||
|
<MatchRow
|
||||||
|
key={match.acoustid}
|
||||||
|
match={match}
|
||||||
|
onUse={() => setSelectedMatch(match)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedMatch && (
|
||||||
|
<DiffView
|
||||||
|
current={form}
|
||||||
|
proposed={selectedMatch}
|
||||||
|
onApply={() => applyMatch(selectedMatch)}
|
||||||
|
onCancel={() => setSelectedMatch(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MatchRow({ match, onUse }: { match: MetadataMatch; onUse: () => void }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const pct = Math.round(match.score * 100);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.75rem',
|
||||||
|
padding: '0.625rem 0.75rem',
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
borderRadius: 8,
|
||||||
|
background: 'var(--color-surface-1)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Badge variant={pct >= 80 ? 'lime' : pct >= 50 ? 'outline' : 'neutral'}>
|
||||||
|
{pct}%
|
||||||
|
</Badge>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
fontWeight: 500,
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{match.title ?? t('metadataEditor.matches.unknownTitle')}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '0.8125rem',
|
||||||
|
color: 'var(--color-text-3)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{[match.artist, match.album, match.year]
|
||||||
|
.filter((v) => v !== undefined && v !== null && v !== '')
|
||||||
|
.join(' · ')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" size="sm" onClick={onUse}>
|
||||||
|
{t('metadataEditor.matches.use')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: '0.875rem 1rem',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '0.625rem',
|
||||||
|
background: 'var(--color-surface-1)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontWeight: 600, fontSize: '0.875rem' }}>
|
||||||
|
{t('metadataEditor.diff.title')}
|
||||||
|
</div>
|
||||||
|
{changed.length === 0 ? (
|
||||||
|
<div style={{ fontSize: '0.8125rem', color: 'var(--color-text-3)' }}>
|
||||||
|
{t('metadataEditor.diff.noChanges')}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.375rem' }}>
|
||||||
|
{changed.map((row) => (
|
||||||
|
<div key={row.key} style={{ fontSize: '0.8125rem' }}>
|
||||||
|
<span style={{ color: 'var(--color-text-3)' }}>{row.label}: </span>
|
||||||
|
<span style={{ textDecoration: 'line-through', color: 'var(--color-text-3)' }}>
|
||||||
|
{row.current || '—'}
|
||||||
|
</span>
|
||||||
|
{' → '}
|
||||||
|
<span style={{ color: 'var(--color-accent)', fontWeight: 600 }}>
|
||||||
|
{row.proposed || '—'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem', justifyContent: 'flex-end' }}>
|
||||||
|
<Button variant="ghost" size="sm" onClick={onCancel}>
|
||||||
|
{t('metadataEditor.diff.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button variant="primary" size="sm" onClick={onApply} disabled={changed.length === 0}>
|
||||||
|
{t('metadataEditor.diff.apply')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -281,6 +281,39 @@ const en = {
|
|||||||
manual: 'Edited manually — not auto-updated',
|
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;
|
} as const;
|
||||||
|
|
||||||
export default en;
|
export default en;
|
||||||
|
|||||||
@@ -283,6 +283,39 @@ const ru: Translations = {
|
|||||||
manual: 'Изменено вручную — не обновляется автоматически',
|
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;
|
export default ru;
|
||||||
|
|||||||
Reference in New Issue
Block a user