diff --git a/src/api/endpoints/streaming.ts b/src/api/endpoints/streaming.ts index 38e0a64..630544a 100644 --- a/src/api/endpoints/streaming.ts +++ b/src/api/endpoints/streaming.ts @@ -17,3 +17,18 @@ export function getCoverUrl(artUrl: string | undefined): string | undefined { const base = getApiBaseUrl(); return `${base}${artUrl}`; } + +/** + * Cover image URL for a track, served by `GET /tracks/{id}/cover`. Like the + * audio stream, an `` 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)}`; +} diff --git a/src/api/mappers.ts b/src/api/mappers.ts index e1927b6..bcbd6db 100644 --- a/src/api/mappers.ts +++ b/src/api/mappers.ts @@ -14,12 +14,27 @@ import type { Album, Artist, + MetadataStatus, PaginatedResponse, Playlist, Track, User, } 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) ---- export interface RawPaged { @@ -49,6 +64,9 @@ export interface RawTrack { file_format: string; file_size: number; metadata_status: string; + metadata_error: string | null; + enriched_at: string | null; + has_cover: boolean; source: string; created_at: string; } @@ -98,13 +116,17 @@ export const toTrack = (r: RawTrack): Track => ({ artistName: r.artist_name, albumId: r.album_id ?? '', albumTitle: r.album_title ?? '', - // Cover endpoints aren't wired on the backend yet — leave art undefined so the - // UI renders generated tile art instead of a broken image. + // `has_cover` says a cover exists; the actual URL (which needs a `?token=`) is + // built in the component from the track id — see `getTrackCoverUrl`. Keep + // `albumArtUrl` undefined so callers fall back to generated tile art. albumArtUrl: undefined, + hasCover: r.has_cover, durationMs: (r.duration_seconds ?? 0) * 1000, // 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. availability: 'server', + metadataStatus: toMetadataStatus(r.metadata_status), + metadataError: r.metadata_error ?? undefined, liked: false, format: r.file_format, fileSize: r.file_size, diff --git a/src/api/types.ts b/src/api/types.ts index d956090..97d2b83 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -1,5 +1,13 @@ 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 { id: string; title: string; @@ -8,12 +16,16 @@ export interface Track { albumId: string; albumTitle: string; albumArtUrl?: string; + hasCover: boolean; durationMs: number; trackNumber?: number; discNumber?: number; year?: number; genre?: string; availability: TrackAvailability; + metadataStatus: MetadataStatus; + /** Human-readable reason the last enrichment run set `failed`; else undefined. */ + metadataError?: string; fileSize?: number; format?: string; bitrate?: number; diff --git a/src/components/track/MetadataStatusBadge.tsx b/src/components/track/MetadataStatusBadge.tsx new file mode 100644 index 0000000..f141961 --- /dev/null +++ b/src/components/track/MetadataStatusBadge.tsx @@ -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 = { + 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 ( + + + {status === 'pending' ? : null} + {label} + + + ); +} diff --git a/src/components/track/TrackRow.tsx b/src/components/track/TrackRow.tsx index 2240c1b..df1dbdd 100644 --- a/src/components/track/TrackRow.tsx +++ b/src/components/track/TrackRow.tsx @@ -1,11 +1,12 @@ import { Row } from '@olly/modern-sk'; import { TrackContextMenu } from './TrackContextMenu'; import { AvailabilityBadge } from './AvailabilityBadge'; +import { MetadataStatusBadge } from './MetadataStatusBadge'; import { formatDuration } from '../../lib/format'; import { useAppDispatch, useAppSelector } from '../../hooks/useAppDispatch'; import { play } from '../../store/slices/player'; import type { Track } from '../../api/types'; -import { getCoverUrl } from '../../api/endpoints/streaming'; +import { getCoverUrl, getTrackCoverUrl } from '../../api/endpoints/streaming'; interface Props { track: Track; @@ -27,8 +28,13 @@ export function TrackRow({ const dispatch = useAppDispatch(); const currentTrackId = useAppSelector((s) => s.player.currentTrackId); const isPlaying = useAppSelector((s) => s.player.isPlaying); + const token = useAppSelector((s) => s.auth.accessToken); 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 — `` can't send a header). + const artUrl = + getCoverUrl(track.albumArtUrl) ?? + (token ? getTrackCoverUrl(track.id, token, track.hasCover) : undefined); return ( - + + + + )} - {done && ( - - {t('upload.unknownArtist')} - - )} + {done && item.trackId && } @@ -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 ( + + + + {status === 'failed' && data?.metadataError + ? data.metadataError + : resolved} + + + ); +} + function StatusBadge({ status }: { status: ItemStatus }) { const { t } = useTranslation(); if (status === 'uploading') { diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index ee85b84..69b461d 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -230,6 +230,20 @@ const en = { 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; export default en; diff --git a/src/i18n/locales/ru.ts b/src/i18n/locales/ru.ts index fa8eb61..3653d5e 100644 --- a/src/i18n/locales/ru.ts +++ b/src/i18n/locales/ru.ts @@ -232,6 +232,20 @@ const ru: Translations = { error: 'Ошибка', }, }, + metadata: { + status: { + pending: 'Обработка…', + enriched: 'Готово', + failed: 'Нет совпадения', + manual: 'Вручную', + }, + statusHint: { + pending: 'Определяем метаданные…', + enriched: 'Метаданные определены', + failed: 'Не удалось определить метаданные', + manual: 'Изменено вручную — не обновляется автоматически', + }, + }, }; export default ru;