diff --git a/api/openapi.yaml b/api/openapi.yaml index 88b60e4..f62cc8e 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -2928,6 +2928,9 @@ components: type: string nullable: true description: Название целевого хранилища; null, если хранилище не выбрано или удалено + batch_has_cover: + type: boolean + description: У пачки есть общая обложка для записи в загружаемые треки target_storage_path: type: string nullable: true diff --git a/apps/web/e2e/upload-review.spec.ts b/apps/web/e2e/upload-review.spec.ts index a5fb9a7..85afc2b 100644 --- a/apps/web/e2e/upload-review.spec.ts +++ b/apps/web/e2e/upload-review.spec.ts @@ -1,5 +1,15 @@ import { expect, test, type Page } from '@playwright/test'; +function previewWav() { + const dataSize = 8000 * 2 * 2; + const wav = Buffer.alloc(44 + dataSize); + wav.write('RIFF', 0); wav.writeUInt32LE(36 + dataSize, 4); wav.write('WAVEfmt ', 8); + wav.writeUInt32LE(16, 16); wav.writeUInt16LE(1, 20); wav.writeUInt16LE(1, 22); + wav.writeUInt32LE(8000, 24); wav.writeUInt32LE(16000, 28); wav.writeUInt16LE(2, 32); wav.writeUInt16LE(16, 34); + wav.write('data', 36); wav.writeUInt32LE(dataSize, 40); + return wav; +} + async function setup(page: Page, scenario: 'queue' | 'template' | 'mine') { await page.addInitScript(() => localStorage.setItem('pipu.locale', 'ru')); await page.goto('/login'); @@ -11,7 +21,7 @@ async function setup(page: Page, scenario: 'queue' | 'template' | 'mine') { 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 file = { ...structuredClone(mobileFixtures['/api/v1/admin/uploads'].items[0]), batch_has_cover: true }; const files = [ { ...file, id: 'visible', original_name: 'visible.flac', target_source_name: source.name, target_rel_path: 'Artist/Album/01 - Song.flac', target_storage_path: 'nas:/music/Artist/Album/01 - Song.flac', stream_url: `/api/v1/admin/uploads/files/${file.id}/stream?exp=9999999999&sig=test` }, { ...file, id: 'approved', original_name: 'approved.flac', status: 'approved' }, @@ -75,6 +85,7 @@ async function setup(page: Page, scenario: 'queue' | 'template' | 'mine') { test('queue shows the storage and path, and recalculates after fixing placement', async ({ page }, info) => { await setup(page, 'queue'); + await expect(page.getByText('Есть обложка', { exact: true })).toBeVisible(); await expect(page.getByText('Artist/Album/01 - Song.flac', { exact: true })).toBeHidden(); await page.getByRole('button', { name: 'Развернуть пачку', exact: true }).click(); await expect(page.getByText('Artist/Album/01 - Song.flac', { exact: true })).toBeVisible(); @@ -170,12 +181,7 @@ test('queue shows the current full destination after saving a changed source tem }); test('queue preview loads duration before play and plays signed audio', async ({ page }) => { - const dataSize = 8000 * 2 * 2; - const wav = Buffer.alloc(44 + dataSize); - wav.write('RIFF', 0); wav.writeUInt32LE(36 + dataSize, 4); wav.write('WAVEfmt ', 8); - wav.writeUInt32LE(16, 16); wav.writeUInt16LE(1, 20); wav.writeUInt16LE(1, 22); - wav.writeUInt32LE(8000, 24); wav.writeUInt32LE(16000, 28); wav.writeUInt16LE(2, 32); wav.writeUInt16LE(16, 34); - wav.write('data', 36); wav.writeUInt32LE(dataSize, 40); + const wav = previewWav(); await page.route('**/api/v1/admin/uploads/files/*/stream?*', (route) => route.fulfill({ contentType: 'audio/wav', body: wav })); await setup(page, 'queue'); await page.getByRole('button', { name: 'Развернуть пачку', exact: true }).click(); @@ -186,3 +192,27 @@ test('queue preview loads duration before play and plays signed audio', async ({ await expect.poll(() => audio.evaluate((el: HTMLAudioElement) => el.currentTime)).toBeGreaterThan(0); expect(await audio.evaluate((el: HTMLAudioElement) => el.error)).toBeNull(); }); + +test('queue preview explains HTTP failures and recovers on retry', async ({ page }) => { + let available = false; + // The E2E bridge mocks window.fetch; media diagnostics must reach the same + // intercepted HTTP route as the native audio element. + await page.addInitScript(() => Object.assign(window, { previewNativeFetch: window.fetch.bind(window) })); + await page.route('**/api/v1/admin/uploads/files/*/stream?*', (route) => available + ? route.fulfill({ contentType: 'audio/wav', body: previewWav() }) + : route.fulfill({ status: 403, contentType: 'text/plain', body: 'Forbidden' })); + await setup(page, 'queue'); + await page.evaluate(() => { + const original = window.fetch; + const native = (window as unknown as { previewNativeFetch: typeof fetch }).previewNativeFetch; + window.fetch = (input, init) => new URL(new Request(input, init).url).pathname.endsWith('/stream') + ? native(input, init) : original(input, init); + }); + await page.getByRole('button', { name: 'Развернуть пачку', exact: true }).click(); + const preview = page.locator('audio').first().locator('..'); + await expect(preview.getByRole('alert')).toHaveText('Не удалось загрузить аудио: HTTP 403.'); + available = true; + await preview.getByRole('button', { name: 'Повторить', exact: true }).click(); + await expect.poll(() => preview.locator('audio').evaluate((el: HTMLAudioElement) => el.duration)).toBe(2); + await expect(preview.getByRole('alert')).toHaveCount(0); +}); diff --git a/apps/web/src/features/uploads/AudioPreview.tsx b/apps/web/src/features/uploads/AudioPreview.tsx new file mode 100644 index 0000000..c77c0e9 --- /dev/null +++ b/apps/web/src/features/uploads/AudioPreview.tsx @@ -0,0 +1,49 @@ +import { useQueryClient } from '@tanstack/react-query'; +import { useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { refreshIcon } from '../../shared/ui/icons'; +import { Button } from '../../shared/ui/ui'; +import u from '../../pages/uploads/uploads.module.css'; + +export function AudioPreview({ url, name, active }: { url: string; name: string; active: boolean }) { + const { t } = useTranslation('admin'); + const queryClient = useQueryClient(); + const audio = useRef(null); + const [error, setError] = useState(null); + const [httpStatus, setHttpStatus] = useState(null); + + useEffect(() => { setError(null); setHttpStatus(null); }, [url]); + useEffect(() => { + if (error === null) return; + const controller = new AbortController(); + // A media error alone cannot distinguish an HTTP failure from an unsupported codec. + // Probe one byte using the same signed URL, then release the response immediately. + void fetch(url, { headers: { Range: 'bytes=0-0' }, cache: 'no-store', signal: controller.signal }) + .then(async (response) => { + await response.body?.cancel(); + if (!controller.signal.aborted) setHttpStatus(response.ok ? null : response.status); + }) + .catch(() => { /* The native media error remains visible for network failures. */ }); + return () => controller.abort(); + }, [url, error]); + + const retry = () => { + setError(null); + setHttpStatus(null); + void queryClient.invalidateQueries({ queryKey: ['admin-uploads'] }); + audio.current?.load(); + }; + + return
+
; +} diff --git a/apps/web/src/features/uploads/QueueRows.tsx b/apps/web/src/features/uploads/QueueRows.tsx index 576ced3..8a1f473 100644 --- a/apps/web/src/features/uploads/QueueRows.tsx +++ b/apps/web/src/features/uploads/QueueRows.tsx @@ -2,6 +2,7 @@ import { formatBytes, formatDateTime, formatDuration } from '../../shared/lib/fo import { chevronRightIcon, editIcon, refreshIcon } from '../../shared/ui/icons'; import { useId, useState } from 'react'; import { needsFormatAttention } from './files'; +import { AudioPreview } from './AudioPreview'; import { UploadStatusBadge, useReasonText, type UploadFile } from './common'; import { useTranslation } from 'react-i18next'; import { Button, cx, Icon } from '../../shared/ui/ui'; @@ -43,6 +44,9 @@ export function BatchCard({ {first.author.username} {formatDateTime(first.created_at, i18n.language)} {t('uploads.batch_size', { count: files.length, size: formatBytes(size, i18n.language) })} + + {t(first.batch_has_cover ? 'uploads.batch_cover_present' : 'uploads.batch_cover_absent')} + {selectable && (