a37c19fd45
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>
380 lines
11 KiB
TypeScript
380 lines
11 KiB
TypeScript
import { useEffect, useRef, useState } from 'react';
|
|
import { useNavigate } from 'react-router';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { Badge, Button, Callout, ScrollArea, Spinner } from '@olly/modern-sk';
|
|
import {
|
|
buildUploadFormData,
|
|
useUploadTrackMutation,
|
|
} 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. */
|
|
type ItemStatus = 'queued' | 'uploading' | 'done' | 'duplicate' | 'error';
|
|
|
|
interface QueueItem {
|
|
id: string;
|
|
file: File;
|
|
status: ItemStatus;
|
|
error?: string;
|
|
trackId?: string;
|
|
}
|
|
|
|
/** The endpoint takes one file per request, so we cap concurrent requests. */
|
|
const MAX_CONCURRENCY = 3;
|
|
|
|
function extractError(err: unknown): string {
|
|
if (typeof err === 'object' && err !== null) {
|
|
const e = err as { data?: { message?: string; detail?: string }; error?: string };
|
|
return e.data?.message ?? e.data?.detail ?? e.error ?? 'Upload failed';
|
|
}
|
|
return 'Upload failed';
|
|
}
|
|
|
|
/** `/upload` — A8 drag-and-drop upload of own files. */
|
|
export function UploadPage() {
|
|
const { t } = useTranslation();
|
|
const navigate = useNavigate();
|
|
const [uploadTrack] = useUploadTrackMutation();
|
|
|
|
const [items, setItems] = useState<QueueItem[]>([]);
|
|
const [dragging, setDragging] = useState(false);
|
|
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
const idCounter = useRef(0);
|
|
const activeCount = useRef(0);
|
|
const pending = useRef<QueueItem[]>([]);
|
|
|
|
const patchItem = (id: string, patch: Partial<QueueItem>) =>
|
|
setItems((prev) => prev.map((it) => (it.id === id ? { ...it, ...patch } : it)));
|
|
|
|
// Ref-based concurrency pump: refs (not state) so it is safe to call from
|
|
// async callbacks without stale closures over the queue.
|
|
const pump = () => {
|
|
while (activeCount.current < MAX_CONCURRENCY && pending.current.length > 0) {
|
|
const item = pending.current.shift()!;
|
|
activeCount.current += 1;
|
|
patchItem(item.id, { status: 'uploading', error: undefined });
|
|
uploadTrack(buildUploadFormData(item.file))
|
|
.unwrap()
|
|
.then((res) => {
|
|
patchItem(item.id, {
|
|
status: res.already_exists ? 'duplicate' : 'done',
|
|
trackId: res.track_id,
|
|
});
|
|
})
|
|
.catch((err: unknown) => {
|
|
patchItem(item.id, { status: 'error', error: extractError(err) });
|
|
})
|
|
.finally(() => {
|
|
activeCount.current -= 1;
|
|
pump();
|
|
});
|
|
}
|
|
};
|
|
|
|
const addFiles = (files: File[]) => {
|
|
if (files.length === 0) return;
|
|
const newItems: QueueItem[] = files.map((file) => ({
|
|
id: `u${idCounter.current++}`,
|
|
file,
|
|
status: 'queued',
|
|
}));
|
|
setItems((prev) => [...prev, ...newItems]);
|
|
pending.current.push(...newItems);
|
|
pump();
|
|
};
|
|
|
|
const retry = (id: string) => {
|
|
let target: QueueItem | undefined;
|
|
setItems((prev) => {
|
|
target = prev.find((it) => it.id === id);
|
|
return prev.map((it) =>
|
|
it.id === id ? { ...it, status: 'queued', error: undefined } : it,
|
|
);
|
|
});
|
|
if (target) {
|
|
pending.current.push({ ...target, status: 'queued', error: undefined });
|
|
pump();
|
|
}
|
|
};
|
|
|
|
const onInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
if (e.target.files) addFiles(Array.from(e.target.files));
|
|
e.target.value = ''; // allow re-selecting the same file
|
|
};
|
|
|
|
const onDrop = (e: React.DragEvent) => {
|
|
e.preventDefault();
|
|
setDragging(false);
|
|
if (e.dataTransfer.files) addFiles(Array.from(e.dataTransfer.files));
|
|
};
|
|
|
|
const completed = items.filter(
|
|
(it) => it.status === 'done' || it.status === 'duplicate',
|
|
).length;
|
|
|
|
return (
|
|
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
|
<div
|
|
style={{
|
|
padding: '1.25rem 1.5rem',
|
|
borderBottom: '1px solid var(--color-border)',
|
|
flexShrink: 0,
|
|
}}
|
|
>
|
|
<h2 style={{ margin: 0, fontSize: '1.125rem', fontWeight: 700 }}>
|
|
{t('upload.title')}
|
|
</h2>
|
|
</div>
|
|
|
|
<ScrollArea style={{ flex: 1 }}>
|
|
<div
|
|
style={{
|
|
padding: '1.5rem',
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
gap: '1.25rem',
|
|
}}
|
|
>
|
|
<div
|
|
onClick={() => inputRef.current?.click()}
|
|
onDragEnter={(e) => {
|
|
e.preventDefault();
|
|
setDragging(true);
|
|
}}
|
|
onDragOver={(e) => e.preventDefault()}
|
|
onDragLeave={() => setDragging(false)}
|
|
onDrop={onDrop}
|
|
style={{
|
|
border: `2px dashed ${
|
|
dragging ? 'var(--color-accent)' : 'var(--color-border)'
|
|
}`,
|
|
borderRadius: 10,
|
|
padding: '2.5rem 1.5rem',
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
alignItems: 'center',
|
|
gap: '0.75rem',
|
|
cursor: 'pointer',
|
|
background: dragging ? 'var(--color-surface-2)' : 'transparent',
|
|
transition: 'background 120ms, border-color 120ms',
|
|
}}
|
|
>
|
|
<div style={{ fontSize: '2rem' }}>⬆</div>
|
|
<div style={{ fontWeight: 600 }}>{t('upload.dropzone.title')}</div>
|
|
<div style={{ fontSize: '0.8125rem', color: 'var(--color-text-3)' }}>
|
|
{t('upload.dropzone.hint')}
|
|
</div>
|
|
<Button variant="primary" size="sm" type="button">
|
|
{t('upload.dropzone.button')}
|
|
</Button>
|
|
<input
|
|
ref={inputRef}
|
|
type="file"
|
|
multiple
|
|
accept="audio/*"
|
|
onChange={onInputChange}
|
|
style={{ display: 'none' }}
|
|
/>
|
|
</div>
|
|
|
|
<Callout variant="info">{t('upload.metadataPending')}</Callout>
|
|
|
|
{items.length > 0 && (
|
|
<div
|
|
style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}
|
|
>
|
|
<div
|
|
style={{
|
|
display: 'flex',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'center',
|
|
}}
|
|
>
|
|
<span style={{ fontWeight: 600, fontSize: '0.875rem' }}>
|
|
{t('upload.queueTitle', {
|
|
completed,
|
|
total: items.length,
|
|
})}
|
|
</span>
|
|
{completed > 0 && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
type="button"
|
|
onClick={() =>
|
|
setItems((prev) =>
|
|
prev.filter(
|
|
(it) =>
|
|
it.status !== 'done' && it.status !== 'duplicate',
|
|
),
|
|
)
|
|
}
|
|
>
|
|
{t('upload.clearCompleted')}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
{items.map((item) => (
|
|
<UploadRow
|
|
key={item.id}
|
|
item={item}
|
|
onRetry={() => retry(item.id)}
|
|
onEditMetadata={() =>
|
|
void navigate(`/tracks/${item.trackId}/metadata`)
|
|
}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</ScrollArea>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function UploadRow({
|
|
item,
|
|
onRetry,
|
|
onEditMetadata,
|
|
}: {
|
|
item: QueueItem;
|
|
onRetry: () => void;
|
|
onEditMetadata: () => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const done = item.status === 'done' || item.status === 'duplicate';
|
|
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)',
|
|
}}
|
|
>
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
<div
|
|
style={{
|
|
fontSize: '0.875rem',
|
|
fontWeight: 500,
|
|
overflow: 'hidden',
|
|
textOverflow: 'ellipsis',
|
|
whiteSpace: 'nowrap',
|
|
}}
|
|
title={item.file.name}
|
|
>
|
|
{item.file.name}
|
|
</div>
|
|
{item.status === 'error' && item.error && (
|
|
<div style={{ fontSize: '0.75rem', color: 'var(--color-text-3)' }}>
|
|
{item.error}
|
|
</div>
|
|
)}
|
|
{done && item.trackId && <EnrichmentStatus trackId={item.trackId} />}
|
|
</div>
|
|
|
|
<StatusBadge status={item.status} />
|
|
|
|
{item.status === 'error' && (
|
|
<Button variant="ghost" size="sm" type="button" onClick={onRetry}>
|
|
{t('upload.retry')}
|
|
</Button>
|
|
)}
|
|
{done && item.trackId && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
type="button"
|
|
onClick={onEditMetadata}
|
|
>
|
|
{t('upload.editMetadata')}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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 }) {
|
|
const { t } = useTranslation();
|
|
if (status === 'uploading') {
|
|
return (
|
|
<Badge variant="neutral">
|
|
<Spinner size="sm" /> {t('upload.status.uploading')}
|
|
</Badge>
|
|
);
|
|
}
|
|
const cfg: Record<
|
|
Exclude<ItemStatus, 'uploading'>,
|
|
{ variant: 'lime' | 'ember' | 'neutral' | 'outline'; key: string }
|
|
> = {
|
|
queued: { variant: 'neutral', key: 'upload.status.queued' },
|
|
done: { variant: 'lime', key: 'upload.status.done' },
|
|
duplicate: { variant: 'outline', key: 'upload.status.duplicate' },
|
|
error: { variant: 'ember', key: 'upload.status.error' },
|
|
};
|
|
const { variant, key } = cfg[status];
|
|
return (
|
|
<Badge variant={variant} dot>
|
|
{t(key)}
|
|
</Badge>
|
|
);
|
|
}
|