Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75b0f7bec3 | ||
|
|
3edb1af037 | ||
|
|
0458cbc46c | ||
|
|
f7ffa5d742 | ||
|
|
5d0decbb2a | ||
|
|
3edbf489a3 | ||
|
|
82f3eed9a0 | ||
|
|
db4548b115 | ||
|
|
3309a79a98 | ||
|
|
bbd629dcf8 | ||
|
|
82bf015283 | ||
|
|
fd17c06397 |
No files matched your search
@@ -75,6 +75,9 @@ jobs:
|
||||
# These suites share one database; dbtest also rolls all migrations down
|
||||
# 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/
|
||||
- name: Go — релевантность поиска на Meilisearch
|
||||
working-directory: services/api
|
||||
run: go test -tags integration -count=1 -v ./internal/search/
|
||||
- name: Go — сквозной сценарий пользователя (newApp на Postgres, Redis, Meilisearch)
|
||||
working-directory: services/api
|
||||
run: go test -tags integration -count=1 -v -run TestUserJourney ./cmd/api/
|
||||
|
||||
@@ -2848,6 +2848,10 @@ components:
|
||||
type: object
|
||||
required: [files]
|
||||
properties:
|
||||
artist_image_base64:
|
||||
type: string
|
||||
maxLength: 11184812
|
||||
description: Фото исполнителя JPEG, PNG или WebP, до 8 МБ. После одобрения применяется к исполнителю альбома только при отсутствии аватарки.
|
||||
cover_base64:
|
||||
type: string
|
||||
maxLength: 11184812
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -382,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 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('Совпало 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('checkbox', { name: /Применить названия/ })).not.toBeChecked();
|
||||
});
|
||||
@@ -446,8 +446,8 @@ test('deluxe completeness and release-group artwork are shown before replacing a
|
||||
await page.getByRole('button', { name: 'Найдено изданий: 2', exact: true }).click();
|
||||
const dialog = page.getByRole('dialog', { name: 'Сопоставление релиза' });
|
||||
await dialog.getByRole('combobox', { name: 'Издание' }).selectOption('22222222-2222-4222-8222-222222222222');
|
||||
await expect(dialog.getByText('Альбом · 2/3 · Неполный состав', { exact: true })).toBeVisible();
|
||||
await expect(dialog.getByText('Не загружено', { exact: true })).toBeVisible();
|
||||
await expect(dialog.getByText('Альбом · сопоставлено 2/3 · Неполный состав', { exact: true })).toBeVisible();
|
||||
await expect(dialog.getByText('Файл не сопоставлен', { exact: true })).toBeVisible();
|
||||
await expect(dialog.getByText(/найдены совпадения в библиотеке/)).toBeVisible();
|
||||
await dialog.getByRole('button', { name: 'Показать обложку из каталога', exact: true }).click();
|
||||
await expect(dialog.getByText(/Обложка группы изданий/)).toBeVisible();
|
||||
|
||||
@@ -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 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.state_${report.data?.state ?? 'searching'}`);
|
||||
|
||||
|
||||
@@ -1,6 +1,19 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { needsFormatAttention } from './files';
|
||||
import { artistImageFor, isArtistImage, needsFormatAttention } from './files';
|
||||
|
||||
it('inherits the nearest artist portrait without crossing sibling folders', () => {
|
||||
const root = new File(['portrait'], 'artist.jpg');
|
||||
const local = new File(['local'], 'ARTIST.PNG');
|
||||
const images = new Map([['творожный бизнес', root], ['творожный бизнес/Альбом', local]]);
|
||||
expect(isArtistImage(root.name)).toBe(true);
|
||||
expect(isArtistImage(local.name)).toBe(true);
|
||||
expect(isArtistImage('cover.jpg')).toBe(false);
|
||||
expect(artistImageFor('творожный бизнес/(2026) творог грехов', images)).toBe(root);
|
||||
expect(artistImageFor('творожный бизнес/Альбом/CD1', images)).toBe(local);
|
||||
expect(artistImageFor('другой исполнитель/Альбом', images)).toBeUndefined();
|
||||
expect(artistImageFor('творожный бизнес 2/Альбом', images)).toBeUndefined();
|
||||
});
|
||||
|
||||
describe('needsFormatAttention', () => {
|
||||
it('marks everything except lossless formats', () => {
|
||||
|
||||
@@ -61,6 +61,20 @@ export function isAlbumCover(name: string): boolean {
|
||||
return /^(cover|folder|front)\.(jpe?g|png|webp)$/i.test(name.split(/[/\\]/).pop() ?? name);
|
||||
}
|
||||
|
||||
export function isArtistImage(name: string): boolean {
|
||||
return /^artist\.(jpe?g|png|webp)$/i.test(name.split(/[/\\]/).pop() ?? name);
|
||||
}
|
||||
|
||||
/** The nearest ancestor wins; sibling artist folders must never share portraits. */
|
||||
export function artistImageFor(folder: string, images: ReadonlyMap<string, File>): File | undefined {
|
||||
for (;;) {
|
||||
const image = images.get(folder);
|
||||
if (image) return image;
|
||||
if (!folder) return undefined;
|
||||
folder = albumFolder(folder);
|
||||
}
|
||||
}
|
||||
|
||||
export function albumFolder(relPath: string): string {
|
||||
const path = relPath.replaceAll('\\', '/');
|
||||
const at = path.lastIndexOf('/');
|
||||
|
||||
@@ -52,3 +52,50 @@ it('keeps external and embedded album covers in separate batches without skippin
|
||||
expect(batches[1]!.files.map((f) => f.rel_path)).toEqual(['B/01.flac', 'B/02.flac']);
|
||||
expect(uploader.getState().items.every((f) => f.stage === 'done')).toBe(true);
|
||||
});
|
||||
|
||||
it('splits an artist discography into album batches and inherits only that artist portrait', async () => {
|
||||
vi.stubGlobal('Worker', class extends EventTarget {
|
||||
postMessage({ id }: { id: string }) {
|
||||
queueMicrotask(() => this.dispatchEvent(new MessageEvent('message', { data: { id, type: 'done', sha256: id.padEnd(64, '0') } })));
|
||||
}
|
||||
});
|
||||
const batches: { artist_image_base64?: string; cover_base64?: string; files: { client_id: string; rel_path: string }[] }[] = [];
|
||||
const session = new AuthSession({ baseUrl: 'http://localhost', clientType: 'web', fetch: async (input) => {
|
||||
const req = input as Request;
|
||||
const body = await req.json();
|
||||
const data = req.url.endsWith('/precheck')
|
||||
? { files: body.files.map((f: { client_id: string }) => ({ client_id: f.client_id, result: 'accepted' })) }
|
||||
: (batches.push(body), { batch_id: String(batches.length), files: body.files.map((f: { client_id: string }) => ({ client_id: f.client_id, upload_file_id: f.client_id, tus_url: `/files/${f.client_id}` })) });
|
||||
return new Response(JSON.stringify(data), { headers: { 'Content-Type': 'application/json' } });
|
||||
} });
|
||||
const uploader = new Uploader(session);
|
||||
const first = 'творожный бизнес/(2026) творог грехов - творожный бизнес';
|
||||
const second = 'творожный бизнес/(2025) молчанка - творожный бизнес';
|
||||
uploader.add([
|
||||
{ file: new File(['portrait'], 'artist.jpg'), relPath: 'творожный бизнес/artist.jpg' },
|
||||
{ file: new File(['cover'], 'cover.jpg'), relPath: `${first}/cover.jpg` },
|
||||
{ file: new File(['a'], '02.flac'), relPath: `${first}/02.flac` },
|
||||
{ file: new File(['b'], '03.flac'), relPath: `${first}/03.flac` },
|
||||
{ file: new File(['c'], '02.flac'), relPath: `${second}/02.flac` },
|
||||
{ file: new File(['d'], '02.flac'), relPath: 'Другой исполнитель/Альбом/02.flac' },
|
||||
]);
|
||||
await waitFor(() => expect(uploader.ready).toHaveLength(4));
|
||||
expect(uploader.getState().skipped).toBe(0);
|
||||
await uploader.start('Дискография');
|
||||
expect(batches).toHaveLength(3);
|
||||
const a = batches.find((batch) => batch.files[0]!.rel_path.startsWith(first))!;
|
||||
const b = batches.find((batch) => batch.files[0]!.rel_path.startsWith(second))!;
|
||||
const other = batches.find((batch) => batch.files[0]!.rel_path.startsWith('Другой'))!;
|
||||
expect(a.files).toHaveLength(2);
|
||||
expect(b.files).toHaveLength(1);
|
||||
expect(a.artist_image_base64).toBe(btoa('portrait'));
|
||||
expect(b.artist_image_base64).toBe(btoa('portrait'));
|
||||
expect(a.cover_base64).toBe(btoa('cover'));
|
||||
expect(b.cover_base64).toBeUndefined();
|
||||
expect(other.artist_image_base64).toBeUndefined();
|
||||
uploader.clear();
|
||||
uploader.add([{ file: new File(['new'], '02.flac'), relPath: `${second}/02.flac` }]);
|
||||
await waitFor(() => expect(uploader.ready).toHaveLength(1));
|
||||
await uploader.start('New selection');
|
||||
expect(batches[3]!.artist_image_base64).toBeUndefined();
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { unwrap, type AuthSession } from '@pipu/core';
|
||||
import * as tus from 'tus-js-client';
|
||||
|
||||
import { albumFolder, coverBase64, isAlbumCover, isAudio, type PickedFile } from './files';
|
||||
import { albumFolder, artistImageFor, coverBase64, isAlbumCover, isArtistImage, isAudio, type PickedFile } from './files';
|
||||
import type { HashRequest, HashResponse } from './hash.worker';
|
||||
|
||||
// Загрузка пачки в очередь на добавление (docs/uploads.md#5): SHA-256 в воркере → предпроверка дублей
|
||||
@@ -74,6 +74,7 @@ export class Uploader {
|
||||
private transferDone: (() => void) | null = null;
|
||||
private refreshing: Promise<unknown> | null = null;
|
||||
private folderCovers = new Map<string, File>();
|
||||
private artistImages = new Map<string, File>();
|
||||
|
||||
constructor(private readonly session: AuthSession) {
|
||||
this.restore();
|
||||
@@ -104,12 +105,13 @@ export class Uploader {
|
||||
if (this.state.busy) return;
|
||||
for (const f of files) {
|
||||
if (isAlbumCover(f.file.name)) this.folderCovers.set(albumFolder(f.relPath), f.file);
|
||||
if (isArtistImage(f.file.name)) this.artistImages.set(albumFolder(f.relPath), f.file);
|
||||
}
|
||||
const fresh: UploadItem[] = [];
|
||||
let skipped = 0;
|
||||
for (const f of files) {
|
||||
if (!isAudio(f.file.name)) {
|
||||
if (!isAlbumCover(f.file.name)) skipped++;
|
||||
if (!isAlbumCover(f.file.name) && !isArtistImage(f.file.name)) skipped++;
|
||||
continue;
|
||||
}
|
||||
// Тот же путь и размер уже в списке — не добавляем второй раз.
|
||||
@@ -134,6 +136,7 @@ export class Uploader {
|
||||
if (this.state.busy) return;
|
||||
this.hashQueue = [];
|
||||
this.folderCovers.clear();
|
||||
this.artistImages.clear();
|
||||
this.set({ items: [], skipped: 0, batchId: null, error: null });
|
||||
this.persist();
|
||||
}
|
||||
@@ -236,11 +239,13 @@ export class Uploader {
|
||||
for (const [folder, group] of groups) {
|
||||
try {
|
||||
const cover = this.folderCovers.get(folder);
|
||||
const artistImage = artistImageFor(folder, this.artistImages);
|
||||
const res = unwrap(
|
||||
await this.session.client.POST('/api/v1/uploads', {
|
||||
body: {
|
||||
comment: comment.trim() || undefined,
|
||||
...(cover ? { cover_base64: await coverBase64(cover) } : {}),
|
||||
...(artistImage ? { artist_image_base64: await coverBase64(artistImage) } : {}),
|
||||
files: group.map((it) => ({
|
||||
client_id: it.id,
|
||||
name: it.file?.name ?? it.relPath,
|
||||
|
||||
@@ -46,7 +46,7 @@ export function TrackPage({ id }: { id: string }) {
|
||||
<img src={coverSrc(tr.album.cover_url, 300)} alt="" />
|
||||
</span>
|
||||
)}
|
||||
<Cover album={tr.album} size={280} className={c.heroCover} />
|
||||
<Cover album={tr.album} size="100%" className={c.heroCover} />
|
||||
<div className={c.heroBody}>
|
||||
<span className={c.kind}>{t('player.track_page')}</span>
|
||||
<h1 className={c.heroTitle}>{tr.title}</h1>
|
||||
|
||||
@@ -99,6 +99,11 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.listen .heroCover {
|
||||
max-width: 280px;
|
||||
aspect-ratio: 1;
|
||||
}
|
||||
|
||||
.listen {
|
||||
display: grid;
|
||||
gap: 28px;
|
||||
@@ -118,7 +123,12 @@
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
padding: 20px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.listen .heroCover {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.listen .heroTitle {
|
||||
|
||||
@@ -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 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 chevronDownIcon } from '@material-symbols/svg-400/rounded/keyboard_arrow_down.svg';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
waveIcon,
|
||||
} from '../shared/ui/icons';
|
||||
import { cx, Icon } from '../shared/ui/ui';
|
||||
import { FullPlayer } from './FullPlayer';
|
||||
import s from './PlayerBar.module.css';
|
||||
|
||||
/** Плеер: полоса внизу (`bar`, по умолчанию) или плавающий остров (`island`) — выбор в настройках (docs/design.md). */
|
||||
@@ -43,6 +44,7 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
|
||||
const locked = st.listening?.locked === true;
|
||||
const narrow = useNarrow();
|
||||
const [sheetOpen, setSheetOpen] = useState(false);
|
||||
const [fullOpen, setFullOpen] = useState(false);
|
||||
const [queueOpen, setQueueOpen] = useState(false);
|
||||
const queueButton = useRef<HTMLButtonElement>(null);
|
||||
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')}>
|
||||
<div
|
||||
className={s.now}
|
||||
onClick={
|
||||
narrow
|
||||
? (e) => {
|
||||
if (!(e.target as HTMLElement).closest('a, button')) setSheetOpen(true);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onClick={(e) => {
|
||||
if ((e.target as HTMLElement).closest('a, button')) return;
|
||||
if (narrow) setSheetOpen(true);
|
||||
else setFullOpen(true);
|
||||
}}
|
||||
>
|
||||
{narrow ? (
|
||||
<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>}
|
||||
</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}>
|
||||
<span className={s.title} title={track.title}>{track.title}</span>
|
||||
@@ -242,6 +244,16 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
|
||||
share={share}
|
||||
/>
|
||||
)}
|
||||
{fullOpen && !narrow && (
|
||||
<FullPlayer
|
||||
onClose={() => setFullOpen(false)}
|
||||
onQueue={() => {
|
||||
setFullOpen(false);
|
||||
setQueueOpen(true);
|
||||
}}
|
||||
share={share}
|
||||
/>
|
||||
)}
|
||||
{queueOpen && <QueueDrawer id={queueId} variant={variant} onClose={closeQueue} />}
|
||||
</footer>
|
||||
);
|
||||
|
||||
@@ -4,6 +4,15 @@ import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
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: {
|
||||
target: 'es2022',
|
||||
},
|
||||
|
||||
@@ -24,7 +24,8 @@ server {
|
||||
real_ip_recursive on;
|
||||
|
||||
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;
|
||||
|
||||
|
||||
@@ -199,10 +199,17 @@ Meilisearch, индексы:
|
||||
| `albums` | `title`, `artist_name`, `title_translit` | `year`, `popularity` |
|
||||
| `tracks` | `title`, `artist_names`, `album_title`, `title_translit` | `genre`, `year`, `popularity`, `available` |
|
||||
|
||||
- `*_translit` — транслитерация (кириллица ↔ латиница), плюс поле с **исправлением раскладки**
|
||||
- `*_translit` — варианты транслитерации (кириллица ↔ латиница), включая словарь
|
||||
устоявшихся написаний (`Boulevard` → `бульвар`) наряду с побуквенным вариантом.
|
||||
**Исправление раскладки**
|
||||
(`ghbdtn` → `привет`): при пустом результате клиентский запрос повторяется в альтернативной раскладке
|
||||
на стороне `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 мс,
|
||||
отменяет предыдущий запрос (`AbortController`).
|
||||
- Персонализация: `suggest` берёт из Meilisearch втрое больше кандидатов и переранжирует их в `api`
|
||||
|
||||
@@ -94,6 +94,20 @@
|
||||
Обложки больше 8 МБ клиент автоматически уменьшает до JPEG с длинной стороной не более 2048 пикселей
|
||||
перед base64-кодированием; это относится и к отдельным файлам, и к встроенной обложке, и к правке пачки.
|
||||
Меньшие картинки остаются без изменений. Код 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`)
|
||||
|
||||
@@ -168,6 +182,9 @@
|
||||
предпросмотр рассчитывается при чтении очереди по текущему шаблону — тем же планировщиком, что и импорт.
|
||||
Сохранение шаблона обновляет кеш очереди. Уже импортированные файлы сохраняют фактический путь.
|
||||
Если рассчитать путь нельзя, показаны причина и действие для исправления вместо старого пути.
|
||||
Отсутствующий год отмечается предупреждением и не блокирует одобрение или импорт:
|
||||
`{year}` опускается вместе со скобками или разделителем, необязательный фрагмент с годом опускается.
|
||||
В теги неизвестный год не записывается.
|
||||
- Прослушивание staging выдаёт сам API с поддержкой Range: приватные файлы tus (`0640`, каталоги `0750`)
|
||||
недоступны пользователю nginx. Выдача не зависит от `X_ACCEL`; права существующих файлов менять не нужно.
|
||||
Аудио и его метаданные загружаются только после Play (`preload="none"`); раскрытие пачки не запускает запросы к файлам.
|
||||
@@ -195,6 +212,14 @@
|
||||
совпадения отмечаются отдельно. Для отсутствующих дорожек отдельно показаны совпадения с доступной
|
||||
библиотекой по тегам и длительности; они не делают загруженную пачку полной.
|
||||
|
||||
Число файлов в пачке и число сопоставленных дорожек — разные показатели. «Файл не сопоставлен»
|
||||
не доказывает отсутствие дорожки: она может быть среди файлов с отличающимися тегами. Для
|
||||
предположительного совпадения допускается разная запись состава исполнителей (запятая, `feat.`, `;`)
|
||||
и пометки `feat.` / `Prod.` в конце названия при совпадении номера, диска и длительности с допуском
|
||||
2 секунды. Пометки версий (`live`, `remix`, `instrumental`) сохраняются. Такие совпадения требуют
|
||||
проверки и сами по себе не подтверждают «Полный состав». При изменении правил сопоставления
|
||||
сохранённые результаты поиска пересчитываются при открытии пачки.
|
||||
|
||||
Поиск сам не меняет теги, путь или обложку. В диалоге можно отдельно применить метаданные к совпавшим
|
||||
редактируемым файлам и/или обложку. Обложка загружается из Cover Art Archive только после запроса
|
||||
предпросмотра. При отсутствии обложки конкретного издания возможна обложка группы изданий с явной пометкой.
|
||||
|
||||
@@ -2295,6 +2295,8 @@ export interface components {
|
||||
}[];
|
||||
};
|
||||
UploadBatchRequest: {
|
||||
/** @description Фото исполнителя JPEG, PNG или WebP, до 8 МБ. После одобрения применяется к исполнителю альбома только при отсутствии аватарки. */
|
||||
artist_image_base64?: string;
|
||||
/** @description Обложка альбома JPEG, PNG или WebP, до 8 МБ после декодирования. Применяется ко всем файлам пачки. */
|
||||
cover_base64?: string;
|
||||
comment?: string;
|
||||
|
||||
@@ -42,12 +42,13 @@
|
||||
"confidence_high": "high",
|
||||
"confidence_medium": "medium",
|
||||
"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.",
|
||||
"uploaded": "Your upload",
|
||||
"catalogue": "Selected edition",
|
||||
"matched": "File matched",
|
||||
"missing": "Not uploaded",
|
||||
"matched_count": "matched {{matched}}/{{total}}",
|
||||
"missing": "No matched file",
|
||||
"tentative": "Tentative match — check manually",
|
||||
"apply_metadata": "Apply titles, artists, album, year and numbering to matched files",
|
||||
"preview_cover": "Preview catalogue cover",
|
||||
|
||||
@@ -122,7 +122,8 @@
|
||||
"source_wave_seed": "Artist wave · {{label}}",
|
||||
"queue_wave": "The wave never ends: next tracks are picked as you listen",
|
||||
"now_playing": "Now playing",
|
||||
"expand": "Open player"
|
||||
"expand": "Open player",
|
||||
"more": "More"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
|
||||
@@ -104,6 +104,7 @@
|
||||
"no_target": "No storage accepts uploads (or several do and none is the default)",
|
||||
"no_template": "The storage has no path template",
|
||||
"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",
|
||||
"size_mismatch": "The file size does not match the declared one",
|
||||
"hash_mismatch": "The file was corrupted in transit (SHA-256 mismatch)",
|
||||
|
||||
@@ -42,12 +42,13 @@
|
||||
"confidence_high": "высокая",
|
||||
"confidence_medium": "средняя",
|
||||
"confidence_low": "низкая",
|
||||
"coverage": "Совпало {{matched}} из {{total}} дорожек · Не хватает: {{missing}} · Не сопоставлено файлов: {{extra}}",
|
||||
"coverage": "Сопоставлено {{matched}} из {{total}} дорожек · Дорожек без сопоставления: {{missing}} · Не сопоставлено файлов: {{extra}}",
|
||||
"existing": "Для {{count}} отсутствующих дорожек найдены совпадения в библиотеке по тегам и длительности. Это не делает загруженную пачку полной.",
|
||||
"uploaded": "Ваша загрузка",
|
||||
"catalogue": "В выбранном издании",
|
||||
"matched": "Файл сопоставлен",
|
||||
"missing": "Не загружено",
|
||||
"matched_count": "сопоставлено {{matched}}/{{total}}",
|
||||
"missing": "Файл не сопоставлен",
|
||||
"tentative": "Предположительное совпадение — проверьте вручную",
|
||||
"apply_metadata": "Применить названия, исполнителей, альбом, год и нумерацию к совпавшим файлам",
|
||||
"preview_cover": "Показать обложку из каталога",
|
||||
|
||||
@@ -122,7 +122,8 @@
|
||||
"source_wave_seed": "Волна по исполнителю · {{label}}",
|
||||
"queue_wave": "Волна бесконечна: следующие треки подбираются по ходу прослушивания",
|
||||
"now_playing": "Сейчас играет",
|
||||
"expand": "Открыть плеер"
|
||||
"expand": "Открыть плеер",
|
||||
"more": "Ещё"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Настройки",
|
||||
|
||||
@@ -104,6 +104,7 @@
|
||||
"no_target": "Нет хранилища, принимающего загрузки (или их несколько и не выбрано по умолчанию)",
|
||||
"no_template": "У хранилища не задан шаблон пути",
|
||||
"missing_field": "Не хватает метаданных для пути в хранилище",
|
||||
"missing_year": "Не указан год — проверьте метаданные. Год пропущен в пути",
|
||||
"offset_mismatch": "Загрузка рассинхронизировалась — повторите",
|
||||
"size_mismatch": "Размер файла не совпал с заявленным",
|
||||
"hash_mismatch": "Файл повредился при загрузке (не совпал SHA-256)",
|
||||
|
||||
@@ -26,6 +26,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
@@ -120,6 +121,7 @@ func TestUserJourney(t *testing.T) {
|
||||
if dbURL == "" {
|
||||
t.Skip("TEST_DATABASE_URL is not set")
|
||||
}
|
||||
dbURL = userJourneyDatabase(t, dbURL)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
@@ -133,6 +135,7 @@ func TestUserJourney(t *testing.T) {
|
||||
t.Setenv("DATABASE_URL", dbURL)
|
||||
t.Setenv("REDIS_URL", os.Getenv("TEST_REDIS_URL"))
|
||||
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
|
||||
cfg, err := config.Load[config.API]()
|
||||
require.NoError(t, err)
|
||||
@@ -172,6 +175,7 @@ func TestUserJourney(t *testing.T) {
|
||||
BootstrapRequired bool `json:"bootstrap_required"`
|
||||
}
|
||||
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 {
|
||||
AccessToken string `json:"access_token"`
|
||||
User struct {
|
||||
@@ -215,7 +219,10 @@ func TestUserJourney(t *testing.T) {
|
||||
}
|
||||
eventually(t, "search index", func() bool {
|
||||
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. Альбом, исполнитель, популярные треки.
|
||||
@@ -376,3 +383,29 @@ func TestUserJourney(t *testing.T) {
|
||||
})
|
||||
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()
|
||||
}
|
||||
@@ -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,
|
||||
library.FPCalc{Bin: cfg.FPCalcPath, Timeout: time.Minute},
|
||||
uploads.Config{StagingDir: cfg.UploadsDir, FFmpeg: cfg.FFmpegPath}, log)
|
||||
upSvc.SetArtistImages(library.NewArtistImages(libStore, covers))
|
||||
if cfg.MusicBrainzEnabled {
|
||||
upSvc.SetReleaseCatalogue(musicbrainz.New(upStore, "PipuMusic/"+buildinfo.Version+" (https://gitea.hsrv.site/mrleo1nid/pipu-music)"))
|
||||
if strings.TrimSpace(cfg.AcoustIDAPIKey) != "" {
|
||||
|
||||
@@ -1762,7 +1762,9 @@ type UploadBatchPatch struct {
|
||||
|
||||
// UploadBatchRequest defines model for UploadBatchRequest.
|
||||
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 *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
|
||||
var swaggerSpec = []string{
|
||||
|
||||
"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",
|
||||
@@ -15135,17 +15137,17 @@ var swaggerSpec = []string{
|
||||
"ZCKvNvMGQ8+TWN6mlSo8pWxGW5Opym2pLkmVsMpkCttWy5eWy3Oat98kel0Uhv0JQmSnf9uJCbbr7z11",
|
||||
"4ZX7wIwagxU70K/EahPWcI0WrrKGtnDh6jFFTLk4a5b+p0vBOzcMaXoxjTZo1IFjc7zDpJKKT7CtXr7q",
|
||||
"CMatCNe9wJ53k2dH1dFPSZfJdUgh+kp4JSPIUhmnB+ledpf88McrvSjsU7JAfo65+T88b5Mf/rjk3aY+",
|
||||
"+ekPz8EjqoaejHmJFblBx0rdUOqvVUKYyHiN/rhhF/4dD+Hf8RBOlvvkdZioqUeH8ZbgsdOFUJCzOxmA",
|
||||
"+ekPz8EjqoaejHmJFblBx0rdUOqvVUKYyHiN/rhhF/4DD+E/8BBOlvvkdZioqUeH8ZbgsdOFUJCzOxmA",
|
||||
"gvlk64k0BmvbWllA++EvPFvRGW0Q7MUXtFuiCAB+Zprl+8Fq6EQiymVyvVQpslOq4i4C46SP0+fAt1h1",
|
||||
"CKWJWNiWPkaWzT4TH4yAg9nHmASfbWG6O9YZQqL7oyba6cl08pOUa6i/nLAsv0TVXuh1a49mvudX8Pk7",
|
||||
"7dY6DSJatdVTWrhhzU0Wlr+ilvXlv9uKA5ovr/oEXJHUK5yDddpZj8LhwMCr/x0zzbPPsVwWlZPHvNhS",
|
||||
"7dY6DSJatdVTWrhhzU0Wlr+ilvXlv9uKA5ovr/oEXJHUK5yDddpZj8LhwMCr/wMzzbPPsVwWlZPHvNhS",
|
||||
"MOAXTIyzr3ck0hpkKeGHmJr6In0Bt953+S1IsvsST+0eYeye7uKV9zg9Yt8QqPfIHkDtqEzlxFkcZZ/B",
|
||||
"xfAZ3rrw4+zhvAIUNQTL/dwrrXbr3Cudc6+32q3zi53z7L+vLHZeYf+9sNi5wP776mLnVfbf1xY7Ax9j",
|
||||
"s7UHSlKtIxASCqT7L7ieF+mRuMUgjwtOtbbIItWO0mcK1XBREXXc9wN/04a+kOdTuDqX1554NbGuzJjr",
|
||||
"s7UHSlKtIxASCqT7r7ieF+mRuMUgjwtOtbbIItWO0mcK1XBREXXc9wN/04a+kOdTuDqX1554NbGuzJjr",
|
||||
"4P9QvT99vFnWKP8HelCbUMxwBmuV13x2r9Rl/SkEqDwQS07SW3KShEaGS8uLOzRgq7CqQev0tjHjq8ZI",
|
||||
"hN+11fdb5sbmZQ4qD/DLCRIolZXW7HQ5zoxDVUxyKU+DPUmslb/GFsCbJJ1ERFbz39RP32rizZLa7VbM",
|
||||
"K+tOgr3YfMNkIq0tJMIdH7XHUTxeLPfh4ROIg6pRm5g6UbcHV73vUxE062H6RotjPRhdyGtR2K+BgtLl",
|
||||
"7YcfXn1z4y+ZgXGPpPtwQ+2lh9m9dISwDftM/88eMBtiF0oKmWC+x585ZEI6u5eOmWSrdZJ2oexmMgM2",
|
||||
"7YcfXn1z4y+YgXGPpPtwQ+2lh9m9dISwDftM/88eMBtiF0oKmWC+x585ZEI6u5eOmWSrdZJ2oexmMgM2",
|
||||
"XFvzLbqtTKCxLfdEfkuLqprvHVgUceJECfdVdSj4suOb3gC2kN4U2VlwEcVDWKHv3QQLI+D/cL1YfiT+",
|
||||
"bdpYxiqdmMZQyXzsAKwoRsw9oequ2A7BqtO9eSUPApq81mUPgbNK/Qqd/5MpLdE6YYsvUAlmNnO7FQlg",
|
||||
"dLxpXt7aG72WMfF8T6SPFCUlvKLjBS7eu6aU+wF1kk6f50XLyO7aGuOKAOWSb+TFuDdcW/Mth3LWwYDa",
|
||||
@@ -15174,7 +15176,7 @@ var swaggerSpec = []string{
|
||||
"SPxbPAtV5YHje79kfxgrgfHZ5k01FBpBYsCkFmfY4UNWOjhtVfn+Tep2XCPi2JuchT0akw0v9hLqkjgk",
|
||||
"a06kQosVWHq+Hhwth5wBbtGWrXOoYDVtlSqJ2/yc5ssUvKRdvQICwO53YsLAaigIpi65fMAIwJRJkXIP",
|
||||
"9QUk+1U6Sp+lz7OHbZKOULfVDTOoWgA8OkiuT58yjTk9lEjbe4ZkeZP9sAxJP4ivVwlcMYUGTW1jFnfj",
|
||||
"Nl3ltO0oAjS8DjaFLVP4H3IvFZiyolyDmRmYwwnJR8zK2E/32gQKEaBaAWyQcbaV3Qe/LKP2AdYqMGuZ",
|
||||
"Nl3ltO0oAjS8DjaFLVP473MvFZiyolyDmRmYwwnJR8zK2E/32gQKEaBaAWyQcbaV3Qe/LKP2AdYqMGuZ",
|
||||
"J4aCxXEIHsTnYIh80UT6HcshYnbXfQrWyz7jkKP0MbQ+fsJb28BCRqojmltp6TM8AU3RSaowRuyAHY3R",
|
||||
"N4zWwCwCpVUAIJzEZZaqdyst08D9sWLDNq7pngAGVeSEKCHgCw1SaqvQSs1kjTZodAUgxwyxnyCJvEnA",
|
||||
"Z5S3WfHoIwr3SKcqvd8YLrQsWUxSC+SVBqlbvKXvA/dodzYcf2jx3Kyt0S7TG83fBhuNhI8sNSgDDVCz",
|
||||
@@ -15182,11 +15184,11 @@ var swaggerSpec = []string{
|
||||
"1234khmOt4Lv2i0YotIVXH/L1MTsJAfzsezLsLgTBl7H2JNrw9iQa1XA/2vH0ahA7WDgbgevagUqHTuS",
|
||||
"wD1/T5Rv7GWfZ1/xmxu9zXvZ3ewzpjbJzGORjKwVLCrRNlTUCp3lLNWJfS/ocJz9TrGVUCkUCXNOD3gZ",
|
||||
"GQYk+Y+uhC4l3KN8lscfn3NnKrhe2xC65As9kq5QduuDI3QsK3LU2lbwK/GdOLe4+PrrxnZvPHUi37OB",
|
||||
"Nxie7Q9jr2ss7BdtzTpY2hhrP2b6O5wVxkLNa/QU3rG1toMsMLVul5xZpv2/pdGcuszW4vy5+cXa4B/P",
|
||||
"yMj7OakMbFpjxV4b2dl+gP46XDXYELrhbbnra+Fg0MwRrkGzBeqH6zZwWcW6NGLPajI5t9PiYbdLKRpa",
|
||||
"Nxie7Q9jr2ss7BdtzTpY2hhrP2b6O5wVxkLNa/QU3rG1toMsMLVul5xZpv2/odGcuszW4vy5+cXa4B/P",
|
||||
"yMj7OakMbFpjxV4b2dl+gP4qXDXYELrhbbnra+Fg0MwRrkGzBeqH6zZwWcW6NGLPajI5t9PiYbdLKRpa",
|
||||
"vEHiDWs7nIbONA7igqNW2oD8tbgwO9mX5aViSjWv62lhSo0z5tDxMlMt2e7CTyfJErI1VFOXYQJAnRSx",
|
||||
"T6VKIR2vRtcWQ9lniQlIdsiGnhMEmMkvY7VOQsHNlx/CvuMFRkaitweoxXfDft9LlAqh1sXWf/475+za",
|
||||
"4tnXb3xyYbH92oU7f9Gq66Q7QcM4IyHQXynWVEeU2N45Jl9O2e/OH6nqZfcLFCzNUr5yWWQG/cCV2VFK",
|
||||
"T6VKIR2vRtcWQ9lniQlIdsiGnhMEmMkvY7VOQsHNlx/CvuMFRkaitweoxXfDft9LlAqh1sXWf/lb5+za",
|
||||
"4tnXb3xyYbH92oU7f96q66Q7QcM4IyHQXynWVEeU2N45Jl9O2e/OH6nqZfcLFCzNUr5yWWQG/cCV2VFK",
|
||||
"T8puJT87gvdVkIA/UUUBFH+dkqiSLr6WpCQT+T0K/bKGAXRlhn+pT8bUp918wBv2xqITFaRbDpPysvJ2",
|
||||
"4+YWtqVdZJwSjYpkLVDIzKpxbIYQOC7Mip4nU7zdIU5+AAXL3BkzUpOxK32bYitNwes9AL1AzWmcPtXV",
|
||||
"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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,7 @@ type TrackRow struct {
|
||||
// ArtistRow — исполнитель со счётчиками.
|
||||
type ArtistRow struct {
|
||||
ImageURL *string
|
||||
ImagePath *string
|
||||
ID uuid.UUID
|
||||
Name string
|
||||
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) {
|
||||
var a ArtistRow
|
||||
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(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) {
|
||||
return ArtistRow{}, ErrNotFound
|
||||
}
|
||||
|
||||
@@ -485,7 +485,11 @@ func (h *Handler) GetArtist(ctx context.Context, req gen.GetArtistRequestObject)
|
||||
if err != nil {
|
||||
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.
|
||||
|
||||
@@ -254,6 +254,65 @@ func (t Template) Render(in TemplateInput) (string, error) {
|
||||
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) {
|
||||
var b strings.Builder
|
||||
for _, n := range nodes {
|
||||
|
||||
@@ -7,6 +7,41 @@ import (
|
||||
|
||||
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 {
|
||||
return TemplateInput{Fields: f, DiscCount: discs, Ext: ext}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ type indexSpec struct {
|
||||
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{
|
||||
{
|
||||
@@ -43,7 +43,7 @@ var specs = []indexSpec{
|
||||
if err := r.Scan(&id, &name); err != nil {
|
||||
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{
|
||||
"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
|
||||
},
|
||||
},
|
||||
@@ -97,12 +97,12 @@ var specs = []indexSpec{
|
||||
}
|
||||
translit := make([]string, 0, len(artists))
|
||||
for _, a := range artists {
|
||||
translit = append(translit, Translit(a))
|
||||
translit = append(translit, TranslitVariants(a)...)
|
||||
}
|
||||
return map[string]any{
|
||||
"id": id, "title": title, "album_title": album, "year": year, "artist_names": artists,
|
||||
"genres": genres, "available": available, "popularity": 0,
|
||||
"title_translit": Translit(title), "artist_translit": translit,
|
||||
"title_translit": TranslitVariants(title), "artist_translit": translit,
|
||||
}, nil
|
||||
},
|
||||
},
|
||||
|
||||
@@ -141,13 +141,15 @@ type MultiQuery struct {
|
||||
Q string `json:"q"`
|
||||
Limit int `json:"limit"`
|
||||
Offset int `json:"offset,omitempty"`
|
||||
MatchingStrategy string `json:"matchingStrategy,omitempty"`
|
||||
AttributesToRetrieve []string `json:"attributesToRetrieve"`
|
||||
}
|
||||
|
||||
// MultiResult — ответ на один запрос: id найденных документов и оценка общего числа.
|
||||
type MultiResult struct {
|
||||
IDs []string
|
||||
Total int
|
||||
IDs []string
|
||||
Total int
|
||||
ExactIDs []string
|
||||
}
|
||||
|
||||
// MultiSearch выполняет несколько запросов за один вызов.
|
||||
@@ -155,7 +157,9 @@ func (m *Meili) MultiSearch(ctx context.Context, queries []MultiQuery) ([]MultiR
|
||||
var res struct {
|
||||
Results []struct {
|
||||
Hits []struct {
|
||||
ID string `json:"id"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
NameTranslit any `json:"name_translit"`
|
||||
} `json:"hits"`
|
||||
EstimatedTotalHits int `json:"estimatedTotalHits"`
|
||||
} `json:"results"`
|
||||
@@ -167,8 +171,32 @@ func (m *Meili) MultiSearch(ctx context.Context, queries []MultiQuery) ([]MultiR
|
||||
for i, r := range res.Results {
|
||||
for _, h := range r.Hits {
|
||||
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
|
||||
}
|
||||
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 (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"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) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
|
||||
@@ -12,6 +12,24 @@ import (
|
||||
// suggestPool — во сколько раз больше кандидатов берётся из Meilisearch для переранжирования подсказок.
|
||||
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 — насколько пользователь «знает» исполнителей, альбомы и треки: прослушивания, доигрывания,
|
||||
// лайки и дизлайки. Отрицательное — не любит. Нужен для персональных подсказок поиска.
|
||||
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")
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,7 @@ type limits struct{ artists, albums, tracks, offset int }
|
||||
|
||||
type found struct {
|
||||
artists, albums, tracks []string
|
||||
exactArtists []string
|
||||
totals [3]int
|
||||
}
|
||||
|
||||
@@ -50,7 +51,11 @@ func (h *Handler) query(ctx context.Context, q string, l limits) (found, error)
|
||||
var queries []MultiQuery
|
||||
add := func(index string, n int) {
|
||||
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)
|
||||
@@ -71,6 +76,7 @@ func (h *Handler) query(ctx context.Context, q string, l limits) (found, error)
|
||||
switch k {
|
||||
case 0:
|
||||
f.artists = res[i].IDs
|
||||
f.exactArtists = res[i].ExactIDs
|
||||
case 1:
|
||||
f.albums = res[i].IDs
|
||||
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)
|
||||
}
|
||||
}
|
||||
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.tracks = personalize(f.tracks, aff, want.tracks)
|
||||
return f
|
||||
|
||||
@@ -6,6 +6,36 @@ import (
|
||||
"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»).
|
||||
var cyrToLat = map[rune]string{
|
||||
'а': "a", 'б': "b", 'в': "v", 'г': "g", 'д': "d", 'е': "e", 'ё': "e", 'ж': "zh", 'з': "z", 'и': "i",
|
||||
|
||||
@@ -16,6 +16,23 @@ func TestTranslit(t *testing.T) {
|
||||
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) {
|
||||
assert.Equal(t, "привет", SwitchLayout("ghbdtn"))
|
||||
assert.Equal(t, "hello", SwitchLayout("руддщ"))
|
||||
|
||||
@@ -143,8 +143,8 @@ func (a *agent) writeFile(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// 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) {
|
||||
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)
|
||||
}
|
||||
@@ -165,7 +165,15 @@ func (h *Handler) CreateUploadBatch(ctx context.Context, req gen.CreateUploadBat
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -57,6 +57,7 @@ const (
|
||||
CodeSizeMismatch = "uploads.size_mismatch"
|
||||
CodeHashMismatch = "uploads.hash_mismatch"
|
||||
CodeMissingField = "uploads.missing_field"
|
||||
CodeMissingYear = "uploads.missing_year"
|
||||
CodeImportFailed = "uploads.import_failed"
|
||||
CodeTemplate = "uploads.no_template"
|
||||
)
|
||||
@@ -80,6 +81,17 @@ type Meta struct {
|
||||
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 — значения для шаблона пути.
|
||||
func (m Meta) Fields() meta.Fields {
|
||||
num := func(p *int) string {
|
||||
@@ -89,7 +101,7 @@ func (m Meta) Fields() meta.Fields {
|
||||
return strconv.Itoa(*p)
|
||||
}
|
||||
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),
|
||||
}
|
||||
}
|
||||
@@ -148,6 +160,7 @@ type File struct {
|
||||
ReviewedAt *time.Time
|
||||
ImportedFileID *uuid.UUID
|
||||
CoverPath *string
|
||||
ArtistImagePath *string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ type PlacementPlanner struct {
|
||||
|
||||
// --- Раскладка в хранилище ---
|
||||
|
||||
// placement — куда ляжет файл; Reason — почему не получится (код), путь тогда nil.
|
||||
// placement — куда ляжет файл; Reason при ненулевом пути — предупреждение.
|
||||
type placement struct {
|
||||
SourceID *uuid.UUID
|
||||
RelPath *string
|
||||
@@ -123,7 +123,7 @@ func (s *PlacementPlanner) plan(ctx context.Context, f File, m Meta, chosen *uui
|
||||
pl placement
|
||||
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
|
||||
}
|
||||
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 {
|
||||
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 {
|
||||
var mf *meta.MissingFieldError
|
||||
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)
|
||||
pl.RelPath, pl.root = &rel, root
|
||||
if m.Year == nil || *m.Year <= 0 {
|
||||
pl.Reason = CodeMissingYear
|
||||
}
|
||||
return pl
|
||||
}
|
||||
@@ -33,6 +33,28 @@ func (previewStore) BatchMaxDisc(context.Context, uuid.UUID, string) (int, error
|
||||
|
||||
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 TestPreviewUsesCurrentImportTemplate(t *testing.T) {
|
||||
@@ -42,8 +64,9 @@ func TestPreviewUsesCurrentImportTemplate(t *testing.T) {
|
||||
AcceptsUploads: true, WriteTemplate: nullable("{albumartist}/{album}/{title}.{ext}")}}
|
||||
planner := &PlacementPlanner{store: previewStore{}, lib: catalog, roots: previewRoot(root)}
|
||||
track := 3
|
||||
year := 2026
|
||||
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)}
|
||||
planner.previewPlacement(ctx, &f)
|
||||
require.Equal(t, "Artist/Album/Song.flac", *f.TargetRelPath)
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"math"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
@@ -70,7 +71,11 @@ func releaseHash(files []File) string {
|
||||
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() })
|
||||
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)
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
@@ -111,6 +116,39 @@ func batchReleaseQuery(files []File) (musicbrainz.Query, bool) {
|
||||
}
|
||||
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 {
|
||||
if f.Meta == nil {
|
||||
return 0
|
||||
@@ -137,8 +175,9 @@ func trackScore(f File, t musicbrainz.Track, albumArtist string) int {
|
||||
if title && (t.DurationMs == 0 || f.DurationMs == nil) {
|
||||
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) {
|
||||
return 60
|
||||
if delta <= 2000 && m.Track != nil && *m.Track == t.Position && (m.Disc == nil && t.Disc == 1 || m.Disc != nil && *m.Disc == t.Disc) &&
|
||||
releaseTitleKey(m.Title) != "" && releaseTitleKey(m.Title) == releaseTitleKey(t.Title) && sameReleaseArtists(m.Artist, t.Artist) {
|
||||
return 75
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ func TestReleaseMatchingDoesNotClaimAmbiguousOrWeakMatchesAreComplete(t *testing
|
||||
f := releaseFile("Untitled", 1, 1, 12000)
|
||||
c = matchRelease([]File{f}, r)
|
||||
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.Artist = "Someone Else"
|
||||
f.Meta.AlbumArtist = "Someone Else"
|
||||
@@ -93,3 +93,88 @@ func TestReleaseFingerprintInvalidatesEditsButNotApproval(t *testing.T) {
|
||||
files[0].Status = StatusWithdrawn
|
||||
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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -45,8 +45,15 @@ type Service struct {
|
||||
catalogue ReleaseCatalogue
|
||||
identifier AudioIdentifier
|
||||
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 {
|
||||
Search(context.Context, musicbrainz.Query) ([]musicbrainz.Release, error)
|
||||
Cover(context.Context, musicbrainz.Release) (musicbrainz.Cover, error)
|
||||
@@ -199,7 +206,7 @@ type CreateFile struct {
|
||||
}
|
||||
|
||||
// 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)
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
keepCover := false
|
||||
var artistImagePath *string
|
||||
defer func() {
|
||||
if coverPath != nil && !keepCover {
|
||||
_ = 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 {
|
||||
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")
|
||||
}
|
||||
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")
|
||||
}
|
||||
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 {
|
||||
_ = os.Remove(s.stagingAbs(*cover))
|
||||
}
|
||||
if image, err := s.store.UnusedArtistImage(ctx, f.BatchID); err == nil && image != nil {
|
||||
_ = os.Remove(s.stagingAbs(*image))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -98,7 +98,7 @@ type Reserved struct {
|
||||
|
||||
// CreateBatch создаёт пачку и резервирует место под файлы: проверка «занято + запрошено ≤ лимит»
|
||||
// под блокировкой строки счётчика (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)
|
||||
if err != nil {
|
||||
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()
|
||||
if _, err := tx.Exec(ctx, `INSERT INTO upload_batches (id, author_id, comment, cover_path) VALUES ($1, $2, $3, $4)`,
|
||||
batchID, author, comment, coverPath); err != nil {
|
||||
var artistImage *string
|
||||
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)
|
||||
}
|
||||
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.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.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
|
||||
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.BitDepth, &f.DurationMs, &tags, &meta, &f.MetadataEdited, &f.MatchedArtistID, &f.MatchedAlbumID,
|
||||
&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) {
|
||||
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
|
||||
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
|
||||
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
|
||||
WHERE f.status::text = ANY($1) AND g.cover_path IS NOT NULL`,
|
||||
occupyingStatuses)
|
||||
@@ -381,6 +387,17 @@ func (st *Store) UnusedBatchCover(ctx context.Context, batch uuid.UUID) (*string
|
||||
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.
|
||||
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)
|
||||
|
||||
@@ -298,7 +298,7 @@ func (s *Service) importFile(ctx context.Context, f File) error {
|
||||
return errors.New("no metadata")
|
||||
}
|
||||
pl := s.plan(ctx, f, *f.Meta, f.TargetSourceID)
|
||||
if pl.Reason != "" {
|
||||
if pl.Reason != "" && pl.RelPath == nil {
|
||||
return errors.New(pl.Reason)
|
||||
}
|
||||
rel := *pl.RelPath
|
||||
@@ -334,6 +334,16 @@ func (s *Service) importFile(ctx context.Context, f File) error {
|
||||
if err != nil {
|
||||
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 {
|
||||
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;
|
||||
Reference in new issue
Block a user