feat(uploads): preview storage paths, detect templates, attach covers and withdraw batches
ci / module boundaries / boundaries (push) Successful in 7s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 55s
ci / ts / ts (packages + web) (push) Failing after 4m4s
ci / api / api (push) Successful in 55s
ci / db / db (push) Failing after 1m14s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m11s

This commit is contained in:
mrleo1nid committed 2026-10-29 10:28:29 +03:00
1 parent 5e2d1e902b
commit 64dd8ed05e
29 files changed
+1219 -471

No files matched your search

+114
View File
@@ -0,0 +1,114 @@
import { expect, test, type Page } from '@playwright/test';
async function setup(page: Page, scenario: 'queue' | 'template' | 'mine') {
await page.addInitScript(() => localStorage.setItem('pipu.locale', 'ru'));
await page.goto('/login');
await page.getByLabel('Логин', { exact: true }).fill('mobile-admin');
await page.getByRole('textbox', { name: 'Пароль', exact: true }).fill('mobile-admin-password');
await page.getByRole('button', { name: 'Войти', exact: true }).click();
await expect(page.getByRole('button', { name: 'Меню профиля' })).toBeVisible();
await page.evaluate(async (scenario) => {
const modulePath = '/src/test/mobileFixtures.ts';
const { mobileFixtures } = await import(modulePath);
const source = structuredClone(mobileFixtures['/api/v1/admin/sources'].items[0]);
const file = structuredClone(mobileFixtures['/api/v1/admin/uploads'].items[0]);
const files = [
{ ...file, id: 'visible', original_name: 'visible.flac', target_source_name: source.name, target_rel_path: 'Artist/Album/01 - Song.flac' },
{ ...file, id: 'approved', original_name: 'approved.flac', status: 'approved' },
{ ...file, id: 'hidden', original_name: 'hidden.flac', status: 'processing' },
];
const missing = { ...file, id: 'missing', original_name: 'missing.flac', target_source_id: null, target_source_name: null, target_rel_path: null, status_reason: 'uploads.no_target' };
let failOnce = true;
const deleted: string[] = [];
const original = window.fetch;
const json = (data: unknown, status = 200) => new Response(JSON.stringify(data), { status, headers: { 'Content-Type': 'application/json' } });
window.fetch = async (input, init) => {
const req = new Request(input, init);
const url = new URL(req.url);
if (scenario === 'queue' && url.pathname === '/api/v1/admin/uploads') return json({ items: [files[0], missing], next_cursor: null });
if (scenario === 'queue' && url.pathname === '/api/v1/admin/uploads/files/missing' && req.method === 'PATCH') {
const body = await req.json();
Object.assign(missing, { metadata: body.metadata, target_source_id: source.id, target_source_name: source.name, target_rel_path: 'Artist/Album/02 - Fixed.flac', status_reason: null });
return json(missing);
}
if (scenario === 'template') {
if (url.pathname.endsWith('/write-template/suggest')) return json({ template: '{albumartist}/{album}/{track:02}. {title}.{ext}', matched: 180, total: 200, examples: ['Кино/Группа крови/01. Группа крови.flac'] });
if (url.pathname.endsWith('/write-template/check')) return json({ ok: false, total: 50, round_trip: 0, same_path: 45, mismatches: [] });
if (url.pathname === `/api/v1/admin/sources/${source.id}` && req.method === 'PATCH') {
const body = await req.json();
document.documentElement.dataset.savedTemplate = body.write_template;
Object.assign(source, body);
return json(source);
}
if (url.pathname === `/api/v1/admin/sources/${source.id}`) return json(source);
}
if (scenario === 'mine' && url.pathname === '/api/v1/uploads/mine') {
if (url.searchParams.has('batch')) {
document.documentElement.dataset.batchPages = String(Number(document.documentElement.dataset.batchPages ?? 0) + 1);
return json({ items: [url.searchParams.has('cursor') ? files[2] : files[0]], next_cursor: url.searchParams.has('cursor') ? null : 'next' });
}
return json({ items: files.slice(0, 2), next_cursor: 'more' });
}
if (scenario === 'mine' && req.method === 'DELETE' && url.pathname.startsWith('/api/v1/uploads/files/')) {
const id = url.pathname.split('/').pop()!;
deleted.push(id);
document.documentElement.dataset.deleted = JSON.stringify(deleted);
if (id === 'hidden' && failOnce) { failOnce = false; return json({ code: 'uploads.wrong_status', status: 409 }, 409); }
files.find((f) => f.id === id)!.status = 'withdrawn';
return new Response(null, { status: 204 });
}
return original(input, init);
};
const target = scenario === 'queue' ? '/admin/uploads' : scenario === 'mine' ? '/uploads/mine' : `/admin/sources/${source.id}/patterns`;
history.pushState({}, '', target);
window.dispatchEvent(new PopStateEvent('popstate'));
}, scenario);
}
test('queue shows the storage and path, and recalculates after fixing placement', async ({ page }, info) => {
await setup(page, 'queue');
await expect(page.getByText('Artist/Album/01 - Song.flac', { exact: true })).toBeVisible();
await expect(page.getByText('Хранилище не выбрано', { exact: true })).toBeVisible();
await page.getByRole('button', { name: 'Выбрать хранилище', exact: true }).click();
const dialog = page.getByRole('dialog');
await dialog.getByLabel('Исполнитель', { exact: true }).fill('Artist');
await dialog.getByLabel('Название', { exact: true }).fill('Fixed');
await dialog.getByRole('combobox').selectOption({ label: 'Музыкальное хранилище' });
await dialog.getByRole('button', { name: 'Сохранить', exact: true }).click();
await expect(page.getByText('Artist/Album/02 - Fixed.flac', { exact: true })).toBeVisible();
await page.screenshot({ path: info.outputPath('queue-placement.png'), fullPage: true });
await page.setViewportSize({ width: 390, height: 844 });
await expect(page.getByText('Artist/Album/02 - Fixed.flac', { exact: true })).toBeVisible();
await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth - innerWidth)).toBeLessThanOrEqual(1);
});
test('automatically suggests a dominant template and saves a custom edit only on request', async ({ page }, info) => {
await setup(page, 'template');
const field = page.getByRole('textbox', { name: 'Шаблон пути', exact: true });
await expect(field).toHaveValue('{albumartist}/{album}/{track:02}. {title}.{ext}');
await expect(page.getByText(/180 из 200/)).toBeVisible();
await expect(page.getByText('Кино/Группа крови/01. Группа крови.flac', { exact: true })).toBeVisible();
await expect(page.locator('html')).not.toHaveAttribute('data-saved-template');
await field.fill('{albumartist}/[{year} - ]{album}/{track:02} - {title}.{ext}');
const section = page.getByRole('heading', { name: 'Приём загрузок и шаблон пути' }).locator('..');
await section.getByRole('checkbox').check();
await section.getByRole('button', { name: 'Сохранить', exact: true }).click();
await expect(page.locator('html')).toHaveAttribute('data-saved-template', '{albumartist}/[{year} - ]{album}/{track:02} - {title}.{ext}');
await page.screenshot({ path: info.outputPath('auto-template.png'), fullPage: true });
});
test('selects a whole own batch across pages and reports partial withdrawal failures', async ({ page }, info) => {
await setup(page, 'mine');
await page.getByRole('checkbox', { name: 'Выбрать всю пачку', exact: true }).check();
await expect(page.locator('html')).toHaveAttribute('data-batch-pages', '2');
await expect(page.getByText(/Выбрано 2 файла/)).toBeVisible();
await page.getByRole('button', { name: 'Отозвать выбранные', exact: true }).click();
await expect(page.getByText(/Не удалось отозвать: 1/)).toBeVisible();
await expect(page.locator('html')).toHaveAttribute('data-deleted', '["visible","hidden"]');
await page.setViewportSize({ width: 390, height: 844 });
await expect.poll(() => page.evaluate(() => Math.max(document.documentElement.scrollWidth - innerWidth, document.querySelector('main')!.scrollWidth - document.querySelector('main')!.clientWidth))).toBeLessThanOrEqual(1);
await page.screenshot({ path: info.outputPath('bulk-withdraw-mobile.png'), fullPage: true });
await page.getByRole('button', { name: 'Отозвать выбранные', exact: true }).click();
await expect(page.locator('html')).toHaveAttribute('data-deleted', '["visible","hidden","hidden"]');
await expect(page.getByRole('region', { name: 'Массовый отзыв предложений' })).toHaveCount(0);
});
+21 -2
View File
@@ -127,8 +127,7 @@ function QueueRow({
m && <span className={cx(s.badge, u.tone_muted)}>{t('uploads.flag_new_album')}</span>
)}
</div>
{f.target_rel_path && <span className={u.target}>{f.target_rel_path}</span>}
{reason && <span className={f.status === 'pending_review' ? u.warn : u.bad}>{reason}</span>}
{reason && (!editable || f.target_rel_path) && <span className={f.status === 'pending_review' ? u.warn : u.bad}>{reason}</span>}
{f.duplicate_level && f.status === 'duplicate' && (
<span className={u.sub}>{t(`uploads.dup_level_${f.duplicate_level}`)}</span>
)}
@@ -148,6 +147,26 @@ function QueueRow({
</Button>
)}
</div>
{(editable || f.target_rel_path) && (
<div className={u.placement} role="group" aria-label={t('uploads.destination')}>
<span className={u.placementLabel}>{t(f.status === 'imported' ? 'uploads.destination_imported' : 'uploads.destination')}</span>
<strong>{f.target_source_name ?? t(f.target_source_id ? 'uploads.target_selected' : 'uploads.target_not_selected')}</strong>
{f.target_rel_path ? (
<>
<code className={u.target}>{f.target_rel_path}</code>
<span className={u.sub}>{t('uploads.target_path_hint')}</span>
</>
) : (
<>
<span className={u.warn}>{t('uploads.target_path_missing')}</span>
{reason && <span className={u.warn}>{reason}</span>}
<Button variant="secondary" onClick={onEdit}>
{t(f.target_source_id ? 'uploads.fix_destination' : 'uploads.choose_destination')}
</Button>
</>
)}
</div>
)}
</div>
);
}
+19
View File
@@ -30,6 +30,25 @@ export function isAudio(name: string): boolean {
return AUDIO_EXTENSIONS.includes(ext);
}
export function isAlbumCover(name: string): boolean {
return /^(cover|folder|front)\.(jpe?g|png|webp)$/i.test(name.split(/[/\\]/).pop() ?? name);
}
export function albumFolder(relPath: string): string {
const path = relPath.replaceAll('\\', '/');
const at = path.lastIndexOf('/');
return at < 0 ? '' : path.slice(0, at);
}
export function coverBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result).split(',')[1]!);
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(file);
});
}
/** Файлы из <input type="file"> (в том числе webkitdirectory — тогда путь есть в webkitRelativePath). */
export function fromInput(list: FileList | null): PickedFile[] {
return [...(list ?? [])].map((file) => ({ file, relPath: file.webkitRelativePath || file.name }));
@@ -0,0 +1,54 @@
import { AuthSession } from '@pipu/core';
import { waitFor } from '@testing-library/react';
import { afterEach, expect, it, vi } from 'vitest';
import { Uploader } from './uploader';
vi.mock('music-metadata', () => ({
parseBlob: vi.fn(async (file: File) => ({
format: {}, common: file.name.startsWith('01') ? { picture: [{ data: new Uint8Array([0xff, 0xd8, 0xff, 1]), format: 'image/jpeg', type: 'Cover (front)' }] } : {},
})),
}));
vi.mock('tus-js-client', () => ({
Upload: class {
constructor(_file: File, private options: { onSuccess: () => void }) {}
start() { queueMicrotask(this.options.onSuccess); }
},
}));
afterEach(() => { vi.unstubAllGlobals(); localStorage.clear(); });
it('keeps external and embedded album covers in separate batches without skipping images', async () => {
vi.stubGlobal('Worker', class extends EventTarget {
postMessage({ id }: { id: string }) {
queueMicrotask(() => this.dispatchEvent(new MessageEvent('message', { data: { id, type: 'done', sha256: id.padEnd(64, '0') } })));
}
});
const batches: { cover_base64?: string; files: { client_id: string; rel_path: string }[] }[] = [];
const session = new AuthSession({ baseUrl: 'http://localhost', clientType: 'web', fetch: async (input) => {
const req = input as Request;
const body = await req.json();
const data = req.url.endsWith('/precheck')
? { files: body.files.map((f: { client_id: string }) => ({ client_id: f.client_id, result: 'accepted' })) }
: (batches.push(body), { batch_id: String(batches.length), files: body.files.map((f: { client_id: string }) => ({ client_id: f.client_id, upload_file_id: f.client_id, tus_url: `/files/${f.client_id}` })) });
return new Response(JSON.stringify(data), { headers: { 'Content-Type': 'application/json' } });
} });
const uploader = new Uploader(session);
uploader.add([
{ file: new File(['external-image'], 'Cover.jpg'), relPath: 'A/Cover.jpg' },
{ file: new File(['audio'], '02.flac'), relPath: 'A/02.flac' },
{ file: new File(['audio'], '01.flac'), relPath: 'A/01.flac' },
{ file: new File(['audio'], '02.flac'), relPath: 'B/02.flac' },
{ file: new File(['audio'], '01.flac'), relPath: 'B/01.flac' },
]);
await waitFor(() => expect(uploader.ready).toHaveLength(4));
expect(uploader.getState().skipped).toBe(0);
expect(uploader.getState().items.find((f) => f.relPath === 'A/01.flac')?.coverName).toBe('Cover.jpg');
expect(uploader.getState().items.find((f) => f.relPath === 'B/01.flac')?.coverName).toBe('embedded-cover');
await uploader.start('Two albums');
expect(batches).toHaveLength(2);
expect(batches[0]!.cover_base64).toBe(btoa('external-image'));
expect(batches[1]!.cover_base64).toBe(btoa(String.fromCharCode(0xff, 0xd8, 0xff, 1)));
expect(batches[0]!.files.map((f) => f.rel_path)).toEqual(['A/01.flac', 'A/02.flac']);
expect(batches[1]!.files.map((f) => f.rel_path)).toEqual(['B/01.flac', 'B/02.flac']);
expect(uploader.getState().items.every((f) => f.stage === 'done')).toBe(true);
});
+53 -24
View File
@@ -1,7 +1,7 @@
import { unwrap, type AuthSession } from '@pipu/core';
import * as tus from 'tus-js-client';
import { isAudio, type PickedFile } from './files';
import { albumFolder, coverBase64, isAlbumCover, isAudio, type PickedFile } from './files';
import type { HashRequest, HashResponse } from './hash.worker';
// Загрузка пачки в очередь на добавление (docs/uploads.md#5): SHA-256 в воркере → предпроверка дублей
@@ -29,6 +29,7 @@ export interface UploadItem {
progress: number; // 0..1 текущего этапа (хэш или загрузка)
sha256?: string;
tags?: { artist?: string; title?: string; album?: string; duration_ms?: number };
coverName?: string;
trackId?: string | null;
possibleDuplicate?: boolean;
code?: string; // код отказа сервера: uploads.queue_full, uploads.duplicate, …
@@ -72,6 +73,7 @@ export class Uploader {
private transferWorkers = 0;
private transferDone: (() => void) | null = null;
private refreshing: Promise<unknown> | null = null;
private folderCovers = new Map<string, File>();
constructor(private readonly session: AuthSession) {
this.restore();
@@ -100,17 +102,21 @@ export class Uploader {
/** Добавить файлы (из перетаскивания или выбора); не-аудио отбрасываются. */
add(files: PickedFile[]) {
if (this.state.busy) return;
for (const f of files) {
if (isAlbumCover(f.file.name)) this.folderCovers.set(albumFolder(f.relPath), f.file);
}
const fresh: UploadItem[] = [];
let skipped = 0;
for (const f of files) {
if (!isAudio(f.file.name)) {
skipped++;
if (!isAlbumCover(f.file.name)) skipped++;
continue;
}
// Тот же путь и размер уже в списке — не добавляем второй раз.
if (this.state.items.some((it) => it.relPath === f.relPath && it.size === f.file.size)) continue;
fresh.push({ id: `f${++seq}`, file: f.file, size: f.file.size, relPath: f.relPath, stage: 'hashing', progress: 0 });
}
fresh.sort((a, b) => a.relPath.localeCompare(b.relPath, undefined, { numeric: true }));
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();
@@ -127,6 +133,7 @@ export class Uploader {
clear() {
if (this.state.busy) return;
this.hashQueue = [];
this.folderCovers.clear();
this.set({ items: [], skipped: 0, batchId: null, error: null });
this.persist();
}
@@ -220,30 +227,43 @@ export class Uploader {
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 ?? it.relPath,
rel_path: it.relPath.includes('/') ? it.relPath : undefined,
size: it.size,
sha256: it.sha256!,
})),
},
}),
);
this.set({ batchId: res.batch_id });
const groups = new Map<string, UploadItem[]>();
for (const it of ready) {
const folder = albumFolder(it.relPath);
groups.set(folder, [...(groups.get(folder) ?? []), it]);
}
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 });
for (const [folder, group] of groups) {
try {
const cover = this.folderCovers.get(folder);
const res = unwrap(
await this.session.client.POST('/api/v1/uploads', {
body: {
comment: comment.trim() || undefined,
...(cover ? { cover_base64: await coverBase64(cover) } : {}),
files: group.map((it) => ({
client_id: it.id,
name: it.file?.name ?? it.relPath,
rel_path: it.relPath.includes('/') ? it.relPath : undefined,
size: it.size,
sha256: it.sha256!,
})),
},
}),
);
this.set({ batchId: res.batch_id });
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 });
}
}
this.persist();
} catch (err) {
this.set({ error: err });
}
}
this.persist();
await this.transfer(queue);
} catch (err) {
this.set({ error: err });
@@ -352,7 +372,12 @@ export class Uploader {
if (!it.file) return;
try {
const { parseBlob } = await import('music-metadata');
const meta = await parseBlob(it.file);
const folder = albumFolder(it.relPath);
const meta = await parseBlob(it.file, { skipCovers: this.folderCovers.has(folder) });
if (!this.folderCovers.has(folder)) {
const picture = meta.common.picture?.find((p) => p.type === 'Cover (front)') ?? meta.common.picture?.[0];
if (picture) this.folderCovers.set(folder, new File([new Uint8Array(picture.data)], 'embedded-cover', { type: picture.format }));
}
const duration = meta.format.duration;
this.patch(it.id, {
tags: {
@@ -365,6 +390,10 @@ export class Uploader {
} catch {
// Теги — подсказка для предпроверки; без них сервер прочитает их сам.
}
const folder = albumFolder(it.relPath);
const cover = this.folderCovers.get(folder);
const first = this.state.items.find((item) => albumFolder(item.relPath) === folder);
if (cover && first) this.patch(first.id, { coverName: cover.name });
}
/** Незавершённые tus-загрузки переживают перезагрузку: файл выбирают заново и докачивают. */
@@ -1,7 +1,7 @@
import type { components } from '@pipu/api-client';
import { unwrap } from '@pipu/core';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useSession } from '../../app/session';
@@ -27,30 +27,40 @@ export function WriteTemplateSection({ source }: { source: Source }) {
const [accepts, setAccepts] = useState(source.accepts_uploads);
const [template, setTemplate] = useState(source.write_template ?? '');
const [check, setCheck] = useState<Check | null>(null);
const revision = useRef(0);
useEffect(() => {
setAccepts(source.accepts_uploads);
setTemplate(source.write_template ?? '');
}, [source.accepts_uploads, source.write_template]);
const runCheck = useMutation({
mutationFn: async (tpl: string) =>
unwrap(
mutationFn: async (tpl: string) => {
const requestedRevision = revision.current;
const result = unwrap(
await session.client.POST('/api/v1/admin/sources/{id}/write-template/check', {
params: { path: { id } },
body: { template: tpl },
}),
),
onSuccess: setCheck,
});
const suggest = useMutation({
mutationFn: async () =>
unwrap(await session.client.POST('/api/v1/admin/sources/{id}/write-template/suggest', { params: { path: { id } } }))
.template,
onSuccess: (tpl) => {
setTemplate(tpl);
runCheck.mutate(tpl);
);
return { result, requestedRevision };
},
onSuccess: ({ result, requestedRevision }) => {
if (requestedRevision === revision.current) setCheck(result);
},
});
const suggest = useMutation({
mutationFn: async (_revision: number) =>
unwrap(await session.client.POST('/api/v1/admin/sources/{id}/write-template/suggest', { params: { path: { id } } })),
onSuccess: (result, requestedRevision) => {
if (requestedRevision !== revision.current) return;
setTemplate(result.template);
runCheck.mutate(result.template);
},
});
const suggestTemplate = suggest.mutate;
useEffect(() => {
if (!source.write_template) suggestTemplate(revision.current);
}, [id, source.write_template, suggestTemplate]);
const save = useMutation({
mutationFn: async () =>
unwrap(
@@ -73,6 +83,15 @@ export function WriteTemplateSection({ source }: { source: Source }) {
<section className={s.section}>
<h2>{t('write_template.title')}</h2>
<p className={s.note}>{t('write_template.intro')}</p>
<p className={s.note}>{t('write_template.auto_hint')}</p>
{suggest.isPending && <p className={s.note} role="status">{t('write_template.detecting')}</p>}
{suggest.data && (
<div className={s.sessions}>
<p className={s.note} role="status">{t('write_template.detected', { matched: suggest.data.matched ?? 0, total: suggest.data.total ?? 0 })}</p>
<code className={l.match}>{suggest.data.template}</code>
{(suggest.data.examples ?? []).map((path) => <code key={path} className={l.match}>{path}</code>)}
</div>
)}
<label className={l.checkRow}>
<input type="checkbox" checked={accepts} onChange={(e) => setAccepts(e.target.checked)} />
<span>{t('write_template.accepts')}</span>
@@ -82,6 +101,7 @@ export function WriteTemplateSection({ source }: { source: Source }) {
label={t('write_template.template')}
value={template}
onChange={(v) => {
revision.current++;
setTemplate(v);
setCheck(null);
save.reset();
@@ -91,7 +111,7 @@ export function WriteTemplateSection({ source }: { source: Source }) {
mono
/>
<div className={s.toolbar}>
<Button busy={suggest.isPending} onClick={() => suggest.mutate()}>
<Button busy={suggest.isPending} onClick={() => suggest.mutate(revision.current)}>
{t('write_template.suggest')}
</Button>
<Button busy={runCheck.isPending} disabled={!template.trim()} onClick={() => runCheck.mutate(template)}>
+104 -15
View File
@@ -1,7 +1,7 @@
import { unwrap } from '@pipu/core';
import { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Link } from '@tanstack/react-router';
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useSession } from '../../app/session';
@@ -25,11 +25,14 @@ const WITHDRAWABLE: UploadStatus[] = ['uploading', 'processing', 'pending_review
/** «Мои предложения»: статусы своих загрузок и решения админа (docs/uploads.md#10). */
export function MyUploadsPage() {
const { t } = useTranslation(['admin', 'common']);
const { t, i18n } = useTranslation(['admin', 'common']);
const session = useSession();
const queryClient = useQueryClient();
const errorText = useErrorText();
const [filter, setFilter] = useState<Filter>('all');
const [selected, setSelected] = useState<Map<string, string>>(new Map());
const [selectedBatches, setSelectedBatches] = useState<Set<string>>(new Set());
useEffect(() => { setSelected(new Map()); setSelectedBatches(new Set()); }, [filter]);
useEffect(() => {
void session.client.POST('/api/v1/uploads/seen').then(() => {
@@ -52,14 +55,61 @@ export function MyUploadsPage() {
q.state.data?.pages.some((p) => p.items.some((it) => MOVING.includes(it.status))) ? 5000 : false,
});
const withdraw = useMutation({
mutationFn: async (id: string) =>
unwrap(await session.client.DELETE('/api/v1/uploads/files/{id}', { params: { path: { id } } })),
onSuccess: async () => {
mutationFn: async (ids: string[]) => {
const failed: { id: string; error: unknown }[] = [];
for (const id of ids) {
try {
unwrap(await session.client.DELETE('/api/v1/uploads/files/{id}', { params: { path: { id } } }));
} catch (error) {
failed.push({ id, error });
}
}
return failed;
},
onSuccess: async (failed) => {
setSelected((cur) => new Map([...cur].filter(([id]) => failed.some((f) => f.id === id))));
setSelectedBatches(new Set());
await queryClient.invalidateQueries({ queryKey: ['my-uploads'] });
await queryClient.invalidateQueries({ queryKey: ['upload-quota'] });
},
});
const items = list.data?.pages.flatMap((p) => p.items) ?? [];
useEffect(() => {
const unavailable = new Set(list.data?.pages.flatMap((p) => p.items.filter((f) => !WITHDRAWABLE.includes(f.status)).map((f) => f.id)) ?? []);
setSelected((cur) => new Map([...cur].filter(([id]) => !unavailable.has(id))));
}, [list.data]);
const batches = useMemo(() => {
const groups = new Map<string, UploadFile[]>();
for (const file of items) groups.set(file.batch_id, [...(groups.get(file.batch_id) ?? []), file]);
return [...groups.values()];
}, [list.data]);
const toggle = (files: UploadFile[], on: boolean) => setSelected((cur) => {
const next = new Map(cur);
for (const file of files) {
if (on) next.set(file.id, file.batch_id);
else next.delete(file.id);
}
return next;
});
const selectBatch = useMutation({
mutationFn: async (batch: string) => {
const files: UploadFile[] = [];
let cursor: string | undefined;
do {
const page = unwrap(await session.client.GET('/api/v1/uploads/mine', { params: { query: { batch, status: WITHDRAWABLE, limit: 100, cursor } } }));
files.push(...page.items);
cursor = page.next_cursor ?? undefined;
} while (cursor);
return { batch, files };
},
onSuccess: ({ batch, files }) => {
toggle(files, true);
setSelectedBatches((cur) => new Set([...cur, batch]));
},
onError: (_error, batch) => setSelectedBatches((cur) => new Set([...cur].filter((id) => id !== batch))),
});
const busy = withdraw.isPending || selectBatch.isPending;
const visibleEligible = items.filter((f) => WITHDRAWABLE.includes(f.status));
return (
<div className={s.page}>
@@ -73,7 +123,7 @@ export function MyUploadsPage() {
<div className={s.toolbar}>
<div className={s.chips} role="group" aria-label={t('uploads.filter')}>
{(Object.keys(FILTERS) as Filter[]).map((f) => (
<button key={f} type="button" className={s.chip} aria-pressed={filter === f} onClick={() => setFilter(f)}>
<button key={f} type="button" className={s.chip} aria-pressed={filter === f} disabled={busy} onClick={() => setFilter(f)}>
{t(`uploads.filter_${f}`)}
</button>
))}
@@ -85,7 +135,8 @@ export function MyUploadsPage() {
</Button>
</Link>
</div>
<FormError>{(list.error ?? withdraw.error) ? errorText(list.error ?? withdraw.error) : null}</FormError>
<FormError>{(list.error ?? withdraw.error ?? selectBatch.error) ? errorText(list.error ?? withdraw.error ?? selectBatch.error) : null}</FormError>
{!!withdraw.data?.length && <FormError>{t('uploads.withdraw_failed', { count: withdraw.data.length, reason: errorText(withdraw.data[0]!.error) })}</FormError>}
{list.isSuccess && items.length === 0 && (
<EmptyState icon={addIcon} action={<Link to="/uploads/new">{t('uploads.suggest_title')}</Link>}>
{t('uploads.mine_empty')}
@@ -93,9 +144,13 @@ export function MyUploadsPage() {
)}
{items.length > 0 && (
<div className={s.tableWrap}>
<table className={s.table}>
<table className={cx(s.table, u.mineTable)}>
<thead>
<tr>
<th><input type="checkbox" aria-label={t('uploads.select_visible')} disabled={busy || !visibleEligible.length} checked={visibleEligible.length > 0 && visibleEligible.every((f) => selected.has(f.id))} onChange={(e) => {
toggle(visibleEligible, e.target.checked);
setSelectedBatches(new Set());
}} /></th>
<th>{t('uploads.col_file')}</th>
<th>{t('uploads.col_status')}</th>
<th>{t('uploads.col_size')}</th>
@@ -103,19 +158,52 @@ export function MyUploadsPage() {
<th />
</tr>
</thead>
<tbody>
{items.map((f) => (
{batches.map((files) => {
const first = files[0]!;
const eligible = files.filter((f) => WITHDRAWABLE.includes(f.status));
return <tbody key={first.batch_id}>
<tr><td colSpan={6} className={u.mineBatchCell}>
<div className={u.mineBatchHead}>
<input type="checkbox" aria-label={t('uploads.select_batch')} disabled={busy || !eligible.length} checked={selectedBatches.has(first.batch_id)} ref={(node) => {
if (node) node.indeterminate = !selectedBatches.has(first.batch_id) && eligible.some((f) => selected.has(f.id));
}} onChange={(e) => {
if (e.target.checked) {
setSelectedBatches((cur) => new Set([...cur, first.batch_id]));
selectBatch.mutate(first.batch_id);
}
else {
setSelected((cur) => new Map([...cur].filter(([, batch]) => batch !== first.batch_id)));
setSelectedBatches((cur) => new Set([...cur].filter((id) => id !== first.batch_id)));
}
}} />
<strong>{formatDateTime(first.created_at, i18n.language)}</strong>
{first.batch_comment && <span>«{first.batch_comment}»</span>}
</div>
</td></tr>
{files.map((f) => (
<MineRow
key={f.id}
file={f}
busy={withdraw.isPending && withdraw.variables === f.id}
onWithdraw={() => withdraw.mutate(f.id)}
busy={busy}
withdrawing={withdraw.isPending && withdraw.variables.includes(f.id)}
checked={selected.has(f.id)}
onCheck={(on) => {
toggle([f], on);
setSelectedBatches((cur) => new Set([...cur].filter((id) => id !== f.batch_id)));
}}
onWithdraw={() => withdraw.mutate([f.id])}
/>
))}
</tbody>
</tbody>;
})}
</table>
</div>
)}
{selected.size > 0 && <section className={u.bulk} aria-label={t('uploads.bulk_withdraw')}>
<span>{t('uploads.selected', { count: selected.size })}</span>
<Button variant="ghost" disabled={busy} onClick={() => { setSelected(new Map()); setSelectedBatches(new Set()); }}>{t('common:form.cancel')}</Button>
<Button variant="danger" busy={withdraw.isPending} disabled={selectBatch.isPending} onClick={() => withdraw.mutate([...selected.keys()])}>{t('uploads.withdraw_selected')}</Button>
</section>}
{list.hasNextPage && (
<Button busy={list.isFetchingNextPage} onClick={() => void list.fetchNextPage()}>
{t('uploads.load_more')}
@@ -125,7 +213,7 @@ export function MyUploadsPage() {
);
}
function MineRow({ file: f, busy, onWithdraw }: { file: UploadFile; busy: boolean; onWithdraw: () => void }) {
function MineRow({ file: f, busy, withdrawing, checked, onCheck, onWithdraw }: { file: UploadFile; busy: boolean; withdrawing: boolean; checked: boolean; onCheck: (on: boolean) => void; onWithdraw: () => void }) {
const { t, i18n } = useTranslation('admin');
// Технические подробности ошибки импорта — для админа; автору достаточно причины.
const raw = f.status === 'import_failed' ? (f.status_reason?.split(':')[0] ?? null) : f.status_reason;
@@ -133,6 +221,7 @@ function MineRow({ file: f, busy, onWithdraw }: { file: UploadFile; busy: boolea
const m = f.metadata;
return (
<tr>
<td>{WITHDRAWABLE.includes(f.status) && <input type="checkbox" aria-label={t('uploads.select_file', { name: f.original_name })} checked={checked} disabled={busy} onChange={(e) => onCheck(e.target.checked)} />}</td>
<td className={u.trackCell}>
<div className={u.meta}>
<span className={u.metaTitle}>{m ? `${m.artist} — ${m.title}` : f.original_name}</span>
@@ -156,7 +245,7 @@ function MineRow({ file: f, busy, onWithdraw }: { file: UploadFile; busy: boolea
<td className={s.muted}>{formatDateTime(f.created_at, i18n.language)}</td>
<td>
{WITHDRAWABLE.includes(f.status) && (
<Button variant="ghost" busy={busy} onClick={onWithdraw}>
<Button variant="ghost" busy={withdrawing} disabled={busy} onClick={onWithdraw}>
{t('uploads.withdraw')}
</Button>
)}
+4 -2
View File
@@ -14,7 +14,7 @@ import s from '../page.module.css';
import { QuotaMeter, useUploadQuota } from './common';
import u from './uploads.module.css';
const ACCEPT = AUDIO_EXTENSIONS.map((e) => `.${e}`).join(',');
const ACCEPT = [...AUDIO_EXTENSIONS, 'jpg', 'jpeg', 'png', 'webp'].map((e) => `.${e}`).join(',');
/**
* «Предложить треки» (docs/uploads.md#5): перетащить файлы или папки → SHA-256 в воркере → предпроверка дублей →
@@ -41,7 +41,7 @@ export function SuggestPage() {
const ready = st.items.filter((it) => it.stage === 'ready');
const readyBytes = ready.reduce((n, it) => n + it.size, 0);
const hashing = st.items.some((it) => it.stage === 'hashing' || it.stage === 'checking');
const finished = !st.busy && st.batchId !== null;
const finished = !st.busy && st.batchId !== null && !ready.length;
const resume = st.items.filter((it) => it.stage === 'resume').length;
const failedUploads = st.items.filter((it) => it.stage === 'error' && it.tusUrl).length;
const uploaded = st.items.filter((it) => it.stage === 'done').length;
@@ -83,6 +83,7 @@ export function SuggestPage() {
<div className={u.dropTitle}>{t('uploads.drop_title')}</div>
<p className={u.formatWarn}>{t('uploads.drop_hint')}</p>
<div className={s.note}>{t('uploads.drop_folders')}</div>
<div className={s.note}>{t('uploads.cover_hint')}</div>
<div className={u.dropActions}>
<Button disabled={st.busy} onClick={() => filesRef.current?.click()}>
{t('uploads.pick_files')}
@@ -233,6 +234,7 @@ function FileRow({ item, locked, retryDisabled, onRetry, onRemove }: {
</div>
)}
{needsFormatAttention(null, item.relPath) && <span className={u.warn}>{t('uploads.format_attention')}</span>}
{item.coverName && <span className={u.sub}>{t(item.coverName === 'embedded-cover' ? 'uploads.cover_embedded' : 'uploads.cover_found', { name: item.coverName })}</span>}
{note && <span className={note.cls}>{note.text}</span>}
</div>
<span className={u.size}>{formatBytes(item.size, i18n.language)}</span>
@@ -315,6 +315,32 @@
overflow-wrap: anywhere;
}
.placement {
grid-column: 2 / -1;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 6px;
padding: 10px 12px;
border: 1px solid var(--line);
border-radius: var(--radius-md);
background: var(--surface2);
overflow-wrap: anywhere;
}
.placementLabel {
font-size: 11px;
color: var(--text3);
}
.mineBatchHead {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
overflow-wrap: anywhere;
}
.trackCell {
min-width: 260px;
max-width: 480px;
@@ -327,6 +353,20 @@
}
@media (max-width: 767px) {
.mineTable, .mineTable tbody { display: block; }
.mineTable thead { display: none; }
.mineTable tr {
display: grid;
grid-template-columns: 24px minmax(0, 1fr);
gap: 10px;
padding: 14px;
border-bottom: 1px solid var(--line);
}
.mineTable td { min-width: 0; height: auto; padding: 0; border: 0; }
.mineTable td:not(:first-child) { grid-column: 2; }
.mineTable .trackCell { grid-column: 2; grid-row: 1; min-width: 0; max-width: none; }
.mineTable .mineBatchCell { grid-column: 1 / -1; }
.mineTable .ellipsis { white-space: normal; overflow-wrap: anywhere; }
.queueRow { grid-template-columns: 24px minmax(0, 1fr); gap: 10px; padding: 12px; }
.queueRow > :nth-child(3), .queueRow > .rowActions { grid-column: 2; }
.rowActions { flex-wrap: wrap; min-width: 0; }