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
+1198 -450

No files matched your search

+25
View File
@@ -910,6 +910,12 @@ paths:
tags: [uploads]
summary: Мои предложения (moderator, admin)
parameters:
- name: batch
in: query
description: Только файлы указанной собственной пачки
schema:
type: string
format: uuid
- $ref: "#/components/parameters/UploadStatusFilter"
- $ref: "#/components/parameters/Limit"
- $ref: "#/components/parameters/Cursor"
@@ -2743,6 +2749,10 @@ components:
type: object
required: [files]
properties:
cover_base64:
type: string
maxLength: 11184812
description: Обложка альбома JPEG, PNG или WebP, до 8 МБ после декодирования. Применяется ко всем файлам пачки.
comment:
type: string
maxLength: 1000
@@ -2914,6 +2924,10 @@ components:
type: string
nullable: true
description: Путь в хранилище по write_template (null — не построился, причина в status_reason)
target_source_name:
type: string
nullable: true
description: Название целевого хранилища; null, если хранилище не выбрано или удалено
status_reason:
type: string
nullable: true
@@ -3614,6 +3628,17 @@ components:
properties:
template:
type: string
matched:
type: integer
description: Число путей выборки, совпавших с предложенным шаблоном
total:
type: integer
description: Число исследованных файлов хранилища
examples:
type: array
items:
type: string
description: До трёх существующих путей, совпавших с шаблоном
WriteTemplateCheckRequest:
type: object
required: [template]
+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);
});
+34 -5
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,11 +227,21 @@ export class Uploader {
if (this.state.busy || !ready.length) return;
this.set({ busy: true, error: null });
try {
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 [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,
files: ready.map((it) => ({
...(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,
@@ -235,7 +252,6 @@ export class Uploader {
}),
);
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) {
@@ -244,6 +260,10 @@ export class Uploader {
}
}
this.persist();
} catch (err) {
this.set({ error: err });
}
}
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; }
+14 -5
View File
@@ -86,7 +86,11 @@
- Staging — локальный диск сервера `/srv/pipu/uploads/<upload_id>/<file_id>` (не NFS).
- Разрешённые форматы: `flac mp3 m4a ogg opus wav aiff wv`. Просим присылать lossless — **FLAC, WAV или AIFF** (WavPack тоже считается lossless); MP3 и остальные lossy — крайний случай: форма предупреждает об этом, а очередь на добавление помечает такой файл «обратить внимание». Проверяется не расширение, а содержимое (`ffprobe`):
должен быть ровно один аудиопоток, длительность 5 с – 3 ч. Иначе `invalid` с кодом причины.
- Допускается отдельная картинка обложки (`cover.jpg|png`) в той же пачке — привязывается к файлам той же папки.
- Картинки `cover.jpg`, `cover.png` (также `folder`/`front`, JPEG/PNG/WebP) загружаются как обложка альбома,
а не пропускаются как не-аудио. Если отдельной картинки нет, берётся встроенная обложка из первого трека,
в котором она найдена. На каждую папку альбома создаётся своя пачка с обложкой, поэтому разные альбомы
не смешиваются. Обложка до 8 МБ сохраняется вместе с резервом пачки и встраивается при импорте
в поддерживаемые контейнеры FLAC/MP3/M4A/Ogg/Opus без перекодирования аудио.
## 6. Обработка (`processing`)
@@ -126,9 +130,10 @@
| `[ ... ]` | Необязательный фрагмент: опускается целиком, если любой плейсхолдер внутри пуст, например `[{year} - ]{album}` |
**Как получить шаблон:**
- **Автоподбор**: система берёт выборку существующих путей хранилища, определяет, какой регэксп совпадает чаще всего,
и предлагает шаблон, порождающий такие же пути (группы регэкспа → плейсхолдеры, литералы между группами → текст шаблона).
Админ подтверждает или правит.
- **Автоподбор**: система сравнивает пути и метаданные до 200 существующих файлов хранилища, строит варианты
раскладки и выбирает вариант с наибольшим числом точных совпадений. Сохранённые регэкспы также используются
как кандидаты, но не обязательны. В интерфейсе показаны число совпадений и примеры реальных путей.
Если шаблон ещё не задан, предложение заполняется автоматически; администратор может изменить его и сохранить.
- **Проверка обратимости** (обязательна при сохранении): шаблон применяется к 50 существующим трекам хранилища,
полученные пути прогоняются через регэкспы этого хранилища — поля должны распознаться обратно без изменений,
а в ≥ 90% случаев путь должен совпасть с реальным путём файла. Иначе — предупреждение с примерами расхождений.
@@ -151,7 +156,8 @@
- Группировка по пачке загрузки / по альбому; фильтры: статус, автор, `possible_duplicate`.
- Для каждого файла: прослушивание (стрим из staging), метаданные с возможностью правки, обложка, качество
(формат / битрейт / sample rate), флаги (возможный дубль — со ссылкой на похожий трек), **итоговый путь в хранилище**
(пересчитывается live при правке метаданных).
с названием хранилища и путём относительно его корня. После сохранения метаданных или выбора хранилища
путь пересчитывается сервером. Если рассчитать его нельзя, показаны причина и действие для исправления.
- Действия: одобрить / отклонить (с причиной — видна автору), массовые действия для выбранных, правка «на весь альбом»
(исполнитель, альбом, год, обложка одной операцией).
- Если админ изменил метаданные — они **записываются в теги файла** при импорте (`ffmpeg -map 0 -c copy -metadata …`
@@ -175,6 +181,9 @@
админ может повторить или отклонить.
## 10. Уведомления
- В «Моих предложениях» файлы сгруппированы по пачкам. Можно выбрать отдельные файлы, загруженные строки
страницы или всю собственную пачку (с загрузкой всех её страниц) и отозвать выбранные до решения администратора.
Успешные отзывы освобождают квоту; при частичной ошибке показана причина, неудавшиеся файлы остаются выбранными.
- Автор видит статусы в «Мои предложения» (раздел админки модератора), бейдж с числом решений с последнего визита.
- Аудитор загрузок видит бейдж «N на рассмотрении» в сайдбаре.
@@ -2233,6 +2233,8 @@ export interface components {
}[];
};
UploadBatchRequest: {
/** @description Обложка альбома JPEG, PNG или WebP, до 8 МБ после декодирования. Применяется ко всем файлам пачки. */
cover_base64?: string;
comment?: string;
files: {
client_id: string;
@@ -2314,6 +2316,8 @@ export interface components {
target_source_id: string | null;
/** @description Путь в хранилище по write_template (null — не построился, причина в status_reason) */
target_rel_path: string | null;
/** @description Название целевого хранилища; null, если хранилище не выбрано или удалено */
target_source_name?: string | null;
status_reason: string | null;
/** Format: date-time */
reviewed_at: string | null;
@@ -2695,6 +2699,12 @@ export interface components {
};
WriteTemplateSuggestion: {
template: string;
/** @description Число путей выборки, совпавших с предложенным шаблоном */
matched?: number;
/** @description Число исследованных файлов хранилища */
total?: number;
/** @description До трёх существующих путей, совпавших с шаблоном */
examples?: string[];
};
WriteTemplateCheckRequest: {
template: string;
@@ -4413,6 +4423,8 @@ export interface operations {
listMyUploads: {
parameters: {
query?: {
/** @description Только файлы указанной собственной пачки */
batch?: string;
/** @description Статусы через запятую */
status?: components["parameters"]["UploadStatusFilter"];
limit?: components["parameters"]["Limit"];
+18
View File
@@ -210,6 +210,9 @@
"analysis_error_unknown": "No error reason was recorded."
},
"write_template": {
"auto_hint": "If no template is saved, we suggest the most common layout in the storage. You can edit it; it is applied only when you save.",
"detecting": "Detecting the storage file layout…",
"detected": "The suggested layout matches {{matched}} of {{total}} sampled files.",
"title": "Uploads and path template",
"intro": "Approved tracks from the upload queue are placed here using a template that repeats this storage's folder layout.",
"accepts": "Accept uploads into this storage",
@@ -294,6 +297,10 @@
"col_date": "Uploaded",
"received": "{{pct}}% received — open “Suggest tracks” in the same tab to continue",
"withdraw": "Withdraw",
"select_visible": "Select eligible files on this page",
"bulk_withdraw": "Withdraw multiple submissions",
"withdraw_selected": "Withdraw selected",
"withdraw_failed": "Could not withdraw {{count}} files: {{reason}}",
"load_more": "Show more",
"queue_title": "Upload queue",
"queue_subtitle": "Check the metadata and the storage path, then approve or reject",
@@ -351,6 +358,17 @@
"meta_track": "Track number",
"meta_disc": "Disc",
"target_source": "Storage",
"cover_hint": "Album covers named cover.jpg or cover.png are uploaded with the tracks. Otherwise we use embedded artwork from the first track that contains it.",
"cover_found": "Album cover: {{name}}",
"cover_embedded": "Album cover: embedded artwork",
"destination": "Will be saved to",
"destination_imported": "Saved to",
"target_selected": "Selected storage",
"target_not_selected": "No storage selected",
"target_path_hint": "Path relative to the storage root",
"target_path_missing": "Destination path has not been calculated",
"choose_destination": "Choose storage",
"fix_destination": "Fix destination",
"target_default": "Default",
"tags_note": "Edited fields are written into the file tags on import, so path and tags stay consistent.",
"save": "Save",
+1 -1
View File
@@ -60,7 +60,7 @@
"source_not_found": "Storage not found",
"scan_running": "A scan is already running",
"source_offline": "The storage is not connected",
"template_unavailable": "Could not suggest a template: no storage pattern converts to a template, or the storage has no files"
"template_unavailable": "Could not infer a layout from paths and metadata. Scan the storage first or enter a template manually."
},
"catalog": {
"not_found": "Not found"
+18
View File
@@ -210,6 +210,9 @@
"analysis_error_unknown": "Причина ошибки не указана."
},
"write_template": {
"auto_hint": "Если шаблон ещё не задан, определим его по преобладающей структуре файлов хранилища. Предложение можно изменить вручную; оно применяется только после сохранения.",
"detecting": "Определяем структуру файлов хранилища…",
"detected": "Предложенная структура совпадает с {{matched}} из {{total}} файлов выборки.",
"title": "Приём загрузок и шаблон пути",
"intro": "Сюда раскладываются одобренные треки из очереди на добавление — по шаблону, который повторяет структуру папок этого хранилища.",
"accepts": "Принимать загрузки в это хранилище",
@@ -294,6 +297,10 @@
"col_date": "Загружен",
"received": "принято {{pct}}% — откройте «Предложить треки» в той же вкладке, чтобы продолжить",
"withdraw": "Отозвать",
"select_visible": "Выбрать доступные файлы на странице",
"bulk_withdraw": "Массовый отзыв предложений",
"withdraw_selected": "Отозвать выбранные",
"withdraw_failed": "Не удалось отозвать: {{count}}. {{reason}}",
"load_more": "Показать ещё",
"queue_title": "Очередь на добавление",
"queue_subtitle": "Проверьте метаданные и путь в хранилище, затем одобрите или отклоните",
@@ -351,6 +358,17 @@
"meta_track": "Номер трека",
"meta_disc": "Диск",
"target_source": "Хранилище",
"cover_hint": "Обложку cover.jpg или cover.png из папки альбома загрузим вместе с треками. Если её нет, возьмём встроенную обложку из первого трека, где она есть.",
"cover_found": "Обложка альбома: {{name}}",
"cover_embedded": "Обложка альбома: из тегов трека",
"destination": "Будет записано в",
"destination_imported": "Записано в",
"target_selected": "Выбранное хранилище",
"target_not_selected": "Хранилище не выбрано",
"target_path_hint": "Путь относительно корня хранилища",
"target_path_missing": "Путь записи не рассчитан",
"choose_destination": "Выбрать хранилище",
"fix_destination": "Исправить размещение",
"target_default": "По умолчанию",
"tags_note": "Правленые поля запишутся в теги файла при импорте — путь и теги не разойдутся.",
"save": "Сохранить",
+1 -1
View File
@@ -60,7 +60,7 @@
"source_not_found": "Хранилище не найдено",
"scan_running": "Скан уже идёт",
"source_offline": "Хранилище не подключено",
"template_unavailable": "Не удалось подобрать шаблон: ни один регэксп хранилища не переводится в шаблон или в хранилище нет файлов"
"template_unavailable": "Не удалось определить структуру по путям и метаданным. Сначала просканируйте хранилище или задайте шаблон вручную."
},
"catalog": {
"not_found": "Не найдено"
+403 -374
View File
@@ -1691,6 +1691,9 @@ type UploadBatchPatch struct {
// UploadBatchRequest defines model for UploadBatchRequest.
type UploadBatchRequest struct {
Comment *string `json:"comment,omitempty"`
// CoverBase64 Обложка альбома JPEG, PNG или WebP, до 8 МБ после декодирования. Применяется ко всем файлам пачки.
CoverBase64 *string `json:"cover_base64,omitempty"`
Files []struct {
ClientId string `json:"client_id"`
Name string `json:"name"`
@@ -1745,6 +1748,9 @@ type UploadFile struct {
TargetRelPath *string `json:"target_rel_path"`
TargetSourceId *openapi_types.UUID `json:"target_source_id"`
// TargetSourceName Название целевого хранилища; null, если хранилище не выбрано или удалено
TargetSourceName *string `json:"target_source_name"`
// TusUrl Только автору и пока файл в статусе uploading
TusUrl *string `json:"tus_url"`
UpdatedAt time.Time `json:"updated_at"`
@@ -2010,7 +2016,15 @@ type WriteTemplateMismatch struct {
// WriteTemplateSuggestion defines model for WriteTemplateSuggestion.
type WriteTemplateSuggestion struct {
// Examples До трёх существующих путей, совпавших с шаблоном
Examples *[]string `json:"examples,omitempty"`
// Matched Число путей выборки, совпавших с предложенным шаблоном
Matched *int `json:"matched,omitempty"`
Template string `json:"template"`
// Total Число исследованных файлов хранилища
Total *int `json:"total,omitempty"`
}
// Cursor defines model for Cursor.
@@ -2223,6 +2237,9 @@ type StreamTrackParams struct {
// ListMyUploadsParams defines parameters for ListMyUploads.
type ListMyUploadsParams struct {
// Batch Только файлы указанной собственной пачки
Batch *openapi_types.UUID `form:"batch,omitempty" json:"batch,omitempty"`
// Status Статусы через запятую
Status *UploadStatusFilter `form:"status,omitempty" json:"status,omitempty"`
Limit *Limit `form:"limit,omitempty" json:"limit,omitempty"`
@@ -6566,6 +6583,14 @@ func (siw *ServerInterfaceWrapper) ListMyUploads(w http.ResponseWriter, r *http.
// Parameter object where we will unmarshal all parameters from the context
var params ListMyUploadsParams
// ------------- Optional query parameter "batch" -------------
err = runtime.BindQueryParameter("form", true, false, "batch", r.URL.Query(), &params.Batch)
if err != nil {
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "batch", Err: err})
return
}
// ------------- Optional query parameter "status" -------------
err = runtime.BindQueryParameter("form", false, false, "status", r.URL.Query(), &params.Status)
@@ -14439,380 +14464,384 @@ func (sh *strictHandler) GetReadyz(w http.ResponseWriter, r *http.Request) {
var swaggerSpec = []string{
"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",
"7s27/18AAAD//zpCzgyQ6AEA",
}
// GetSwagger returns the content of the embedded swagger specification file
@@ -158,11 +158,12 @@ func TestLibraryAndUploads(t *testing.T) {
assert.EqualValues(t, 1000, settings.QueueLimitBytes)
fresh := sha256.Sum256([]byte("new file"))
coverPath := "covers/album.jpg"
batch, res, err := up.CreateBatch(ctx, author, nil, []uploads.NewFile{
{ClientID: "a", Name: "a.flac", Size: 400, SHA256: fresh[:]},
{ClientID: "dup", Name: "b.flac", Size: 100, SHA256: sum[:]}, // уже в библиотеке
{ClientID: "big", Name: "c.flac", Size: 900, SHA256: []byte("cccccccccccccccccccccccccccccccc")},
}, settings)
}, settings, &coverPath)
require.NoError(t, err)
assert.NotEqual(t, uuid.Nil, batch)
byClient := map[string]uploads.Reserved{}
@@ -183,6 +184,11 @@ func TestLibraryAndUploads(t *testing.T) {
next, err := up.NextWithStatus(ctx, uploads.StatusProcessing)
require.NoError(t, err)
assert.Equal(t, fileID, next.ID)
require.NotNil(t, next.CoverPath)
assert.Equal(t, coverPath, *next.CoverPath)
coverInUse, err := up.StagingInUse(ctx)
require.NoError(t, err)
assert.True(t, coverInUse[coverPath], "active batch covers must survive staging reconciliation")
require.ErrorIs(t, up.Transition(ctx, fileID, []uploads.Status{uploads.StatusUploading}, uploads.StatusExpired, nil),
uploads.ErrWrongStatus)
@@ -190,7 +196,7 @@ func TestLibraryAndUploads(t *testing.T) {
require.NoError(t, up.Transition(ctx, fileID, []uploads.Status{uploads.StatusProcessing}, uploads.StatusPendingReview,
func(tx pgx.Tx) error {
_, err := tx.Exec(ctx, `UPDATE upload_files SET metadata = '{"artist":"Кино","album":"Группа крови","disc":2}',
fingerprint = '{1,2,3}', duration_ms = 200000 WHERE id = $1`, fileID)
fingerprint = '{1,2,3}', duration_ms = 200000, target_source_id = $2 WHERE id = $1`, fileID, src.ID)
return err
}))
f, err := up.File(ctx, fileID)
@@ -198,6 +204,8 @@ func TestLibraryAndUploads(t *testing.T) {
assert.Equal(t, uploads.StatusPendingReview, f.Status)
require.NotNil(t, f.Meta)
assert.Equal(t, "Кино", f.Meta.Artist)
require.NotNil(t, f.TargetSourceName)
assert.Equal(t, src.Name, *f.TargetSourceName)
files, nextCursor, err := up.List(ctx, uploads.ListFilter{AuthorID: &author, Statuses: []uploads.Status{uploads.StatusPendingReview}, Limit: 1})
require.NoError(t, err)
@@ -256,6 +264,10 @@ func TestLibraryAndUploads(t *testing.T) {
require.NoError(t, up.ChangeStatus(ctx, fileID, []uploads.Status{uploads.StatusApproved}, uploads.StatusImporting))
require.NoError(t, up.CompleteImport(ctx, fileID, src.ID, "Кино - Группа крови.flac", fileIDs[1]))
require.ErrorIs(t, up.CompleteImport(ctx, fileID, src.ID, "ignored.flac", fileIDs[1]), uploads.ErrWrongStatus)
unusedCover, err := up.UnusedBatchCover(ctx, batch)
require.NoError(t, err)
require.NotNil(t, unusedCover)
assert.Equal(t, coverPath, *unusedCover)
used, _, err = up.Usage(ctx, author)
require.NoError(t, err)
@@ -264,7 +276,7 @@ func TestLibraryAndUploads(t *testing.T) {
secondHash := sha256.Sum256([]byte("concurrent decision"))
_, reserved, err := up.CreateBatch(ctx, author, nil, []uploads.NewFile{
{ClientID: "race", Name: "race.flac", Size: 300, SHA256: secondHash[:]},
}, settings)
}, settings, nil)
require.NoError(t, err)
require.Len(t, reserved, 1)
raceID := reserved[0].FileID
+1 -1
View File
@@ -184,7 +184,7 @@ func (h *Handler) SuggestWriteTemplate(ctx context.Context, req gen.SuggestWrite
if err != nil {
return nil, err
}
return gen.SuggestWriteTemplate200JSONResponse{Template: tpl}, nil
return gen.SuggestWriteTemplate200JSONResponse{Template: tpl.Template, Matched: &tpl.Matched, Total: &tpl.Total, Examples: &tpl.Examples}, nil
}
// CheckWriteTemplate реализует gen.StrictServerInterface.
@@ -0,0 +1,129 @@
package meta
import (
"path"
"sort"
"strconv"
"strings"
"golang.org/x/text/unicode/norm"
)
// TemplateSuggestion compares candidate layouts against the same sample of stored paths.
type TemplateSuggestion struct {
Template string
Matched int
Total int
Examples []string
}
// SuggestFromSamples also works without configured path regexes, using catalog metadata.
func SuggestFromSamples(exprs []string, samples []TemplateSample) (TemplateSuggestion, error) {
candidates := map[string]bool{}
paths := make([]string, 0, len(samples))
for _, s := range samples {
paths = append(paths, s.RelPath)
if tpl := inferTemplate(s); tpl != "" {
candidates[tpl] = true
}
}
for _, expr := range exprs {
if tpl, err := SuggestTemplate([]string{expr}, paths); err == nil {
candidates[tpl] = true
}
}
keys := make([]string, 0, len(candidates))
for tpl := range candidates {
keys = append(keys, tpl)
}
sort.Strings(keys)
best := TemplateSuggestion{Total: len(samples), Examples: []string{}}
for _, tpl := range keys {
t, err := ParseTemplate(tpl)
if err != nil {
continue
}
count := 0
var examples []string
for _, s := range samples {
if rendered, err := t.Render(s.Input); err == nil && rendered == norm.NFC.String(s.RelPath) {
count++
if len(examples) < 3 {
examples = append(examples, s.RelPath)
}
}
}
if count > best.Matched {
best.Template, best.Matched, best.Examples = tpl, count, examples
}
}
if best.Matched == 0 {
return best, ErrNoTemplate
}
return best, nil
}
func inferTemplate(s TemplateSample) string {
type token struct{ value, placeholder string }
segments := strings.Split(norm.NFC.String(s.RelPath), "/")
for i, segment := range segments {
last := i == len(segments)-1
fields := []string{"albumartist", "artist", "album", "year", "genre", "disc"}
if last {
ext := path.Ext(segment)
if ext == "" {
return ""
}
segment = strings.TrimSuffix(segment, ext)
fields = []string{"title", "albumartist", "artist", "album", "year", "track", "disc"}
}
var tokens []token
for _, field := range fields {
v := cleanValue(fieldValue(s.Input, tnode{field: field}))
if v == "" {
continue
}
tokens = append(tokens, token{v, "{" + field + "}"})
if field == "track" || field == "disc" {
n, _ := strconv.Atoi(v)
for width := 2; width <= 3; width++ {
padded := strings.Repeat("0", max(0, width-len(v))) + strconv.Itoa(n)
if padded != v {
tokens = append(tokens, token{padded, "{" + field + ":0" + strconv.Itoa(width) + "}"})
}
}
}
}
// Longest value wins; stable ties prefer album artist in folders and title in filenames.
sort.SliceStable(tokens, func(a, b int) bool { return len(tokens[a].value) > len(tokens[b].value) })
var out strings.Builder
for at := 0; at < len(segment); {
matched := false
for _, tok := range tokens {
if !strings.HasPrefix(segment[at:], tok.value) {
continue
}
end := at + len(tok.value)
if tok.value[0] >= '0' && tok.value[0] <= '9' && ((at > 0 && segment[at-1] >= '0' && segment[at-1] <= '9') || (end < len(segment) && segment[end] >= '0' && segment[end] <= '9')) {
continue
}
out.WriteString(tok.placeholder)
at = end
matched = true
break
}
if !matched {
if strings.ContainsRune("{}[]", rune(segment[at])) {
return ""
}
out.WriteByte(segment[at])
at++
}
}
segments[i] = out.String()
if last {
segments[i] += ".{ext}"
}
}
return strings.Join(segments, "/")
}
@@ -0,0 +1,22 @@
package meta
import "testing"
func TestSuggestFromSamplesUsesDominantLayoutWithoutRegexes(t *testing.T) {
samples := []TemplateSample{
{RelPath: "Кино/Группа крови (1988)/01. Группа крови.flac", Input: in(Fields{Artist: "Кино", Album: "Группа крови", Title: "Группа крови", Year: "1988", Track: "1"}, 1, "flac")},
{RelPath: "Кино/Группа крови (1988)/02. Закрой за мной дверь.flac", Input: in(Fields{Artist: "Кино", Album: "Группа крови", Title: "Закрой за мной дверь", Year: "1988", Track: "2"}, 1, "flac")},
{RelPath: "Queen/Jazz (1978)/03. Jealousy.mp3", Input: in(Fields{Artist: "Queen", Album: "Jazz", Title: "Jealousy", Year: "1978", Track: "3"}, 1, "mp3")},
{RelPath: "Other - Song.flac", Input: in(Fields{Artist: "Other", Album: "Single", Title: "Song"}, 1, "flac")},
}
got, err := SuggestFromSamples(nil, samples)
if err != nil || got.Template != "{albumartist}/{album} ({year})/{track:02}. {title}.{ext}" || got.Matched != 3 || got.Total != 4 || len(got.Examples) != 3 {
t.Fatalf("suggestion = %+v, err = %v", got, err)
}
}
func TestSuggestFromSamplesDoesNotGuessWithoutMetadata(t *testing.T) {
if _, err := SuggestFromSamples(nil, []TemplateSample{{RelPath: "unknown/file.flac"}}); err == nil {
t.Fatal("cannot infer a safe layout without matching metadata")
}
}
+8 -8
View File
@@ -183,16 +183,16 @@ func (s *TemplateService) previewOne(ctx context.Context, root, rel string, patt
const templateSampleSize = 50
// SuggestWriteTemplate подбирает шаблон записи по существующим путям и выражениям источника.
func (s *TemplateService) SuggestWriteTemplate(ctx context.Context, id uuid.UUID) (string, error) {
func (s *TemplateService) SuggestWriteTemplate(ctx context.Context, id uuid.UUID) (meta.TemplateSuggestion, error) {
if err := requirePerm(ctx, auth.PermManageLibrary); err != nil {
return "", err
return meta.TemplateSuggestion{}, err
}
if _, err := s.store.GetSource(ctx, id); err != nil {
return "", notFound(err)
return meta.TemplateSuggestion{}, notFound(err)
}
rows, err := s.store.Patterns(ctx, id)
if err != nil {
return "", err
return meta.TemplateSuggestion{}, err
}
var exprs []string
for _, r := range rows {
@@ -200,13 +200,13 @@ func (s *TemplateService) SuggestWriteTemplate(ctx context.Context, id uuid.UUID
exprs = append(exprs, r.Regex)
}
}
paths, err := s.store.SampleFiles(ctx, id, 200)
samples, err := s.store.TemplateSamples(ctx, id, 200)
if err != nil {
return "", err
return meta.TemplateSuggestion{}, err
}
tpl, err := meta.SuggestTemplate(exprs, paths)
tpl, err := meta.SuggestFromSamples(exprs, samples)
if err != nil {
return "", apperr.New(http.StatusUnprocessableEntity, CodeTemplateNone, "no path pattern can be turned into a template")
return meta.TemplateSuggestion{}, apperr.New(http.StatusUnprocessableEntity, CodeTemplateNone, "no layout could be inferred from catalog paths and metadata")
}
return tpl, nil
}
+24
View File
@@ -26,6 +26,30 @@ type BatchPatch struct {
const maxCoverBytes = 8 << 20
// createCover writes a new immutable cover before the quota transaction; Create removes it on failure.
func (s *Service) createCover(data []byte) (*string, error) {
if data == nil {
return nil, nil
}
if len(data) > maxCoverBytes {
return nil, apperr.Validation("cover_base64", "validation.cover_too_large", "cover is larger than 8 MB", nil)
}
ext, ok := coverExt(data)
if !ok {
return nil, apperr.Validation("cover_base64", "validation.cover_type", "cover must be jpeg, png or webp", nil)
}
rel := path.Join("covers", newID().String()+ext)
abs := s.stagingAbs(rel)
if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {
return nil, err
}
if err := os.WriteFile(abs, data, 0o644); err != nil {
_ = os.Remove(abs)
return nil, err
}
return &rel, nil
}
// UpdateBatch применяет исполнителя, альбом, год и обложку к файлам пачки на рассмотрении.
func (s *Service) UpdateBatch(ctx context.Context, id uuid.UUID, in BatchPatch) ([]File, error) {
if _, err := principal(ctx, auth.PermReviewUploads); err != nil {
+11 -2
View File
@@ -35,6 +35,7 @@ func (h *Handler) fileDTO(ctx context.Context, f File) gen.UploadFile {
MetadataEdited: f.MetadataEdited, MatchedArtistId: f.MatchedArtistID, MatchedAlbumId: f.MatchedAlbumID,
DuplicateOfTrackId: f.DuplicateOfTrackID, PossibleDuplicate: f.PossibleDuplicate, QualityUpgrade: f.QualityUpgrade,
TargetSourceId: f.TargetSourceID, TargetRelPath: f.TargetRelPath, StatusReason: f.StatusReason,
TargetSourceName: f.TargetSourceName,
ReviewedAt: f.ReviewedAt, CreatedAt: f.CreatedAt, UpdatedAt: f.UpdatedAt,
}
if out.Tags == nil {
@@ -154,7 +155,15 @@ func (h *Handler) CreateUploadBatch(ctx context.Context, req gen.CreateUploadBat
for _, f := range req.Body.Files {
in = append(in, CreateFile{ClientID: f.ClientId, Name: f.Name, RelPath: f.RelPath, Size: f.Size, SHA256: f.Sha256})
}
batch, res, err := h.svc.Create(ctx, req.Body.Comment, in)
var cover []byte
if req.Body.CoverBase64 != nil {
var err error
cover, err = DecodeCover(*req.Body.CoverBase64)
if err != nil {
return nil, err
}
}
batch, res, err := h.svc.Create(ctx, req.Body.Comment, in, cover)
if err != nil {
return nil, err
}
@@ -181,7 +190,7 @@ func (h *Handler) CreateUploadBatch(ctx context.Context, req gen.CreateUploadBat
// ListMyUploads реализует gen.StrictServerInterface.
func (h *Handler) ListMyUploads(ctx context.Context, req gen.ListMyUploadsRequestObject) (gen.ListMyUploadsResponseObject, error) {
files, next, err := h.svc.ListMine(ctx, statuses(req.Params.Status), deref(req.Params.Cursor, ""), deref(req.Params.Limit, 50))
files, next, err := h.svc.ListMine(ctx, statuses(req.Params.Status), deref(req.Params.Cursor, ""), deref(req.Params.Limit, 50), req.Params.Batch)
if err != nil {
return nil, err
}
+1
View File
@@ -140,6 +140,7 @@ type File struct {
PossibleDuplicate bool
QualityUpgrade bool
TargetSourceID *uuid.UUID
TargetSourceName *string
TargetRelPath *string
StatusReason *string
ReviewedBy *uuid.UUID
+27 -7
View File
@@ -178,7 +178,7 @@ type CreateFile struct {
}
// Create создаёт пачку и резервирует место; не поместившиеся и дубли отклоняются с кодом.
func (s *Service) Create(ctx context.Context, comment *string, files []CreateFile) (uuid.UUID, []Reserved, error) {
func (s *Service) Create(ctx context.Context, comment *string, files []CreateFile, cover []byte) (uuid.UUID, []Reserved, error) {
p, err := principal(ctx, auth.PermSuggestTracks)
if err != nil {
return uuid.Nil, nil, err
@@ -209,10 +209,25 @@ func (s *Service) Create(ctx context.Context, comment *string, files []CreateFil
}
accepted = append(accepted, NewFile{ClientID: f.ClientID, Name: name, RelPath: cleanClientPath(f.RelPath), Size: f.Size, SHA256: sum})
}
batch, res, err := s.store.CreateBatch(ctx, p.UserID, comment, accepted, st)
coverPath, err := s.createCover(cover)
if err != nil {
return uuid.Nil, nil, err
}
keepCover := false
defer func() {
if coverPath != nil && !keepCover {
_ = os.Remove(s.stagingAbs(*coverPath))
}
}()
batch, res, err := s.store.CreateBatch(ctx, p.UserID, comment, accepted, st, coverPath)
if err != nil {
return uuid.Nil, nil, err
}
for _, r := range res {
if r.Rejected == "" {
keepCover = true
}
}
return batch, append(res, rejected...), nil
}
@@ -240,12 +255,12 @@ func (s *Service) Complete(ctx context.Context, f File) error {
// --- Списки ---
// ListMine — предложения текущего пользователя.
func (s *Service) ListMine(ctx context.Context, statuses []Status, cursor string, limit int) ([]File, string, error) {
func (s *Service) ListMine(ctx context.Context, statuses []Status, cursor string, limit int, batch *uuid.UUID) ([]File, string, error) {
p, err := principal(ctx, auth.PermSuggestTracks)
if err != nil {
return nil, "", err
}
return s.list(ctx, ListFilter{AuthorID: &p.UserID, Statuses: statuses, Cursor: cursor, Limit: limit})
return s.list(ctx, ListFilter{AuthorID: &p.UserID, BatchID: batch, Statuses: statuses, Cursor: cursor, Limit: limit})
}
// ListAll — вся очередь (админ).
@@ -279,7 +294,7 @@ func (s *Service) Withdraw(ctx context.Context, id uuid.UUID) error {
if err := s.store.ChangeStatus(ctx, id, []Status{StatusUploading, StatusProcessing, StatusPendingReview}, StatusWithdrawn); err != nil {
return notFound(err)
}
s.removeStaging(f)
s.removeStaging(ctx, f)
return nil
}
@@ -380,7 +395,7 @@ func (s *Service) reviewOne(ctx context.Context, reviewer, id uuid.UUID, approve
if err := s.store.Decide(ctx, id, reviewer, false, reason); err != nil {
return notFound(err)
}
s.removeStaging(f)
s.removeStaging(ctx, f)
s.store.Audit(ctx, &reviewer, "upload.reject", id, map[string]any{"reason": reason})
return nil
}
@@ -542,7 +557,7 @@ func reuseExisting(root, rel string) string {
}
// removeStaging удаляет файл staging (и временные копии с тегами).
func (s *Service) removeStaging(f File) {
func (s *Service) removeStaging(ctx context.Context, f File) {
if f.StagingPath == "" {
return
}
@@ -552,6 +567,11 @@ func (s *Service) removeStaging(f File) {
_ = os.Remove(m)
}
_ = os.Remove(filepath.Dir(p)) // каталог пачки, если опустел
if f.CoverPath != nil {
if cover, err := s.store.UnusedBatchCover(ctx, f.BatchID); err == nil && cover != nil {
_ = os.Remove(s.stagingAbs(*cover))
}
}
}
// TusURL — адрес tus для файла.
+21 -7
View File
@@ -98,7 +98,7 @@ type Reserved struct {
// CreateBatch создаёт пачку и резервирует место под файлы: проверка «занято + запрошено ≤ лимит»
// под блокировкой строки счётчика (docs/uploads.md#3). Дубли по SHA-256 отклоняются, не занимая места.
func (st *Store) CreateBatch(ctx context.Context, author uuid.UUID, comment *string, files []NewFile, s Settings) (uuid.UUID, []Reserved, error) {
func (st *Store) CreateBatch(ctx context.Context, author uuid.UUID, comment *string, files []NewFile, s Settings, coverPath *string) (uuid.UUID, []Reserved, error) {
tx, err := st.pool.Begin(ctx)
if err != nil {
return uuid.Nil, nil, fmt.Errorf("uploads: create batch: %w", err)
@@ -116,8 +116,8 @@ func (st *Store) CreateBatch(ctx context.Context, author uuid.UUID, comment *str
}
batchID := newID()
if _, err := tx.Exec(ctx, `INSERT INTO upload_batches (id, author_id, comment) VALUES ($1, $2, $3)`,
batchID, author, comment); err != nil {
if _, err := tx.Exec(ctx, `INSERT INTO upload_batches (id, author_id, comment, cover_path) VALUES ($1, $2, $3, $4)`,
batchID, author, comment, coverPath); err != nil {
return uuid.Nil, nil, fmt.Errorf("uploads: insert batch: %w", err)
}
out := make([]Reserved, 0, len(files))
@@ -231,9 +231,10 @@ const fileColumns = `f.id, f.batch_id, f.author_id, u.username, b.comment, f.sta
f.size_bytes, f.received_bytes, f.sha256, coalesce(f.staging_path, ''), f.format, f.codec, f.bitrate, f.sample_rate,
f.bit_depth, f.duration_ms, f.tags, f.metadata, f.metadata_edited, f.matched_artist_id, f.matched_album_id,
f.duplicate_of_track_id, f.duplicate_level, f.possible_duplicate, f.quality_upgrade, f.target_source_id, f.target_rel_path,
f.status_reason, f.reviewed_by, f.reviewed_at, f.imported_file_id, b.cover_path, f.created_at, f.updated_at`
f.status_reason, f.reviewed_by, f.reviewed_at, f.imported_file_id, b.cover_path, f.created_at, f.updated_at, src.name`
const fileFrom = ` FROM upload_files f JOIN upload_batches b ON b.id = f.batch_id JOIN users u ON u.id = f.author_id`
const fileFrom = ` FROM upload_files f JOIN upload_batches b ON b.id = f.batch_id JOIN users u ON u.id = f.author_id
LEFT JOIN library_sources src ON src.id = f.target_source_id`
func scanFile(row pgx.Row) (File, error) {
var (
@@ -244,7 +245,7 @@ func scanFile(row pgx.Row) (File, error) {
&f.ClientRelPath, &f.Size, &f.Received, &f.SHA256, &f.StagingPath, &f.Format, &f.Codec, &f.Bitrate, &f.SampleRate,
&f.BitDepth, &f.DurationMs, &tags, &meta, &f.MetadataEdited, &f.MatchedArtistID, &f.MatchedAlbumID,
&f.DuplicateOfTrackID, &f.DuplicateLevel, &f.PossibleDuplicate, &f.QualityUpgrade, &f.TargetSourceID, &f.TargetRelPath,
&f.StatusReason, &f.ReviewedBy, &f.ReviewedAt, &f.ImportedFileID, &f.CoverPath, &f.CreatedAt, &f.UpdatedAt)
&f.StatusReason, &f.ReviewedBy, &f.ReviewedAt, &f.ImportedFileID, &f.CoverPath, &f.CreatedAt, &f.UpdatedAt, &f.TargetSourceName)
if errors.Is(err, pgx.ErrNoRows) {
return File{}, ErrNotFound
}
@@ -348,7 +349,9 @@ func (st *Store) NextWithStatus(ctx context.Context, s Status) (File, error) {
// StagingInUse — пути staging файлов, которым он ещё нужен (сверка при старте).
func (st *Store) StagingInUse(ctx context.Context) (map[string]bool, error) {
rows, err := st.pool.Query(ctx, `SELECT staging_path FROM upload_files WHERE status::text = ANY($1) AND staging_path IS NOT NULL`,
rows, err := st.pool.Query(ctx, `SELECT staging_path FROM upload_files WHERE status::text = ANY($1) AND staging_path IS NOT NULL
UNION SELECT b.cover_path FROM upload_batches b JOIN upload_files f ON f.batch_id = b.id
WHERE f.status::text = ANY($1) AND b.cover_path IS NOT NULL`,
occupyingStatuses)
if err != nil {
return nil, fmt.Errorf("uploads: staging in use: %w", err)
@@ -364,6 +367,17 @@ func (st *Store) StagingInUse(ctx context.Context) (map[string]bool, error) {
return out, nil
}
// UnusedBatchCover returns a cover only after every file in its batch has released staging.
func (st *Store) UnusedBatchCover(ctx context.Context, batch uuid.UUID) (*string, error) {
var cover *string
err := st.pool.QueryRow(ctx, `SELECT b.cover_path FROM upload_batches b WHERE b.id = $1
AND NOT EXISTS (SELECT 1 FROM upload_files f WHERE f.batch_id = b.id AND f.status::text = ANY($2))`, batch, occupyingStatuses).Scan(&cover)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return cover, err
}
// Stale — недокачанные файлы старше before.
func (st *Store) Stale(ctx context.Context, before time.Time) ([]uuid.UUID, error) {
rows, err := st.pool.Query(ctx, `SELECT id FROM upload_files WHERE status = 'uploading' AND updated_at < $1`, before)
@@ -182,6 +182,26 @@ func TestExtFor(t *testing.T) {
}
}
func TestInitialAlbumCover(t *testing.T) {
s := &Service{cfg: Config{StagingDir: t.TempDir()}}
if cover, err := s.createCover(nil); err != nil || cover != nil {
t.Fatalf("missing cover = %v, %v", cover, err)
}
if _, err := s.createCover([]byte("not an image")); err == nil {
t.Fatal("invalid cover must be rejected")
}
data := []byte{0xff, 0xd8, 0xff, 1}
rel, err := s.createCover(data)
require.NoError(t, err)
require.NotNil(t, rel)
got, err := os.ReadFile(s.stagingAbs(*rel))
require.NoError(t, err)
assert.Equal(t, data, got)
second, err := s.createCover(data)
require.NoError(t, err)
assert.NotEqual(t, *rel, *second, "album batches must not overwrite each other's cover")
}
func TestReuseExistingFolders(t *testing.T) {
root := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(root, "Кино", "1988 - Группа крови"), 0o755))
+3 -3
View File
@@ -116,7 +116,7 @@ func (s *Service) process(ctx context.Context, f File) error {
return err
}
if o.status != StatusPendingReview {
s.removeStaging(f)
s.removeStaging(ctx, f)
}
s.log.Info("upload processed", "upload_id", f.ID, "status", o.status, "reason", o.reason)
return nil
@@ -326,7 +326,7 @@ func (s *Service) importFile(ctx context.Context, f File) error {
if err := s.store.CompleteImport(ctx, f.ID, pl.src.ID, rel, fileID); err != nil {
return err
}
s.removeStaging(f)
s.removeStaging(ctx, f)
s.store.Audit(ctx, f.ReviewedBy, "upload.import", f.ID, map[string]any{"source_id": pl.src.ID, "rel_path": rel})
s.log.Info("upload imported", "upload_id", f.ID, "source_id", pl.src.ID, "rel_path", rel)
return nil
@@ -397,7 +397,7 @@ func (s *Service) expire(ctx context.Context) {
continue
}
if err := s.store.ChangeStatus(ctx, id, []Status{StatusUploading}, StatusExpired); err == nil {
s.removeStaging(f)
s.removeStaging(ctx, f)
}
}
}