feat(library): surface metadata enrichment status, errors and covers
The mapper dropped metadata_status and hardcoded availability, so enrichment state was invisible and a just-uploaded track never appeared to change. Map metadata_status/metadata_error/has_cover onto Track; add MetadataStatusBadge (pending spinner / enriched / failed-with-reason / manual) shown in TrackRow, and serve token-bearing track covers via getTrackCoverUrl. UploadPage now polls each uploaded track (stops once enrichment settles) so the resolved title/artist — or a failure reason — appears live. i18n in en + ru. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -17,3 +17,18 @@ export function getCoverUrl(artUrl: string | undefined): string | undefined {
|
|||||||
const base = getApiBaseUrl();
|
const base = getApiBaseUrl();
|
||||||
return `${base}${artUrl}`;
|
return `${base}${artUrl}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cover image URL for a track, served by `GET /tracks/{id}/cover`. Like the
|
||||||
|
* audio stream, an `<img>` can't send an `Authorization` header, so the access
|
||||||
|
* token rides as `?token=`. Returns undefined when the track has no cover.
|
||||||
|
*/
|
||||||
|
export function getTrackCoverUrl(
|
||||||
|
trackId: string,
|
||||||
|
token: string,
|
||||||
|
hasCover: boolean,
|
||||||
|
): string | undefined {
|
||||||
|
if (!hasCover) return undefined;
|
||||||
|
const base = getApiBaseUrl();
|
||||||
|
return `${base}/tracks/${trackId}/cover?token=${encodeURIComponent(token)}`;
|
||||||
|
}
|
||||||
|
|||||||
+24
-2
@@ -14,12 +14,27 @@
|
|||||||
import type {
|
import type {
|
||||||
Album,
|
Album,
|
||||||
Artist,
|
Artist,
|
||||||
|
MetadataStatus,
|
||||||
PaginatedResponse,
|
PaginatedResponse,
|
||||||
Playlist,
|
Playlist,
|
||||||
Track,
|
Track,
|
||||||
User,
|
User,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
|
const METADATA_STATUSES: readonly MetadataStatus[] = [
|
||||||
|
'pending',
|
||||||
|
'enriched',
|
||||||
|
'failed',
|
||||||
|
'manual',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Map the backend's free-form status string onto the UI union, defaulting any
|
||||||
|
* unknown value to `pending` (a safe "not yet identified" state). */
|
||||||
|
const toMetadataStatus = (raw: string): MetadataStatus =>
|
||||||
|
(METADATA_STATUSES as readonly string[]).includes(raw)
|
||||||
|
? (raw as MetadataStatus)
|
||||||
|
: 'pending';
|
||||||
|
|
||||||
// ---- raw wire shapes (snake_case, exactly as the backend emits) ----
|
// ---- raw wire shapes (snake_case, exactly as the backend emits) ----
|
||||||
|
|
||||||
export interface RawPaged<T> {
|
export interface RawPaged<T> {
|
||||||
@@ -49,6 +64,9 @@ export interface RawTrack {
|
|||||||
file_format: string;
|
file_format: string;
|
||||||
file_size: number;
|
file_size: number;
|
||||||
metadata_status: string;
|
metadata_status: string;
|
||||||
|
metadata_error: string | null;
|
||||||
|
enriched_at: string | null;
|
||||||
|
has_cover: boolean;
|
||||||
source: string;
|
source: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
@@ -98,13 +116,17 @@ export const toTrack = (r: RawTrack): Track => ({
|
|||||||
artistName: r.artist_name,
|
artistName: r.artist_name,
|
||||||
albumId: r.album_id ?? '',
|
albumId: r.album_id ?? '',
|
||||||
albumTitle: r.album_title ?? '',
|
albumTitle: r.album_title ?? '',
|
||||||
// Cover endpoints aren't wired on the backend yet — leave art undefined so the
|
// `has_cover` says a cover exists; the actual URL (which needs a `?token=`) is
|
||||||
// UI renders generated tile art instead of a broken image.
|
// built in the component from the track id — see `getTrackCoverUrl`. Keep
|
||||||
|
// `albumArtUrl` undefined so callers fall back to generated tile art.
|
||||||
albumArtUrl: undefined,
|
albumArtUrl: undefined,
|
||||||
|
hasCover: r.has_cover,
|
||||||
durationMs: (r.duration_seconds ?? 0) * 1000,
|
durationMs: (r.duration_seconds ?? 0) * 1000,
|
||||||
// The lean TrackOut carries no availability/like state: a track returned by
|
// The lean TrackOut carries no availability/like state: a track returned by
|
||||||
// the library is on the server, and per-track like state comes from /likes.
|
// the library is on the server, and per-track like state comes from /likes.
|
||||||
availability: 'server',
|
availability: 'server',
|
||||||
|
metadataStatus: toMetadataStatus(r.metadata_status),
|
||||||
|
metadataError: r.metadata_error ?? undefined,
|
||||||
liked: false,
|
liked: false,
|
||||||
format: r.file_format,
|
format: r.file_format,
|
||||||
fileSize: r.file_size,
|
fileSize: r.file_size,
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
export type TrackAvailability = 'server' | 'downloading' | 'error' | 'missing';
|
export type TrackAvailability = 'server' | 'downloading' | 'error' | 'missing';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Metadata-enrichment state, distinct from file `availability`. `pending` = the
|
||||||
|
* worker hasn't finished (or hasn't started); `enriched` = identity found;
|
||||||
|
* `failed` = no match / a worker error (see `metadataError`); `manual` = user-
|
||||||
|
* edited and never auto-overwritten.
|
||||||
|
*/
|
||||||
|
export type MetadataStatus = 'pending' | 'enriched' | 'failed' | 'manual';
|
||||||
|
|
||||||
export interface Track {
|
export interface Track {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -8,12 +16,16 @@ export interface Track {
|
|||||||
albumId: string;
|
albumId: string;
|
||||||
albumTitle: string;
|
albumTitle: string;
|
||||||
albumArtUrl?: string;
|
albumArtUrl?: string;
|
||||||
|
hasCover: boolean;
|
||||||
durationMs: number;
|
durationMs: number;
|
||||||
trackNumber?: number;
|
trackNumber?: number;
|
||||||
discNumber?: number;
|
discNumber?: number;
|
||||||
year?: number;
|
year?: number;
|
||||||
genre?: string;
|
genre?: string;
|
||||||
availability: TrackAvailability;
|
availability: TrackAvailability;
|
||||||
|
metadataStatus: MetadataStatus;
|
||||||
|
/** Human-readable reason the last enrichment run set `failed`; else undefined. */
|
||||||
|
metadataError?: string;
|
||||||
fileSize?: number;
|
fileSize?: number;
|
||||||
format?: string;
|
format?: string;
|
||||||
bitrate?: number;
|
bitrate?: number;
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { Badge, Spinner, Tooltip } from '@olly/modern-sk';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import type { MetadataStatus } from '../../api/types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
status: MetadataStatus;
|
||||||
|
/** Reason shown in the tooltip for a `failed` status. */
|
||||||
|
error?: string;
|
||||||
|
/** When true, render nothing for the normal `enriched` state (keeps dense
|
||||||
|
* track lists quiet; the upload screen sets this false to confirm success). */
|
||||||
|
hideWhenEnriched?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Variant = 'lime' | 'ember' | 'neutral' | 'outline';
|
||||||
|
|
||||||
|
const VARIANT: Record<MetadataStatus, Variant> = {
|
||||||
|
pending: 'neutral',
|
||||||
|
enriched: 'lime',
|
||||||
|
failed: 'ember',
|
||||||
|
manual: 'outline',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows a track's metadata-enrichment state (distinct from file availability).
|
||||||
|
* `pending` carries a spinner; `failed` exposes the backend reason on hover.
|
||||||
|
*/
|
||||||
|
export function MetadataStatusBadge({
|
||||||
|
status,
|
||||||
|
error,
|
||||||
|
hideWhenEnriched = true,
|
||||||
|
}: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
if (status === 'enriched' && hideWhenEnriched) return null;
|
||||||
|
|
||||||
|
const label = t(`metadata.status.${status}`);
|
||||||
|
const tooltip =
|
||||||
|
status === 'failed' && error ? error : t(`metadata.statusHint.${status}`);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip content={tooltip}>
|
||||||
|
<Badge variant={VARIANT[status]} dot={status !== 'pending'}>
|
||||||
|
{status === 'pending' ? <Spinner size="sm" /> : null}
|
||||||
|
{label}
|
||||||
|
</Badge>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
import { Row } from '@olly/modern-sk';
|
import { Row } from '@olly/modern-sk';
|
||||||
import { TrackContextMenu } from './TrackContextMenu';
|
import { TrackContextMenu } from './TrackContextMenu';
|
||||||
import { AvailabilityBadge } from './AvailabilityBadge';
|
import { AvailabilityBadge } from './AvailabilityBadge';
|
||||||
|
import { MetadataStatusBadge } from './MetadataStatusBadge';
|
||||||
import { formatDuration } from '../../lib/format';
|
import { formatDuration } from '../../lib/format';
|
||||||
import { useAppDispatch, useAppSelector } from '../../hooks/useAppDispatch';
|
import { useAppDispatch, useAppSelector } from '../../hooks/useAppDispatch';
|
||||||
import { play } from '../../store/slices/player';
|
import { play } from '../../store/slices/player';
|
||||||
import type { Track } from '../../api/types';
|
import type { Track } from '../../api/types';
|
||||||
import { getCoverUrl } from '../../api/endpoints/streaming';
|
import { getCoverUrl, getTrackCoverUrl } from '../../api/endpoints/streaming';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
track: Track;
|
track: Track;
|
||||||
@@ -27,8 +28,13 @@ export function TrackRow({
|
|||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const currentTrackId = useAppSelector((s) => s.player.currentTrackId);
|
const currentTrackId = useAppSelector((s) => s.player.currentTrackId);
|
||||||
const isPlaying = useAppSelector((s) => s.player.isPlaying);
|
const isPlaying = useAppSelector((s) => s.player.isPlaying);
|
||||||
|
const token = useAppSelector((s) => s.auth.accessToken);
|
||||||
const isActive = currentTrackId === track.id;
|
const isActive = currentTrackId === track.id;
|
||||||
const artUrl = getCoverUrl(track.albumArtUrl);
|
// Prefer an explicit album art URL; otherwise serve the track's own cover
|
||||||
|
// (needs the token in the query string — `<img>` can't send a header).
|
||||||
|
const artUrl =
|
||||||
|
getCoverUrl(track.albumArtUrl) ??
|
||||||
|
(token ? getTrackCoverUrl(track.id, token, track.hasCover) : undefined);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Row
|
<Row
|
||||||
@@ -95,7 +101,13 @@ export function TrackRow({
|
|||||||
{showAlbum && ` · ${track.albumTitle}`}
|
{showAlbum && ` · ${track.albumTitle}`}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<AvailabilityBadge availability={track.availability} />
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||||
|
<MetadataStatusBadge
|
||||||
|
status={track.metadataStatus}
|
||||||
|
error={track.metadataError}
|
||||||
|
/>
|
||||||
|
<AvailabilityBadge availability={track.availability} />
|
||||||
|
</div>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router';
|
import { useNavigate } from 'react-router';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Badge, Button, Callout, ScrollArea, Spinner } from '@olly/modern-sk';
|
import { Badge, Button, Callout, ScrollArea, Spinner } from '@olly/modern-sk';
|
||||||
@@ -6,6 +6,8 @@ import {
|
|||||||
buildUploadFormData,
|
buildUploadFormData,
|
||||||
useUploadTrackMutation,
|
useUploadTrackMutation,
|
||||||
} from '../../api/endpoints/upload';
|
} from '../../api/endpoints/upload';
|
||||||
|
import { useGetTrackQuery } from '../../api/endpoints/library';
|
||||||
|
import { MetadataStatusBadge } from '../../components/track/MetadataStatusBadge';
|
||||||
|
|
||||||
/** Pure client-side state — this is a transient upload queue, never server data. */
|
/** Pure client-side state — this is a transient upload queue, never server data. */
|
||||||
type ItemStatus = 'queued' | 'uploading' | 'done' | 'duplicate' | 'error';
|
type ItemStatus = 'queued' | 'uploading' | 'done' | 'duplicate' | 'error';
|
||||||
@@ -273,11 +275,7 @@ function UploadRow({
|
|||||||
{item.error}
|
{item.error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{done && (
|
{done && item.trackId && <EnrichmentStatus trackId={item.trackId} />}
|
||||||
<div style={{ fontSize: '0.75rem', color: 'var(--color-text-3)' }}>
|
|
||||||
{t('upload.unknownArtist')}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<StatusBadge status={item.status} />
|
<StatusBadge status={item.status} />
|
||||||
@@ -301,6 +299,59 @@ function UploadRow({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Polls a just-uploaded track until enrichment settles, then shows the outcome.
|
||||||
|
* Metadata enrichment runs asynchronously in a worker after the upload response
|
||||||
|
* returns, so without polling the row would never reflect the resolved title/
|
||||||
|
* artist or a failure reason. Polling stops (interval → 0) once the status
|
||||||
|
* leaves `pending`.
|
||||||
|
*/
|
||||||
|
function EnrichmentStatus({ trackId }: { trackId: string }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [pollMs, setPollMs] = useState(2500);
|
||||||
|
const { data } = useGetTrackQuery(trackId, { pollingInterval: pollMs });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (data && data.metadataStatus !== 'pending') setPollMs(0);
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
const status = data?.metadataStatus ?? 'pending';
|
||||||
|
const resolved =
|
||||||
|
data && data.metadataStatus === 'enriched'
|
||||||
|
? `${data.artistName} · ${data.title}`
|
||||||
|
: t(`metadata.statusHint.${status}`);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.5rem',
|
||||||
|
marginTop: '0.25rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MetadataStatusBadge
|
||||||
|
status={status}
|
||||||
|
error={data?.metadataError}
|
||||||
|
hideWhenEnriched={false}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
color: 'var(--color-text-3)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{status === 'failed' && data?.metadataError
|
||||||
|
? data.metadataError
|
||||||
|
: resolved}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function StatusBadge({ status }: { status: ItemStatus }) {
|
function StatusBadge({ status }: { status: ItemStatus }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
if (status === 'uploading') {
|
if (status === 'uploading') {
|
||||||
|
|||||||
@@ -230,6 +230,20 @@ const en = {
|
|||||||
error: 'Failed',
|
error: 'Failed',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
metadata: {
|
||||||
|
status: {
|
||||||
|
pending: 'Enriching…',
|
||||||
|
enriched: 'Enriched',
|
||||||
|
failed: 'No match',
|
||||||
|
manual: 'Manual',
|
||||||
|
},
|
||||||
|
statusHint: {
|
||||||
|
pending: 'Identifying metadata…',
|
||||||
|
enriched: 'Metadata identified',
|
||||||
|
failed: 'Metadata could not be identified',
|
||||||
|
manual: 'Edited manually — not auto-updated',
|
||||||
|
},
|
||||||
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export default en;
|
export default en;
|
||||||
|
|||||||
@@ -232,6 +232,20 @@ const ru: Translations = {
|
|||||||
error: 'Ошибка',
|
error: 'Ошибка',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
metadata: {
|
||||||
|
status: {
|
||||||
|
pending: 'Обработка…',
|
||||||
|
enriched: 'Готово',
|
||||||
|
failed: 'Нет совпадения',
|
||||||
|
manual: 'Вручную',
|
||||||
|
},
|
||||||
|
statusHint: {
|
||||||
|
pending: 'Определяем метаданные…',
|
||||||
|
enriched: 'Метаданные определены',
|
||||||
|
failed: 'Не удалось определить метаданные',
|
||||||
|
manual: 'Изменено вручную — не обновляется автоматически',
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ru;
|
export default ru;
|
||||||
|
|||||||
Reference in New Issue
Block a user