Import missing artist portraits from uploaded album folders
ci / module boundaries / boundaries (push) Successful in 6s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 2m1s
ci / ts / ts (packages + web) (push) Failing after 5m13s
ci / api / api (push) Successful in 41s
ci / db / db (push) Failing after 1m33s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 2m25s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 2m19s
ci / module boundaries / boundaries (push) Successful in 6s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 2m1s
ci / ts / ts (packages + web) (push) Failing after 5m13s
ci / api / api (push) Successful in 41s
ci / db / db (push) Failing after 1m33s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 2m25s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 2m19s
This commit is contained in:
1 parent
fd17c06397
commit
82bf015283
23 files changed
+829
-317
No files matched your search
@@ -0,0 +1,62 @@
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
test('an artist directory uploads each album as a separate batch with a shared portrait', async ({ page }, info) => {
|
||||
const root = info.outputPath('творожный бизнес');
|
||||
const albums = ['(2026) творог грехов - творожный бизнес', '(2025) молчанка - творожный бизнес'];
|
||||
for (const album of albums) {
|
||||
await mkdir(join(root, album), { recursive: true });
|
||||
await writeFile(join(root, album, 'song.flac'), Buffer.alloc(128));
|
||||
}
|
||||
const portrait = Buffer.from('portrait');
|
||||
await writeFile(join(root, 'artist.jpg'), portrait);
|
||||
await writeFile(join(root, albums[0]!, 'cover.jpg'), Buffer.from('album cover'));
|
||||
type Batch = { artist_image_base64?: string; cover_base64?: string; files: { client_id: string; rel_path: string }[] };
|
||||
const batches: Batch[] = [];
|
||||
await page.exposeFunction('recordArtistBatch', (batch: Batch) => { batches.push(batch); });
|
||||
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(() => {
|
||||
const original = window.fetch;
|
||||
window.fetch = async (input, init) => {
|
||||
const req = new Request(input, init);
|
||||
const path = new URL(req.url).pathname;
|
||||
const json = (data: unknown) => new Response(JSON.stringify(data), { headers: { 'Content-Type': 'application/json' } });
|
||||
if (path === '/api/v1/uploads/precheck') {
|
||||
const body = await req.json();
|
||||
return json({ files: body.files.map((f: { client_id: string }) => ({ client_id: f.client_id, result: 'accepted' })) });
|
||||
}
|
||||
if (path === '/api/v1/uploads' && req.method === 'POST') {
|
||||
const body = await req.json();
|
||||
await (window as unknown as { recordArtistBatch: (body: unknown) => Promise<void> }).recordArtistBatch(body);
|
||||
return json({ batch_id: crypto.randomUUID(), files: body.files.map((f: { client_id: string }) => ({ client_id: f.client_id, upload_file_id: f.client_id, tus_url: `/api/v1/uploads/files/${f.client_id}` })) });
|
||||
}
|
||||
return original(input, init);
|
||||
};
|
||||
history.pushState({}, '', '/uploads/new');
|
||||
window.dispatchEvent(new PopStateEvent('popstate'));
|
||||
});
|
||||
await page.route('**/api/v1/uploads/files/*', async (route) => {
|
||||
await route.fulfill({ status: route.request().method() === 'HEAD' ? 200 : 204,
|
||||
headers: { 'Upload-Length': '128', 'Upload-Offset': route.request().method() === 'HEAD' ? '0' : '128', 'Tus-Resumable': '1.0.0' } });
|
||||
});
|
||||
await page.locator('input[webkitdirectory]').setInputFiles(root);
|
||||
const send = page.getByRole('button', { name: /^Отправить/ });
|
||||
await expect(send).toBeEnabled();
|
||||
await expect(page.getByText(/Пропущен/)).toHaveCount(0);
|
||||
await send.click();
|
||||
await expect(page.getByText(/Готово: 2 файла/)).toBeVisible();
|
||||
expect(batches).toHaveLength(2);
|
||||
expect(batches.map((batch) => batch.files[0]!.rel_path).sort()).toEqual(albums.map((album) => `творожный бизнес/${album}/song.flac`).sort());
|
||||
for (const batch of batches) {
|
||||
expect(batch.files).toHaveLength(1);
|
||||
expect(batch.artist_image_base64).toBe(portrait.toString('base64'));
|
||||
}
|
||||
expect(batches.find((batch) => batch.files[0]!.rel_path.includes(albums[0]!))!.cover_base64).toBe(Buffer.from('album cover').toString('base64'));
|
||||
expect(batches.find((batch) => batch.files[0]!.rel_path.includes(albums[1]!))!.cover_base64).toBeUndefined();
|
||||
});
|
||||
@@ -1,6 +1,19 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { needsFormatAttention } from './files';
|
||||
import { artistImageFor, isArtistImage, needsFormatAttention } from './files';
|
||||
|
||||
it('inherits the nearest artist portrait without crossing sibling folders', () => {
|
||||
const root = new File(['portrait'], 'artist.jpg');
|
||||
const local = new File(['local'], 'ARTIST.PNG');
|
||||
const images = new Map([['творожный бизнес', root], ['творожный бизнес/Альбом', local]]);
|
||||
expect(isArtistImage(root.name)).toBe(true);
|
||||
expect(isArtistImage(local.name)).toBe(true);
|
||||
expect(isArtistImage('cover.jpg')).toBe(false);
|
||||
expect(artistImageFor('творожный бизнес/(2026) творог грехов', images)).toBe(root);
|
||||
expect(artistImageFor('творожный бизнес/Альбом/CD1', images)).toBe(local);
|
||||
expect(artistImageFor('другой исполнитель/Альбом', images)).toBeUndefined();
|
||||
expect(artistImageFor('творожный бизнес 2/Альбом', images)).toBeUndefined();
|
||||
});
|
||||
|
||||
describe('needsFormatAttention', () => {
|
||||
it('marks everything except lossless formats', () => {
|
||||
|
||||
@@ -61,6 +61,20 @@ export function isAlbumCover(name: string): boolean {
|
||||
return /^(cover|folder|front)\.(jpe?g|png|webp)$/i.test(name.split(/[/\\]/).pop() ?? name);
|
||||
}
|
||||
|
||||
export function isArtistImage(name: string): boolean {
|
||||
return /^artist\.(jpe?g|png|webp)$/i.test(name.split(/[/\\]/).pop() ?? name);
|
||||
}
|
||||
|
||||
/** The nearest ancestor wins; sibling artist folders must never share portraits. */
|
||||
export function artistImageFor(folder: string, images: ReadonlyMap<string, File>): File | undefined {
|
||||
for (;;) {
|
||||
const image = images.get(folder);
|
||||
if (image) return image;
|
||||
if (!folder) return undefined;
|
||||
folder = albumFolder(folder);
|
||||
}
|
||||
}
|
||||
|
||||
export function albumFolder(relPath: string): string {
|
||||
const path = relPath.replaceAll('\\', '/');
|
||||
const at = path.lastIndexOf('/');
|
||||
|
||||
@@ -52,3 +52,50 @@ it('keeps external and embedded album covers in separate batches without skippin
|
||||
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);
|
||||
});
|
||||
|
||||
it('splits an artist discography into album batches and inherits only that artist portrait', 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: { artist_image_base64?: string; 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);
|
||||
const first = 'творожный бизнес/(2026) творог грехов - творожный бизнес';
|
||||
const second = 'творожный бизнес/(2025) молчанка - творожный бизнес';
|
||||
uploader.add([
|
||||
{ file: new File(['portrait'], 'artist.jpg'), relPath: 'творожный бизнес/artist.jpg' },
|
||||
{ file: new File(['cover'], 'cover.jpg'), relPath: `${first}/cover.jpg` },
|
||||
{ file: new File(['a'], '02.flac'), relPath: `${first}/02.flac` },
|
||||
{ file: new File(['b'], '03.flac'), relPath: `${first}/03.flac` },
|
||||
{ file: new File(['c'], '02.flac'), relPath: `${second}/02.flac` },
|
||||
{ file: new File(['d'], '02.flac'), relPath: 'Другой исполнитель/Альбом/02.flac' },
|
||||
]);
|
||||
await waitFor(() => expect(uploader.ready).toHaveLength(4));
|
||||
expect(uploader.getState().skipped).toBe(0);
|
||||
await uploader.start('Дискография');
|
||||
expect(batches).toHaveLength(3);
|
||||
const a = batches.find((batch) => batch.files[0]!.rel_path.startsWith(first))!;
|
||||
const b = batches.find((batch) => batch.files[0]!.rel_path.startsWith(second))!;
|
||||
const other = batches.find((batch) => batch.files[0]!.rel_path.startsWith('Другой'))!;
|
||||
expect(a.files).toHaveLength(2);
|
||||
expect(b.files).toHaveLength(1);
|
||||
expect(a.artist_image_base64).toBe(btoa('portrait'));
|
||||
expect(b.artist_image_base64).toBe(btoa('portrait'));
|
||||
expect(a.cover_base64).toBe(btoa('cover'));
|
||||
expect(b.cover_base64).toBeUndefined();
|
||||
expect(other.artist_image_base64).toBeUndefined();
|
||||
uploader.clear();
|
||||
uploader.add([{ file: new File(['new'], '02.flac'), relPath: `${second}/02.flac` }]);
|
||||
await waitFor(() => expect(uploader.ready).toHaveLength(1));
|
||||
await uploader.start('New selection');
|
||||
expect(batches[3]!.artist_image_base64).toBeUndefined();
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { unwrap, type AuthSession } from '@pipu/core';
|
||||
import * as tus from 'tus-js-client';
|
||||
|
||||
import { albumFolder, coverBase64, isAlbumCover, isAudio, type PickedFile } from './files';
|
||||
import { albumFolder, artistImageFor, coverBase64, isAlbumCover, isArtistImage, isAudio, type PickedFile } from './files';
|
||||
import type { HashRequest, HashResponse } from './hash.worker';
|
||||
|
||||
// Загрузка пачки в очередь на добавление (docs/uploads.md#5): SHA-256 в воркере → предпроверка дублей
|
||||
@@ -74,6 +74,7 @@ export class Uploader {
|
||||
private transferDone: (() => void) | null = null;
|
||||
private refreshing: Promise<unknown> | null = null;
|
||||
private folderCovers = new Map<string, File>();
|
||||
private artistImages = new Map<string, File>();
|
||||
|
||||
constructor(private readonly session: AuthSession) {
|
||||
this.restore();
|
||||
@@ -104,12 +105,13 @@ export class Uploader {
|
||||
if (this.state.busy) return;
|
||||
for (const f of files) {
|
||||
if (isAlbumCover(f.file.name)) this.folderCovers.set(albumFolder(f.relPath), f.file);
|
||||
if (isArtistImage(f.file.name)) this.artistImages.set(albumFolder(f.relPath), f.file);
|
||||
}
|
||||
const fresh: UploadItem[] = [];
|
||||
let skipped = 0;
|
||||
for (const f of files) {
|
||||
if (!isAudio(f.file.name)) {
|
||||
if (!isAlbumCover(f.file.name)) skipped++;
|
||||
if (!isAlbumCover(f.file.name) && !isArtistImage(f.file.name)) skipped++;
|
||||
continue;
|
||||
}
|
||||
// Тот же путь и размер уже в списке — не добавляем второй раз.
|
||||
@@ -134,6 +136,7 @@ export class Uploader {
|
||||
if (this.state.busy) return;
|
||||
this.hashQueue = [];
|
||||
this.folderCovers.clear();
|
||||
this.artistImages.clear();
|
||||
this.set({ items: [], skipped: 0, batchId: null, error: null });
|
||||
this.persist();
|
||||
}
|
||||
@@ -236,11 +239,13 @@ export class Uploader {
|
||||
for (const [folder, group] of groups) {
|
||||
try {
|
||||
const cover = this.folderCovers.get(folder);
|
||||
const artistImage = artistImageFor(folder, this.artistImages);
|
||||
const res = unwrap(
|
||||
await this.session.client.POST('/api/v1/uploads', {
|
||||
body: {
|
||||
comment: comment.trim() || undefined,
|
||||
...(cover ? { cover_base64: await coverBase64(cover) } : {}),
|
||||
...(artistImage ? { artist_image_base64: await coverBase64(artistImage) } : {}),
|
||||
files: group.map((it) => ({
|
||||
client_id: it.id,
|
||||
name: it.file?.name ?? it.relPath,
|
||||
|
||||
Reference in new issue
Block a user