From a3f212d253b206b7b2864aa3ac4fa578f16ddee6 Mon Sep 17 00:00:00 2001 From: mrleo1nid <43583385+mrleo1nid@users.noreply.github.com> Date: Tue, 20 Oct 2026 17:46:18 +0300 Subject: [PATCH] feat(web): suggest tracks, my uploads and admin upload queue screens (M5.5) - Suggest tracks: drag-and-drop of files and folders with relative paths, streaming SHA-256 in a Web Worker (hash-wasm), duplicate precheck, batch reservation and tus-js-client upload (8 MB chunks, 4 in parallel, token refresh on 401, resume of failed files). - My uploads: statuses, rejection reasons, withdraw, quota meter. - Upload queue (admin): status tabs, possible-duplicate filter, batches, metadata and storage editing, bulk approve / reject with a reason, import retry, and the queue settings form. - Sidebar section for moderators and admins; ru / en strings. Co-Authored-By: Claude Opus 5.5 --- apps/web/package.json | 4 +- apps/web/src/app/router.tsx | 22 + apps/web/src/features/uploads/files.ts | 48 ++ apps/web/src/features/uploads/hash.worker.ts | 40 ++ apps/web/src/features/uploads/uploader.ts | 287 ++++++++++ apps/web/src/pages/admin/UploadQueuePage.tsx | 534 ++++++++++++++++++ apps/web/src/pages/uploads/MyUploadsPage.tsx | 156 +++++ apps/web/src/pages/uploads/SuggestPage.tsx | 228 ++++++++ apps/web/src/pages/uploads/common.tsx | 82 +++ apps/web/src/pages/uploads/uploads.module.css | 271 +++++++++ apps/web/src/shared/lib/format.ts | 12 + apps/web/src/shared/ui/icons.ts | 6 + apps/web/src/widgets/AppShell.tsx | 12 +- docs/uploads.md | 13 +- packages/i18n/locales/en/admin.json | 144 +++++ packages/i18n/locales/en/common.json | 6 +- packages/i18n/locales/ru/admin.json | 144 +++++ packages/i18n/locales/ru/common.json | 6 +- pnpm-lock.yaml | 143 ++++- 19 files changed, 2144 insertions(+), 14 deletions(-) create mode 100644 apps/web/src/features/uploads/files.ts create mode 100644 apps/web/src/features/uploads/hash.worker.ts create mode 100644 apps/web/src/features/uploads/uploader.ts create mode 100644 apps/web/src/pages/admin/UploadQueuePage.tsx create mode 100644 apps/web/src/pages/uploads/MyUploadsPage.tsx create mode 100644 apps/web/src/pages/uploads/SuggestPage.tsx create mode 100644 apps/web/src/pages/uploads/common.tsx create mode 100644 apps/web/src/pages/uploads/uploads.module.css diff --git a/apps/web/package.json b/apps/web/package.json index ab95acf..9186e8a 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -21,9 +21,11 @@ "@pipu/i18n": "workspace:*", "@tanstack/react-query": "^5.80.7", "@tanstack/react-router": "^1.120.20", + "hash-wasm": "^4.12.0", "react": "^19.1.0", "react-dom": "^19.1.0", - "react-i18next": "^15.5.3" + "react-i18next": "^15.5.3", + "tus-js-client": "^4.3.1" }, "devDependencies": { "@testing-library/dom": "^10.4.0", diff --git a/apps/web/src/app/router.tsx b/apps/web/src/app/router.tsx index 8b721e2..e5007da 100644 --- a/apps/web/src/app/router.tsx +++ b/apps/web/src/app/router.tsx @@ -11,6 +11,9 @@ import { import { AuditPage } from '../pages/admin/AuditPage'; import { LibraryPage } from '../pages/admin/LibraryPage'; import { PatternsPage } from '../pages/admin/PatternsPage'; +import { UploadQueuePage } from '../pages/admin/UploadQueuePage'; +import { MyUploadsPage } from '../pages/uploads/MyUploadsPage'; +import { SuggestPage } from '../pages/uploads/SuggestPage'; import { SourcesPage } from '../pages/admin/SourcesPage'; import { UsersPage } from '../pages/admin/UsersPage'; import { AlbumPage } from '../pages/AlbumPage'; @@ -205,6 +208,22 @@ const libraryRoute = createRoute({ component: LibraryPage, }); +/** Предложение треков — модераторы и админ (docs/uploads.md#1). */ +const staffOnly = ({ context }: { context: { user: AuthUser } }) => { + if (context.user.role === 'user') throw redirect({ to: '/' }); +}; + +const suggestRoute = createRoute({ getParentRoute: () => appRoute, path: '/uploads/new', beforeLoad: staffOnly, component: SuggestPage }); + +const myUploadsRoute = createRoute({ getParentRoute: () => appRoute, path: '/uploads/mine', beforeLoad: staffOnly, component: MyUploadsPage }); + +const uploadQueueRoute = createRoute({ + getParentRoute: () => appRoute, + path: '/admin/uploads', + beforeLoad: adminOnly, + component: UploadQueuePage, +}); + const routeTree = rootRoute.addChildren([ loginRoute, setupAdminRoute, @@ -223,6 +242,9 @@ const routeTree = rootRoute.addChildren([ sourcesRoute, patternsRoute, libraryRoute, + suggestRoute, + myUploadsRoute, + uploadQueueRoute, ]), ]); diff --git a/apps/web/src/features/uploads/files.ts b/apps/web/src/features/uploads/files.ts new file mode 100644 index 0000000..911100d --- /dev/null +++ b/apps/web/src/features/uploads/files.ts @@ -0,0 +1,48 @@ +// Сбор файлов из drag-and-drop и выбора папки с относительными путями (они подсказывают альбом, docs/uploads.md#5). + +export interface PickedFile { + file: File; + relPath: string; // «Кино/1988 - Группа крови/01 - Группа крови.flac» или просто имя файла +} + +/** Форматы, которые принимает очередь (сервер всё равно проверяет содержимое через ffprobe). */ +export const AUDIO_EXTENSIONS = ['flac', 'mp3', 'm4a', 'ogg', 'opus', 'wav', 'aiff', 'aif', 'wv']; + +export function isAudio(name: string): boolean { + const ext = name.split('.').pop()?.toLowerCase() ?? ''; + return AUDIO_EXTENSIONS.includes(ext); +} + +/** Файлы из (в том числе webkitdirectory — тогда путь есть в webkitRelativePath). */ +export function fromInput(list: FileList | null): PickedFile[] { + return [...(list ?? [])].map((file) => ({ file, relPath: file.webkitRelativePath || file.name })); +} + +/** Файлы из перетаскивания: папки обходятся рекурсивно (FileSystemEntry API). */ +export async function fromDrop(dt: DataTransfer): Promise { + const entries = [...dt.items] + .filter((it) => it.kind === 'file') + .map((it) => it.webkitGetAsEntry?.()) + .filter((e): e is FileSystemEntry => !!e); + if (!entries.length) return fromInput(dt.files); + const out: PickedFile[] = []; + for (const e of entries) await walk(e, '', out); + return out; +} + +async function walk(entry: FileSystemEntry, prefix: string, out: PickedFile[]) { + const path = prefix ? `${prefix}/${entry.name}` : entry.name; + if (entry.isFile) { + const file = await new Promise((resolve, reject) => (entry as FileSystemFileEntry).file(resolve, reject)); + out.push({ file, relPath: path }); + return; + } + if (!entry.isDirectory || entry.name.startsWith('.')) return; + const reader = (entry as FileSystemDirectoryEntry).createReader(); + // readEntries отдаёт порциями (~100), пока не вернёт пустой массив. + for (;;) { + const batch = await new Promise((resolve, reject) => reader.readEntries(resolve, reject)); + if (!batch.length) break; + for (const child of batch) await walk(child, path, out); + } +} diff --git a/apps/web/src/features/uploads/hash.worker.ts b/apps/web/src/features/uploads/hash.worker.ts new file mode 100644 index 0000000..e8e483f --- /dev/null +++ b/apps/web/src/features/uploads/hash.worker.ts @@ -0,0 +1,40 @@ +/// +// SHA-256 файла потоком (docs/uploads.md#4): Web Crypto не умеет считать по частям, а файлы бывают по 2 ГБ — +// hash-wasm считает чанками в воркере, не держа файл в памяти и не блокируя интерфейс. +import { createSHA256 } from 'hash-wasm'; + +export interface HashRequest { + id: string; + file: File; +} + +export type HashResponse = + | { id: string; type: 'progress'; done: number } + | { id: string; type: 'done'; sha256: string } + | { id: string; type: 'error'; message: string }; + +const ctx = self as unknown as DedicatedWorkerGlobalScope; + +ctx.onmessage = async (e: MessageEvent) => { + const { id, file } = e.data; + try { + const h = await createSHA256(); + h.init(); + const reader = file.stream().getReader(); + let done = 0; + let lastReport = 0; + for (;;) { + const chunk = await reader.read(); + if (chunk.done) break; + h.update(chunk.value); + done += chunk.value.byteLength; + if (done - lastReport > 8 << 20) { + lastReport = done; + ctx.postMessage({ id, type: 'progress', done } satisfies HashResponse); + } + } + ctx.postMessage({ id, type: 'done', sha256: h.digest('hex') } satisfies HashResponse); + } catch (err) { + ctx.postMessage({ id, type: 'error', message: err instanceof Error ? err.message : String(err) } satisfies HashResponse); + } +}; diff --git a/apps/web/src/features/uploads/uploader.ts b/apps/web/src/features/uploads/uploader.ts new file mode 100644 index 0000000..2a59e16 --- /dev/null +++ b/apps/web/src/features/uploads/uploader.ts @@ -0,0 +1,287 @@ +import { unwrap, type AuthSession } from '@pipu/core'; +import * as tus from 'tus-js-client'; + +import { isAudio, type PickedFile } from './files'; +import type { HashRequest, HashResponse } from './hash.worker'; + +// Загрузка пачки в очередь на добавление (docs/uploads.md#5): SHA-256 в воркере → предпроверка дублей +// (ничего не резервирует) → POST /uploads (резерв квоты) → tus, до 4 файлов параллельно, куски по 8 МБ. +// Синглтон модуля: загрузка продолжается при переходах по приложению, но не переживает перезагрузку страницы. + +export type Stage = + | 'hashing' + | 'checking' + | 'ready' + | 'duplicate' + | 'too_large' + | 'uploading' + | 'done' + | 'rejected' + | 'error'; + +export interface UploadItem { + id: string; + file: File; + relPath: string; + stage: Stage; + progress: number; // 0..1 текущего этапа (хэш или загрузка) + sha256?: string; + trackId?: string | null; + possibleDuplicate?: boolean; + code?: string; // код отказа сервера: uploads.queue_full, uploads.duplicate, … + message?: string; + uploadFileId?: string; + tusUrl?: string; +} + +export interface UploaderState { + items: UploadItem[]; + skipped: number; // не аудио (обложки, .cue, …) + busy: boolean; // идёт загрузка + batchId: string | null; + error: unknown; +} + +const PARALLEL = 4; +const CHUNK = 8 << 20; + +let seq = 0; + +export class Uploader { + private state: UploaderState = { items: [], skipped: 0, busy: false, batchId: null, error: null }; + private listeners = new Set<() => void>(); + private worker: Worker | null = null; + private hashQueue: string[] = []; + private hashing = false; + private uploads = new Map(); + private refreshing: Promise | null = null; + + constructor(private readonly session: AuthSession) {} + + subscribe = (fn: () => void) => { + this.listeners.add(fn); + return () => this.listeners.delete(fn); + }; + + getState = () => this.state; + + private set(patch: Partial) { + this.state = { ...this.state, ...patch }; + for (const fn of this.listeners) fn(); + } + + private patch(id: string, p: Partial) { + this.set({ items: this.state.items.map((it) => (it.id === id ? { ...it, ...p } : it)) }); + } + + private item(id: string) { + return this.state.items.find((it) => it.id === id); + } + + /** Добавить файлы (из перетаскивания или выбора); не-аудио отбрасываются. */ + add(files: PickedFile[]) { + if (this.state.busy) return; + const fresh: UploadItem[] = []; + let skipped = 0; + for (const f of files) { + if (!isAudio(f.file.name)) { + skipped++; + continue; + } + // Тот же путь и размер уже в списке — не добавляем второй раз. + if (this.state.items.some((it) => it.relPath === f.relPath && it.file.size === f.file.size)) continue; + fresh.push({ id: `f${++seq}`, file: f.file, relPath: f.relPath, stage: 'hashing', progress: 0 }); + } + this.set({ items: [...this.state.items, ...fresh], skipped: this.state.skipped + skipped, batchId: null, error: null }); + this.hashQueue.push(...fresh.map((it) => it.id)); + void this.hashNext(); + } + + remove(id: string) { + if (this.state.busy) return; + this.hashQueue = this.hashQueue.filter((x) => x !== id); + this.set({ items: this.state.items.filter((it) => it.id !== id) }); + } + + /** Очистить список (после завершения или чтобы начать заново). */ + clear() { + if (this.state.busy) return; + this.hashQueue = []; + this.set({ items: [], skipped: 0, batchId: null, error: null }); + } + + private hashWorker(): Worker { + this.worker ??= new Worker(new URL('./hash.worker.ts', import.meta.url), { type: 'module' }); + return this.worker; + } + + private async hashNext() { + if (this.hashing) return; + this.hashing = true; + try { + while (this.hashQueue.length) { + const id = this.hashQueue.shift()!; + const it = this.item(id); + if (!it) continue; + try { + const sha256 = await this.hash(it); + this.patch(id, { sha256, stage: 'checking', progress: 1 }); + } catch (err) { + this.patch(id, { stage: 'error', message: err instanceof Error ? err.message : String(err) }); + } + } + await this.precheck(); + } finally { + this.hashing = false; + } + } + + private hash(it: UploadItem): Promise { + const w = this.hashWorker(); + return new Promise((resolve, reject) => { + const onMessage = (e: MessageEvent) => { + const m = e.data; + if (m.id !== it.id) return; + if (m.type === 'progress') { + this.patch(it.id, { progress: m.done / Math.max(it.file.size, 1) }); + return; + } + w.removeEventListener('message', onMessage); + if (m.type === 'done') resolve(m.sha256); + else reject(new Error(m.message)); + }; + w.addEventListener('message', onMessage); + w.postMessage({ id: it.id, file: it.file } satisfies HashRequest); + }); + } + + /** Предпроверка всех посчитанных файлов: дубли отсеиваются до загрузки. */ + private async precheck() { + const pending = this.state.items.filter((it) => it.stage === 'checking' && it.sha256); + if (!pending.length) return; + try { + const res = unwrap( + await this.session.client.POST('/api/v1/uploads/precheck', { + body: { files: pending.map((it) => ({ client_id: it.id, size: it.file.size, sha256: it.sha256! })) }, + }), + ); + for (const r of res.files) { + const stage: Stage = r.result === 'accepted' ? 'ready' : r.result; + this.patch(r.client_id, { stage, trackId: r.track_id, possibleDuplicate: r.possible_duplicate }); + } + } catch (err) { + // Предпроверка — подсказка: при сбое файлы всё равно можно отправить, сервер проверит дубли сам. + for (const it of pending) this.patch(it.id, { stage: 'ready' }); + this.set({ error: err }); + } + } + + get ready(): UploadItem[] { + return this.state.items.filter((it) => it.stage === 'ready'); + } + + /** Создать пачку (резерв квоты) и загрузить принятые файлы. */ + async start(comment: string) { + const ready = this.ready; + if (this.state.busy || !ready.length) return; + this.set({ busy: true, error: null }); + try { + const res = unwrap( + await this.session.client.POST('/api/v1/uploads', { + body: { + comment: comment.trim() || undefined, + files: ready.map((it) => ({ + client_id: it.id, + name: it.file.name, + rel_path: it.relPath.includes('/') ? it.relPath : undefined, + size: it.file.size, + sha256: it.sha256!, + })), + }, + }), + ); + this.set({ batchId: res.batch_id }); + const queue: { id: string; url: string }[] = []; + for (const f of res.files) { + if (f.rejected) this.patch(f.client_id, { stage: 'rejected', code: f.rejected, trackId: f.track_id }); + else if (f.tus_url && f.upload_file_id) { + this.patch(f.client_id, { uploadFileId: f.upload_file_id, tusUrl: f.tus_url, stage: 'uploading', progress: 0 }); + queue.push({ id: f.client_id, url: f.tus_url }); + } + } + await Promise.all(Array.from({ length: Math.min(PARALLEL, queue.length) }, () => this.drain(queue))); + } catch (err) { + this.set({ error: err }); + } finally { + this.set({ busy: false }); + } + } + + /** Повторить оборвавшиеся загрузки: место уже зарезервировано, tus продолжит с принятого смещения. */ + async retryFailed() { + const failed = this.state.items.filter((it) => it.stage === 'error' && it.tusUrl); + if (this.state.busy || !failed.length) return; + this.set({ busy: true, error: null }); + try { + const queue = failed.map((it) => { + this.patch(it.id, { stage: 'uploading', message: undefined }); + return { id: it.id, url: it.tusUrl! }; + }); + await Promise.all(Array.from({ length: Math.min(PARALLEL, queue.length) }, () => this.drain(queue))); + } finally { + this.set({ busy: false }); + } + } + + private async drain(queue: { id: string; url: string }[]) { + for (let next = queue.shift(); next; next = queue.shift()) await this.upload(next.id, next.url); + } + + private upload(id: string, url: string): Promise { + const it = this.item(id); + if (!it) return Promise.resolve(); + return new Promise((resolve) => { + const up = new tus.Upload(it.file, { + uploadUrl: url, + chunkSize: CHUNK, + retryDelays: [0, 1000, 3000, 5000, 10000, 20000], + storeFingerprintForResuming: false, + onBeforeRequest: async (req) => { + await this.refreshing; + const token = this.session.accessToken; + if (token) req.setHeader('Authorization', `Bearer ${token}`); + }, + onShouldRetry: (err) => { + const status = (err as tus.DetailedError).originalResponse?.getStatus(); + if (status === 401) { + this.refreshing = this.session.refresh(); + return true; + } + // Сеть, перегрузка, рассинхрон смещения — повторяем (tus-js-client спросит HEAD и продолжит). + return status === undefined || status === 0 || status >= 500 || status === 409 || status === 423; + }, + onProgress: (sent, total) => this.patch(id, { progress: sent / Math.max(total, 1) }), + onSuccess: () => { + this.uploads.delete(id); + this.patch(id, { stage: 'done', progress: 1 }); + resolve(); + }, + onError: (err) => { + this.uploads.delete(id); + this.patch(id, { stage: 'error', message: err.message }); + resolve(); + }, + }); + this.uploads.set(id, up); + up.start(); + }); + } +} + +let instance: { session: AuthSession; uploader: Uploader } | null = null; + +/** Загрузчик текущей сессии (новый при смене пользователя). */ +export function uploaderFor(session: AuthSession): Uploader { + if (instance?.session !== session) instance = { session, uploader: new Uploader(session) }; + return instance.uploader; +} diff --git a/apps/web/src/pages/admin/UploadQueuePage.tsx b/apps/web/src/pages/admin/UploadQueuePage.tsx new file mode 100644 index 0000000..7c6f8a9 --- /dev/null +++ b/apps/web/src/pages/admin/UploadQueuePage.tsx @@ -0,0 +1,534 @@ +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 { 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 [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' && ( + + )} +
+ )} + + + + {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, + onRetry, +}: { + files: UploadFile[]; + selectable: boolean; + selected: Set; + onToggle: (ids: string[], on: boolean) => void; + onEdit: (f: UploadFile) => 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}»} +
+ {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} + +
+
+
+ + {f.possible_duplicate && {t('uploads.flag_possible')}} + {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}`)} + )} +
+
+ {editable && ( + + )} + {f.status === 'import_failed' && ( + + )} +
+
+ ); +} + +const EMPTY_META: Meta = { artist: '', albumartist: '', album: '', title: '', genre: '', year: null, track: null, disc: 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 ( + + + + + } + > +