import type { components } from '@pipu/api-client'; import { ApiError, unwrap } from '@pipu/core'; import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useSession } from '../../app/session'; import { needsFormatAttention } from '../../features/uploads/files'; import { useErrorText, useFieldError } from '../../shared/lib/errors'; import { formatBytes, formatDateTime, formatDuration } from '../../shared/lib/format'; import { checkIcon, closeIcon, editIcon, refreshIcon } from '../../shared/ui/icons'; import { Button, cx, Dialog, Field, FormError, Icon } from '../../shared/ui/ui'; import s from '../page.module.css'; import { MOVING, QuotaMeter, UploadStatusBadge, useReasonText, type UploadFile, type UploadStatus } from '../uploads/common'; import u from '../uploads/uploads.module.css'; type Meta = components['schemas']['UploadMeta']; type Settings = components['schemas']['UploadSettings']; const TABS = { review: ['pending_review'], failed: ['import_failed'], progress: ['uploading', 'processing', 'approved', 'importing'], imported: ['imported'], declined: ['rejected', 'duplicate', 'invalid', 'withdrawn', 'expired'], } satisfies Record; type Tab = keyof typeof TABS; /** «Очередь на добавление» (docs/uploads.md#8): рассмотрение по пачкам, правка метаданных, массовые решения. */ export function UploadQueuePage() { const { t } = useTranslation(['admin', 'common']); const session = useSession(); const queryClient = useQueryClient(); const errorText = useErrorText(); const [tab, setTab] = useState('review'); const [onlyPossible, setOnlyPossible] = useState(false); const [selected, setSelected] = useState>(new Set()); const [editing, setEditing] = useState(null); const [batchEdit, setBatchEdit] = useState(null); const [rejecting, setRejecting] = useState(false); const list = useInfiniteQuery({ queryKey: ['admin-uploads', tab, onlyPossible], initialPageParam: undefined as string | undefined, queryFn: async ({ pageParam }) => unwrap( await session.client.GET('/api/v1/admin/uploads', { params: { query: { status: [...TABS[tab]], possible_duplicate: onlyPossible || undefined, cursor: pageParam, limit: 100 }, }, }), ), getNextPageParam: (p) => p.next_cursor ?? undefined, refetchInterval: (q) => q.state.data?.pages.some((p) => p.items.some((it) => MOVING.includes(it.status))) ? 5000 : false, }); const items = useMemo(() => list.data?.pages.flatMap((p) => p.items) ?? [], [list.data]); useEffect(() => setSelected(new Set()), [tab, onlyPossible]); const refresh = async () => { setSelected(new Set()); await queryClient.invalidateQueries({ queryKey: ['admin-uploads'] }); await queryClient.invalidateQueries({ queryKey: ['upload-quota'] }); }; const review = useMutation({ mutationFn: async (v: { decision: 'approve' | 'reject'; ids: string[]; reason?: string }) => unwrap( await session.client.POST('/api/v1/admin/uploads/review', { body: { file_ids: v.ids, decision: v.decision, reason: v.reason }, }), ), onSuccess: refresh, }); const retry = useMutation({ mutationFn: async (id: string) => unwrap(await session.client.POST('/api/v1/admin/uploads/files/{id}/retry', { params: { path: { id } } })), onSuccess: refresh, }); const failed = review.data?.items.filter((r) => !r.ok) ?? []; // Группировка по пачке: файлы одной загрузки (обычно альбом) рассматриваются вместе. const batches = useMemo(() => { const m = new Map(); for (const f of items) m.set(f.batch_id, [...(m.get(f.batch_id) ?? []), f]); return [...m.values()]; }, [items]); const toggle = (ids: string[], on: boolean) => setSelected((cur) => { const next = new Set(cur); for (const id of ids) { if (on) next.add(id); else next.delete(id); } return next; }); const selectable = tab === 'review' || tab === 'failed'; const ids = [...selected]; return (

{t('uploads.queue_title')}

{t('uploads.queue_subtitle')}

{(Object.keys(TABS) as Tab[]).map((k) => ( ))}
{(list.error ?? review.error ?? retry.error) ? errorText(list.error ?? review.error ?? retry.error) : null} {failed.length > 0 && ( {t('uploads.review_failed', { count: failed.length, reason: failed[0]?.error ? errorText(problemError(failed[0].error)) : '' })} )} {list.isSuccess && items.length === 0 &&
{t('uploads.queue_empty')}
} {batches.map((files) => ( retry.mutate(id)} /> ))} {list.hasNextPage && ( )} {ids.length > 0 && (
{t('uploads.selected', { count: ids.length })} {tab === 'review' && ( )}
)} {batchEdit && ( setBatchEdit(null)} onSaved={() => { setBatchEdit(null); void refresh(); }} /> )} {editing && ( setEditing(null)} onSaved={() => { review.reset(); void refresh(); }} /> )} {rejecting && ( setRejecting(false)} onReject={(reason) => { setRejecting(false); review.mutate({ decision: 'reject', ids, reason }); }} /> )}
); } /** Problem из ответа массового решения → объект, понятный useErrorText. */ function problemError(p: components['schemas']['Problem']) { return new ApiError(p.status, p.code, p.params ?? {}); } function BatchCard({ files, selectable, selected, onToggle, onEdit, onEditBatch, onRetry, }: { files: UploadFile[]; selectable: boolean; selected: Set; onToggle: (ids: string[], on: boolean) => void; onEdit: (f: UploadFile) => void; onEditBatch: (batchId: string) => void; onRetry: (id: string) => void; }) { const { t, i18n } = useTranslation('admin'); const first = files[0]!; const ids = files.map((f) => f.id); const all = ids.every((id) => selected.has(id)); const size = files.reduce((n, f) => n + f.size_bytes, 0); return (
{selectable && ( onToggle(ids, e.target.checked)} /> )} {first.author.username} {formatDateTime(first.created_at, i18n.language)} {t('uploads.batch_size', { count: files.length, size: formatBytes(size, i18n.language) })} {first.batch_comment && «{first.batch_comment}»} {selectable && ( )}
{files.map((f) => ( onToggle([f.id], on)} onEdit={() => onEdit(f)} onRetry={() => onRetry(f.id)} /> ))}
); } function QueueRow({ file: f, selectable, checked, onCheck, onEdit, onRetry, }: { file: UploadFile; selectable: boolean; checked: boolean; onCheck: (on: boolean) => void; onEdit: () => void; onRetry: () => void; }) { const { t, i18n } = useTranslation('admin'); const reason = useReasonText()(f.status_reason); const m = f.metadata; const quality = [ f.format?.toUpperCase(), f.bitrate ? `${Math.round(f.bitrate / 1000)} kbps` : null, f.sample_rate ? `${(f.sample_rate / 1000).toFixed(1)} kHz` : null, f.bit_depth ? `${f.bit_depth} bit` : null, f.duration_ms ? formatDuration(f.duration_ms) : null, formatBytes(f.size_bytes, i18n.language), ] .filter(Boolean) .join(' · '); const editable = f.status === 'pending_review' || f.status === 'import_failed'; return (
{selectable ? ( onCheck(e.target.checked)} /> ) : ( )}
{m ? `${m.artist} — ${m.title}` : f.original_name} {m && ( {[m.album, m.year, m.disc && m.disc > 1 ? t('uploads.disc', { n: m.disc }) : null, m.track ? `#${m.track}` : null, m.genre] .filter(Boolean) .join(' · ')} {f.metadata_edited && ` · ${t('uploads.edited')}`} )} {quality} {f.client_rel_path ?? f.original_name}
{needsFormatAttention(f.format, f.original_name) && ( {t('uploads.flag_attention')} )} {f.possible_duplicate && {t('uploads.flag_possible')}} {f.quality_upgrade && {t('uploads.flag_quality')}} {f.matched_album_id ? ( {t('uploads.flag_existing_album')} ) : ( m && {t('uploads.flag_new_album')} )}
{f.target_rel_path && {f.target_rel_path}} {reason && {reason}} {f.duplicate_level && f.status === 'duplicate' && ( {t(`uploads.dup_level_${f.duplicate_level}`)} )}
{f.stream_url && (
); } const EMPTY_META: Meta = { artist: '', albumartist: '', album: '', title: '', genre: '', year: null, track: null, disc: null }; function BatchEditDialog({ batchId, onClose, onSaved }: { batchId: string; onClose: () => void; onSaved: () => void }) { const { t } = useTranslation(['admin', 'common']); const session = useSession(); const errorText = useErrorText(); const [artist, setArtist] = useState(''); const [album, setAlbum] = useState(''); const [year, setYear] = useState(''); const [cover, setCover] = useState(undefined); const save = useMutation({ mutationFn: async () => unwrap( await session.client.PATCH('/api/v1/admin/uploads/batches/{id}', { params: { path: { id: batchId } }, body: { ...(artist.trim() ? { artist: artist.trim() } : {}), ...(album.trim() ? { album: album.trim() } : {}), ...(year.trim() ? { year: Number.parseInt(year, 10) } : {}), ...(cover !== undefined ? { cover_base64: cover } : {}), }, }), ), onSuccess: onSaved, }); const pickCover = (file: File | undefined) => { if (!file) { setCover(undefined); return; } const reader = new FileReader(); reader.onload = () => { const data = String(reader.result ?? ''); const comma = data.indexOf(','); setCover(comma >= 0 ? data.slice(comma + 1) : data); }; reader.readAsDataURL(file); }; return ( } >

{t('uploads.batch_edit_hint')}

{save.error ? errorText(save.error) : null}
); } function EditDialog({ file, onClose, onSaved }: { file: UploadFile; onClose: () => void; onSaved: () => void }) { const { t } = useTranslation(['admin', 'common']); const session = useSession(); const errorText = useErrorText(); const fieldError = useFieldError(); const [m, setM] = useState(file.metadata ?? EMPTY_META); const [source, setSource] = useState(file.target_source_id ?? ''); const sources = useQuery({ queryKey: ['sources'], queryFn: async () => unwrap(await session.client.GET('/api/v1/admin/sources')).items, }); const save = useMutation({ mutationFn: async () => unwrap( await session.client.PATCH('/api/v1/admin/uploads/files/{id}', { params: { path: { id: file.id } }, body: { metadata: m, ...(source ? { target_source_id: source } : {}) }, }), ), onSuccess: () => { onSaved(); onClose(); }, }); const text = (k: 'artist' | 'albumartist' | 'album' | 'title' | 'genre') => ( setM({ ...m, [k]: v })} maxLength={500} /> ); const num = (k: 'year' | 'track' | 'disc') => ( setM({ ...m, [k]: v.trim() === '' ? null : Number.parseInt(v, 10) || null })} /> ); const accepting = sources.data?.filter((x) => x.accepts_uploads) ?? []; return ( } >

{file.client_rel_path ?? file.original_name}

{text('artist')} {text('albumartist')} {text('album')} {text('title')} {text('genre')} {num('year')} {num('track')} {num('disc')}

{t('uploads.tags_note')}

{save.error ? (fieldError(save.error, 'metadata') ?? errorText(save.error)) : null}
); } function RejectDialog({ count, busy, onClose, onReject, }: { count: number; busy: boolean; onClose: () => void; onReject: (reason: string) => void; }) { const { t } = useTranslation(['admin', 'common']); const [reason, setReason] = useState(''); return ( } >