feat(metadata): implement single-track metadata editor page (§A7)
Docker Build & Publish / Prune old image versions (push) Has been cancelled
Docker Build & Publish / build (push) Has been cancelled
Docker Build & Publish / push (push) Has been cancelled

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:
Senko-san
2026-06-13 14:36:17 +03:00
parent 8a70f478c3
commit d1b2b40ffd
6 changed files with 663 additions and 3 deletions
+42
View File
@@ -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<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,
});
@@ -176,4 +215,7 @@ export const {
useGetArtistAlbumsQuery,
useGetArtistTracksQuery,
useSearchLibraryQuery,
useLazyGetMetadataMatchesQuery,
useApplyMetadataMutation,
useEnrichTrackMutation,
} = libraryApi;
+30
View File
@@ -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,
+23
View File
@@ -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;
}