From 64dd8ed05ec3cdbe883b306c8f79d6718a3f478d Mon Sep 17 00:00:00 2001 From: mrleo1nid <43583385+mrleo1nid@users.noreply.github.com> Date: Thu, 29 Oct 2026 10:28:29 +0300 Subject: [PATCH] feat(uploads): preview storage paths, detect templates, attach covers and withdraw batches --- api/openapi.yaml | 25 + apps/web/e2e/upload-review.spec.ts | 114 +++ apps/web/src/features/uploads/QueueRows.tsx | 23 +- apps/web/src/features/uploads/files.ts | 19 + .../web/src/features/uploads/uploader.test.ts | 54 ++ apps/web/src/features/uploads/uploader.ts | 77 +- .../src/pages/admin/WriteTemplateSection.tsx | 48 +- apps/web/src/pages/uploads/MyUploadsPage.tsx | 119 ++- apps/web/src/pages/uploads/SuggestPage.tsx | 6 +- apps/web/src/pages/uploads/uploads.module.css | 40 + docs/uploads.md | 19 +- packages/api-client/src/generated/schema.ts | 12 + packages/i18n/locales/en/admin.json | 18 + packages/i18n/locales/en/errors.json | 2 +- packages/i18n/locales/ru/admin.json | 18 + packages/i18n/locales/ru/errors.json | 2 +- services/api/internal/api/gen/api.gen.go | 779 +++++++++--------- .../api/internal/dbtest/integration_test.go | 18 +- services/api/internal/library/handler.go | 2 +- .../internal/library/meta/template_suggest.go | 129 +++ .../library/meta/template_suggest_test.go | 22 + services/api/internal/library/templates.go | 16 +- services/api/internal/uploads/batch.go | 24 + services/api/internal/uploads/handler.go | 15 +- services/api/internal/uploads/model.go | 1 + services/api/internal/uploads/service.go | 34 +- services/api/internal/uploads/store.go | 28 +- services/api/internal/uploads/uploads_test.go | 20 + services/api/internal/uploads/worker.go | 6 +- 29 files changed, 1219 insertions(+), 471 deletions(-) create mode 100644 apps/web/e2e/upload-review.spec.ts create mode 100644 apps/web/src/features/uploads/uploader.test.ts create mode 100644 services/api/internal/library/meta/template_suggest.go create mode 100644 services/api/internal/library/meta/template_suggest_test.go diff --git a/api/openapi.yaml b/api/openapi.yaml index ecfa38e..d6cba98 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -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] diff --git a/apps/web/e2e/upload-review.spec.ts b/apps/web/e2e/upload-review.spec.ts new file mode 100644 index 0000000..e892c29 --- /dev/null +++ b/apps/web/e2e/upload-review.spec.ts @@ -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); +}); diff --git a/apps/web/src/features/uploads/QueueRows.tsx b/apps/web/src/features/uploads/QueueRows.tsx index cbc5ca1..6741102 100644 --- a/apps/web/src/features/uploads/QueueRows.tsx +++ b/apps/web/src/features/uploads/QueueRows.tsx @@ -127,8 +127,7 @@ function QueueRow({ m && {t('uploads.flag_new_album')} )} - {f.target_rel_path && {f.target_rel_path}} - {reason && {reason}} + {reason && (!editable || f.target_rel_path) && {reason}} {f.duplicate_level && f.status === 'duplicate' && ( {t(`uploads.dup_level_${f.duplicate_level}`)} )} @@ -148,6 +147,26 @@ function QueueRow({ )} + {(editable || f.target_rel_path) && ( +
{f.target_rel_path}
+ {t('uploads.target_path_hint')}
+ >
+ ) : (
+ <>
+ {t('uploads.target_path_missing')}
+ {reason && {reason}}
+
+ >
+ )}
+ {t('write_template.intro')}
+{t('write_template.auto_hint')}
+ {suggest.isPending &&{t('write_template.detecting')}
} + {suggest.data && ( +{t('write_template.detected', { matched: suggest.data.matched ?? 0, total: suggest.data.total ?? 0 })}
+{suggest.data.template}
+ {(suggest.data.examples ?? []).map((path) => {path})}
+