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
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:
1 parent
5e2d1e902b
commit
64dd8ed05e
29 files changed
+1219
-471
No files matched your search
@@ -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);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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)}>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in new issue
Block a user