fix(uploads): serialize status filters and resize oversized covers
ci / module boundaries / boundaries (push) Successful in 9s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m37s
ci / api / api (push) Successful in 58s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m22s
ci / db / db (push) Successful in 6m19s
ci / ts / ts (packages + web) (push) Failing after 27m2s

This commit is contained in:
mrleo1nid committed 2026-10-29 12:14:49 +03:00
1 parent ca2a50b515
commit e78cab6ce6
11 files changed
+152 -17

No files matched your search

+29 -1
View File
@@ -38,6 +38,15 @@ async function setup(page: Page, scenario: 'queue' | 'template' | 'mine') {
const req = new Request(input, init); const req = new Request(input, init);
const url = new URL(req.url); 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') return json({ items: [files[0], missing], next_cursor: null });
if (scenario === 'queue' && url.pathname.startsWith('/api/v1/admin/uploads/batches/') && req.method === 'PATCH') {
const body = await req.json();
const bytes = Uint8Array.from(atob(body.cover_base64), (char) => char.charCodeAt(0));
const image = await createImageBitmap(new Blob([bytes], { type: 'image/jpeg' }));
document.documentElement.dataset.coverDimensions = `${image.width}x${image.height}`;
document.documentElement.dataset.coverBytes = String(bytes.length);
image.close();
return json({ items: [files[0], missing] });
}
if (scenario === 'queue' && url.pathname === '/api/v1/admin/uploads/files/missing' && req.method === 'PATCH') { if (scenario === 'queue' && url.pathname === '/api/v1/admin/uploads/files/missing' && req.method === 'PATCH') {
const body = await req.json(); 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 }); 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 });
@@ -59,11 +68,12 @@ async function setup(page: Page, scenario: 'queue' | 'template' | 'mine') {
if (url.pathname === `/api/v1/admin/sources/${source.id}`) 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 (scenario === 'mine' && url.pathname === '/api/v1/uploads/mine') {
if (url.searchParams.getAll('status').length > 1) return json({ code: 'common.bad_request', status: 400 }, 400);
if (url.searchParams.has('batch')) { if (url.searchParams.has('batch')) {
document.documentElement.dataset.batchPages = String(Number(document.documentElement.dataset.batchPages ?? 0) + 1); 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: [url.searchParams.has('cursor') ? files[2] : files[0]], next_cursor: url.searchParams.has('cursor') ? null : 'next' });
} }
const statuses = url.searchParams.getAll('status'); const statuses = url.searchParams.get('status')?.split(',') ?? [];
const visible = [files[0], files[1], files[3], files[4]].filter((f) => !statuses.length || statuses.includes(f.status)); const visible = [files[0], files[1], files[3], files[4]].filter((f) => !statuses.length || statuses.includes(f.status));
return json({ items: visible, next_cursor: 'more' }); return json({ items: visible, next_cursor: 'more' });
} }
@@ -253,3 +263,21 @@ test('starting another queue preview pauses the previous track', async ({ page }
await page.getByRole('button', { name: 'Свернуть пачку', exact: true }).click(); await page.getByRole('button', { name: 'Свернуть пачку', exact: true }).click();
await expect.poll(() => first.evaluate((el: HTMLAudioElement) => el.paused)).toBe(true); await expect.poll(() => first.evaluate((el: HTMLAudioElement) => el.paused)).toBe(true);
}); });
test('oversized album cover is reduced before saving the batch', async ({ page }) => {
await setup(page, 'queue');
const png = await page.evaluate(() => {
const canvas = document.createElement('canvas');
canvas.width = 3000; canvas.height = 1800;
canvas.getContext('2d')!.fillRect(0, 0, canvas.width, canvas.height);
return canvas.toDataURL('image/png').split(',')[1]!;
});
// A valid image with trailing padding reproduces an oversized input without a large fixture in Git.
const largeCover = Buffer.concat([Buffer.from(png, 'base64'), Buffer.alloc(12 * 1024 * 1024)]);
await page.getByRole('button', { name: 'На всю пачку', exact: true }).click();
const dialog = page.getByRole('dialog');
await dialog.locator('input[type=file]').setInputFiles({ name: 'Cover.png', mimeType: 'image/png', buffer: largeCover });
await dialog.getByRole('button', { name: 'Сохранить', exact: true }).click();
await expect(page.locator('html')).toHaveAttribute('data-cover-dimensions', '2048x1229');
expect(Number(await page.locator('html').getAttribute('data-cover-bytes'))).toBeLessThan(8 * 1024 * 1024);
});
@@ -2,6 +2,7 @@ import { useMutation } from '@tanstack/react-query';
import { useState } from 'react'; import { useState } from 'react';
import { useErrorText } from '../../shared/lib/errors'; import { useErrorText } from '../../shared/lib/errors';
import { useUploadsApi } from './queries'; import { useUploadsApi } from './queries';
import { coverBase64 } from './files';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Button, Dialog, Field, FormError } from '../../shared/ui/ui'; import { Button, Dialog, Field, FormError } from '../../shared/ui/ui';
import s from '../../pages/page.module.css'; import s from '../../pages/page.module.css';
@@ -13,6 +14,7 @@ export function BatchEditDialog({ batchId, onClose, onSaved }: { batchId: string
const [album, setAlbum] = useState(''); const [album, setAlbum] = useState('');
const [year, setYear] = useState(''); const [year, setYear] = useState('');
const [cover, setCover] = useState<string | null | undefined>(undefined); const [cover, setCover] = useState<string | null | undefined>(undefined);
const prepare = useMutation({ mutationFn: coverBase64, onSuccess: setCover });
const save = useMutation({ const save = useMutation({
mutationFn: () => api.updateBatch(batchId, { mutationFn: () => api.updateBatch(batchId, {
...(artist.trim() ? { artist: artist.trim() } : {}), ...(artist.trim() ? { artist: artist.trim() } : {}),
@@ -23,17 +25,13 @@ export function BatchEditDialog({ batchId, onClose, onSaved }: { batchId: string
onSuccess: onSaved, onSuccess: onSaved,
}); });
const pickCover = (file: File | undefined) => { const pickCover = (file: File | undefined) => {
save.reset();
prepare.reset();
setCover(undefined);
if (!file) { if (!file) {
setCover(undefined);
return; return;
} }
const reader = new FileReader(); prepare.mutate(file);
reader.onload = () => {
const data = String(reader.result ?? '');
const comma = data.indexOf(',');
setCover(comma >= 0 ? data.slice(comma + 1) : data);
};
reader.readAsDataURL(file);
}; };
return ( return (
<Dialog <Dialog
@@ -44,7 +42,7 @@ export function BatchEditDialog({ batchId, onClose, onSaved }: { batchId: string
<Button variant="ghost" onClick={onClose}> <Button variant="ghost" onClick={onClose}>
{t('common:form.cancel')} {t('common:form.cancel')}
</Button> </Button>
<Button variant="primary" busy={save.isPending} onClick={() => save.mutate()}> <Button variant="primary" busy={save.isPending || prepare.isPending} disabled={prepare.isError} onClick={() => save.mutate()}>
{t('uploads.save')} {t('uploads.save')}
</Button> </Button>
</> </>
@@ -57,12 +55,12 @@ export function BatchEditDialog({ batchId, onClose, onSaved }: { batchId: string
<Field label={t('uploads.meta_year')} value={year} inputMode="numeric" onChange={setYear} /> <Field label={t('uploads.meta_year')} value={year} inputMode="numeric" onChange={setYear} />
<label className={s.sessions}> <label className={s.sessions}>
<span>{t('uploads.batch_cover')}</span> <span>{t('uploads.batch_cover')}</span>
<input type="file" accept="image/jpeg,image/png,image/webp" onChange={(e) => pickCover(e.target.files?.[0])} /> <input type="file" accept="image/jpeg,image/png,image/webp" disabled={prepare.isPending || save.isPending} onChange={(e) => pickCover(e.target.files?.[0])} />
</label> </label>
<Button variant="ghost" onClick={() => setCover(null)}> <Button variant="ghost" disabled={prepare.isPending || save.isPending} onClick={() => { prepare.reset(); setCover(null); }}>
{t('uploads.batch_cover_clear')} {t('uploads.batch_cover_clear')}
</Button> </Button>
<FormError>{save.error ? errorText(save.error) : null}</FormError> <FormError>{(save.error ?? prepare.error) ? errorText(save.error ?? prepare.error) : null}</FormError>
</div> </div>
</Dialog> </Dialog>
); );
+20
View File
@@ -0,0 +1,20 @@
import { createApiClient } from '@pipu/api-client';
import { describe, expect, it } from 'vitest';
describe('upload status query serialization', () => {
it('sends comma-separated statuses for own and admin queues, including batch selection', async () => {
const urls: URL[] = [];
const client = createApiClient({ baseUrl: 'https://pipu.test', fetch: async (input) => {
urls.push(new URL((input as Request).url));
return new Response(JSON.stringify({ items: [], next_cursor: null }), { headers: { 'Content-Type': 'application/json' } });
} });
await client.GET('/api/v1/uploads/mine', { params: { query: { status: ['pending_review', 'imported', 'expired'], limit: 50 } } });
await client.GET('/api/v1/admin/uploads', { params: { query: { status: ['uploading', 'processing'], limit: 100 } } });
await client.GET('/api/v1/uploads/mine', { params: { query: { batch: '00000000-0000-4000-8000-000000000001', status: ['uploading', 'processing', 'pending_review'] } } });
expect(urls.map((url) => url.searchParams.getAll('status'))).toEqual([
['pending_review,imported,expired'], ['uploading,processing'], ['uploading,processing,pending_review'],
]);
expect(urls[0]!.searchParams.get('limit')).toBe('50');
expect(urls[2]!.searchParams.get('batch')).toBe('00000000-0000-4000-8000-000000000001');
});
});
+30 -2
View File
@@ -1,4 +1,31 @@
// Сбор файлов из drag-and-drop и выбора папки с относительными путями (они подсказывают альбом, docs/uploads.md#5). // Сбор файлов из drag-and-drop и выбора папки с относительными путями (они подсказывают альбом, docs/uploads.md#5).
import { ApiError } from '@pipu/core';
const MAX_COVER_BYTES = 8 * 1024 * 1024;
/** Reduce oversized artwork before base64 expansion; small covers keep their original bytes. */
async function prepareCover(file: File): Promise<Blob> {
if (file.size <= MAX_COVER_BYTES) return file;
let image: ImageBitmap;
try { image = await createImageBitmap(file); }
catch { throw new ApiError(422, 'validation.cover_type'); }
try {
const scale = Math.min(1, 2048 / Math.max(image.width, image.height));
const canvas = document.createElement('canvas');
canvas.width = Math.max(1, Math.round(image.width * scale));
canvas.height = Math.max(1, Math.round(image.height * scale));
const context = canvas.getContext('2d');
if (!context) throw new ApiError(422, 'validation.cover_type');
context.fillStyle = '#fff';
context.fillRect(0, 0, canvas.width, canvas.height);
context.drawImage(image, 0, 0, canvas.width, canvas.height);
for (const quality of [0.9, 0.75]) {
const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, 'image/jpeg', quality));
if (blob && blob.size <= MAX_COVER_BYTES) return blob;
}
throw new ApiError(422, 'validation.cover_too_large');
} finally { image.close(); }
}
export interface PickedFile { export interface PickedFile {
file: File; file: File;
@@ -40,12 +67,13 @@ export function albumFolder(relPath: string): string {
return at < 0 ? '' : path.slice(0, at); return at < 0 ? '' : path.slice(0, at);
} }
export function coverBase64(file: File): Promise<string> { export async function coverBase64(file: File): Promise<string> {
const prepared = await prepareCover(file);
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const reader = new FileReader(); const reader = new FileReader();
reader.onload = () => resolve(String(reader.result).split(',')[1]!); reader.onload = () => resolve(String(reader.result).split(',')[1]!);
reader.onerror = () => reject(reader.error); reader.onerror = () => reject(reader.error);
reader.readAsDataURL(file); reader.readAsDataURL(prepared);
}); });
} }
+3
View File
@@ -91,6 +91,9 @@
в котором она найдена. На каждую папку альбома создаётся своя пачка с обложкой, поэтому разные альбомы в котором она найдена. На каждую папку альбома создаётся своя пачка с обложкой, поэтому разные альбомы
не смешиваются. Обложка до 8 МБ сохраняется вместе с резервом пачки и встраивается при импорте не смешиваются. Обложка до 8 МБ сохраняется вместе с резервом пачки и встраивается при импорте
в поддерживаемые контейнеры FLAC/MP3/M4A/Ogg/Opus без перекодирования аудио. в поддерживаемые контейнеры FLAC/MP3/M4A/Ogg/Opus без перекодирования аудио.
Обложки больше 8 МБ клиент автоматически уменьшает до JPEG с длинной стороной не более 2048 пикселей
перед base64-кодированием; это относится и к отдельным файлам, и к встроенной обложке, и к правке пачки.
Меньшие картинки остаются без изменений. Код HTTP 413 от прокси показывается как превышение размера запроса.
## 6. Обработка (`processing`) ## 6. Обработка (`processing`)
+6 -1
View File
@@ -15,5 +15,10 @@ export interface ApiClientOptions {
/** Создаёт типизированный клиент публичного API (контракт — api/openapi.yaml). */ /** Создаёт типизированный клиент публичного API (контракт — api/openapi.yaml). */
export function createApiClient(opts: ApiClientOptions = {}): ApiClient { export function createApiClient(opts: ApiClientOptions = {}): ApiClient {
return createClient<paths>({ baseUrl: opts.baseUrl ?? '', fetch: opts.fetch }); return createClient<paths>({
baseUrl: opts.baseUrl ?? '',
fetch: opts.fetch,
// OpenAPI query arrays (UploadStatusFilter) use form + explode: false.
querySerializer: { array: { style: 'form', explode: false } },
});
} }
+9
View File
@@ -83,6 +83,15 @@ describe('AuthSession', () => {
expect(states).toEqual(['signed_in']); expect(states).toEqual(['signed_in']);
}); });
it('reports proxy HTML 413 as a request size error', async () => {
const s = server({ 'POST /api/v1/auth/login': () => new Response('<html>Request Entity Too Large</html>', { status: 413 }) });
const auth = new AuthSession({ baseUrl: 'http://x', fetch: s.fetch, clientType: 'web' });
const error = await auth.login('alice', 'password').catch((e: unknown) => e);
expect(error).toBeInstanceOf(ApiError);
expect((error as ApiError).status).toBe(413);
expect((error as ApiError).code).toBe('common.request_too_large');
});
it('turns problem+json into ApiError with field errors', async () => { it('turns problem+json into ApiError with field errors', async () => {
const s = server({ const s = server({
'POST /api/v1/auth/login': () => problem(401, 'auth.invalid_credentials'), 'POST /api/v1/auth/login': () => problem(401, 'auth.invalid_credentials'),
+1 -1
View File
@@ -33,7 +33,7 @@ export function toApiError(error: unknown, response?: Response): ApiError {
if (typeof p.code === 'string') { if (typeof p.code === 'string') {
return new ApiError(p.status ?? response?.status ?? 0, p.code, p.params ?? {}, p.errors ?? []); return new ApiError(p.status ?? response?.status ?? 0, p.code, p.params ?? {}, p.errors ?? []);
} }
if (response) return new ApiError(response.status, response.status >= 500 ? 'common.internal' : 'common.bad_request'); if (response) return new ApiError(response.status, response.status === 413 ? 'common.request_too_large' : response.status >= 500 ? 'common.internal' : 'common.bad_request');
return new ApiError(0, 'common.network'); return new ApiError(0, 'common.network');
} }
+3
View File
@@ -2,6 +2,7 @@
"common": { "common": {
"internal": "Something went wrong on the server. Please try again", "internal": "Something went wrong on the server. Please try again",
"bad_request": "Invalid request", "bad_request": "Invalid request",
"request_too_large": "The request exceeds the server size limit. Reduce the amount of data being sent.",
"not_found": "Not found", "not_found": "Not found",
"method_not_allowed": "This action is not supported", "method_not_allowed": "This action is not supported",
"network": "Cannot reach the server. Check your network" "network": "Cannot reach the server. Check your network"
@@ -26,6 +27,8 @@
"forbidden": "You don't have permission to do this" "forbidden": "You don't have permission to do this"
}, },
"validation": { "validation": {
"cover_type": "Could not read the cover. Choose a JPEG, PNG or WebP image.",
"cover_too_large": "Could not reduce the cover below 8 MB. Choose a smaller image.",
"user_archive_invalid": "Invalid user file: check the version, usernames, roles and password hashes. No changes were saved.", "user_archive_invalid": "Invalid user file: check the version, usernames, roles and password hashes. No changes were saved.",
"password_too_short": "The password must be at least {{min}} characters", "password_too_short": "The password must be at least {{min}} characters",
"password_too_long": "The password must be at most {{max}} characters", "password_too_long": "The password must be at most {{max}} characters",
+3
View File
@@ -2,6 +2,7 @@
"common": { "common": {
"internal": "Что-то пошло не так на сервере. Попробуйте ещё раз", "internal": "Что-то пошло не так на сервере. Попробуйте ещё раз",
"bad_request": "Некорректный запрос", "bad_request": "Некорректный запрос",
"request_too_large": "Размер запроса превышает лимит сервера. Уменьшите объём отправляемых данных.",
"not_found": "Не найдено", "not_found": "Не найдено",
"method_not_allowed": "Действие не поддерживается", "method_not_allowed": "Действие не поддерживается",
"network": "Нет связи с сервером. Проверьте сеть" "network": "Нет связи с сервером. Проверьте сеть"
@@ -26,6 +27,8 @@
"forbidden": "Недостаточно прав для этого действия" "forbidden": "Недостаточно прав для этого действия"
}, },
"validation": { "validation": {
"cover_type": "Не удалось прочитать обложку. Выберите изображение JPEG, PNG или WebP.",
"cover_too_large": "Не удалось уменьшить обложку до 8 МБ. Выберите изображение меньшего размера.",
"user_archive_invalid": "Некорректный файл пользователей: проверьте версию, логины, роли и хэши паролей. Изменения не сохранены.", "user_archive_invalid": "Некорректный файл пользователей: проверьте версию, логины, роли и хэши паролей. Изменения не сохранены.",
"password_too_short": "Пароль должен быть не короче {{min}} символов", "password_too_short": "Пароль должен быть не короче {{min}} символов",
"password_too_long": "Пароль должен быть не длиннее {{max}} символов", "password_too_long": "Пароль должен быть не длиннее {{max}} символов",
@@ -0,0 +1,38 @@
package uploads
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/require"
"pipu/api/internal/api/gen"
)
type uploadQueryHandler struct {
gen.Unimplemented
statuses []gen.UploadStatus
}
func (h *uploadQueryHandler) ListMyUploads(w http.ResponseWriter, _ *http.Request, p gen.ListMyUploadsParams) {
h.statuses = *p.Status
w.WriteHeader(http.StatusNoContent)
}
func (h *uploadQueryHandler) ListUploads(w http.ResponseWriter, _ *http.Request, p gen.ListUploadsParams) {
h.statuses = *p.Status
w.WriteHeader(http.StatusNoContent)
}
func TestUploadFiltersBindCommaSeparatedStatuses(t *testing.T) {
for _, path := range []string{"/api/v1/uploads/mine", "/api/v1/admin/uploads"} {
t.Run(path, func(t *testing.T) {
h := &uploadQueryHandler{}
rec := httptest.NewRecorder()
gen.Handler(h).ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path+"?status=pending_review,imported,expired&limit=50", nil))
require.Equal(t, http.StatusNoContent, rec.Code, rec.Body.String())
require.Equal(t, []gen.UploadStatus{gen.UploadStatusPendingReview, gen.UploadStatusImported, gen.UploadStatusExpired}, h.statuses)
})
}
}