fix(uploads): serve private previews through API and show batch cover status
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m30s
ci / ts / ts (packages + web) (push) Canceled after 4m13s
ci / module boundaries / boundaries (push) Successful in 8s
ci / api / api (push) Successful in 56s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m1s
ci / db / db (push) Successful in 1m7s

This commit is contained in:
mrleo1nid committed 2026-10-29 11:34:55 +03:00
1 parent 37e33c656b
commit 7d62531c02
15 files changed
+533 -410

No files matched your search

+37 -7
View File
@@ -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);
});
@@ -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<HTMLAudioElement>(null);
const [error, setError] = useState<number | null>(null);
const [httpStatus, setHttpStatus] = useState<number | null>(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 <div className={u.preview}>
<audio ref={audio} controls preload={active ? 'metadata' : 'none'} src={url}
aria-label={t('uploads.listen', { name })}
onLoadedMetadata={() => { setError(null); setHttpStatus(null); }}
onError={(event) => setError(event.currentTarget.error?.code ?? 0)} />
{error !== null && <div className={u.previewError}>
<span role="alert" className={u.bad}>{httpStatus !== null
? t('uploads.preview_http_error', { status: httpStatus })
: t(error === 3 || error === 4 ? 'uploads.preview_decode_error' : 'uploads.preview_error')}</span>
<Button variant="ghost" icon={refreshIcon} onClick={retry}>{t('uploads.retry')}</Button>
</div>}
</div>;
}
+8 -1
View File
@@ -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({
<b>{first.author.username}</b>
<span>{formatDateTime(first.created_at, i18n.language)}</span>
<span>{t('uploads.batch_size', { count: files.length, size: formatBytes(size, i18n.language) })}</span>
<span className={cx(s.badge, first.batch_has_cover ? u.tone_info : u.tone_muted)}>
{t(first.batch_has_cover ? 'uploads.batch_cover_present' : 'uploads.batch_cover_absent')}
</span>
<span style={{ flex: 1 }} />
{selectable && (
<Button variant="ghost" onClick={() => onEditBatch(first.batch_id)}>
@@ -56,6 +60,7 @@ export function BatchCard({
<QueueRow
key={f.id}
file={f}
active={!collapsed}
selectable={selectable}
checked={selected.has(f.id)}
onCheck={(on) => onToggle([f.id], on)}
@@ -70,6 +75,7 @@ export function BatchCard({
function QueueRow({
file: f,
active,
selectable,
checked,
onCheck,
@@ -77,6 +83,7 @@ function QueueRow({
onRetry,
}: {
file: UploadFile;
active: boolean;
selectable: boolean;
checked: boolean;
onCheck: (on: boolean) => void;
@@ -142,7 +149,7 @@ function QueueRow({
</div>
<div className={u.rowActions}>
{f.stream_url && (
<audio controls preload="metadata" src={f.stream_url} aria-label={t('uploads.listen', { name: f.original_name })} />
<AudioPreview url={f.stream_url} name={f.original_name} active={active} />
)}
{editable && (
<Button variant="ghost" aria-label={t('uploads.edit', { name: f.original_name })} onClick={onEdit}>
@@ -221,6 +221,10 @@
align-items: center;
}
.preview { width: 300px; max-width: 100%; min-width: 0; }
.preview audio { display: block; width: 100%; }
.previewError { display: flex; flex-direction: column; align-items: flex-start; gap: 4px; margin-top: 6px; font-size: 12px; }
.bulk {
position: sticky;
bottom: 12px;