Compare commits

..
14 Commits
Author SHA1 Message Date
mrleo1nidandClaude Sonnet 5.5 75b0f7bec3 Hide full-screen player controls after mouse click
ci / ts / ts (packages + web) (push) Successful in 5m35s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-04 02:34:26 +03:00
mrleo1nidandClaude Sonnet 5.5 3edb1af037 Fix indentation in full-screen player
ci / ts / ts (packages + web) (push) Successful in 4m36s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-04 01:51:31 +03:00
mrleo1nidandClaude Sonnet 5.5 0458cbc46c Reveal full-screen player controls on hover and balance their layout
ci / ts / ts (packages + web) (push) Canceled after 14s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-04 01:51:12 +03:00
mrleo1nidandClaude Sonnet 5.5 f7ffa5d742 Fix track page hero layout and add full-screen player
ci / ts / ts (packages + web) (push) Successful in 5m9s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m37s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-04 00:17:55 +03:00
mrleo1nidandClaude Sonnet 5.5 5d0decbb2a Use the primary artist for upload destination folders
ci / module boundaries / boundaries (push) Successful in 7s
ci / api / api (push) Successful in 33s
ci / db / db (push) Successful in 1m47s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-03 23:14:48 +03:00
mrleo1nidandClaude Sonnet 5.5 3edbf489a3 Place cover and artist images in library folders on upload import
ci / module boundaries / boundaries (push) Successful in 8s
ci / api / api (push) Successful in 36s
ci / db / db (push) Successful in 1m16s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-03 01:04:24 +03:00
mrleo1nid 82f3eed9a0 Improve search relevance and artist transliteration
ci / api / api (push) Successful in 56s
ci / db / db (push) Successful in 1m13s
2026-10-30 12:09:24 +03:00
mrleo1nid db4548b115 Omit missing years from upload destination paths
ci / module boundaries / boundaries (push) Successful in 7s
ci / db / db (push) Successful in 2m33s
ci / api / api (push) Successful in 1m10s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 3m45s
ci / ts / ts (packages + web) (push) Successful in 6m29s
2026-10-30 11:40:42 +03:00
mrleo1nid 3309a79a98 Wait for all search indexes in API user journey
release / android (APK в релиз) (push) Successful in 4m55s
ci / api / api (push) Successful in 31s
ci / db / db (push) Successful in 53s
release / release (версия и заметки) (push) Successful in 6s
release / desktop (Linux и Windows в релиз) (push) Successful in 19m16s
release / prune (старые файлы и прогоны) (push) Successful in 10s
2026-10-30 11:24:44 +03:00
mrleo1nid bbd629dcf8 Isolate CI database journey and stabilize E2E dependency cache
ci / module boundaries / boundaries (push) Successful in 7s
ci / db / db (push) Failing after 29s
ci / api / api (push) Successful in 52s
ci / ts / ts (packages + web) (push) Successful in 4m22s
2026-10-30 11:05:13 +03:00
mrleo1nid 82bf015283 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
2026-10-30 10:46:07 +03:00
mrleo1nid fd17c06397 Fix release matching for featured artists and credit annotations
ci / module boundaries / boundaries (push) Successful in 1m11s
ci / api / api (push) Successful in 1m54s
ci / mobile / mobile (Metro-бандл Android) (push) Failing after 6m50s
ci / ts / ts (packages + web) (push) Failing after 6m19s
ci / db / db (push) Failing after 11s
2026-10-30 10:23:12 +03:00
mrleo1nid f4681da876 Stabilize E2E server and retain failure diagnostics
ci / ts / ts (packages + web) (push) Successful in 6m9s
release / release (версия и заметки) (push) Successful in 6s
release / desktop (Linux и Windows в релиз) (push) Successful in 12m48s
release / android (APK в релиз) (push) Successful in 4m35s
release / prune (старые файлы и прогоны) (push) Successful in 6s
2026-10-29 23:17:48 +03:00
mrleo1nid 5248074964 fix(web): stabilize navigation labels and upload browser tests
ci / ts / ts (packages + web) (push) Failing after 10m23s
2026-10-29 22:54:22 +03:00
62 changed files with 2037 additions and 368 deletions

No files matched your search

+3
View File
@@ -75,6 +75,9 @@ jobs:
# These suites share one database; dbtest also rolls all migrations down # These suites share one database; dbtest also rolls all migrations down
# and cleans orphan catalog rows. Do not run the packages concurrently. # and cleans orphan catalog rows. Do not run the packages concurrently.
run: go test -p 1 -tags integration -count=1 -v ./internal/dbtest/ ./internal/social/ ./internal/uploads/ run: go test -p 1 -tags integration -count=1 -v ./internal/dbtest/ ./internal/social/ ./internal/uploads/
- name: Go — релевантность поиска на Meilisearch
working-directory: services/api
run: go test -tags integration -count=1 -v ./internal/search/
- name: Go — сквозной сценарий пользователя (newApp на Postgres, Redis, Meilisearch) - name: Go — сквозной сценарий пользователя (newApp на Postgres, Redis, Meilisearch)
working-directory: services/api working-directory: services/api
run: go test -tags integration -count=1 -v -run TestUserJourney ./cmd/api/ run: go test -tags integration -count=1 -v -run TestUserJourney ./cmd/api/
+9
View File
@@ -63,3 +63,12 @@ jobs:
pnpm --filter @pipu/web exec playwright install --with-deps chromium || pnpm --filter @pipu/web exec playwright install --with-deps chromium ||
pnpm --filter @pipu/web exec playwright install chromium pnpm --filter @pipu/web exec playwright install chromium
- run: pnpm --filter @pipu/web test:e2e - run: pnpm --filter @pipu/web test:e2e
- name: E2E failure diagnostics
if: failure()
continue-on-error: true
uses: actions/upload-artifact@v3
with:
name: web-e2e-failure
path: apps/web/test-results/
if-no-files-found: ignore
retention-days: 3
+4
View File
@@ -2848,6 +2848,10 @@ components:
type: object type: object
required: [files] required: [files]
properties: properties:
artist_image_base64:
type: string
maxLength: 11184812
description: Фото исполнителя JPEG, PNG или WebP, до 8 МБ. После одобрения применяется к исполнителю альбома только при отсутствии аватарки.
cover_base64: cover_base64:
type: string type: string
maxLength: 11184812 maxLength: 11184812
+62
View File
@@ -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();
});
+2
View File
@@ -69,6 +69,8 @@ for (const [width, username] of [[320, 'mobile-admin'], [390, 'moderator'], [430
await page.getByRole('button', { name: 'Ещё', exact: true }).click(); await page.getByRole('button', { name: 'Ещё', exact: true }).click();
await page.getByRole('dialog').getByRole('link', { name: 'Очередь', exact: true }).click(); await page.getByRole('dialog').getByRole('link', { name: 'Очередь', exact: true }).click();
await expect(page.getByRole('heading', { name: 'Очередь на добавление' })).toBeVisible(); await expect(page.getByRole('heading', { name: 'Очередь на добавление' })).toBeVisible();
await expect(page.locator('audio')).toBeHidden();
await page.getByRole('button', { name: 'Развернуть пачку', exact: true }).click();
await expect(page.locator('audio')).toBeVisible(); await expect(page.locator('audio')).toBeVisible();
await fits(page); await fits(page);
await page.getByRole('checkbox', { name: /^Выбрать Длинное/ }).check(); await page.getByRole('checkbox', { name: /^Выбрать Длинное/ }).check();
+6 -4
View File
@@ -180,7 +180,9 @@ test('queue shows the storage and path, and recalculates after fixing placement'
await expect(page.getByRole('checkbox', { name: 'Выбрать всю пачку', exact: true })).toBeChecked(); await expect(page.getByRole('checkbox', { name: 'Выбрать всю пачку', exact: true })).toBeChecked();
await page.getByRole('button', { name: 'Развернуть пачку', exact: true }).press('Enter'); await page.getByRole('button', { name: 'Развернуть пачку', exact: true }).press('Enter');
await expect(page.getByText('Artist/Album/01 - Song.flac', { exact: true })).toBeVisible(); await expect(page.getByText('Artist/Album/01 - Song.flac', { exact: true })).toBeVisible();
await page.getByRole('checkbox', { name: 'Выбрать всю пачку', exact: true }).uncheck(); // Continue keyboard selection after keyboard expansion, avoiding another scroll-dependent pointer action.
await page.getByRole('checkbox', { name: 'Выбрать всю пачку', exact: true }).press('Space');
await expect(page.getByRole('checkbox', { name: 'Выбрать всю пачку', exact: true })).not.toBeChecked();
await page.getByRole('button', { name: 'Выбрать хранилище', exact: true }).click(); await page.getByRole('button', { name: 'Выбрать хранилище', exact: true }).click();
const dialog = page.getByRole('dialog'); const dialog = page.getByRole('dialog');
await dialog.getByLabel('Исполнитель', { exact: true }).fill('Artist'); await dialog.getByLabel('Исполнитель', { exact: true }).fill('Artist');
@@ -380,7 +382,7 @@ test('mixed batch checks each release and applies only within the chosen group',
await expect(page.locator('html')).toHaveAttribute('data-release-applied', /"group_id":"bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"/); await expect(page.locator('html')).toHaveAttribute('data-release-applied', /"group_id":"bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"/);
await dialog.getByRole('combobox', { name: 'Релиз в пачке' }).selectOption('aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'); await dialog.getByRole('combobox', { name: 'Релиз в пачке' }).selectOption('aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa');
await expect(dialog.getByText('Artist A — Album A', { exact: true })).toBeVisible(); await expect(dialog.getByText('Artist A — Album A', { exact: true })).toBeVisible();
await expect(dialog.getByText('Совпало 1 из 1', { exact: false })).toBeVisible(); await expect(dialog.getByText('Сопоставлено 1 из 1', { exact: false })).toBeVisible();
await expect(dialog.getByRole('button', { name: 'Применить выбранное', exact: true })).toBeDisabled(); await expect(dialog.getByRole('button', { name: 'Применить выбранное', exact: true })).toBeDisabled();
await expect(dialog.getByRole('checkbox', { name: /Применить названия/ })).not.toBeChecked(); await expect(dialog.getByRole('checkbox', { name: /Применить названия/ })).not.toBeChecked();
}); });
@@ -444,8 +446,8 @@ test('deluxe completeness and release-group artwork are shown before replacing a
await page.getByRole('button', { name: 'Найдено изданий: 2', exact: true }).click(); await page.getByRole('button', { name: 'Найдено изданий: 2', exact: true }).click();
const dialog = page.getByRole('dialog', { name: 'Сопоставление релиза' }); const dialog = page.getByRole('dialog', { name: 'Сопоставление релиза' });
await dialog.getByRole('combobox', { name: 'Издание' }).selectOption('22222222-2222-4222-8222-222222222222'); await dialog.getByRole('combobox', { name: 'Издание' }).selectOption('22222222-2222-4222-8222-222222222222');
await expect(dialog.getByText('Альбом · 2/3 · Неполный состав', { exact: true })).toBeVisible(); await expect(dialog.getByText('Альбом · сопоставлено 2/3 · Неполный состав', { exact: true })).toBeVisible();
await expect(dialog.getByText('Не загружено', { exact: true })).toBeVisible(); await expect(dialog.getByText('Файл не сопоставлен', { exact: true })).toBeVisible();
await expect(dialog.getByText(/найдены совпадения в библиотеке/)).toBeVisible(); await expect(dialog.getByText(/найдены совпадения в библиотеке/)).toBeVisible();
await dialog.getByRole('button', { name: 'Показать обложку из каталога', exact: true }).click(); await dialog.getByRole('button', { name: 'Показать обложку из каталога', exact: true }).click();
await expect(dialog.getByText(/Обложка группы изданий/)).toBeVisible(); await expect(dialog.getByText(/Обложка группы изданий/)).toBeVisible();
+6 -1
View File
@@ -3,7 +3,12 @@ import { defineConfig, devices } from '@playwright/test';
export default defineConfig({ export default defineConfig({
testDir: './e2e', testDir: './e2e',
timeout: 30_000, timeout: 30_000,
use: { baseURL: 'http://127.0.0.1:4173', ...devices['Desktop Chrome'] }, use: {
baseURL: 'http://127.0.0.1:4173',
...devices['Desktop Chrome'],
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
},
webServer: { webServer: {
command: 'pnpm exec vite --port 4173 --strictPort', command: 'pnpm exec vite --port 4173 --strictPort',
url: 'http://127.0.0.1:4173', url: 'http://127.0.0.1:4173',
@@ -77,7 +77,7 @@ export function BatchRelease({ files, onGroupsChange }: { files: UploadFile[]; o
}); });
const kind = (value: string) => value === 'Album' ? t('release_lookup.album') : value === 'Single' ? t('release_lookup.single') : value === 'EP' ? 'EP' : value || t('release_lookup.release'); const kind = (value: string) => value === 'Album' ? t('release_lookup.album') : value === 'Single' ? t('release_lookup.single') : value === 'EP' ? 'EP' : value || t('release_lookup.release');
const stateText = needsGroup ? t('release_lookup.groups_count', { count: groups.length }) : report.data?.state === 'ready' const stateText = needsGroup ? t('release_lookup.groups_count', { count: groups.length }) : report.data?.state === 'ready'
? candidate ? `${kind(candidate.release.type)} · ${candidate.matched}/${candidate.total} · ${t(candidate.complete ? 'release_lookup.complete' : candidate.confidence === 'high' && candidate.missing.length > 0 && !candidate.unmatched.length ? 'release_lookup.incomplete' : 'release_lookup.partial')}` ? candidate ? `${kind(candidate.release.type)} · ${t('release_lookup.matched_count', { matched: candidate.matched, total: candidate.total })} · ${t(candidate.complete ? 'release_lookup.complete' : candidate.confidence === 'high' && candidate.missing.length > 0 && !candidate.unmatched.length ? 'release_lookup.incomplete' : 'release_lookup.partial')}`
: t('release_lookup.variants', { count: report.data.candidates.length }) : t('release_lookup.variants', { count: report.data.candidates.length })
: t(`release_lookup.state_${report.data?.state ?? 'searching'}`); : t(`release_lookup.state_${report.data?.state ?? 'searching'}`);
+14 -1
View File
@@ -1,6 +1,19 @@
import { describe, expect, it } from 'vitest'; 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', () => { describe('needsFormatAttention', () => {
it('marks everything except lossless formats', () => { it('marks everything except lossless formats', () => {
+14
View File
@@ -61,6 +61,20 @@ export function isAlbumCover(name: string): boolean {
return /^(cover|folder|front)\.(jpe?g|png|webp)$/i.test(name.split(/[/\\]/).pop() ?? name); 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 { export function albumFolder(relPath: string): string {
const path = relPath.replaceAll('\\', '/'); const path = relPath.replaceAll('\\', '/');
const at = path.lastIndexOf('/'); 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(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); 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();
});
+7 -2
View File
@@ -1,7 +1,7 @@
import { unwrap, type AuthSession } from '@pipu/core'; import { unwrap, type AuthSession } from '@pipu/core';
import * as tus from 'tus-js-client'; 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'; import type { HashRequest, HashResponse } from './hash.worker';
// Загрузка пачки в очередь на добавление (docs/uploads.md#5): SHA-256 в воркере → предпроверка дублей // Загрузка пачки в очередь на добавление (docs/uploads.md#5): SHA-256 в воркере → предпроверка дублей
@@ -74,6 +74,7 @@ export class Uploader {
private transferDone: (() => void) | null = null; private transferDone: (() => void) | null = null;
private refreshing: Promise<unknown> | null = null; private refreshing: Promise<unknown> | null = null;
private folderCovers = new Map<string, File>(); private folderCovers = new Map<string, File>();
private artistImages = new Map<string, File>();
constructor(private readonly session: AuthSession) { constructor(private readonly session: AuthSession) {
this.restore(); this.restore();
@@ -104,12 +105,13 @@ export class Uploader {
if (this.state.busy) return; if (this.state.busy) return;
for (const f of files) { for (const f of files) {
if (isAlbumCover(f.file.name)) this.folderCovers.set(albumFolder(f.relPath), f.file); 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[] = []; const fresh: UploadItem[] = [];
let skipped = 0; let skipped = 0;
for (const f of files) { for (const f of files) {
if (!isAudio(f.file.name)) { if (!isAudio(f.file.name)) {
if (!isAlbumCover(f.file.name)) skipped++; if (!isAlbumCover(f.file.name) && !isArtistImage(f.file.name)) skipped++;
continue; continue;
} }
// Тот же путь и размер уже в списке — не добавляем второй раз. // Тот же путь и размер уже в списке — не добавляем второй раз.
@@ -134,6 +136,7 @@ export class Uploader {
if (this.state.busy) return; if (this.state.busy) return;
this.hashQueue = []; this.hashQueue = [];
this.folderCovers.clear(); this.folderCovers.clear();
this.artistImages.clear();
this.set({ items: [], skipped: 0, batchId: null, error: null }); this.set({ items: [], skipped: 0, batchId: null, error: null });
this.persist(); this.persist();
} }
@@ -236,11 +239,13 @@ export class Uploader {
for (const [folder, group] of groups) { for (const [folder, group] of groups) {
try { try {
const cover = this.folderCovers.get(folder); const cover = this.folderCovers.get(folder);
const artistImage = artistImageFor(folder, this.artistImages);
const res = unwrap( const res = unwrap(
await this.session.client.POST('/api/v1/uploads', { await this.session.client.POST('/api/v1/uploads', {
body: { body: {
comment: comment.trim() || undefined, comment: comment.trim() || undefined,
...(cover ? { cover_base64: await coverBase64(cover) } : {}), ...(cover ? { cover_base64: await coverBase64(cover) } : {}),
...(artistImage ? { artist_image_base64: await coverBase64(artistImage) } : {}),
files: group.map((it) => ({ files: group.map((it) => ({
client_id: it.id, client_id: it.id,
name: it.file?.name ?? it.relPath, name: it.file?.name ?? it.relPath,
+1 -1
View File
@@ -46,7 +46,7 @@ export function TrackPage({ id }: { id: string }) {
<img src={coverSrc(tr.album.cover_url, 300)} alt="" /> <img src={coverSrc(tr.album.cover_url, 300)} alt="" />
</span> </span>
)} )}
<Cover album={tr.album} size={280} className={c.heroCover} /> <Cover album={tr.album} size="100%" className={c.heroCover} />
<div className={c.heroBody}> <div className={c.heroBody}>
<span className={c.kind}>{t('player.track_page')}</span> <span className={c.kind}>{t('player.track_page')}</span>
<h1 className={c.heroTitle}>{tr.title}</h1> <h1 className={c.heroTitle}>{tr.title}</h1>
+11 -1
View File
@@ -99,6 +99,11 @@
min-width: 0; min-width: 0;
} }
.listen .heroCover {
max-width: 280px;
aspect-ratio: 1;
}
.listen { .listen {
display: grid; display: grid;
gap: 28px; gap: 28px;
@@ -118,7 +123,12 @@
flex-direction: column; flex-direction: column;
align-items: stretch; align-items: stretch;
margin: 0; margin: 0;
padding: 0; padding: 20px;
border-radius: 20px;
}
.listen .heroCover {
max-width: none;
} }
.listen .heroTitle { .listen .heroTitle {
+1
View File
@@ -75,3 +75,4 @@ export { default as tuneFillIcon } from '@material-symbols/svg-400/rounded/tune-
export { default as queueIcon } from '@material-symbols/svg-400/rounded/queue_music.svg'; export { default as queueIcon } from '@material-symbols/svg-400/rounded/queue_music.svg';
export { default as playlistAddIcon } from '@material-symbols/svg-400/rounded/playlist_add.svg'; export { default as playlistAddIcon } from '@material-symbols/svg-400/rounded/playlist_add.svg';
export { default as undoIcon } from '@material-symbols/svg-400/rounded/undo.svg'; export { default as undoIcon } from '@material-symbols/svg-400/rounded/undo.svg';
export { default as chevronDownIcon } from '@material-symbols/svg-400/rounded/keyboard_arrow_down.svg';
+3 -3
View File
@@ -151,9 +151,9 @@ function NavSection({
{mobile ? ( {mobile ? (
<h3 className={cx(s.navSection, nested && s.navSubsection)}><span className={s.navSectionTitle}>{title}</span></h3> <h3 className={cx(s.navSection, nested && s.navSubsection)}><span className={s.navSectionTitle}>{title}</span></h3>
) : ( ) : (
<button type="button" className={cx(s.navSection, nested && s.navSubsection)} aria-expanded={!folded} aria-controls={groupId} onClick={toggle}> <button type="button" className={cx(s.navSection, nested && s.navSubsection)} aria-labelledby={`${groupId}-title`} aria-describedby={folded && badge ? `${groupId}-badge` : undefined} aria-expanded={!folded} aria-controls={groupId} onClick={toggle}>
<span className={s.navSectionTitle}>{title}</span> <span id={`${groupId}-title`} className={s.navSectionTitle}>{title}</span>
{folded && badge ? <span className={s.navBadge}>{badge > 99 ? '99+' : badge}</span> : null} {folded && badge ? <span id={`${groupId}-badge`} className={s.navBadge}>{badge > 99 ? '99+' : badge}</span> : null}
<Icon src={chevronRightIcon} size={16} className={cx(s.navChevron, !folded && s.navChevronOpen)} /> <Icon src={chevronRightIcon} size={16} className={cx(s.navChevron, !folded && s.navChevronOpen)} />
</button> </button>
)} )}
+280
View File
@@ -0,0 +1,280 @@
.root {
position: fixed;
inset: 0;
z-index: 40;
display: grid;
place-items: center;
overflow-y: auto;
background: var(--bg, #05070d);
animation: pipuIn 0.18s ease-out;
}
.bg {
position: fixed;
inset: -20%;
opacity: 0.35;
filter: blur(80px) saturate(1.2);
pointer-events: none;
}
.bg img {
width: 100%;
height: 100%;
object-fit: cover;
}
.close {
position: fixed;
top: 20px;
right: 24px;
z-index: 2;
display: grid;
place-items: center;
width: 56px;
height: 56px;
border: 2px solid rgba(255, 255, 255, 0.35);
border-radius: 50%;
background: rgba(255, 255, 255, 0.12);
color: #fff;
backdrop-filter: blur(12px);
}
.close:hover {
background: rgba(255, 255, 255, 0.22);
}
.stage {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
gap: 20px;
width: min(444px, calc(100vw - 32px), calc(100dvh - 240px));
min-width: 260px;
padding: 72px 0 32px;
}
.card {
position: relative;
aspect-ratio: 1;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
}
.cover {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
border-radius: 0;
}
.shade {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.35);
pointer-events: none;
}
/* Кнопки на обложке видны только при наведении / фокусе (на тач-экранах — всегда). */
.shade,
.round,
.controls,
.moreWrap {
opacity: 0;
transition: opacity 0.18s, background 0.12s;
}
.card:hover .shade,
.card:hover .round,
.card:hover .controls,
.card:hover .moreWrap,
.card:has(:focus-visible) .shade,
.card:has(:focus-visible) .round,
.card:has(:focus-visible) .controls,
.card:has(:focus-visible) .moreWrap,
.cardOpen .shade,
.cardOpen .round,
.cardOpen .controls,
.cardOpen .moreWrap {
opacity: 1;
}
@media (hover: none) {
.shade,
.round,
.controls,
.moreWrap {
opacity: 1;
}
}
.round {
position: absolute;
flex: none;
display: grid;
place-items: center;
width: 52px;
height: 52px;
border-radius: 50%;
background: rgba(20, 20, 20, 0.82);
color: #fff;
transition: background 0.12s;
}
.round:hover:not(:disabled) {
background: rgba(50, 50, 50, 0.9);
}
.controls .round {
position: static;
}
.accent {
color: #ffe600;
}
.topLeft {
top: 16px;
left: 16px;
}
.topRight {
top: 16px;
right: 16px;
}
.bottomRight {
right: 16px;
bottom: 16px;
}
.moreWrap {
position: absolute;
left: 16px;
bottom: 16px;
}
.moreWrap .round {
position: static;
}
.menu {
position: absolute;
left: 0;
bottom: 56px;
display: flex;
flex-direction: column;
min-width: 200px;
padding: 6px;
border-radius: 12px;
background: rgba(20, 20, 20, 0.96);
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.5);
}
.menuItem {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-radius: 8px;
color: #fff;
text-align: left;
}
.menuItem:hover {
background: rgba(255, 255, 255, 0.1);
}
.controls {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
gap: 20px;
pointer-events: none;
}
.controls > * {
pointer-events: auto;
}
.play {
display: grid;
place-items: center;
width: 72px;
height: 72px;
border-radius: 50%;
background: #ffe600;
color: #111;
transition: transform 0.12s;
}
.play:hover:not(:disabled) {
transform: scale(1.06);
}
.play[aria-busy='true'] {
opacity: 0.8;
}
.meta {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
text-align: center;
min-width: 0;
}
.title {
font-size: 20px;
font-weight: 700;
color: var(--text, #fff);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
.artists {
color: var(--text2);
font-size: 16px;
}
.progress {
display: flex;
flex-direction: column;
gap: 6px;
}
.range {
width: 100%;
height: 4px;
appearance: none;
border-radius: 2px;
background: linear-gradient(90deg, #fff var(--pct, 0%), rgba(255, 255, 255, 0.25) var(--pct, 0%));
cursor: pointer;
}
.range::-webkit-slider-thumb {
appearance: none;
width: 0;
height: 0;
}
.range::-moz-range-thumb {
width: 0;
height: 0;
border: 0;
}
.times {
display: flex;
justify-content: space-between;
font-size: 12px;
color: var(--text2);
font-variant-numeric: tabular-nums;
}
+151
View File
@@ -0,0 +1,151 @@
import { useEffect, useState, type CSSProperties } from 'react';
import { createPortal } from 'react-dom';
import { Link } from '@tanstack/react-router';
import { useTranslation } from 'react-i18next';
import { ArtistLinks, Cover, coverSrc } from '../entities/catalog';
import { AddToPlaylist } from '../entities/playlists';
import { usePlayer, usePlayerState } from '../player/context';
import { formatDuration } from '../shared/lib/format';
import {
chevronDownIcon,
likeFillIcon,
likeIcon,
moreIcon,
nextIcon,
pauseIcon,
playIcon,
prevIcon,
queueIcon,
repeatIcon,
repeatOneIcon,
shareIcon,
} from '../shared/ui/icons';
import { cx, Icon } from '../shared/ui/ui';
import s from './FullPlayer.module.css';
/** Полноэкранный плеер: обложка с кнопками поверх неё, название и прогресс. Открывается кликом по обложке в плеере. */
export function FullPlayer({ onClose, onQueue, share }: { onClose: () => void; onQueue: () => void; share: () => void }) {
const { t } = useTranslation();
const player = usePlayer();
const st = usePlayerState();
const [menu, setMenu] = useState(false);
const locked = st.listening?.locked === true;
const track = st.track;
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key !== 'Escape') return;
if (menu) setMenu(false);
else onClose();
};
document.addEventListener('keydown', onKey);
const prev = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.removeEventListener('keydown', onKey);
document.body.style.overflow = prev;
};
}, [onClose, menu]);
if (!track) return null;
const duration = st.durationMs || track.duration_ms;
const liked = track.reaction === 'like';
return createPortal(
<div className={s.root} role="dialog" aria-modal="true" aria-label={t('player.now_playing')}>
{track.album.cover_url && (
<span className={s.bg} aria-hidden>
<img src={coverSrc(track.album.cover_url, 300)} alt="" />
</span>
)}
<button type="button" className={s.close} aria-label={t('form.close')} onClick={onClose}>
<Icon src={chevronDownIcon} size={26} />
</button>
<div className={s.stage}>
<div className={cx(s.card, menu && s.cardOpen)}>
<Cover album={track.album} size="100%" className={s.cover} />
<div className={s.shade} aria-hidden />
<button type="button" className={cx(s.round, s.topRight)} aria-label={t('player.queue')} onClick={onQueue}>
<Icon src={queueIcon} size={22} />
</button>
<button
type="button"
className={cx(s.round, s.topLeft, st.repeat !== 'off' && s.accent)}
aria-label={t(`player.repeat_${st.repeat}`)}
disabled={locked}
onClick={() => player.cycleRepeat()}
>
<Icon src={st.repeat === 'one' ? repeatOneIcon : repeatIcon} size={20} />
</button>
<div className={s.controls}>
<button type="button" className={s.round} aria-label={t('player.prev')} disabled={locked} onClick={() => player.prev()}>
<Icon src={prevIcon} size={22} />
</button>
<button
type="button"
className={s.play}
aria-label={t(st.playing ? 'player.pause' : 'player.play')}
aria-busy={st.loading || undefined}
disabled={locked}
onClick={() => player.toggle()}
>
<Icon src={st.playing ? pauseIcon : playIcon} size={30} />
</button>
<button type="button" className={s.round} aria-label={t('player.next')} disabled={locked} onClick={() => player.next()}>
<Icon src={nextIcon} size={22} />
</button>
</div>
<div className={s.moreWrap}>
<button type="button" className={s.round} aria-label={t('player.more')} aria-expanded={menu} onClick={() => setMenu((v) => !v)}>
<Icon src={moreIcon} size={26} />
</button>
{menu && (
<div className={s.menu}>
<AddToPlaylist trackIds={[track.id]} className={s.menuItem} />
<button type="button" className={s.menuItem} onClick={() => { setMenu(false); share(); }}>
<Icon src={shareIcon} size={18} />
{t('player.share')}
</button>
</div>
)}
</div>
<button
type="button"
className={cx(s.round, s.bottomRight, liked && s.accent)}
aria-label={t(liked ? 'player.unlike' : 'player.like')}
aria-pressed={liked}
onClick={() => void player.react(track, liked ? null : 'like').catch(() => undefined)}
>
<Icon src={liked ? likeFillIcon : likeIcon} size={24} />
</button>
</div>
<div className={s.meta}>
<Link to="/track/$id" params={{ id: track.id }} className={s.title} onClick={onClose}>
{track.title}
</Link>
<span className={s.artists}>
<ArtistLinks artists={track.artists} hideUrlPreview />
</span>
</div>
<div className={s.progress}>
<input
type="range"
className={s.range}
min={0}
max={duration}
step={1000}
value={Math.min(st.positionMs, duration)}
aria-label={t('player.progress')}
aria-valuetext={formatDuration(st.positionMs)}
disabled={locked}
onChange={(e) => player.seek(Number(e.target.value))}
style={{ '--pct': `${(st.positionMs / duration) * 100}%` } as CSSProperties}
/>
<span className={s.times}>
<span>{formatDuration(st.positionMs)}</span>
<span>{formatDuration(duration)}</span>
</span>
</div>
</div>
</div>,
document.body,
);
}
+20 -8
View File
@@ -32,6 +32,7 @@ import {
waveIcon, waveIcon,
} from '../shared/ui/icons'; } from '../shared/ui/icons';
import { cx, Icon } from '../shared/ui/ui'; import { cx, Icon } from '../shared/ui/ui';
import { FullPlayer } from './FullPlayer';
import s from './PlayerBar.module.css'; import s from './PlayerBar.module.css';
/** Плеер: полоса внизу (`bar`, по умолчанию) или плавающий остров (`island`) — выбор в настройках (docs/design.md). */ /** Плеер: полоса внизу (`bar`, по умолчанию) или плавающий остров (`island`) — выбор в настройках (docs/design.md). */
@@ -43,6 +44,7 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
const locked = st.listening?.locked === true; const locked = st.listening?.locked === true;
const narrow = useNarrow(); const narrow = useNarrow();
const [sheetOpen, setSheetOpen] = useState(false); const [sheetOpen, setSheetOpen] = useState(false);
const [fullOpen, setFullOpen] = useState(false);
const [queueOpen, setQueueOpen] = useState(false); const [queueOpen, setQueueOpen] = useState(false);
const queueButton = useRef<HTMLButtonElement>(null); const queueButton = useRef<HTMLButtonElement>(null);
const queueId = useId(); const queueId = useId();
@@ -86,13 +88,11 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
<footer className={cx(s.bar, variant === 'island' && s.island)} aria-label={t('player.label')}> <footer className={cx(s.bar, variant === 'island' && s.island)} aria-label={t('player.label')}>
<div <div
className={s.now} className={s.now}
onClick={ onClick={(e) => {
narrow if ((e.target as HTMLElement).closest('a, button')) return;
? (e) => { if (narrow) setSheetOpen(true);
if (!(e.target as HTMLElement).closest('a, button')) setSheetOpen(true); else setFullOpen(true);
} }}
: undefined
}
> >
{narrow ? ( {narrow ? (
<button type="button" className={s.nowArt} aria-label={t('player.expand')} onClick={() => setSheetOpen(true)}> <button type="button" className={s.nowArt} aria-label={t('player.expand')} onClick={() => setSheetOpen(true)}>
@@ -100,7 +100,9 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
{st.playing && <span className={s.nowEq}><Equalizer playing /></span>} {st.playing && <span className={s.nowEq}><Equalizer playing /></span>}
</button> </button>
) : ( ) : (
<Cover album={track.album} size={52} className={st.playing ? s.coverLive : undefined} /> <button type="button" className={s.nowArt} aria-label={t('player.expand')} onClick={() => setFullOpen(true)}>
<Cover album={track.album} size={52} className={st.playing ? s.coverLive : undefined} />
</button>
)} )}
<div className={s.meta}> <div className={s.meta}>
<span className={s.title} title={track.title}>{track.title}</span> <span className={s.title} title={track.title}>{track.title}</span>
@@ -242,6 +244,16 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
share={share} share={share}
/> />
)} )}
{fullOpen && !narrow && (
<FullPlayer
onClose={() => setFullOpen(false)}
onQueue={() => {
setFullOpen(false);
setQueueOpen(true);
}}
share={share}
/>
)}
{queueOpen && <QueueDrawer id={queueId} variant={variant} onClose={closeQueue} />} {queueOpen && <QueueDrawer id={queueId} variant={variant} onClose={closeQueue} />}
</footer> </footer>
); );
+12
View File
@@ -4,10 +4,22 @@ import { defineConfig } from 'vite';
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
// Keep Vitest's dependency cache separate from the browser test server.
cacheDir: process.env.VITE_E2E === '1' ? 'node_modules/.vite-e2e' : undefined,
optimizeDeps: process.env.VITE_E2E === '1' ? {
// Lazy routes and workers must be scanned before browsers cache React chunks.
// Late discovery otherwise mixes cached and newly optimized React instances
// across full document navigations when HMR is disabled.
entries: ['index.html', 'src/**/*.{ts,tsx}', '!src/**/*.test.{ts,tsx}', '!src/**/*.d.ts', '!src/test/**'],
include: ['react', 'react-dom', 'react-dom/client', 'react/jsx-runtime', 'react/jsx-dev-runtime'],
} : undefined,
build: { build: {
target: 'es2022', target: 'es2022',
}, },
server: { server: {
// E2E installs a stateful fake API in the page. A dev-server reload would
// discard it in the middle of a scenario (including other parallel workers).
hmr: process.env.VITE_E2E === '1' ? false : undefined,
host: '127.0.0.1', host: '127.0.0.1',
port: 5173, port: 5173,
strictPort: true, strictPort: true,
+2 -1
View File
@@ -24,7 +24,8 @@ server {
real_ip_recursive on; real_ip_recursive on;
root /usr/share/nginx/html; root /usr/share/nginx/html;
client_max_body_size 16m; # Two images (album cover + artist portrait), each up to 8 MiB before base64.
client_max_body_size 32m;
include /etc/nginx/snippets/security-headers.conf; include /etc/nginx/snippets/security-headers.conf;
+9 -2
View File
@@ -199,10 +199,17 @@ Meilisearch, индексы:
| `albums` | `title`, `artist_name`, `title_translit` | `year`, `popularity` | | `albums` | `title`, `artist_name`, `title_translit` | `year`, `popularity` |
| `tracks` | `title`, `artist_names`, `album_title`, `title_translit` | `genre`, `year`, `popularity`, `available` | | `tracks` | `title`, `artist_names`, `album_title`, `title_translit` | `genre`, `year`, `popularity`, `available` |
- `*_translit` — транслитерация (кириллица ↔ латиница), плюс поле с **исправлением раскладки** - `*_translit` — варианты транслитерации (кириллица ↔ латиница), включая словарь
устоявшихся написаний (`Boulevard` → `бульвар`) наряду с побуквенным вариантом.
**Исправление раскладки**
(`ghbdtn` → `привет`): при пустом результате клиентский запрос повторяется в альтернативной раскладке (`ghbdtn` → `привет`): при пустом результате клиентский запрос повторяется в альтернативной раскладке
на стороне `api`. на стороне `api`.
- Ранжирование: стандартные правила Meilisearch + `popularity:desc` (глобальные засчитанные прослушивания за 90 дней). - Запрос учитывает все слова (`matchingStrategy=all`), не отбрасывая последнее ради заполнения списка.
Опечатки и поиск по префиксу по-прежнему обрабатывает Meilisearch.
- Ранжирование: `words`, `typo`, `exactness`, `proximity`, `attribute`, `sort`, `popularity:desc`.
Точное имя исполнителя (включая вариант транслитерации) закрепляется перед составными именами
в подсказках и не опускается из-за персонализации. Новые варианты появляются после переиндексации;
API запускает её при старте.
- `/search/suggest` использует `multi-search` (один запрос — три индекса), отдаёт ≤ 3/3/5. Клиент делает debounce 150 мс, - `/search/suggest` использует `multi-search` (один запрос — три индекса), отдаёт ≤ 3/3/5. Клиент делает debounce 150 мс,
отменяет предыдущий запрос (`AbortController`). отменяет предыдущий запрос (`AbortController`).
- Персонализация: `suggest` берёт из Meilisearch втрое больше кандидатов и переранжирует их в `api` - Персонализация: `suggest` берёт из Meilisearch втрое больше кандидатов и переранжирует их в `api`
+25
View File
@@ -94,6 +94,20 @@
Обложки больше 8 МБ клиент автоматически уменьшает до JPEG с длинной стороной не более 2048 пикселей Обложки больше 8 МБ клиент автоматически уменьшает до JPEG с длинной стороной не более 2048 пикселей
перед base64-кодированием; это относится и к отдельным файлам, и к встроенной обложке, и к правке пачки. перед base64-кодированием; это относится и к отдельным файлам, и к встроенной обложке, и к правке пачки.
Меньшие картинки остаются без изменений. Код HTTP 413 от прокси показывается как превышение размера запроса. Меньшие картинки остаются без изменений. Код HTTP 413 от прокси показывается как превышение размера запроса.
- `artist.jpg` (также `artist.jpeg`, `artist.png`, `artist.webp`, регистр не важен) в папке исполнителя
передаётся отдельно от обложки. Ближайшая такая картинка наследуется вложенными папками альбомов;
соседние исполнители не смешиваются. Например `Исполнитель/artist.jpg` и две папки альбомов
дают две отдельные пачки с одной фотографией исполнителя и своими обложками.
- При импорте трека картинки кладутся и в хранилище: `cover.<ext>` — в папку альбома, `artist.<ext>` — в папку
исполнителя; папки определяются по `write_template` (сегмент с `{album}` и первый сегмент с `{albumartist}`/`{artist}`).
Если в папке уже есть такая картинка (`cover`/`folder`/`front`/`album`/`albumart` или `artist`), она не перезаписывается.
Если шаблон не позволяет определить папку (плоская раскладка), картинка не записывается.
После одобрения и импорта трека фотография сохраняется в кеш WebP и ставится исполнителю альбома,
только если у него нет ни локальной аватарки, ни изображения Genius. Гостям она не назначается.
Существующая аватарка сохраняется, в том числе при одновременном импорте нескольких альбомов.
До одобрения каталог не меняется. Лимит фотографии — 8 МБ после уменьшения, как у обложки;
nginx принимает до 32 МБ, чтобы вместить обе картинки в base64. Временная фотография удаляется
после завершения всех файлов пачки, а готовая аватарка остаётся в кеше.
## 6. Обработка (`processing`) ## 6. Обработка (`processing`)
@@ -168,6 +182,9 @@
предпросмотр рассчитывается при чтении очереди по текущему шаблону — тем же планировщиком, что и импорт. предпросмотр рассчитывается при чтении очереди по текущему шаблону — тем же планировщиком, что и импорт.
Сохранение шаблона обновляет кеш очереди. Уже импортированные файлы сохраняют фактический путь. Сохранение шаблона обновляет кеш очереди. Уже импортированные файлы сохраняют фактический путь.
Если рассчитать путь нельзя, показаны причина и действие для исправления вместо старого пути. Если рассчитать путь нельзя, показаны причина и действие для исправления вместо старого пути.
Отсутствующий год отмечается предупреждением и не блокирует одобрение или импорт:
`{year}` опускается вместе со скобками или разделителем, необязательный фрагмент с годом опускается.
В теги неизвестный год не записывается.
- Прослушивание staging выдаёт сам API с поддержкой Range: приватные файлы tus (`0640`, каталоги `0750`) - Прослушивание staging выдаёт сам API с поддержкой Range: приватные файлы tus (`0640`, каталоги `0750`)
недоступны пользователю nginx. Выдача не зависит от `X_ACCEL`; права существующих файлов менять не нужно. недоступны пользователю nginx. Выдача не зависит от `X_ACCEL`; права существующих файлов менять не нужно.
Аудио и его метаданные загружаются только после Play (`preload="none"`); раскрытие пачки не запускает запросы к файлам. Аудио и его метаданные загружаются только после Play (`preload="none"`); раскрытие пачки не запускает запросы к файлам.
@@ -195,6 +212,14 @@
совпадения отмечаются отдельно. Для отсутствующих дорожек отдельно показаны совпадения с доступной совпадения отмечаются отдельно. Для отсутствующих дорожек отдельно показаны совпадения с доступной
библиотекой по тегам и длительности; они не делают загруженную пачку полной. библиотекой по тегам и длительности; они не делают загруженную пачку полной.
Число файлов в пачке и число сопоставленных дорожек — разные показатели. «Файл не сопоставлен»
не доказывает отсутствие дорожки: она может быть среди файлов с отличающимися тегами. Для
предположительного совпадения допускается разная запись состава исполнителей (запятая, `feat.`, `;`)
и пометки `feat.` / `Prod.` в конце названия при совпадении номера, диска и длительности с допуском
2 секунды. Пометки версий (`live`, `remix`, `instrumental`) сохраняются. Такие совпадения требуют
проверки и сами по себе не подтверждают «Полный состав». При изменении правил сопоставления
сохранённые результаты поиска пересчитываются при открытии пачки.
Поиск сам не меняет теги, путь или обложку. В диалоге можно отдельно применить метаданные к совпавшим Поиск сам не меняет теги, путь или обложку. В диалоге можно отдельно применить метаданные к совпавшим
редактируемым файлам и/или обложку. Обложка загружается из Cover Art Archive только после запроса редактируемым файлам и/или обложку. Обложка загружается из Cover Art Archive только после запроса
предпросмотра. При отсутствии обложки конкретного издания возможна обложка группы изданий с явной пометкой. предпросмотра. При отсутствии обложки конкретного издания возможна обложка группы изданий с явной пометкой.
@@ -2295,6 +2295,8 @@ export interface components {
}[]; }[];
}; };
UploadBatchRequest: { UploadBatchRequest: {
/** @description Фото исполнителя JPEG, PNG или WebP, до 8 МБ. После одобрения применяется к исполнителю альбома только при отсутствии аватарки. */
artist_image_base64?: string;
/** @description Обложка альбома JPEG, PNG или WebP, до 8 МБ после декодирования. Применяется ко всем файлам пачки. */ /** @description Обложка альбома JPEG, PNG или WebP, до 8 МБ после декодирования. Применяется ко всем файлам пачки. */
cover_base64?: string; cover_base64?: string;
comment?: string; comment?: string;
+3 -2
View File
@@ -42,12 +42,13 @@
"confidence_high": "high", "confidence_high": "high",
"confidence_medium": "medium", "confidence_medium": "medium",
"confidence_low": "low", "confidence_low": "low",
"coverage": "Matched {{matched}} of {{total}} tracks · Missing: {{missing}} · Unmatched files: {{extra}}", "coverage": "Matched {{matched}} of {{total}} tracks · Unmatched tracks: {{missing}} · Unmatched files: {{extra}}",
"existing": "Metadata and duration matches were found in the library for {{count}} missing tracks. This does not make the uploaded batch complete.", "existing": "Metadata and duration matches were found in the library for {{count}} missing tracks. This does not make the uploaded batch complete.",
"uploaded": "Your upload", "uploaded": "Your upload",
"catalogue": "Selected edition", "catalogue": "Selected edition",
"matched": "File matched", "matched": "File matched",
"missing": "Not uploaded", "matched_count": "matched {{matched}}/{{total}}",
"missing": "No matched file",
"tentative": "Tentative match — check manually", "tentative": "Tentative match — check manually",
"apply_metadata": "Apply titles, artists, album, year and numbering to matched files", "apply_metadata": "Apply titles, artists, album, year and numbering to matched files",
"preview_cover": "Preview catalogue cover", "preview_cover": "Preview catalogue cover",
+2 -1
View File
@@ -122,7 +122,8 @@
"source_wave_seed": "Artist wave · {{label}}", "source_wave_seed": "Artist wave · {{label}}",
"queue_wave": "The wave never ends: next tracks are picked as you listen", "queue_wave": "The wave never ends: next tracks are picked as you listen",
"now_playing": "Now playing", "now_playing": "Now playing",
"expand": "Open player" "expand": "Open player",
"more": "More"
}, },
"settings": { "settings": {
"title": "Settings", "title": "Settings",
+1
View File
@@ -104,6 +104,7 @@
"no_target": "No storage accepts uploads (or several do and none is the default)", "no_target": "No storage accepts uploads (or several do and none is the default)",
"no_template": "The storage has no path template", "no_template": "The storage has no path template",
"missing_field": "Not enough metadata to build the storage path", "missing_field": "Not enough metadata to build the storage path",
"missing_year": "Year is missing — check the metadata. The year is omitted from the path",
"offset_mismatch": "The upload got out of sync; try again", "offset_mismatch": "The upload got out of sync; try again",
"size_mismatch": "The file size does not match the declared one", "size_mismatch": "The file size does not match the declared one",
"hash_mismatch": "The file was corrupted in transit (SHA-256 mismatch)", "hash_mismatch": "The file was corrupted in transit (SHA-256 mismatch)",
+3 -2
View File
@@ -42,12 +42,13 @@
"confidence_high": "высокая", "confidence_high": "высокая",
"confidence_medium": "средняя", "confidence_medium": "средняя",
"confidence_low": "низкая", "confidence_low": "низкая",
"coverage": "Совпало {{matched}} из {{total}} дорожек · Не хватает: {{missing}} · Не сопоставлено файлов: {{extra}}", "coverage": "Сопоставлено {{matched}} из {{total}} дорожек · Дорожек без сопоставления: {{missing}} · Не сопоставлено файлов: {{extra}}",
"existing": "Для {{count}} отсутствующих дорожек найдены совпадения в библиотеке по тегам и длительности. Это не делает загруженную пачку полной.", "existing": "Для {{count}} отсутствующих дорожек найдены совпадения в библиотеке по тегам и длительности. Это не делает загруженную пачку полной.",
"uploaded": "Ваша загрузка", "uploaded": "Ваша загрузка",
"catalogue": "В выбранном издании", "catalogue": "В выбранном издании",
"matched": "Файл сопоставлен", "matched": "Файл сопоставлен",
"missing": "Не загружено", "matched_count": "сопоставлено {{matched}}/{{total}}",
"missing": "Файл не сопоставлен",
"tentative": "Предположительное совпадение — проверьте вручную", "tentative": "Предположительное совпадение — проверьте вручную",
"apply_metadata": "Применить названия, исполнителей, альбом, год и нумерацию к совпавшим файлам", "apply_metadata": "Применить названия, исполнителей, альбом, год и нумерацию к совпавшим файлам",
"preview_cover": "Показать обложку из каталога", "preview_cover": "Показать обложку из каталога",
+2 -1
View File
@@ -122,7 +122,8 @@
"source_wave_seed": "Волна по исполнителю · {{label}}", "source_wave_seed": "Волна по исполнителю · {{label}}",
"queue_wave": "Волна бесконечна: следующие треки подбираются по ходу прослушивания", "queue_wave": "Волна бесконечна: следующие треки подбираются по ходу прослушивания",
"now_playing": "Сейчас играет", "now_playing": "Сейчас играет",
"expand": "Открыть плеер" "expand": "Открыть плеер",
"more": "Ещё"
}, },
"settings": { "settings": {
"title": "Настройки", "title": "Настройки",
+1
View File
@@ -104,6 +104,7 @@
"no_target": "Нет хранилища, принимающего загрузки (или их несколько и не выбрано по умолчанию)", "no_target": "Нет хранилища, принимающего загрузки (или их несколько и не выбрано по умолчанию)",
"no_template": "У хранилища не задан шаблон пути", "no_template": "У хранилища не задан шаблон пути",
"missing_field": "Не хватает метаданных для пути в хранилище", "missing_field": "Не хватает метаданных для пути в хранилище",
"missing_year": "Не указан год — проверьте метаданные. Год пропущен в пути",
"offset_mismatch": "Загрузка рассинхронизировалась — повторите", "offset_mismatch": "Загрузка рассинхронизировалась — повторите",
"size_mismatch": "Размер файла не совпал с заявленным", "size_mismatch": "Размер файла не совпал с заявленным",
"hash_mismatch": "Файл повредился при загрузке (не совпал SHA-256)", "hash_mismatch": "Файл повредился при загрузке (не совпал SHA-256)",
+34 -1
View File
@@ -26,6 +26,7 @@ import (
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/redis/go-redis/v9" "github.com/redis/go-redis/v9"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@@ -120,6 +121,7 @@ func TestUserJourney(t *testing.T) {
if dbURL == "" { if dbURL == "" {
t.Skip("TEST_DATABASE_URL is not set") t.Skip("TEST_DATABASE_URL is not set")
} }
dbURL = userJourneyDatabase(t, dbURL)
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
defer cancel() defer cancel()
@@ -133,6 +135,7 @@ func TestUserJourney(t *testing.T) {
t.Setenv("DATABASE_URL", dbURL) t.Setenv("DATABASE_URL", dbURL)
t.Setenv("REDIS_URL", os.Getenv("TEST_REDIS_URL")) t.Setenv("REDIS_URL", os.Getenv("TEST_REDIS_URL"))
t.Setenv("BOOTSTRAP_ADMIN_PASSWORD", "admin") t.Setenv("BOOTSTRAP_ADMIN_PASSWORD", "admin")
t.Setenv("BOOTSTRAP_ADMIN_USERNAME", "admin")
t.Setenv("MUSICBRAINZ_ENABLED", "false") // public provider availability is covered by explicit live tests t.Setenv("MUSICBRAINZ_ENABLED", "false") // public provider availability is covered by explicit live tests
cfg, err := config.Load[config.API]() cfg, err := config.Load[config.API]()
require.NoError(t, err) require.NoError(t, err)
@@ -172,6 +175,7 @@ func TestUserJourney(t *testing.T) {
BootstrapRequired bool `json:"bootstrap_required"` BootstrapRequired bool `json:"bootstrap_required"`
} }
c.ok("GET", "/api/v1/server-info", nil, &info) c.ok("GET", "/api/v1/server-info", nil, &info)
require.True(t, info.BootstrapRequired, "user journey requires a fresh database before the first admin login")
var tokens struct { var tokens struct {
AccessToken string `json:"access_token"` AccessToken string `json:"access_token"`
User struct { User struct {
@@ -215,7 +219,10 @@ func TestUserJourney(t *testing.T) {
} }
eventually(t, "search index", func() bool { eventually(t, "search index", func() bool {
time.Sleep(time.Second) time.Sleep(time.Second)
return c.call("GET", "/api/v1/search?q="+url.QueryEscape("кино"), nil, &found) == 200 && len(found.Albums) == 1 // Artists, albums and tracks are swapped separately. Seeing the album
// does not mean the track index is ready for personalized suggestions.
return c.call("GET", "/api/v1/search?q="+url.QueryEscape("кино"), nil, &found) == 200 &&
len(found.Artists) == 1 && len(found.Albums) == 1 && len(found.Tracks) == 3
}) })
// 4. Альбом, исполнитель, популярные треки. // 4. Альбом, исполнитель, популярные треки.
@@ -376,3 +383,29 @@ func TestUserJourney(t *testing.T) {
}) })
fmt.Println("user journey ok") fmt.Println("user journey ok")
} }
// Other integration packages deliberately create users and catalog fixtures.
// The bootstrap journey needs a new database, without modifying those fixtures.
// TEST_DATABASE_URL must use a role with CREATEDB (the CI Postgres owner does).
func userJourneyDatabase(t *testing.T, address string) string {
t.Helper()
ctx := context.Background()
admin, err := db.Connect(ctx, address)
require.NoError(t, err)
t.Cleanup(admin.Close)
name := "pipu_journey_" + strings.ReplaceAll(uuid.NewString(), "-", "")
identifier := pgx.Identifier{name}.Sanitize()
_, err = admin.Exec(ctx, "CREATE DATABASE "+identifier)
require.NoError(t, err)
t.Cleanup(func() {
cleanup, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
_, err := admin.Exec(cleanup, "DROP DATABASE "+identifier+" WITH (FORCE)")
require.NoError(t, err)
})
dsn, err := url.Parse(address)
require.NoError(t, err)
require.Contains(t, []string{"postgres", "postgresql"}, dsn.Scheme)
dsn.Path = "/" + name
return dsn.String()
}
+1
View File
@@ -172,6 +172,7 @@ func newApp(ctx context.Context, cfg config.API, pool *pgxpool.Pool, rdb *redis.
upSvc := uploads.NewService(upStore, libStore, libSvc, scanner, agent, prober, upSvc := uploads.NewService(upStore, libStore, libSvc, scanner, agent, prober,
library.FPCalc{Bin: cfg.FPCalcPath, Timeout: time.Minute}, library.FPCalc{Bin: cfg.FPCalcPath, Timeout: time.Minute},
uploads.Config{StagingDir: cfg.UploadsDir, FFmpeg: cfg.FFmpegPath}, log) uploads.Config{StagingDir: cfg.UploadsDir, FFmpeg: cfg.FFmpegPath}, log)
upSvc.SetArtistImages(library.NewArtistImages(libStore, covers))
if cfg.MusicBrainzEnabled { if cfg.MusicBrainzEnabled {
upSvc.SetReleaseCatalogue(musicbrainz.New(upStore, "PipuMusic/"+buildinfo.Version+" (https://gitea.hsrv.site/mrleo1nid/pipu-music)")) upSvc.SetReleaseCatalogue(musicbrainz.New(upStore, "PipuMusic/"+buildinfo.Version+" (https://gitea.hsrv.site/mrleo1nid/pipu-music)"))
if strings.TrimSpace(cfg.AcoustIDAPIKey) != "" { if strings.TrimSpace(cfg.AcoustIDAPIKey) != "" {
+306 -302
View File
@@ -1762,7 +1762,9 @@ type UploadBatchPatch struct {
// UploadBatchRequest defines model for UploadBatchRequest. // UploadBatchRequest defines model for UploadBatchRequest.
type UploadBatchRequest struct { type UploadBatchRequest struct {
Comment *string `json:"comment,omitempty"` // ArtistImageBase64 Фото исполнителя JPEG, PNG или WebP, до 8 МБ. После одобрения применяется к исполнителю альбома только при отсутствии аватарки.
ArtistImageBase64 *string `json:"artist_image_base64,omitempty"`
Comment *string `json:"comment,omitempty"`
// CoverBase64 Обложка альбома JPEG, PNG или WebP, до 8 МБ после декодирования. Применяется ко всем файлам пачки. // CoverBase64 Обложка альбома JPEG, PNG или WebP, до 8 МБ после декодирования. Применяется ко всем файлам пачки.
CoverBase64 *string `json:"cover_base64,omitempty"` CoverBase64 *string `json:"cover_base64,omitempty"`
@@ -15071,45 +15073,45 @@ func (sh *strictHandler) GetReadyz(w http.ResponseWriter, r *http.Request) {
// Base64 encoded, gzipped, json marshaled Swagger object // Base64 encoded, gzipped, json marshaled Swagger object
var swaggerSpec = []string{ var swaggerSpec = []string{
"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",
@@ -15135,17 +15137,17 @@ var swaggerSpec = []string{
"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",
@@ -15174,7 +15176,7 @@ var swaggerSpec = []string{
"SPxbPAtV5YHje79kfxgrgfHZ5k01FBpBYsCkFmfY4UNWOjhtVfn+Tep2XCPi2JuchT0akw0v9hLqkjgk", "SPxbPAtV5YHje79kfxgrgfHZ5k01FBpBYsCkFmfY4UNWOjhtVfn+Tep2XCPi2JuchT0akw0v9hLqkjgk",
"a06kQosVWHq+Hhwth5wBbtGWrXOoYDVtlSqJ2/yc5ssUvKRdvQICwO53YsLAaigIpi65fMAIwJRJkXIP", "a06kQosVWHq+Hhwth5wBbtGWrXOoYDVtlSqJ2/yc5ssUvKRdvQICwO53YsLAaigIpi65fMAIwJRJkXIP",
"9QUk+1U6Sp+lz7OHbZKOULfVDTOoWgA8OkiuT58yjTk9lEjbe4ZkeZP9sAxJP4ivVwlcMYUGTW1jFnfj", "9QUk+1U6Sp+lz7OHbZKOULfVDTOoWgA8OkiuT58yjTk9lEjbe4ZkeZP9sAxJP4ivVwlcMYUGTW1jFnfj",
"Nl3ltO0oAjS8DjaFLVP4H3IvFZiyolyDmRmYwwnJR8zK2E/32gQKEaBaAWyQcbaV3Qe/LKP2AdYqMGuZ", "Nl3ltO0oAjS8DjaFLVP473MvFZiyolyDmRmYwwnJR8zK2E/32gQKEaBaAWyQcbaV3Qe/LKP2AdYqMGuZ",
"J4aCxXEIHsTnYIh80UT6HcshYnbXfQrWyz7jkKP0MbQ+fsJb28BCRqojmltp6TM8AU3RSaowRuyAHY3R", "J4aCxXEIHsTnYIh80UT6HcshYnbXfQrWyz7jkKP0MbQ+fsJb28BCRqojmltp6TM8AU3RSaowRuyAHY3R",
"N4zWwCwCpVUAIJzEZZaqdyst08D9sWLDNq7pngAGVeSEKCHgCw1SaqvQSs1kjTZodAUgxwyxnyCJvEnA", "N4zWwCwCpVUAIJzEZZaqdyst08D9sWLDNq7pngAGVeSEKCHgCw1SaqvQSs1kjTZodAUgxwyxnyCJvEnA",
"Z5S3WfHoIwr3SKcqvd8YLrQsWUxSC+SVBqlbvKXvA/dodzYcf2jx3Kyt0S7TG83fBhuNhI8sNSgDDVCz", "Z5S3WfHoIwr3SKcqvd8YLrQsWUxSC+SVBqlbvKXvA/dodzYcf2jx3Kyt0S7TG83fBhuNhI8sNSgDDVCz",
@@ -15182,11 +15184,11 @@ var swaggerSpec = []string{
"1234khmOt4Lv2i0YotIVXH/L1MTsJAfzsezLsLgTBl7H2JNrw9iQa1XA/2vH0ahA7WDgbgevagUqHTuS", "1234khmOt4Lv2i0YotIVXH/L1MTsJAfzsezLsLgTBl7H2JNrw9iQa1XA/2vH0ahA7WDgbgevagUqHTuS",
"wD1/T5Rv7GWfZ1/xmxu9zXvZ3ewzpjbJzGORjKwVLCrRNlTUCp3lLNWJfS/ocJz9TrGVUCkUCXNOD3gZ", "wD1/T5Rv7GWfZ1/xmxu9zXvZ3ewzpjbJzGORjKwVLCrRNlTUCp3lLNWJfS/ocJz9TrGVUCkUCXNOD3gZ",
"GQYk+Y+uhC4l3KN8lscfn3NnKrhe2xC65As9kq5QduuDI3QsK3LU2lbwK/GdOLe4+PrrxnZvPHUi37OB", "GQYk+Y+uhC4l3KN8lscfn3NnKrhe2xC65As9kq5QduuDI3QsK3LU2lbwK/GdOLe4+PrrxnZvPHUi37OB",
"Nxie7Q9jr2ss7BdtzTpY2hhrP2b6O5wVxkLNa/QU3rG1toMsMLVul5xZpv2/pdGcuszW4vy5+cXa4B/P", "Nxie7Q9jr2ss7BdtzTpY2hhrP2b6O5wVxkLNa/QU3rG1toMsMLVul5xZpv2/odGcuszW4vy5+cXa4B/P",
"yMj7OakMbFpjxV4b2dl+gP46XDXYELrhbbnra+Fg0MwRrkGzBeqH6zZwWcW6NGLPajI5t9PiYbdLKRpa", "yMj7OakMbFpjxV4b2dl+gP4qXDXYELrhbbnra+Fg0MwRrkGzBeqH6zZwWcW6NGLPajI5t9PiYbdLKRpa",
"vEHiDWs7nIbONA7igqNW2oD8tbgwO9mX5aViSjWv62lhSo0z5tDxMlMt2e7CTyfJErI1VFOXYQJAnRSx", "vEHiDWs7nIbONA7igqNW2oD8tbgwO9mX5aViSjWv62lhSo0z5tDxMlMt2e7CTyfJErI1VFOXYQJAnRSx",
"T6VKIR2vRtcWQ9lniQlIdsiGnhMEmMkvY7VOQsHNlx/CvuMFRkaitweoxXfDft9LlAqh1sXWf/475+za", "T6VKIR2vRtcWQ9lniQlIdsiGnhMEmMkvY7VOQsHNlx/CvuMFRkaitweoxXfDft9LlAqh1sXWf/lb5+za",
"4tnXb3xyYbH92oU7f9Gq66Q7QcM4IyHQXynWVEeU2N45Jl9O2e/OH6nqZfcLFCzNUr5yWWQG/cCV2VFK", "4tnXb3xyYbH92oU7f96q66Q7QcM4IyHQXynWVEeU2N45Jl9O2e/OH6nqZfcLFCzNUr5yWWQG/cCV2VFK",
"T8puJT87gvdVkIA/UUUBFH+dkqiSLr6WpCQT+T0K/bKGAXRlhn+pT8bUp918wBv2xqITFaRbDpPysvJ2", "T8puJT87gvdVkIA/UUUBFH+dkqiSLr6WpCQT+T0K/bKGAXRlhn+pT8bUp918wBv2xqITFaRbDpPysvJ2",
"4+YWtqVdZJwSjYpkLVDIzKpxbIYQOC7Mip4nU7zdIU5+AAXL3BkzUpOxK32bYitNwes9AL1AzWmcPtXV", "4+YWtqVdZJwSjYpkLVDIzKpxbIYQOC7Mip4nU7zdIU5+AAXL3BkzUpOxK32bYitNwes9AL1AzWmcPtXV",
"p2dMk4Dsp3SUHmrpTkY1r4AUMzXAEydOOsP4xOml6vR0GJhC+qg2YE6/Cj6YRqa0YKnjZ0gvh13P8ZtD", "p2dMk4Dsp3SUHmrpTkY1r4AUMzXAEydOOsP4xOml6vR0GJhC+qg2YE6/Cj6YRqa0YKnjZ0gvh13P8ZtD",
@@ -15201,276 +15203,278 @@ var swaggerSpec = []string{
"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",
"AAAA//+rgjPrSAYCAA==",
} }
// GetSwagger returns the content of the embedded swagger specification file // GetSwagger returns the content of the embedded swagger specification file
@@ -0,0 +1,77 @@
//go:build integration
package dbtest
import (
"context"
"log/slog"
"os"
"path/filepath"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"pipu/api/internal/library"
"pipu/api/internal/library/meta"
"pipu/api/internal/platform/db"
)
type portraitProber struct{ artist string }
func (p portraitProber) Probe(context.Context, string) (library.Probe, error) {
return library.Probe{Format: "flac", Codec: "flac", DurationMs: 180000,
Tags: meta.Fields{Artist: p.artist + ", Guest " + p.artist, AlbumArtist: p.artist, Album: "Portrait album", Title: "Song", Track: "1"}}, nil
}
func TestArtistImagesPreserveLocalExternalAndConcurrentPortraits(t *testing.T) {
pool := connect(t)
ctx := context.Background()
require.NoError(t, db.Migrate(ctx, pool))
store := library.NewStore(pool)
source, err := store.SaveSource(ctx, library.Source{ID: uuid.New(), Name: "portraits", Type: library.SourceLocal, Status: "mounted", TagsFirst: true, NFSOptions: "ro"})
require.NoError(t, err)
t.Cleanup(func() {
require.NoError(t, store.DeleteSource(ctx, source.ID))
require.NoError(t, store.CleanupOrphans(ctx))
})
root := t.TempDir()
for _, scenario := range []string{"empty", "local", "external", "concurrent"} {
t.Run(scenario, func(t *testing.T) {
artist := "Portrait " + uuid.NewString()
scanner := library.NewScanner(store, portraitProber{artist}, library.NewProgressBoard(), slog.New(slog.DiscardHandler))
rel := scenario + ".flac"
require.NoError(t, os.WriteFile(filepath.Join(root, rel), []byte(scenario), 0o644))
file, err := scanner.ScanFile(ctx, source, root, rel)
require.NoError(t, err)
id, err := store.ArtistWithoutImageForFile(ctx, file)
require.NoError(t, err)
if scenario == "local" {
require.NoError(t, store.SetArtistImageIfMissing(ctx, id, "existing-local"))
}
if scenario == "external" || scenario == "concurrent" {
_, err = pool.Exec(ctx, `UPDATE artists SET genius_image_url='https://example.org/portrait.jpg' WHERE id=$1`, id)
require.NoError(t, err)
}
if scenario != "empty" {
_, err = store.ArtistWithoutImageForFile(ctx, file)
require.ErrorIs(t, err, library.ErrNotFound)
}
// Even a caller that looked up the artist before another writer cannot overwrite it.
require.NoError(t, store.SetArtistImageIfMissing(ctx, id, "uploaded-portrait"))
a, err := store.Artist(ctx, id)
require.NoError(t, err)
switch scenario {
case "empty":
require.Equal(t, "uploaded-portrait", *a.ImagePath)
case "local":
require.Equal(t, "existing-local", *a.ImagePath)
default:
require.Nil(t, a.ImagePath)
require.Equal(t, "https://example.org/portrait.jpg", *a.ImageURL)
}
var guestImage *string
require.NoError(t, pool.QueryRow(ctx, `SELECT image_path FROM artists WHERE name=$1`, "Guest "+artist).Scan(&guestImage))
require.Nil(t, guestImage, "a parent folder portrait belongs to the album artist, not guests")
})
}
}
@@ -0,0 +1,74 @@
package library
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"os"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
type artistImageStore interface {
ArtistWithoutImageForFile(context.Context, uuid.UUID) (uuid.UUID, error)
SetArtistImageIfMissing(context.Context, uuid.UUID, string) error
}
type imageRenderer interface {
StoreImage(context.Context, []byte) (string, error)
}
// ArtistImages applies approved portraits to the album artist, never guests.
type ArtistImages struct {
store artistImageStore
images imageRenderer
}
func NewArtistImages(store *Store, covers *Covers) *ArtistImages {
return &ArtistImages{store: store, images: covers}
}
func (a *ArtistImages) ApplyArtistImage(ctx context.Context, file uuid.UUID, data []byte) error {
id, err := a.store.ArtistWithoutImageForFile(ctx, file)
if errors.Is(err, ErrNotFound) {
return nil
}
if err != nil {
return err
}
key, err := a.images.StoreImage(ctx, data)
if err != nil {
return err
}
return a.store.SetArtistImageIfMissing(ctx, id, key)
}
func (c *Covers) StoreImage(ctx context.Context, data []byte) (string, error) {
if err := os.MkdirAll(c.Dir, 0o755); err != nil {
return "", err
}
sum := sha256.Sum256(data)
key := hex.EncodeToString(sum[:8])
_, err := c.render(ctx, data, key)
return key, err
}
func (st *Store) ArtistWithoutImageForFile(ctx context.Context, file uuid.UUID) (uuid.UUID, error) {
var id uuid.UUID
err := st.pool.QueryRow(ctx, `SELECT a.id FROM media_files f JOIN tracks t ON t.id=f.track_id
JOIN albums al ON al.id=t.album_id JOIN artists a ON a.id=al.album_artist_id
WHERE f.id=$1 AND a.image_path IS NULL AND a.genius_image_url IS NULL`, file).Scan(&id)
if errors.Is(err, pgx.ErrNoRows) {
return id, ErrNotFound
}
return id, err
}
func (st *Store) SetArtistImageIfMissing(ctx context.Context, id uuid.UUID, key string) error {
// Another import or Genius may have supplied a portrait during rendering.
_, err := st.pool.Exec(ctx, `UPDATE artists SET image_path=$2 WHERE id=$1
AND image_path IS NULL AND genius_image_url IS NULL`, id, key)
return err
}
@@ -0,0 +1,69 @@
package library
import (
"context"
"errors"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
)
type portraitStore struct {
id uuid.UUID
err error
saved uuid.UUID
key string
}
func (s *portraitStore) ArtistWithoutImageForFile(context.Context, uuid.UUID) (uuid.UUID, error) {
return s.id, s.err
}
func (s *portraitStore) SetArtistImageIfMissing(_ context.Context, id uuid.UUID, key string) error {
s.saved, s.key = id, key
return nil
}
type portraitRenderer struct {
data []byte
err error
}
func (r *portraitRenderer) StoreImage(_ context.Context, data []byte) (string, error) {
r.data = data
return "portrait-key", r.err
}
func TestArtistPortraitOnlyRendersForAnArtistWithoutImage(t *testing.T) {
for _, scenario := range []string{"new portrait", "already has image", "lookup failure", "render failure"} {
t.Run(scenario, func(t *testing.T) {
st := &portraitStore{id: uuid.New()}
renderer := &portraitRenderer{}
switch scenario {
case "already has image":
st.err = ErrNotFound
case "lookup failure":
st.err = errors.New("database unavailable")
case "render failure":
renderer.err = errors.New("invalid image")
}
images := &ArtistImages{store: st, images: renderer}
err := images.ApplyArtistImage(context.Background(), uuid.New(), []byte("portrait"))
if scenario == "lookup failure" || scenario == "render failure" {
require.Error(t, err)
} else {
require.NoError(t, err)
}
if scenario == "new portrait" {
require.Equal(t, st.id, st.saved)
require.Equal(t, "portrait-key", st.key)
require.Equal(t, []byte("portrait"), renderer.data)
} else {
require.Equal(t, uuid.Nil, st.saved)
}
if scenario == "already has image" || scenario == "lookup failure" {
require.Nil(t, renderer.data)
}
})
}
}
+3 -2
View File
@@ -49,6 +49,7 @@ type TrackRow struct {
// ArtistRow — исполнитель со счётчиками. // ArtistRow — исполнитель со счётчиками.
type ArtistRow struct { type ArtistRow struct {
ImageURL *string ImageURL *string
ImagePath *string
ID uuid.UUID ID uuid.UUID
Name string Name string
AlbumCount int AlbumCount int
@@ -196,10 +197,10 @@ func (st *Store) ArtistAlbums(ctx context.Context, artistID uuid.UUID) ([]AlbumR
func (st *Store) Artist(ctx context.Context, id uuid.UUID) (ArtistRow, error) { func (st *Store) Artist(ctx context.Context, id uuid.UUID) (ArtistRow, error) {
var a ArtistRow var a ArtistRow
err := st.pool.QueryRow(ctx, ` err := st.pool.QueryRow(ctx, `
SELECT a.id, a.name, a.genius_image_url, SELECT a.id, a.name, a.genius_image_url, a.image_path,
(SELECT count(*) FROM albums al WHERE al.album_artist_id = a.id), (SELECT count(*) FROM albums al WHERE al.album_artist_id = a.id),
(SELECT count(DISTINCT ta.track_id) FROM track_artists ta WHERE ta.artist_id = a.id) (SELECT count(DISTINCT ta.track_id) FROM track_artists ta WHERE ta.artist_id = a.id)
FROM artists a WHERE a.id = $1`, id).Scan(&a.ID, &a.Name, &a.ImageURL, &a.AlbumCount, &a.TrackCount) FROM artists a WHERE a.id = $1`, id).Scan(&a.ID, &a.Name, &a.ImageURL, &a.ImagePath, &a.AlbumCount, &a.TrackCount)
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
return ArtistRow{}, ErrNotFound return ArtistRow{}, ErrNotFound
} }
+5 -1
View File
@@ -485,7 +485,11 @@ func (h *Handler) GetArtist(ctx context.Context, req gen.GetArtistRequestObject)
if err != nil { if err != nil {
return nil, catalogErr(err) return nil, catalogErr(err)
} }
return gen.GetArtist200JSONResponse{Id: a.ID, Name: a.Name, ImageUrl: a.ImageURL, AlbumCount: a.AlbumCount, TrackCount: a.TrackCount}, nil image := a.ImageURL
if a.ImagePath != nil {
image = h.coverURL(AlbumRow{ID: a.ID, CoverPath: a.ImagePath})
}
return gen.GetArtist200JSONResponse{Id: a.ID, Name: a.Name, ImageUrl: image, AlbumCount: a.AlbumCount, TrackCount: a.TrackCount}, nil
} }
// ListArtistAlbums реализует gen.StrictServerInterface. // ListArtistAlbums реализует gen.StrictServerInterface.
@@ -254,6 +254,65 @@ func (t Template) Render(in TemplateInput) (string, error) {
return finishPath(b.String()) return finishPath(b.String())
} }
// RenderWithoutMissingYear omits an unknown year and its punctuation for uploads.
// Other required metadata still uses strict rendering. Optional fragments retain
// their usual all-or-nothing semantics.
func (t Template) RenderWithoutMissingYear(in TemplateInput) (string, error) {
if fieldValue(in, tnode{field: "year"}) != "" {
return t.Render(in)
}
var src strings.Builder
for _, n := range t.nodes {
if n.isOpt {
if _, ok := renderOptional(in, n.opt); ok {
src.WriteString("[")
for _, o := range n.opt {
src.WriteString(templateNodeSource(o))
}
src.WriteString("]")
}
} else {
src.WriteString(templateNodeSource(n))
}
}
s := src.String()
for _, re := range missingYearParts {
s = re.ReplaceAllString(s, "")
}
// A year-only directory disappears as a whole.
var segments []string
for _, seg := range strings.Split(s, "/") {
if strings.TrimSpace(seg) != "" {
segments = append(segments, seg)
}
}
tpl, err := ParseTemplate(strings.Join(segments, "/"))
if err != nil {
return "", err
}
return tpl.Render(in)
}
var missingYearParts = []*regexp.Regexp{
regexp.MustCompile(`\(\s*\{year\}\s*\)`),
regexp.MustCompile(`\{year\}[ \t]*[-–—_][ \t]*`),
regexp.MustCompile(`[ \t]*[-–—_][ \t]*\{year\}`),
regexp.MustCompile(`\{year\}`),
}
func templateNodeSource(n tnode) string {
if n.field == "" {
return n.lit
}
if n.multi {
return "{" + n.field + ":1" + n.suffix + "}"
}
if n.pad > 0 {
return fmt.Sprintf("{%s:0%d}", n.field, n.pad)
}
return "{" + n.field + "}"
}
func renderOptional(in TemplateInput, nodes []tnode) (string, bool) { func renderOptional(in TemplateInput, nodes []tnode) (string, bool) {
var b strings.Builder var b strings.Builder
for _, n := range nodes { for _, n := range nodes {
@@ -7,6 +7,41 @@ import (
const docTemplate = "{albumartist}/{year} - {album}/{disc:1-}{track:02} - {title}.{ext}" const docTemplate = "{albumartist}/{year} - {album}/{disc:1-}{track:02} - {title}.{ext}"
func TestRenderWithoutMissingYear(t *testing.T) {
for _, pattern := range []string{
"{artist}/{album} ({year})/{track:02} - {title}.{ext}",
"{artist}/{year} - {album}/{track:02} - {title}.{ext}",
"{artist}/{album} - {year}/{track:02} - {title}.{ext}",
"{artist}/{year}/{album}/{track:02} - {title}.{ext}",
"{artist}/[{year} - ]{album}/{track:02} - {title}.{ext}",
} {
t.Run(pattern, func(t *testing.T) {
tpl, err := ParseTemplate(pattern)
if err != nil {
t.Fatal(err)
}
input := in(Fields{Artist: "A", Album: "B", Title: "C", Track: "1"}, 1, "mp3")
got, err := tpl.RenderWithoutMissingYear(input)
if err != nil || got != "A/B/01 - C.mp3" {
t.Fatalf("got %q, %v", got, err)
}
input.Fields.Title = ""
if _, err := tpl.RenderWithoutMissingYear(input); err == nil {
t.Fatal("missing title must still fail")
}
input.Fields.Title, input.Fields.Year = "C", "2026"
want, err := tpl.Render(input)
if err != nil {
t.Fatal(err)
}
got, err = tpl.RenderWithoutMissingYear(input)
if err != nil || got != want {
t.Fatalf("known year: got %q, %v; want %q", got, err, want)
}
})
}
}
func in(f Fields, discs int, ext string) TemplateInput { func in(f Fields, discs int, ext string) TemplateInput {
return TemplateInput{Fields: f, DiscCount: discs, Ext: ext} return TemplateInput{Fields: f, DiscCount: discs, Ext: ext}
} }
+5 -5
View File
@@ -27,7 +27,7 @@ type indexSpec struct {
doc func(pgx.Rows) (map[string]any, error) doc func(pgx.Rows) (map[string]any, error)
} }
var rankingRules = []string{"words", "typo", "proximity", "attribute", "sort", "exactness", "popularity:desc"} var rankingRules = []string{"words", "typo", "exactness", "proximity", "attribute", "sort", "popularity:desc"}
var specs = []indexSpec{ var specs = []indexSpec{
{ {
@@ -43,7 +43,7 @@ var specs = []indexSpec{
if err := r.Scan(&id, &name); err != nil { if err := r.Scan(&id, &name); err != nil {
return nil, err return nil, err
} }
return map[string]any{"id": id, "name": name, "name_translit": Translit(name), "popularity": 0}, nil return map[string]any{"id": id, "name": name, "name_translit": TranslitVariants(name), "popularity": 0}, nil
}, },
}, },
{ {
@@ -65,7 +65,7 @@ var specs = []indexSpec{
} }
return map[string]any{ return map[string]any{
"id": id, "title": title, "artist_name": artist, "year": year, "popularity": 0, "id": id, "title": title, "artist_name": artist, "year": year, "popularity": 0,
"title_translit": Translit(title), "artist_translit": Translit(artist), "title_translit": TranslitVariants(title), "artist_translit": TranslitVariants(artist),
}, nil }, nil
}, },
}, },
@@ -97,12 +97,12 @@ var specs = []indexSpec{
} }
translit := make([]string, 0, len(artists)) translit := make([]string, 0, len(artists))
for _, a := range artists { for _, a := range artists {
translit = append(translit, Translit(a)) translit = append(translit, TranslitVariants(a)...)
} }
return map[string]any{ return map[string]any{
"id": id, "title": title, "album_title": album, "year": year, "artist_names": artists, "id": id, "title": title, "album_title": album, "year": year, "artist_names": artists,
"genres": genres, "available": available, "popularity": 0, "genres": genres, "available": available, "popularity": 0,
"title_translit": Translit(title), "artist_translit": translit, "title_translit": TranslitVariants(title), "artist_translit": translit,
}, nil }, nil
}, },
}, },
+31 -3
View File
@@ -141,13 +141,15 @@ type MultiQuery struct {
Q string `json:"q"` Q string `json:"q"`
Limit int `json:"limit"` Limit int `json:"limit"`
Offset int `json:"offset,omitempty"` Offset int `json:"offset,omitempty"`
MatchingStrategy string `json:"matchingStrategy,omitempty"`
AttributesToRetrieve []string `json:"attributesToRetrieve"` AttributesToRetrieve []string `json:"attributesToRetrieve"`
} }
// MultiResult — ответ на один запрос: id найденных документов и оценка общего числа. // MultiResult — ответ на один запрос: id найденных документов и оценка общего числа.
type MultiResult struct { type MultiResult struct {
IDs []string IDs []string
Total int Total int
ExactIDs []string
} }
// MultiSearch выполняет несколько запросов за один вызов. // MultiSearch выполняет несколько запросов за один вызов.
@@ -155,7 +157,9 @@ func (m *Meili) MultiSearch(ctx context.Context, queries []MultiQuery) ([]MultiR
var res struct { var res struct {
Results []struct { Results []struct {
Hits []struct { Hits []struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"`
NameTranslit any `json:"name_translit"`
} `json:"hits"` } `json:"hits"`
EstimatedTotalHits int `json:"estimatedTotalHits"` EstimatedTotalHits int `json:"estimatedTotalHits"`
} `json:"results"` } `json:"results"`
@@ -167,8 +171,32 @@ func (m *Meili) MultiSearch(ctx context.Context, queries []MultiQuery) ([]MultiR
for i, r := range res.Results { for i, r := range res.Results {
for _, h := range r.Hits { for _, h := range r.Hits {
out[i].IDs = append(out[i].IDs, h.ID) out[i].IDs = append(out[i].IDs, h.ID)
if exactArtistName(queries[i].Q, h.Name, h.NameTranslit) {
out[i].ExactIDs = append(out[i].ExactIDs, h.ID)
}
} }
out[i].Total = r.EstimatedTotalHits out[i].Total = r.EstimatedTotalHits
} }
return out, nil return out, nil
} }
func exactArtistName(query, name string, translit any) bool {
key := searchKey(query)
if key == "" {
return false
}
if searchKey(name) == key {
return true
}
switch v := translit.(type) {
case string: // indexes built before transliteration alternatives
return searchKey(v) == key
case []any:
for _, item := range v {
if s, ok := item.(string); ok && searchKey(s) == key {
return true
}
}
}
return false
}
@@ -2,13 +2,49 @@ package search
import ( import (
"context" "context"
"encoding/json"
"errors" "errors"
"log/slog"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"reflect" "reflect"
"testing" "testing"
"github.com/stretchr/testify/require"
) )
func TestQueryRequiresAllWordsAndProtectsExactArtist(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body struct {
Queries []MultiQuery `json:"queries"`
}
require.NoError(t, json.NewDecoder(r.Body).Decode(&body))
require.Len(t, body.Queries, 3)
for _, q := range body.Queries {
require.Equal(t, "all", q.MatchingStrategy)
}
require.Contains(t, body.Queries[0].AttributesToRetrieve, "name_translit")
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"results":[{"hits":[{"id":"collab","name":"Boulevard Depo X 161"},{"id":"exact","name":"BOULEVARD DEPO","name_translit":["боулевард депо","бульвар депо"]}],"estimatedTotalHits":2},{"hits":[]},{"hits":[]}]}`))
}))
defer srv.Close()
h := &Handler{meili: NewMeili(srv.URL, ""), log: slog.Default()}
f, err := h.query(context.Background(), "бульвар депо", limits{artists: 3, albums: 3, tracks: 5})
require.NoError(t, err)
require.Equal(t, []string{"exact"}, f.exactArtists)
f = h.personal(context.Background(), f, limits{artists: 1})
require.Equal(t, []string{"exact"}, f.artists)
}
func TestExactArtistName(t *testing.T) {
require.True(t, exactArtistName("boulevard depo", "BOULEVARD DEPO", nil))
require.True(t, exactArtistName("БУЛЬВАР ДЕПО", "BOULEVARD DEPO", []any{"бульвар депо"}))
require.True(t, exactArtistName("кино", "Kino", "кино"))
require.False(t, exactArtistName("boulevard depo", "Depo Boulevard", nil))
require.False(t, exactArtistName("boulevard depo", "Boulevard Depo X 161", nil))
require.False(t, exactArtistName("!", "", nil))
}
func TestDeleteIndexIfExists(t *testing.T) { func TestDeleteIndexIfExists(t *testing.T) {
for _, tc := range []struct { for _, tc := range []struct {
name string name string
+18
View File
@@ -12,6 +12,24 @@ import (
// suggestPool — во сколько раз больше кандидатов берётся из Meilisearch для переранжирования подсказок. // suggestPool — во сколько раз больше кандидатов берётся из Meilisearch для переранжирования подсказок.
const suggestPool = 3 const suggestPool = 3
// Exact names stay ahead of related artists, regardless of listening history.
func personalizeArtists(found, exact []string, aff map[uuid.UUID]float64, n int) []string {
protected := make(map[string]bool, len(exact))
for _, id := range exact {
protected[id] = true
}
var first, rest []string
for _, id := range found {
if protected[id] {
first = append(first, id)
} else {
rest = append(rest, id)
}
}
first = append(first, personalize(rest, aff, max(0, n-len(first)))...)
return first[:min(n, len(first))]
}
// Affinity — насколько пользователь «знает» исполнителей, альбомы и треки: прослушивания, доигрывания, // Affinity — насколько пользователь «знает» исполнителей, альбомы и треки: прослушивания, доигрывания,
// лайки и дизлайки. Отрицательное — не любит. Нужен для персональных подсказок поиска. // лайки и дизлайки. Отрицательное — не любит. Нужен для персональных подсказок поиска.
type Affinity struct{ pool *pgxpool.Pool } type Affinity struct{ pool *pgxpool.Pool }
@@ -0,0 +1,55 @@
//go:build integration
package search
import (
"context"
"net/http"
"os"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
)
// Use a private index: never replace a running application's search indexes.
func TestArtistSearchRelevance(t *testing.T) {
url := os.Getenv("TEST_MEILI_URL")
if url == "" {
t.Skip("TEST_MEILI_URL is not set")
}
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
m := NewMeili(url, os.Getenv("TEST_MEILI_KEY"))
index := "search_test_" + uuid.New().String()
require.NoError(t, m.call(ctx, http.MethodPost, "/indexes", map[string]any{"uid": index, "primaryKey": "id"}))
t.Cleanup(func() {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
require.NoError(t, m.deleteIndexIfExists(ctx, index))
})
require.NoError(t, m.call(ctx, http.MethodPatch, "/indexes/"+index+"/settings", specs[0].settings))
var docs []map[string]any
for id, name := range map[string]string{
"exact": "BOULEVARD DEPO", "collaboration": "Boulevard Depo X 161",
"reversed": "Depo Boulevard", "unrelated": "Thoma Bulwer",
"partial": "Станция бульвар малина",
} {
docs = append(docs, map[string]any{"id": id, "name": name, "name_translit": TranslitVariants(name), "popularity": 0})
}
require.NoError(t, m.call(ctx, http.MethodPost, "/indexes/"+index+"/documents", docs))
for _, query := range []string{"boulevard depo", "бульвар депо", "боулевард депо"} {
t.Run(query, func(t *testing.T) {
results, err := m.MultiSearch(ctx, []MultiQuery{{IndexUID: index, Q: query, Limit: 10,
MatchingStrategy: "all", AttributesToRetrieve: []string{"id", "name", "name_translit"}}})
require.NoError(t, err)
require.Len(t, results, 1)
require.NotEmpty(t, results[0].IDs)
require.Equal(t, "exact", results[0].IDs[0])
require.NotContains(t, results[0].IDs, "unrelated")
require.NotContains(t, results[0].IDs, "partial")
require.Contains(t, results[0].ExactIDs, "exact")
})
}
}
+8 -2
View File
@@ -43,6 +43,7 @@ type limits struct{ artists, albums, tracks, offset int }
type found struct { type found struct {
artists, albums, tracks []string artists, albums, tracks []string
exactArtists []string
totals [3]int totals [3]int
} }
@@ -50,7 +51,11 @@ func (h *Handler) query(ctx context.Context, q string, l limits) (found, error)
var queries []MultiQuery var queries []MultiQuery
add := func(index string, n int) { add := func(index string, n int) {
if n > 0 { if n > 0 {
queries = append(queries, MultiQuery{IndexUID: index, Q: q, Limit: n, Offset: l.offset, AttributesToRetrieve: []string{"id"}}) attrs := []string{"id"}
if index == IndexArtists {
attrs = append(attrs, "name", "name_translit")
}
queries = append(queries, MultiQuery{IndexUID: index, Q: q, Limit: n, Offset: l.offset, MatchingStrategy: "all", AttributesToRetrieve: attrs})
} }
} }
add(IndexArtists, l.artists) add(IndexArtists, l.artists)
@@ -71,6 +76,7 @@ func (h *Handler) query(ctx context.Context, q string, l limits) (found, error)
switch k { switch k {
case 0: case 0:
f.artists = res[i].IDs f.artists = res[i].IDs
f.exactArtists = res[i].ExactIDs
case 1: case 1:
f.albums = res[i].IDs f.albums = res[i].IDs
case 2: case 2:
@@ -181,7 +187,7 @@ func (h *Handler) personal(ctx context.Context, f found, want limits) found {
h.log.WarnContext(ctx, "suggest personalization failed", "err", err) h.log.WarnContext(ctx, "suggest personalization failed", "err", err)
} }
} }
f.artists = personalize(f.artists, aff, want.artists) f.artists = personalizeArtists(f.artists, f.exactArtists, aff, want.artists)
f.albums = personalize(f.albums, aff, want.albums) f.albums = personalize(f.albums, aff, want.albums)
f.tracks = personalize(f.tracks, aff, want.tracks) f.tracks = personalize(f.tracks, aff, want.tracks)
return f return f
+30
View File
@@ -6,6 +6,36 @@ import (
"unicode" "unicode"
) )
// Established spellings cannot always be derived letter by letter. Keep these
// as whole-word alternatives, retaining the mechanical transliteration too.
var pronunciationWords = map[string]string{"boulevard": "бульвар"}
func TranslitVariants(s string) []string {
out := []string{}
if mechanical := Translit(s); mechanical != "" {
out = append(out, mechanical)
}
words := strings.Fields(strings.ToLower(s))
changed := false
for i, word := range words {
if spoken, ok := pronunciationWords[word]; ok {
words[i], changed = spoken, true
} else if translated := Translit(word); translated != "" {
words[i] = translated
}
}
if changed {
out = append(out, strings.Join(words, " "))
}
return out
}
func searchKey(s string) string {
return strings.Join(strings.FieldsFunc(strings.ToLower(s), func(r rune) bool {
return !unicode.IsLetter(r) && !unicode.IsDigit(r)
}), " ")
}
// Кириллица → латиница (упрощённая ГОСТ-подобная схема: «Кино» → «kino», «Щедрин» → «shchedrin»). // Кириллица → латиница (упрощённая ГОСТ-подобная схема: «Кино» → «kino», «Щедрин» → «shchedrin»).
var cyrToLat = map[rune]string{ var cyrToLat = map[rune]string{
'а': "a", 'б': "b", 'в': "v", 'г': "g", 'д': "d", 'е': "e", 'ё': "e", 'ж': "zh", 'з': "z", 'и': "i", 'а': "a", 'б': "b", 'в': "v", 'г': "g", 'д': "d", 'е': "e", 'ё': "e", 'ж': "zh", 'з': "z", 'и': "i",
@@ -16,6 +16,23 @@ func TestTranslit(t *testing.T) {
assert.Equal(t, "", Translit("1984")) assert.Equal(t, "", Translit("1984"))
} }
func TestTranslitVariants(t *testing.T) {
assert.Equal(t, []string{"боулевард депо", "бульвар депо"}, TranslitVariants("BOULEVARD DEPO"))
assert.Equal(t, []string{"боулевард депо кс 161", "бульвар депо кс 161"}, TranslitVariants("Boulevard Depo X 161"))
assert.Equal(t, []string{"kino"}, TranslitVariants("Кино"))
assert.Empty(t, TranslitVariants("1984"))
assert.Equal(t, []string{"боулевардиер"}, TranslitVariants("Boulevardier"), "whole words only")
}
func TestPersonalizeKeepsExactArtistFirst(t *testing.T) {
exact, collaboration, reversed := uuid.NewString(), uuid.NewString(), uuid.NewString()
found := []string{collaboration, reversed, exact}
aff := map[uuid.UUID]float64{uuid.MustParse(collaboration): 10000, uuid.MustParse(exact): -100}
assert.Equal(t, []string{exact, collaboration}, personalizeArtists(found, []string{exact}, aff, 2))
assert.Equal(t, []string{exact}, personalizeArtists(found, []string{exact}, aff, 1))
assert.Equal(t, found[:2], personalizeArtists(found, nil, nil, 2))
}
func TestSwitchLayout(t *testing.T) { func TestSwitchLayout(t *testing.T) {
assert.Equal(t, "привет", SwitchLayout("ghbdtn")) assert.Equal(t, "привет", SwitchLayout("ghbdtn"))
assert.Equal(t, "hello", SwitchLayout("руддщ")) assert.Equal(t, "hello", SwitchLayout("руддщ"))
+2 -2
View File
@@ -143,8 +143,8 @@ func (a *agent) writeFile(w http.ResponseWriter, r *http.Request) {
} }
// write копирует staging сразу под итоговым именем (O_EXCL — существующий файл не перезаписывается, при // write копирует staging сразу под итоговым именем (O_EXCL — существующий файл не перезаписывается, при
// конфликте — « (2)»), fsync, сверяет SHA-256 копии. На музыкальный диск не попадает ничего, кроме самого трека: // конфликте — « (2)»), fsync, сверяет SHA-256 копии. На музыкальный диск не попадает ничего, кроме трека и его
// ни временных файлов, ни служебных пометок. При любой ошибке недописанный файл и созданные для него пустые // картинок cover/artist: ни временных файлов, ни служебных пометок. При любой ошибке недописанный файл и созданные для него пустые
// каталоги удаляются — агент убирает только то, что создал сам. // каталоги удаляются — агент убирает только то, что создал сам.
func (a *agent) write(root, rel, staging string, want []byte) (WriteResult, error) { func (a *agent) write(root, rel, staging string, want []byte) (WriteResult, error) {
dir := filepath.Dir(filepath.Join(root, filepath.FromSlash(rel))) dir := filepath.Dir(filepath.Join(root, filepath.FromSlash(rel)))
@@ -0,0 +1,66 @@
//go:build integration
package uploads
import (
"context"
"crypto/sha256"
"encoding/hex"
"log/slog"
"os"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"pipu/api/internal/auth"
"pipu/api/internal/library"
"pipu/api/internal/platform/db"
)
func TestUploadedArtistPortraitStagingLifetime(t *testing.T) {
address := os.Getenv("TEST_DATABASE_URL")
if address == "" {
t.Skip("TEST_DATABASE_URL is not set")
}
ctx := context.Background()
pool, err := db.Connect(ctx, address)
require.NoError(t, err)
t.Cleanup(pool.Close)
require.NoError(t, db.Migrate(ctx, pool))
store := NewStore(pool)
author := uuid.New()
_, err = pool.Exec(ctx, `INSERT INTO users(id,username,password_hash,role) VALUES($1,$2,'x','admin')`, author, "portrait-"+author.String())
require.NoError(t, err)
t.Cleanup(func() {
_, err := pool.Exec(ctx, `DELETE FROM upload_batches WHERE author_id=$1`, author)
require.NoError(t, err)
_, err = pool.Exec(ctx, `DELETE FROM users WHERE id=$1`, author)
require.NoError(t, err)
require.NoError(t, store.ReconcileQuota(ctx))
})
svc := NewService(store, nil, nil, nil, nil, nil, library.FPCalc{}, Config{StagingDir: t.TempDir()}, slog.New(slog.DiscardHandler))
owner := auth.WithPrincipal(ctx, auth.Principal{UserID: author, Role: auth.RoleAdmin})
files := []CreateFile{}
for _, name := range []string{"one.flac", "two.flac"} {
sum := sha256.Sum256([]byte(uuid.NewString()))
files = append(files, CreateFile{ClientID: name, Name: name, Size: 10, SHA256: hex.EncodeToString(sum[:])})
}
batch, reserved, err := svc.Create(owner, nil, files, nil, []byte{0xff, 0xd8, 0xff, 1})
require.NoError(t, err)
require.Len(t, reserved, 2)
first, err := store.File(ctx, reserved[0].FileID)
require.NoError(t, err)
require.NotNil(t, first.ArtistImagePath)
image := svc.stagingAbs(*first.ArtistImagePath)
require.FileExists(t, image)
inUse, err := store.StagingInUse(ctx)
require.NoError(t, err)
require.True(t, inUse[*first.ArtistImagePath], "restart reconciliation must retain pending portraits")
require.NoError(t, svc.Withdraw(owner, first.ID))
require.FileExists(t, image, "the second file still owns this portrait")
require.NoError(t, svc.Withdraw(owner, reserved[1].FileID))
require.NoFileExists(t, image)
unused, err := store.UnusedArtistImage(ctx, batch)
require.NoError(t, err)
require.Equal(t, first.ArtistImagePath, unused)
}
+9 -1
View File
@@ -165,7 +165,15 @@ func (h *Handler) CreateUploadBatch(ctx context.Context, req gen.CreateUploadBat
return nil, err return nil, err
} }
} }
batch, res, err := h.svc.Create(ctx, req.Body.Comment, in, cover) var artistImage []byte
if req.Body.ArtistImageBase64 != nil {
var err error
artistImage, err = DecodeCover(*req.Body.ArtistImageBase64)
if err != nil {
return nil, err
}
}
batch, res, err := h.svc.Create(ctx, req.Body.Comment, in, cover, artistImage)
if err != nil { if err != nil {
return nil, err return nil, err
} }
+14 -1
View File
@@ -57,6 +57,7 @@ const (
CodeSizeMismatch = "uploads.size_mismatch" CodeSizeMismatch = "uploads.size_mismatch"
CodeHashMismatch = "uploads.hash_mismatch" CodeHashMismatch = "uploads.hash_mismatch"
CodeMissingField = "uploads.missing_field" CodeMissingField = "uploads.missing_field"
CodeMissingYear = "uploads.missing_year"
CodeImportFailed = "uploads.import_failed" CodeImportFailed = "uploads.import_failed"
CodeTemplate = "uploads.no_template" CodeTemplate = "uploads.no_template"
) )
@@ -80,6 +81,17 @@ type Meta struct {
Disc *int `json:"disc,omitempty"` Disc *int `json:"disc,omitempty"`
} }
// PrimaryArtist — первый исполнитель альбома (при пустом album artist — трека; « & » имя не делит): «A;B» и «A feat. B»
// лежат в папке «A», как и в каталоге, где альбом принадлежит первому исполнителю.
func (m Meta) PrimaryArtist() string {
for _, field := range []string{m.AlbumArtist, m.Artist} {
if credits := meta.SplitArtists(field, func(string) bool { return true }); len(credits) > 0 {
return credits[0].Name
}
}
return ""
}
// Fields — значения для шаблона пути. // Fields — значения для шаблона пути.
func (m Meta) Fields() meta.Fields { func (m Meta) Fields() meta.Fields {
num := func(p *int) string { num := func(p *int) string {
@@ -89,7 +101,7 @@ func (m Meta) Fields() meta.Fields {
return strconv.Itoa(*p) return strconv.Itoa(*p)
} }
return meta.Fields{ return meta.Fields{
Artist: m.Artist, AlbumArtist: m.AlbumArtist, Album: m.Album, Title: m.Title, Genre: m.Genre, Artist: m.Artist, AlbumArtist: m.PrimaryArtist(), Album: m.Album, Title: m.Title, Genre: m.Genre,
Year: num(m.Year), Track: num(m.Track), Disc: num(m.Disc), Year: num(m.Year), Track: num(m.Track), Disc: num(m.Disc),
} }
} }
@@ -148,6 +160,7 @@ type File struct {
ReviewedAt *time.Time ReviewedAt *time.Time
ImportedFileID *uuid.UUID ImportedFileID *uuid.UUID
CoverPath *string CoverPath *string
ArtistImagePath *string
CreatedAt time.Time CreatedAt time.Time
UpdatedAt time.Time UpdatedAt time.Time
} }
+6 -3
View File
@@ -38,7 +38,7 @@ type PlacementPlanner struct {
// --- Раскладка в хранилище --- // --- Раскладка в хранилище ---
// placement — куда ляжет файл; Reason — почему не получится (код), путь тогда nil. // placement — куда ляжет файл; Reason при ненулевом пути — предупреждение.
type placement struct { type placement struct {
SourceID *uuid.UUID SourceID *uuid.UUID
RelPath *string RelPath *string
@@ -123,7 +123,7 @@ func (s *PlacementPlanner) plan(ctx context.Context, f File, m Meta, chosen *uui
pl placement pl placement
discs int // дисков в альбоме: в каталоге, в пачке, у самого файла discs int // дисков в альбоме: в каталоге, в пачке, у самого файла
) )
if artist, album, maxDisc, err := s.store.MatchCatalog(ctx, meta.Key(m.AlbumArtist), meta.Key(m.Album)); err == nil { if artist, album, maxDisc, err := s.store.MatchCatalog(ctx, meta.Key(m.PrimaryArtist()), meta.Key(m.Album)); err == nil {
pl.ArtistID, pl.AlbumID, discs = artist, album, maxDisc pl.ArtistID, pl.AlbumID, discs = artist, album, maxDisc
} }
src, reason := s.pickSource(ctx, chosen) src, reason := s.pickSource(ctx, chosen)
@@ -147,7 +147,7 @@ func (s *PlacementPlanner) plan(ctx context.Context, f File, m Meta, chosen *uui
if m.Disc != nil { if m.Disc != nil {
discs = max(discs, *m.Disc) discs = max(discs, *m.Disc)
} }
rel, err := tpl.Render(meta.TemplateInput{Fields: m.Fields(), DiscCount: discs, Ext: extFor(f)}) rel, err := tpl.RenderWithoutMissingYear(meta.TemplateInput{Fields: m.Fields(), DiscCount: discs, Ext: extFor(f)})
if err != nil { if err != nil {
var mf *meta.MissingFieldError var mf *meta.MissingFieldError
if errors.As(err, &mf) { if errors.As(err, &mf) {
@@ -164,5 +164,8 @@ func (s *PlacementPlanner) plan(ctx context.Context, f File, m Meta, chosen *uui
} }
rel = reuseExisting(root, rel) rel = reuseExisting(root, rel)
pl.RelPath, pl.root = &rel, root pl.RelPath, pl.root = &rel, root
if m.Year == nil || *m.Year <= 0 {
pl.Reason = CodeMissingYear
}
return pl return pl
} }
@@ -33,6 +33,28 @@ func (previewStore) BatchMaxDisc(context.Context, uuid.UUID, string) (int, error
type previewRoot string type previewRoot string
func TestPreviewMissingYearStillHasImportPath(t *testing.T) {
root := t.TempDir()
catalog := &previewCatalog{source: library.Source{ID: uuid.New(), Name: "Base", Type: library.SourceLocal,
AcceptsUploads: true, WriteTemplate: nullable("{albumartist}/{albumartist} - {album} ({year})/{title}.{ext}")}}
planner := &PlacementPlanner{store: previewStore{}, lib: catalog, roots: previewRoot(root)}
f := File{Status: StatusPendingReview, OriginalName: "song.mp3", Meta: &Meta{
Artist: "Artist feat. Guest", AlbumArtist: "Artist", Album: "Album", Title: "Song"}}
planner.previewPlacement(context.Background(), &f)
require.NotNil(t, f.TargetRelPath)
require.Equal(t, "Artist/Artist - Album/Song.mp3", *f.TargetRelPath)
require.NotNil(t, f.TargetStoragePath)
require.Equal(t, CodeMissingYear, *f.StatusReason)
pl := planner.plan(context.Background(), f, *f.Meta, f.TargetSourceID)
require.Equal(t, f.TargetRelPath, pl.RelPath)
require.Nil(t, f.Meta.Year, "path rendering must not invent metadata")
year := 2026
f.Meta.Year = &year
planner.previewPlacement(context.Background(), &f)
require.Equal(t, "Artist/Artist - Album (2026)/Song.mp3", *f.TargetRelPath)
require.Nil(t, f.StatusReason)
}
func (r previewRoot) Root(library.Source) (string, error) { return string(r), nil } func (r previewRoot) Root(library.Source) (string, error) { return string(r), nil }
func TestPreviewUsesCurrentImportTemplate(t *testing.T) { func TestPreviewUsesCurrentImportTemplate(t *testing.T) {
@@ -42,8 +64,9 @@ func TestPreviewUsesCurrentImportTemplate(t *testing.T) {
AcceptsUploads: true, WriteTemplate: nullable("{albumartist}/{album}/{title}.{ext}")}} AcceptsUploads: true, WriteTemplate: nullable("{albumartist}/{album}/{title}.{ext}")}}
planner := &PlacementPlanner{store: previewStore{}, lib: catalog, roots: previewRoot(root)} planner := &PlacementPlanner{store: previewStore{}, lib: catalog, roots: previewRoot(root)}
track := 3 track := 3
year := 2026
f := File{Status: StatusPendingReview, OriginalName: "song.flac", Meta: &Meta{ f := File{Status: StatusPendingReview, OriginalName: "song.flac", Meta: &Meta{
Artist: "Artist", AlbumArtist: "Artist", Album: "Album", Title: "Song", Track: &track}, Artist: "Artist", AlbumArtist: "Artist", Album: "Album", Title: "Song", Track: &track, Year: &year},
TargetSourceID: &catalog.source.ID, TargetRelPath: nullable("stale/path.flac"), StatusReason: nullable(CodeNoTarget)} TargetSourceID: &catalog.source.ID, TargetRelPath: nullable("stale/path.flac"), StatusReason: nullable(CodeNoTarget)}
planner.previewPlacement(ctx, &f) planner.previewPlacement(ctx, &f)
require.Equal(t, "Artist/Album/Song.flac", *f.TargetRelPath) require.Equal(t, "Artist/Album/Song.flac", *f.TargetRelPath)
+42 -3
View File
@@ -5,6 +5,7 @@ import (
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"math" "math"
"regexp"
"sort" "sort"
"strings" "strings"
@@ -70,7 +71,11 @@ func releaseHash(files []File) string {
entries = append(entries, entry{f.ID, f.Meta, f.DurationMs, f.Tags}) entries = append(entries, entry{f.ID, f.Meta, f.DurationMs, f.Tags})
} }
sort.Slice(entries, func(i, j int) bool { return entries[i].ID.String() < entries[j].ID.String() }) sort.Slice(entries, func(i, j int) bool { return entries[i].ID.String() < entries[j].ID.String() })
b, _ := json.Marshal(entries) // Invalidate persisted reports when matching rules change, as well as tags.
b, _ := json.Marshal(struct {
Version int
Files []entry
}{2, entries})
h := sha256.Sum256(b) h := sha256.Sum256(b)
return hex.EncodeToString(h[:]) return hex.EncodeToString(h[:])
} }
@@ -111,6 +116,39 @@ func batchReleaseQuery(files []File) (musicbrainz.Query, bool) {
} }
return q, q.Artist != "" return q, q.Artist != ""
} }
// Only credit annotations are removed for tentative matching. Version labels
// such as live, remix or instrumental remain significant.
var releaseTitleCredits = regexp.MustCompile(`(?i)\s*[\(\[]\s*(?:feat\.?|ft\.?|featuring|prod\.?|produced by)\s+[^()\[\]]+[\)\]]\s*$`)
func releaseTitleKey(title string) string {
for {
cleaned := releaseTitleCredits.ReplaceAllString(title, "")
if cleaned == title {
return meta.Key(title)
}
title = cleaned
}
}
func sameReleaseArtists(a, b string) bool {
left := meta.SplitArtists(strings.ToLower(a), nil)
right := meta.SplitArtists(strings.ToLower(b), nil)
if len(left) == 0 || len(left) != len(right) {
return false
}
credits := make(map[string]bool, len(left))
for _, artist := range left {
credits[meta.Key(artist.Name)] = true
}
for _, artist := range right {
if !credits[meta.Key(artist.Name)] {
return false
}
}
return true
}
func trackScore(f File, t musicbrainz.Track, albumArtist string) int { func trackScore(f File, t musicbrainz.Track, albumArtist string) int {
if f.Meta == nil { if f.Meta == nil {
return 0 return 0
@@ -137,8 +175,9 @@ func trackScore(f File, t musicbrainz.Track, albumArtist string) int {
if title && (t.DurationMs == 0 || f.DurationMs == nil) { if title && (t.DurationMs == 0 || f.DurationMs == nil) {
return 70 return 70
} }
if delta <= 2000 && m.Track != nil && *m.Track == t.Position && (m.Disc == nil && t.Disc == 1 || m.Disc != nil && *m.Disc == t.Disc) && meta.Key(m.Artist) == meta.Key(t.Artist) { if delta <= 2000 && m.Track != nil && *m.Track == t.Position && (m.Disc == nil && t.Disc == 1 || m.Disc != nil && *m.Disc == t.Disc) &&
return 60 releaseTitleKey(m.Title) != "" && releaseTitleKey(m.Title) == releaseTitleKey(t.Title) && sameReleaseArtists(m.Artist, t.Artist) {
return 75
} }
return 0 return 0
} }
@@ -58,7 +58,7 @@ func TestReleaseMatchingDoesNotClaimAmbiguousOrWeakMatchesAreComplete(t *testing
f := releaseFile("Untitled", 1, 1, 12000) f := releaseFile("Untitled", 1, 1, 12000)
c = matchRelease([]File{f}, r) c = matchRelease([]File{f}, r)
require.False(t, c.Complete) require.False(t, c.Complete)
require.Equal(t, "medium", c.Confidence) require.Empty(t, c.Matches, "number and duration alone do not identify a differently titled recording")
f.Meta.Title = "Intro" f.Meta.Title = "Intro"
f.Meta.Artist = "Someone Else" f.Meta.Artist = "Someone Else"
f.Meta.AlbumArtist = "Someone Else" f.Meta.AlbumArtist = "Someone Else"
@@ -93,3 +93,88 @@ func TestReleaseFingerprintInvalidatesEditsButNotApproval(t *testing.T) {
files[0].Status = StatusWithdrawn files[0].Status = StatusWithdrawn
require.NotEqual(t, hash, releaseHash(files)) require.NotEqual(t, hash, releaseHash(files))
} }
func TestReleaseMatchesOldBloodCreditsWithoutClaimingVerifiedCompleteness(t *testing.T) {
tracks := []struct {
title, uploaded, guest string
seconds int
}{
{"Введение", "Введение", "", 34},
{"Ни много ни мало", "Ни много ни мало (Prod. Blacksurfer)", "", 143},
{"MOONWORK", "MOONWORK (feat. Basic Boy) (Prod. SP4K)", "Basic Boy", 214},
{"Angry Toy$", "Angry toy$ (Prod. Ray Qwa)", "", 127},
{"Возраст", "Возраст (feat. Artur Kreem) (Prod. Babyboi)", "Artur Kreem", 138},
{"OLD BLOOD", "OLD BLOOD (Prod. Chivaz)", "", 138},
{"Умы", "Умы (feat. DopeVvs) (Prod. MRKBVNGXR)", "DopeVvs", 125},
{"NO HOOK NO HOES", "NO HOOK NO HOES (feat. Yung Hurn) (Prod. Pretty Scream)", "Yung Hurn", 133},
{"ART HOES", "ART HOES (Prod. DavidRema)", "", 183},
{"Friendly Fire", "Friendly Fire (Prod. Avepack)", "", 122},
{"X2", "X2 (Prod. Gredy)", "", 176},
{"4 шага", "4 шага (Prod. DavidRema)", "", 135},
}
r := musicbrainz.Release{Artist: "Boulevard Depo", Title: "OLD BLOOD"}
files := []File{}
for i, track := range tracks {
artist, uploadedArtist := "Boulevard Depo", "BOULEVARD DEPO"
if track.guest != "" {
artist += " feat. " + track.guest
uploadedArtist += ", " + track.guest
}
r.Tracks = append(r.Tracks, musicbrainz.Track{Title: track.title, Artist: artist, Disc: 1, Position: i + 1, DurationMs: track.seconds * 1000})
f := releaseFile(track.uploaded, i+1, 1, track.seconds*1000+500)
f.Meta.Artist, f.Meta.AlbumArtist, f.Meta.Album = uploadedArtist, "BOULEVARD DEPO", "OLD BLOOD"
files = append([]File{f}, files...)
}
c := matchRelease(files, r)
require.Equal(t, 12, c.Matched)
require.Empty(t, c.Missing)
require.Empty(t, c.Unmatched)
require.False(t, c.Complete, "credit-normalized matches still require review")
require.Equal(t, "medium", c.Confidence)
for _, match := range c.Matches {
require.Equal(t, files[len(files)-1-match.TrackIndex].ID, match.FileID)
}
}
func TestReleaseCreditMatchingRequiresTitleArtistsPositionAndDuration(t *testing.T) {
for _, change := range []string{"none", "different guest", "different title", "remix", "live", "instrumental", "disc", "position", "duration", "unknown duration"} {
t.Run(change, func(t *testing.T) {
f := releaseFile("Song (feat. Guest) (Prod. Producer)", 3, 1, 180500)
f.Meta.Artist = "ARTIST; Guest"
r := musicbrainz.Release{Artist: "Artist", Tracks: []musicbrainz.Track{{Title: "Song", Artist: "Artist feat. Guest", Disc: 1, Position: 3, DurationMs: 180000}}}
switch change {
case "different guest":
f.Meta.Artist = "Artist, Someone Else"
case "different title":
f.Meta.Title = "Another song (Prod. Producer)"
f.Meta.Artist = r.Tracks[0].Artist
case "remix":
f.Meta.Title = "Song (Remix) (feat. Guest)"
f.Meta.Artist = r.Tracks[0].Artist
case "live":
f.Meta.Title = "Song [Live] (feat. Guest)"
f.Meta.Artist = r.Tracks[0].Artist
case "instrumental":
f.Meta.Title = "Song (Instrumental) (feat. Guest)"
f.Meta.Artist = r.Tracks[0].Artist
case "disc":
*f.Meta.Disc = 2
case "position":
*f.Meta.Track = 4
case "duration":
*f.DurationMs = 190000
case "unknown duration":
f.DurationMs = nil
}
c := matchRelease([]File{f}, r)
if change == "none" {
require.Equal(t, 1, c.Matched)
require.Equal(t, "medium", c.Matches[0].Confidence)
} else {
require.Zero(t, c.Matched)
require.Equal(t, []uuid.UUID{f.ID}, c.Unmatched)
}
require.False(t, c.Complete)
})
}
}
+23 -3
View File
@@ -45,8 +45,15 @@ type Service struct {
catalogue ReleaseCatalogue catalogue ReleaseCatalogue
identifier AudioIdentifier identifier AudioIdentifier
encodedFingerprint func(context.Context, string) (string, error) encodedFingerprint func(context.Context, string) (string, error)
artistImages ArtistImages
} }
type ArtistImages interface {
ApplyArtistImage(context.Context, uuid.UUID, []byte) error
}
func (s *Service) SetArtistImages(images ArtistImages) { s.artistImages = images }
type ReleaseCatalogue interface { type ReleaseCatalogue interface {
Search(context.Context, musicbrainz.Query) ([]musicbrainz.Release, error) Search(context.Context, musicbrainz.Query) ([]musicbrainz.Release, error)
Cover(context.Context, musicbrainz.Release) (musicbrainz.Cover, error) Cover(context.Context, musicbrainz.Release) (musicbrainz.Cover, error)
@@ -199,7 +206,7 @@ type CreateFile struct {
} }
// Create создаёт пачку и резервирует место; не поместившиеся и дубли отклоняются с кодом. // Create создаёт пачку и резервирует место; не поместившиеся и дубли отклоняются с кодом.
func (s *Service) Create(ctx context.Context, comment *string, files []CreateFile, cover []byte) (uuid.UUID, []Reserved, error) { func (s *Service) Create(ctx context.Context, comment *string, files []CreateFile, cover []byte, artistImages ...[]byte) (uuid.UUID, []Reserved, error) {
p, err := principal(ctx, auth.PermSuggestTracks) p, err := principal(ctx, auth.PermSuggestTracks)
if err != nil { if err != nil {
return uuid.Nil, nil, err return uuid.Nil, nil, err
@@ -235,12 +242,22 @@ func (s *Service) Create(ctx context.Context, comment *string, files []CreateFil
return uuid.Nil, nil, err return uuid.Nil, nil, err
} }
keepCover := false keepCover := false
var artistImagePath *string
defer func() { defer func() {
if coverPath != nil && !keepCover { if coverPath != nil && !keepCover {
_ = os.Remove(s.stagingAbs(*coverPath)) _ = os.Remove(s.stagingAbs(*coverPath))
} }
if artistImagePath != nil && !keepCover {
_ = os.Remove(s.stagingAbs(*artistImagePath))
}
}() }()
batch, res, err := s.store.CreateBatch(ctx, p.UserID, comment, accepted, st, coverPath) if len(artistImages) > 0 {
artistImagePath, err = s.createCover(artistImages[0])
if err != nil {
return uuid.Nil, nil, err
}
}
batch, res, err := s.store.CreateBatch(ctx, p.UserID, comment, accepted, st, coverPath, artistImagePath)
if err != nil { if err != nil {
return uuid.Nil, nil, err return uuid.Nil, nil, err
} }
@@ -435,7 +452,7 @@ func (s *Service) reviewOne(ctx context.Context, reviewer, id uuid.UUID, approve
return apperr.Conflict(CodeMissingField, "file has no metadata") return apperr.Conflict(CodeMissingField, "file has no metadata")
} }
pl := s.plan(ctx, f, *f.Meta, f.TargetSourceID) pl := s.plan(ctx, f, *f.Meta, f.TargetSourceID)
if pl.Reason != "" { if pl.Reason != "" && pl.RelPath == nil {
return apperr.Conflict(pl.Reason, "cannot place the file into a storage") return apperr.Conflict(pl.Reason, "cannot place the file into a storage")
} }
reason = "" reason = ""
@@ -609,6 +626,9 @@ func (s *Service) removeStaging(ctx context.Context, f File) {
if cover, err := s.store.UnusedBatchCover(ctx, f.BatchID); err == nil && cover != nil { if cover, err := s.store.UnusedBatchCover(ctx, f.BatchID); err == nil && cover != nil {
_ = os.Remove(s.stagingAbs(*cover)) _ = os.Remove(s.stagingAbs(*cover))
} }
if image, err := s.store.UnusedArtistImage(ctx, f.BatchID); err == nil && image != nil {
_ = os.Remove(s.stagingAbs(*image))
}
} }
} }
+128
View File
@@ -0,0 +1,128 @@
package uploads
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"net/http"
"os"
"path"
"path/filepath"
"regexp"
"strings"
"pipu/api/internal/library"
"pipu/api/internal/storageagent"
)
var (
albumFieldRe = regexp.MustCompile(`\{album[:}]`)
artistFieldRe = regexp.MustCompile(`\{(albumartist|artist)[:}]`)
)
// Имена картинок, которые сканер библиотеки уже считает обложкой альбома / фотографией исполнителя.
var (
coverSidecarNames = []string{"cover", "folder", "front", "album", "albumart"}
artistSidecarNames = []string{"artist"}
)
// sidecarDirs — каталоги альбома и исполнителя внутри rel по структуре шаблона записи.
// "" — шаблон не позволяет определить такой каталог (плоская раскладка, необязательные сегменты).
func sidecarDirs(template, rel string) (albumDir, artistDir string) {
tpl, got := strings.Split(template, "/"), strings.Split(rel, "/")
if len(tpl) != len(got) {
return "", ""
}
album, artist := -1, -1
for i, seg := range tpl[:len(tpl)-1] {
if album < 0 && albumFieldRe.MatchString(seg) {
album = i
}
if artist < 0 && artistFieldRe.MatchString(seg) {
artist = i
}
}
if album >= 0 {
albumDir = strings.Join(got[:album+1], "/")
}
if artist >= 0 && artist != album {
artistDir = strings.Join(got[:artist+1], "/")
}
return albumDir, artistDir
}
func imageExt(data []byte) string {
switch http.DetectContentType(data) {
case "image/jpeg":
return ".jpg"
case "image/png":
return ".png"
case "image/webp":
return ".webp"
}
return ""
}
func hasSidecar(dir string, names []string) bool {
entries, err := os.ReadDir(dir)
if err != nil {
return false
}
for _, e := range entries {
if e.IsDir() {
continue
}
ext := strings.ToLower(filepath.Ext(e.Name()))
base := strings.ToLower(strings.TrimSuffix(e.Name(), filepath.Ext(e.Name())))
if ext != ".jpg" && ext != ".jpeg" && ext != ".png" && ext != ".webp" {
continue
}
for _, n := range names {
if base == n {
return true
}
}
}
return false
}
// placeSidecars кладёт cover.* в папку альбома и artist.* в папку исполнителя рядом с импортированным треком.
// Существующие картинки не трогаются; сбой не отменяет импорт трека — картинку добавит следующий файл пачки.
func (s *Service) placeSidecars(ctx context.Context, f File, pl placement, rel string) {
if pl.src.WriteTemplate == nil {
return
}
albumDir, artistDir := sidecarDirs(*pl.src.WriteTemplate, rel)
if albumDir != "" && f.CoverPath != nil && *f.CoverPath != "" {
s.placeSidecar(ctx, pl, *f.CoverPath, albumDir, "cover", coverSidecarNames)
}
if artistDir != "" && f.ArtistImagePath != nil && *f.ArtistImagePath != "" {
s.placeSidecar(ctx, pl, *f.ArtistImagePath, artistDir, "artist", artistSidecarNames)
}
}
func (s *Service) placeSidecar(ctx context.Context, pl placement, staging, dir, name string, existing []string) {
log := s.log.With("source_id", pl.src.ID, "dir", dir, "image", name)
data, err := os.ReadFile(s.stagingAbs(staging))
if err != nil {
log.Warn("upload sidecar: read staging", "err", err)
return
}
ext := imageExt(data)
if ext == "" || hasSidecar(filepath.Join(pl.root, filepath.FromSlash(dir)), existing) {
return
}
sum := sha256.Sum256(data)
req := storageagent.WriteRequest{SourceID: pl.src.ID.String(), RelPath: path.Join(dir, name+ext), StagingPath: staging, SHA256: hex.EncodeToString(sum[:])}
if pl.src.Type == library.SourceLocal {
lp := ""
if pl.src.LocalPath != nil {
lp = *pl.src.LocalPath
}
req.LocalPath = &lp
}
if _, err := s.agent.WriteFile(ctx, req); err != nil {
log.Warn("upload sidecar: write", "err", fmt.Errorf("storage-agent: %w", err))
}
}
@@ -0,0 +1,32 @@
package uploads
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestSidecarDirs(t *testing.T) {
tpl := "{albumartist}/{year} - {album}/{disc:1-}{track:02} - {title}.{ext}"
album, artist := sidecarDirs(tpl, "Artist/2020 - Album/01 - Song.flac")
require.Equal(t, "Artist/2020 - Album", album)
require.Equal(t, "Artist", artist)
album, artist = sidecarDirs("{albumartist}/{album}/{disc:1}/{track:02} - {title}.{ext}", "A/B/1/01 - S.flac")
require.Equal(t, "A/B", album)
require.Equal(t, "A", artist)
album, artist = sidecarDirs("{albumartist} - {album}/{title}.{ext}", "A - B/S.flac")
require.Equal(t, "A - B", album)
require.Empty(t, artist)
album, artist = sidecarDirs("{albumartist} - {title}.{ext}", "A - S.flac")
require.Empty(t, album)
require.Empty(t, artist)
}
func TestPrimaryArtistKeepsFeaturingInFirstArtistFolder(t *testing.T) {
require.Equal(t, "BOULEVARD DEPO", Meta{AlbumArtist: "BOULEVARD DEPO;JEEMBO"}.PrimaryArtist())
require.Equal(t, "BOULEVARD DEPO", Meta{Artist: "BOULEVARD DEPO feat. SP4K"}.PrimaryArtist())
require.Equal(t, "Simon & Garfunkel", Meta{AlbumArtist: "Simon & Garfunkel"}.Fields().AlbumArtist)
}
+22 -5
View File
@@ -98,7 +98,7 @@ type Reserved struct {
// CreateBatch создаёт пачку и резервирует место под файлы: проверка «занято + запрошено ≤ лимит» // CreateBatch создаёт пачку и резервирует место под файлы: проверка «занято + запрошено ≤ лимит»
// под блокировкой строки счётчика (docs/uploads.md#3). Дубли по SHA-256 отклоняются, не занимая места. // под блокировкой строки счётчика (docs/uploads.md#3). Дубли по SHA-256 отклоняются, не занимая места.
func (st *Store) CreateBatch(ctx context.Context, author uuid.UUID, comment *string, files []NewFile, s Settings, coverPath *string) (uuid.UUID, []Reserved, error) { func (st *Store) CreateBatch(ctx context.Context, author uuid.UUID, comment *string, files []NewFile, s Settings, coverPath *string, artistImages ...*string) (uuid.UUID, []Reserved, error) {
tx, err := st.pool.Begin(ctx) tx, err := st.pool.Begin(ctx)
if err != nil { if err != nil {
return uuid.Nil, nil, fmt.Errorf("uploads: create batch: %w", err) return uuid.Nil, nil, fmt.Errorf("uploads: create batch: %w", err)
@@ -116,8 +116,12 @@ func (st *Store) CreateBatch(ctx context.Context, author uuid.UUID, comment *str
} }
batchID := newID() batchID := newID()
if _, err := tx.Exec(ctx, `INSERT INTO upload_batches (id, author_id, comment, cover_path) VALUES ($1, $2, $3, $4)`, var artistImage *string
batchID, author, comment, coverPath); err != nil { if len(artistImages) > 0 {
artistImage = artistImages[0]
}
if _, err := tx.Exec(ctx, `INSERT INTO upload_batches (id, author_id, comment, cover_path, artist_image_path) VALUES ($1, $2, $3, $4, $5)`,
batchID, author, comment, coverPath, artistImage); err != nil {
return uuid.Nil, nil, fmt.Errorf("uploads: insert batch: %w", err) return uuid.Nil, nil, fmt.Errorf("uploads: insert batch: %w", err)
} }
out := make([]Reserved, 0, len(files)) out := make([]Reserved, 0, len(files))
@@ -231,7 +235,7 @@ const fileColumns = `f.id, f.batch_id, f.author_id, u.username, b.comment, f.sta
f.size_bytes, f.received_bytes, f.sha256, coalesce(f.staging_path, ''), f.format, f.codec, f.bitrate, f.sample_rate, f.size_bytes, f.received_bytes, f.sha256, coalesce(f.staging_path, ''), f.format, f.codec, f.bitrate, f.sample_rate,
f.bit_depth, f.duration_ms, f.tags, f.metadata, f.metadata_edited, f.matched_artist_id, f.matched_album_id, f.bit_depth, f.duration_ms, f.tags, f.metadata, f.metadata_edited, f.matched_artist_id, f.matched_album_id,
f.duplicate_of_track_id, f.duplicate_level, f.possible_duplicate, f.quality_upgrade, f.target_source_id, f.target_rel_path, f.duplicate_of_track_id, f.duplicate_level, f.possible_duplicate, f.quality_upgrade, f.target_source_id, f.target_rel_path,
f.status_reason, f.reviewed_by, f.reviewed_at, f.imported_file_id, CASE WHEN f.release_group_id IS NULL THEN b.cover_path ELSE rg.cover_path END, f.created_at, f.updated_at, src.name` f.status_reason, f.reviewed_by, f.reviewed_at, f.imported_file_id, CASE WHEN f.release_group_id IS NULL THEN b.cover_path ELSE rg.cover_path END, f.created_at, f.updated_at, src.name, b.artist_image_path`
const fileFrom = ` FROM upload_files f JOIN upload_batches b ON b.id = f.batch_id JOIN users u ON u.id = f.author_id const fileFrom = ` FROM upload_files f JOIN upload_batches b ON b.id = f.batch_id JOIN users u ON u.id = f.author_id
LEFT JOIN upload_release_groups rg ON rg.id=f.release_group_id LEFT JOIN upload_release_groups rg ON rg.id=f.release_group_id
@@ -246,7 +250,7 @@ func scanFile(row pgx.Row) (File, error) {
&f.ClientRelPath, &f.Size, &f.Received, &f.SHA256, &f.StagingPath, &f.Format, &f.Codec, &f.Bitrate, &f.SampleRate, &f.ClientRelPath, &f.Size, &f.Received, &f.SHA256, &f.StagingPath, &f.Format, &f.Codec, &f.Bitrate, &f.SampleRate,
&f.BitDepth, &f.DurationMs, &tags, &meta, &f.MetadataEdited, &f.MatchedArtistID, &f.MatchedAlbumID, &f.BitDepth, &f.DurationMs, &tags, &meta, &f.MetadataEdited, &f.MatchedArtistID, &f.MatchedAlbumID,
&f.DuplicateOfTrackID, &f.DuplicateLevel, &f.PossibleDuplicate, &f.QualityUpgrade, &f.TargetSourceID, &f.TargetRelPath, &f.DuplicateOfTrackID, &f.DuplicateLevel, &f.PossibleDuplicate, &f.QualityUpgrade, &f.TargetSourceID, &f.TargetRelPath,
&f.StatusReason, &f.ReviewedBy, &f.ReviewedAt, &f.ImportedFileID, &f.CoverPath, &f.CreatedAt, &f.UpdatedAt, &f.TargetSourceName) &f.StatusReason, &f.ReviewedBy, &f.ReviewedAt, &f.ImportedFileID, &f.CoverPath, &f.CreatedAt, &f.UpdatedAt, &f.TargetSourceName, &f.ArtistImagePath)
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
return File{}, ErrNotFound return File{}, ErrNotFound
} }
@@ -353,6 +357,8 @@ func (st *Store) StagingInUse(ctx context.Context) (map[string]bool, error) {
rows, err := st.pool.Query(ctx, `SELECT staging_path FROM upload_files WHERE status::text = ANY($1) AND staging_path IS NOT NULL rows, err := st.pool.Query(ctx, `SELECT staging_path FROM upload_files WHERE status::text = ANY($1) AND staging_path IS NOT NULL
UNION SELECT b.cover_path FROM upload_batches b JOIN upload_files f ON f.batch_id = b.id UNION SELECT b.cover_path FROM upload_batches b JOIN upload_files f ON f.batch_id = b.id
WHERE f.status::text = ANY($1) AND b.cover_path IS NOT NULL WHERE f.status::text = ANY($1) AND b.cover_path IS NOT NULL
UNION SELECT b.artist_image_path FROM upload_batches b JOIN upload_files f ON f.batch_id = b.id
WHERE f.status::text = ANY($1) AND b.artist_image_path IS NOT NULL
UNION SELECT g.cover_path FROM upload_release_groups g JOIN upload_files f ON f.release_group_id=g.id UNION SELECT g.cover_path FROM upload_release_groups g JOIN upload_files f ON f.release_group_id=g.id
WHERE f.status::text = ANY($1) AND g.cover_path IS NOT NULL`, WHERE f.status::text = ANY($1) AND g.cover_path IS NOT NULL`,
occupyingStatuses) occupyingStatuses)
@@ -381,6 +387,17 @@ func (st *Store) UnusedBatchCover(ctx context.Context, batch uuid.UUID) (*string
return cover, err return cover, err
} }
// UnusedArtistImage releases a staged portrait only after every batch file is terminal.
func (st *Store) UnusedArtistImage(ctx context.Context, batch uuid.UUID) (*string, error) {
var image *string
err := st.pool.QueryRow(ctx, `SELECT b.artist_image_path FROM upload_batches b WHERE b.id=$1
AND NOT EXISTS (SELECT 1 FROM upload_files f WHERE f.batch_id=b.id AND f.status::text=ANY($2))`, batch, occupyingStatuses).Scan(&image)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return image, err
}
// Stale — недокачанные файлы старше before. // Stale — недокачанные файлы старше before.
func (st *Store) Stale(ctx context.Context, before time.Time) ([]uuid.UUID, error) { func (st *Store) Stale(ctx context.Context, before time.Time) ([]uuid.UUID, error) {
rows, err := st.pool.Query(ctx, `SELECT id FROM upload_files WHERE status = 'uploading' AND updated_at < $1`, before) rows, err := st.pool.Query(ctx, `SELECT id FROM upload_files WHERE status = 'uploading' AND updated_at < $1`, before)
+11 -1
View File
@@ -298,7 +298,7 @@ func (s *Service) importFile(ctx context.Context, f File) error {
return errors.New("no metadata") return errors.New("no metadata")
} }
pl := s.plan(ctx, f, *f.Meta, f.TargetSourceID) pl := s.plan(ctx, f, *f.Meta, f.TargetSourceID)
if pl.Reason != "" { if pl.Reason != "" && pl.RelPath == nil {
return errors.New(pl.Reason) return errors.New(pl.Reason)
} }
rel := *pl.RelPath rel := *pl.RelPath
@@ -334,6 +334,16 @@ func (s *Service) importFile(ctx context.Context, f File) error {
if err != nil { if err != nil {
return fmt.Errorf("scan: %w", err) return fmt.Errorf("scan: %w", err)
} }
if f.ArtistImagePath != nil && s.artistImages != nil {
image, err := os.ReadFile(s.stagingAbs(*f.ArtistImagePath))
if err != nil {
return fmt.Errorf("read artist image: %w", err)
}
if err := s.artistImages.ApplyArtistImage(ctx, fileID, image); err != nil {
return fmt.Errorf("artist image: %w", err)
}
}
s.placeSidecars(ctx, f, pl, rel)
if err := s.store.CompleteImport(ctx, f.ID, pl.src.ID, rel, fileID); err != nil { if err := s.store.CompleteImport(ctx, f.ID, pl.src.ID, rel, fileID); err != nil {
return err return err
} }
@@ -0,0 +1,5 @@
-- +goose Up
ALTER TABLE upload_batches ADD COLUMN artist_image_path text;
-- +goose Down
ALTER TABLE upload_batches DROP COLUMN artist_image_path;