feat(uploads): match release groups with MusicBrainz and AcoustID
ci / module boundaries / boundaries (push) Successful in 10s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m30s
ci / api / api (push) Successful in 58s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m1s
ci / db / db (push) Failing after 25s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m36s
ci / ts / ts (packages + web) (push) Failing after 6m3s
ci / module boundaries / boundaries (push) Successful in 10s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m30s
ci / api / api (push) Successful in 58s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m1s
ci / db / db (push) Failing after 25s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m36s
ci / ts / ts (packages + web) (push) Failing after 6m3s
This commit is contained in:
1 parent
1e3a149aef
commit
39bf7d4b70
45 files changed
+4792
-417
No files matched your search
@@ -10,14 +10,14 @@ function previewWav(seconds = 2) {
|
||||
return wav;
|
||||
}
|
||||
|
||||
async function setup(page: Page, scenario: 'queue' | 'template' | 'mine') {
|
||||
async function setup(page: Page, scenario: 'queue' | 'template' | 'mine' | 'release' | 'mixed', audioAvailable = true) {
|
||||
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(async (scenario) => {
|
||||
await page.evaluate(async ({ scenario, audioAvailable }) => {
|
||||
const modulePath = '/src/test/mobileFixtures.ts';
|
||||
const { mobileFixtures } = await import(modulePath);
|
||||
const source = structuredClone(mobileFixtures['/api/v1/admin/sources'].items[0]);
|
||||
@@ -31,6 +31,21 @@ async function setup(page: Page, scenario: 'queue' | 'template' | 'mine') {
|
||||
];
|
||||
files[0]!.bitrate = 778000;
|
||||
const missing = { ...file, id: 'missing', original_name: 'missing.flac', bitrate: 924000, target_source_id: null, target_source_name: null, target_rel_path: null, status_reason: 'uploads.no_target' };
|
||||
const releaseId = '11111111-1111-4111-8111-111111111111';
|
||||
const deluxeId = '22222222-2222-4222-8222-222222222222';
|
||||
const tracks = [1, 2].map((n) => ({ recording_id: `recording-${n}`, title: `Song ${n}`, artist: 'Catalogue Artist', disc: 1, position: n, duration_ms: 180000 }));
|
||||
const standard = { release: { id: releaseId, group_id: releaseId, title: 'Album', artist: 'Catalogue Artist', type: 'Album', date: '2024-01-01', country: 'RU', comment: 'Digital', has_cover: true, tracks },
|
||||
matches: [{ file_id: 'visible', track_index: 0, confidence: 'high' }, { file_id: 'missing', track_index: 1, confidence: 'high' }], missing: [], existing: [], unmatched: [], matched: 2, total: 2, complete: true, confidence: 'high' };
|
||||
const bonus = { ...tracks[0]!, title: 'Bonus', position: 3 };
|
||||
const deluxe = { ...standard, release: { ...standard.release, id: deluxeId, comment: 'Deluxe', tracks: [...tracks, bonus] }, missing: [bonus], existing: [bonus], total: 3, complete: false };
|
||||
let releaseReport = { state: 'ready', input_hash: 'original', selected_release: null as string | null, candidates: [standard, deluxe], audio_available: audioAvailable, audio_results: [] as { file_id: string; state: string }[] };
|
||||
const groupA = { id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', artist: 'Artist A', album: 'Album A', file_ids: ['visible'], has_cover: true, state: 'ready' };
|
||||
const groupB = { id: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', artist: 'Artist B', album: 'Album B', file_ids: ['missing'], has_cover: false, state: 'ready' };
|
||||
const groups = [groupA, groupB];
|
||||
const groupReports = Object.fromEntries(groups.map((g, i) => [g.id, { ...releaseReport, group_id: g.id, groups, input_hash: g.id,
|
||||
candidates: [{ ...standard, release: { ...standard.release, id: i ? deluxeId : releaseId, artist: g.artist, title: g.album, tracks: [tracks[i]] }, matches: [{ file_id: g.file_ids[0], track_index: 0, confidence: 'high' }], matched: 1, total: 1 }],
|
||||
}]));
|
||||
let rejectReleaseOnce = true;
|
||||
let failOnce = true;
|
||||
const deleted: string[] = [];
|
||||
const original = window.fetch;
|
||||
@@ -38,6 +53,60 @@ async function setup(page: Page, scenario: 'queue' | 'template' | 'mine') {
|
||||
window.fetch = async (input, init) => {
|
||||
const req = new Request(input, init);
|
||||
const url = new URL(req.url);
|
||||
if (scenario === 'mixed' && url.pathname.includes('/admin/uploads/batches/')) {
|
||||
if (url.pathname.endsWith('/releases')) {
|
||||
if (req.method === 'POST') {
|
||||
const body = await req.json();
|
||||
document.documentElement.dataset.releaseSearch = JSON.stringify(body);
|
||||
return json({ ...groupReports[body.group_id], state: 'queued' }, 202);
|
||||
}
|
||||
return json(groupReports[url.searchParams.get('group_id') ?? ''] ?? { ...releaseReport, state: 'needs_input', candidates: [], groups });
|
||||
}
|
||||
if (url.pathname.endsWith('/cover')) {
|
||||
document.documentElement.dataset.releaseCoverGroup = url.searchParams.get('group_id') ?? '';
|
||||
return json({ image_base64: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII=', mime_type: 'image/png', source_url: 'https://coverartarchive.org/release/' + releaseId + '/front-1200', exact_release: true });
|
||||
}
|
||||
if (url.pathname.endsWith('/release') && req.method === 'POST') {
|
||||
const body = await req.json();
|
||||
document.documentElement.dataset.releaseApplied = JSON.stringify(body);
|
||||
const target = groups.find((g) => g.id === body.group_id)!;
|
||||
if (body.apply_cover) target.has_cover = true;
|
||||
if (body.apply_metadata) {
|
||||
const f = body.group_id === groupA.id ? files[0]! : missing;
|
||||
Object.assign(f, { metadata: { artist: target.artist, albumartist: target.artist, album: target.album, title: 'Confirmed ' + target.album, genre: '' }, updated_at: new Date().toISOString() });
|
||||
}
|
||||
groupReports[body.group_id]!.selected_release = body.release_id;
|
||||
return json(groupReports[body.group_id]);
|
||||
}
|
||||
}
|
||||
if (scenario === 'mixed' && url.pathname === '/api/v1/admin/uploads') return json({ items: [files[0], missing], next_cursor: null });
|
||||
if (url.pathname.endsWith('/releases') && url.pathname.includes('/admin/uploads/batches/')) {
|
||||
if (scenario !== 'release') return json({ state: 'disabled', input_hash: '', selected_release: null, candidates: [] });
|
||||
if (req.method === 'POST') {
|
||||
const body = await req.json();
|
||||
document.documentElement.dataset.releaseSearch = JSON.stringify(body);
|
||||
if (body.audio) releaseReport = { ...releaseReport, audio_results: [{ file_id: 'visible', state: 'matched' }, { file_id: 'missing', state: 'ambiguous' }], candidates: [
|
||||
{ ...standard, complete: false, confidence: 'medium', matches: [{ ...standard.matches[0]!, method: 'acoustid' }] } as typeof standard, deluxe,
|
||||
] };
|
||||
return json({ ...releaseReport, state: 'queued' }, 202);
|
||||
}
|
||||
return json(releaseReport);
|
||||
}
|
||||
if (scenario === 'release' && url.pathname.endsWith('/cover')) {
|
||||
document.documentElement.dataset.releaseCoverRequests = String(Number(document.documentElement.dataset.releaseCoverRequests ?? 0) + 1);
|
||||
return json({ image_base64: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII=', mime_type: 'image/png', source_url: 'https://coverartarchive.org/release-group/11111111-1111-4111-8111-111111111111/front-1200', exact_release: false });
|
||||
}
|
||||
if (scenario === 'release' && url.pathname.endsWith('/release') && req.method === 'POST') {
|
||||
const body = await req.json();
|
||||
if (body.apply_metadata && rejectReleaseOnce) { rejectReleaseOnce = false; return json({ code: 'uploads.release_stale', status: 409 }, 409); }
|
||||
document.documentElement.dataset.releaseApplied = JSON.stringify(body);
|
||||
if (body.apply_metadata) {
|
||||
for (const [index, f] of [files[0]!, missing].entries()) Object.assign(f, { metadata: { artist: 'Catalogue Artist', albumartist: 'Catalogue Artist', title: `Song ${index + 1}`, album: 'Album', year: 2024, track: index + 1, disc: 1, genre: '' }, updated_at: new Date().toISOString() });
|
||||
}
|
||||
releaseReport = { ...releaseReport, input_hash: 'applied', selected_release: body.release_id };
|
||||
return json(releaseReport);
|
||||
}
|
||||
if (scenario === 'release' && url.pathname === '/api/v1/admin/uploads') return json({ items: [files[0], missing], next_cursor: null });
|
||||
if (scenario === 'queue' && url.pathname === '/api/v1/admin/uploads') return json({ items: [files[0], missing], next_cursor: null });
|
||||
if (scenario === 'queue' && url.pathname.startsWith('/api/v1/admin/uploads/batches/') && req.method === 'PATCH') {
|
||||
const body = await req.json();
|
||||
@@ -88,10 +157,10 @@ async function setup(page: Page, scenario: 'queue' | 'template' | 'mine') {
|
||||
}
|
||||
return original(input, init);
|
||||
};
|
||||
const target = scenario === 'queue' ? '/admin/uploads' : scenario === 'mine' ? '/uploads/mine' : `/admin/sources/${source.id}/patterns`;
|
||||
const target = scenario === 'queue' || scenario === 'release' || scenario === 'mixed' ? '/admin/uploads' : scenario === 'mine' ? '/uploads/mine' : `/admin/sources/${source.id}/patterns`;
|
||||
history.pushState({}, '', target);
|
||||
window.dispatchEvent(new PopStateEvent('popstate'));
|
||||
}, scenario);
|
||||
}, { scenario, audioAvailable });
|
||||
}
|
||||
|
||||
test('queue shows the storage and path, and recalculates after fixing placement', async ({ page }, info) => {
|
||||
@@ -284,3 +353,108 @@ test('oversized album cover is reduced before saving the batch', async ({ page }
|
||||
await expect(page.locator('html')).toHaveAttribute('data-cover-dimensions', '2048x1229');
|
||||
expect(Number(await page.locator('html').getAttribute('data-cover-bytes'))).toBeLessThan(8 * 1024 * 1024);
|
||||
});
|
||||
|
||||
test('mixed batch checks each release and applies only within the chosen group', async ({ page }, info) => {
|
||||
await setup(page, 'mixed');
|
||||
await page.getByRole('button', { name: 'Развернуть пачку', exact: true }).click();
|
||||
await expect(page.getByText('Artist A — Album A', { exact: true })).toBeVisible();
|
||||
await expect(page.getByText('Artist B — Album B', { exact: true })).toBeVisible();
|
||||
await page.getByRole('checkbox', { name: 'Выбрать загруженные строки релиза Album A', exact: true }).check();
|
||||
await expect(page.getByRole('checkbox', { name: 'Выбрать visible.flac', exact: true })).toBeChecked();
|
||||
await expect(page.getByRole('checkbox', { name: 'Выбрать missing.flac', exact: true })).not.toBeChecked();
|
||||
await page.getByRole('button', { name: 'Релизов в пачке: 2', exact: true }).click();
|
||||
const dialog = page.getByRole('dialog', { name: 'Сопоставление релиза' });
|
||||
await expect(dialog.getByRole('button', { name: 'Применить выбранное', exact: true })).toBeDisabled();
|
||||
await expect(dialog.getByRole('button', { name: 'Распознать по звуку', exact: true })).toBeDisabled();
|
||||
await dialog.getByRole('combobox', { name: 'Релиз в пачке' }).selectOption('bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb');
|
||||
await expect(dialog.getByText('Artist B — Album B', { exact: true })).toBeVisible();
|
||||
await expect(dialog.getByText('Без обложки', { exact: true })).toBeVisible();
|
||||
await dialog.getByRole('button', { name: 'Показать обложку из каталога', exact: true }).click();
|
||||
await expect(page.locator('html')).toHaveAttribute('data-release-cover-group', 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb');
|
||||
await page.screenshot({ path: info.outputPath('mixed-release.png') });
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth - innerWidth)).toBeLessThanOrEqual(1);
|
||||
await dialog.getByRole('checkbox', { name: /Применить названия/ }).check();
|
||||
await dialog.getByRole('checkbox', { name: 'Добавить обложку выбранного релиза', exact: true }).check();
|
||||
await dialog.getByRole('button', { name: 'Применить выбранное', exact: true }).click();
|
||||
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.getByRole('button', { name: 'Применить выбранное', exact: true })).toBeDisabled();
|
||||
await expect(dialog.getByRole('checkbox', { name: /Применить названия/ })).not.toBeChecked();
|
||||
});
|
||||
|
||||
test('audio recognition starts only on request and keeps suggestions separate', async ({ page }) => {
|
||||
await setup(page, 'release');
|
||||
await page.getByRole('button', { name: 'Найдено изданий: 2', exact: true }).click();
|
||||
const dialog = page.getByRole('dialog', { name: 'Сопоставление релиза' });
|
||||
await expect(page.locator('html')).not.toHaveAttribute('data-release-search');
|
||||
await expect(dialog.getByText(/только аудиоотпечатки и длительности/)).toBeVisible();
|
||||
await dialog.getByRole('button', { name: 'Распознать по звуку', exact: true }).click();
|
||||
await expect(page.locator('html')).toHaveAttribute('data-release-search', '{"audio":true}');
|
||||
await expect(dialog.getByText('По звуку распознано: 1 из 2 треков', { exact: true })).toBeVisible();
|
||||
await expect(dialog.getByText(/Неоднозначное или недостаточно уверенное совпадение/)).toBeVisible();
|
||||
await dialog.getByRole('combobox', { name: 'Издание' }).selectOption('11111111-1111-4111-8111-111111111111');
|
||||
await expect(dialog.getByText('Совпадение по звуку · AcoustID', { exact: true })).toBeVisible();
|
||||
await expect(dialog.getByRole('button', { name: 'Применить выбранное', exact: true })).toBeDisabled();
|
||||
await expect(page.locator('html')).not.toHaveAttribute('data-release-applied');
|
||||
await expect(page.locator('html')).not.toHaveAttribute('data-release-cover-requests');
|
||||
});
|
||||
|
||||
test('audio identification without application key shows setup and stays disabled', async ({ page }) => {
|
||||
await setup(page, 'release', false);
|
||||
await page.getByRole('button', { name: 'Найдено изданий: 2', exact: true }).click();
|
||||
const dialog = page.getByRole('dialog', { name: 'Сопоставление релиза' });
|
||||
await expect(dialog.getByRole('button', { name: 'Распознать по звуку', exact: true })).toBeDisabled();
|
||||
await expect(dialog.getByText(/задайте ACOUSTID_API_KEY/)).toBeVisible();
|
||||
await expect(page.locator('html')).not.toHaveAttribute('data-release-search');
|
||||
});
|
||||
|
||||
test('release suggestions require explicit application and handle stale metadata', async ({ page }) => {
|
||||
await setup(page, 'release');
|
||||
await page.getByRole('button', { name: 'Найдено изданий: 2', exact: true }).click();
|
||||
const dialog = page.getByRole('dialog', { name: 'Сопоставление релиза' });
|
||||
await expect(dialog.getByRole('button', { name: 'Применить выбранное', exact: true })).toBeDisabled();
|
||||
await dialog.getByRole('combobox', { name: 'Издание' }).selectOption('11111111-1111-4111-8111-111111111111');
|
||||
await expect(dialog.getByText('Уверенность сопоставления: высокая', { exact: true })).toBeVisible();
|
||||
const apply = dialog.getByRole('button', { name: 'Применить выбранное', exact: true });
|
||||
await expect(apply).toBeDisabled();
|
||||
await expect(page.locator('html')).not.toHaveAttribute('data-release-applied');
|
||||
await expect(page.locator('html')).not.toHaveAttribute('data-release-cover-requests');
|
||||
await dialog.getByRole('checkbox', { name: /Применить названия/ }).check();
|
||||
await apply.click();
|
||||
await expect(dialog.getByText(/Состав или теги пачки изменились/)).toBeVisible();
|
||||
await expect(page.locator('html')).not.toHaveAttribute('data-release-applied');
|
||||
await dialog.getByRole('textbox', { name: 'Исполнитель', exact: true }).fill('Catalogue Artist');
|
||||
await dialog.getByRole('textbox', { name: 'Альбом', exact: true }).fill('Album');
|
||||
await dialog.getByRole('button', { name: 'Найти / повторить поиск', exact: true }).click();
|
||||
await expect(dialog.getByRole('status')).toHaveText('Найдено изданий: 2');
|
||||
await expect(dialog.getByRole('combobox', { name: 'Издание' })).toBeVisible();
|
||||
await dialog.getByRole('combobox', { name: 'Издание' }).selectOption('11111111-1111-4111-8111-111111111111');
|
||||
await dialog.getByRole('checkbox', { name: /Применить названия/ }).check();
|
||||
await expect(apply).toBeEnabled();
|
||||
await apply.click();
|
||||
await expect(page.locator('html')).toHaveAttribute('data-release-applied', '{"release_id":"11111111-1111-4111-8111-111111111111","apply_metadata":true,"apply_cover":false}');
|
||||
await expect(dialog.getByText('Выбранное издание применено', { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test('deluxe completeness and release-group artwork are shown before replacing an existing cover', async ({ page }, info) => {
|
||||
await setup(page, 'release');
|
||||
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(/найдены совпадения в библиотеке/)).toBeVisible();
|
||||
await dialog.getByRole('button', { name: 'Показать обложку из каталога', exact: true }).click();
|
||||
await expect(dialog.getByText(/Обложка группы изданий/)).toBeVisible();
|
||||
await expect(dialog.getByRole('img', { name: 'Обложка пачки' })).toBeVisible();
|
||||
await expect(dialog.getByRole('button', { name: 'Применить выбранное', exact: true })).toBeDisabled();
|
||||
await page.screenshot({ path: info.outputPath('release-comparison.png') });
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth - innerWidth)).toBeLessThanOrEqual(1);
|
||||
await dialog.getByRole('checkbox', { name: 'Заменить текущую обложку пачки этой обложкой', exact: true }).check();
|
||||
await dialog.getByRole('button', { name: 'Применить выбранное', exact: true }).click();
|
||||
await expect(page.locator('html')).toHaveAttribute('data-release-applied', '{"release_id":"22222222-2222-4222-8222-222222222222","apply_metadata":false,"apply_cover":true}');
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
import type { components } from '@pipu/api-client';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useUploadsApi } from './queries';
|
||||
import { useErrorText } from '../../shared/lib/errors';
|
||||
import { Button, Dialog, Field, FormError } from '../../shared/ui/ui';
|
||||
import type { UploadFile } from './common';
|
||||
import s from '../../pages/page.module.css';
|
||||
import u from '../../pages/uploads/uploads.module.css';
|
||||
|
||||
type Candidate = components['schemas']['ReleaseCandidate'];
|
||||
export type ReleaseGroup = components['schemas']['UploadReleaseGroup'];
|
||||
|
||||
/** External suggestions remain separate from uploaded metadata until explicitly applied. */
|
||||
export function BatchRelease({ files, onGroupsChange }: { files: UploadFile[]; onGroupsChange?: (groups: ReleaseGroup[]) => void }) {
|
||||
const { t } = useTranslation('admin');
|
||||
const api = useUploadsApi();
|
||||
const qc = useQueryClient();
|
||||
const errorText = useErrorText();
|
||||
const first = files[0]!;
|
||||
const id = first.batch_id;
|
||||
const [groupId, setGroupId] = useState('');
|
||||
const [groups, setGroups] = useState<ReleaseGroup[]>([]);
|
||||
const key = ['upload-release', id, groupId];
|
||||
const [open, setOpen] = useState(false);
|
||||
const [artist, setArtist] = useState(first.metadata?.albumartist || first.metadata?.artist || '');
|
||||
const [album, setAlbum] = useState(first.metadata?.album || '');
|
||||
const [mbid, setMbid] = useState('');
|
||||
const [selected, setSelected] = useState('');
|
||||
const [preview, setPreview] = useState(false);
|
||||
const [applyMetadata, setApplyMetadata] = useState(false);
|
||||
const [applyCover, setApplyCover] = useState(false);
|
||||
const version = files.map((f) => `${f.id}:${f.updated_at}`).sort().join('|');
|
||||
const previousVersion = useRef(version);
|
||||
useEffect(() => {
|
||||
if (previousVersion.current !== version) {
|
||||
previousVersion.current = version;
|
||||
void qc.invalidateQueries({ queryKey: ['upload-release', id] });
|
||||
}
|
||||
}, [version, qc, id]);
|
||||
const report = useQuery({
|
||||
queryKey: key,
|
||||
queryFn: () => api.releaseReport(id, groupId || undefined),
|
||||
retry: false,
|
||||
refetchInterval: (q) => q.state.data?.state === 'queued' || q.state.data?.state === 'searching' || (!groupId && q.state.data?.groups?.some((g) => g.state === 'queued' || g.state === 'searching')) ? 3000 : q.state.data?.state === 'error' ? 60000 : false,
|
||||
});
|
||||
useEffect(() => {
|
||||
if (report.data?.groups) { setGroups(report.data.groups); onGroupsChange?.(report.data.groups); }
|
||||
}, [report.data?.groups, onGroupsChange]);
|
||||
const scopeId = report.data?.group_id || groupId;
|
||||
const group = groups.find((g) => g.id === scopeId);
|
||||
const needsGroup = groups.length > 1 && !scopeId;
|
||||
const candidate: Candidate | undefined = report.data?.candidates.find((c) => c.release.id === selected)
|
||||
?? report.data?.candidates.find((c) => c.release.id === report.data?.selected_release)
|
||||
?? (report.data?.candidates.length === 1 ? report.data.candidates[0] : undefined);
|
||||
const releaseId = candidate?.release.id ?? '';
|
||||
useEffect(() => { setPreview(false); setApplyCover(false); setApplyMetadata(false); }, [releaseId, report.data?.input_hash]);
|
||||
const cover = useQuery({
|
||||
queryKey: ['upload-release-cover', id, scopeId, releaseId, report.data?.input_hash],
|
||||
queryFn: () => api.releaseCover(id, releaseId, scopeId || undefined),
|
||||
enabled: open && preview && !!releaseId,
|
||||
retry: false,
|
||||
staleTime: 3600000,
|
||||
});
|
||||
const search = useMutation({
|
||||
mutationFn: (audio: boolean) => api.searchReleases(id, { ...(scopeId ? { group_id: scopeId } : {}), ...(audio ? { audio: true } : mbid.trim() ? { release_id: mbid.trim() } : { artist: artist.trim(), album: album.trim() }) }),
|
||||
onSuccess: (data) => { setSelected(''); apply.reset(); qc.setQueryData(key, data); },
|
||||
});
|
||||
const apply = useMutation({
|
||||
mutationFn: () => api.applyRelease(id, { ...(scopeId ? { group_id: scopeId } : {}), release_id: releaseId, apply_metadata: applyMetadata, apply_cover: applyCover }),
|
||||
onSuccess: async (data) => {
|
||||
qc.setQueryData(key, data);
|
||||
setApplyCover(false); setApplyMetadata(false);
|
||||
await qc.invalidateQueries({ queryKey: ['admin-uploads'] });
|
||||
},
|
||||
});
|
||||
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')}`
|
||||
: t('release_lookup.variants', { count: report.data.candidates.length })
|
||||
: t(`release_lookup.state_${report.data?.state ?? 'searching'}`);
|
||||
|
||||
if (report.data?.state === 'disabled') return null;
|
||||
return <>
|
||||
<Button variant="ghost" onClick={() => setOpen(true)} title={t('release_lookup.title')}>
|
||||
{report.error ? t('release_lookup.open') : stateText}
|
||||
</Button>
|
||||
{open && <Dialog className={u.releaseDialog} title={t('release_lookup.title')} onClose={() => setOpen(false)} footer={
|
||||
<Button variant="primary" busy={apply.isPending} disabled={needsGroup || search.isPending || !candidate || report.data?.state !== 'ready' || (!applyMetadata && !applyCover) || (applyCover && !cover.data)} onClick={() => apply.mutate()}>
|
||||
{t('release_lookup.apply')}
|
||||
</Button>
|
||||
}>
|
||||
<div className={s.sessions}>
|
||||
<p className={s.note}>{t('release_lookup.intro')}</p>
|
||||
{groups.length > 1 && <>
|
||||
<p className={s.note}>{t('release_lookup.groups_hint')}</p>
|
||||
<label className={s.sessions}>
|
||||
{t('release_lookup.group')}
|
||||
<select className={s.select} value={scopeId} disabled={apply.isPending || search.isPending} onChange={(e) => {
|
||||
const next = groups.find((g) => g.id === e.target.value)!;
|
||||
setGroupId(next.id); setArtist(next.artist); setAlbum(next.album); setMbid(''); setSelected(''); setPreview(false); setApplyMetadata(false); setApplyCover(false); search.reset(); apply.reset();
|
||||
}}>
|
||||
<option value="" disabled>{t('release_lookup.choose_group')}</option>
|
||||
{groups.map((g) => <option key={g.id} value={g.id}>{g.artist} — {g.album} · {t('release_lookup.tracks_total', { total: g.file_ids.length })}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
{group && <p className={s.note}>{t(group.has_cover ? 'uploads.batch_cover_present' : 'uploads.batch_cover_absent')}</p>}
|
||||
</>}
|
||||
<div className={u.grid}>
|
||||
<Field label={t('uploads.meta_artist')} value={artist} onChange={setArtist} maxLength={500} />
|
||||
<Field label={t('uploads.meta_album')} value={album} onChange={setAlbum} maxLength={500} />
|
||||
</div>
|
||||
<Field label={t('release_lookup.mbid')} value={mbid} onChange={setMbid} maxLength={36} />
|
||||
<Button busy={search.isPending} disabled={needsGroup || apply.isPending || report.data?.state === 'queued' || report.data?.state === 'searching' || (!mbid.trim() && (!artist.trim() || !album.trim()))} onClick={() => search.mutate(false)}>{t('release_lookup.search')}</Button>
|
||||
<Button busy={search.isPending && search.variables === true} disabled={needsGroup || !report.data?.audio_available || search.isPending || apply.isPending || report.data?.state === 'queued' || report.data?.state === 'searching'} onClick={() => search.mutate(true)}>{t('release_lookup.audio_search')}</Button>
|
||||
<p className={s.note}>{t(report.data?.audio_available ? 'release_lookup.audio_privacy' : 'release_lookup.audio_setup')}</p>
|
||||
<p role="status" className={s.note}>{stateText}</p>
|
||||
{!!report.data?.audio_results?.length && <>
|
||||
<p className={s.note}>{t('release_lookup.audio_summary', { matched: report.data.audio_results.filter((r) => r.state === 'matched').length, total: report.data.audio_results.length })}</p>
|
||||
{report.data.audio_results.filter((r) => r.state !== 'matched').map((r) => <div key={r.file_id} className={u.sub}>{files.find((f) => f.id === r.file_id)?.original_name ?? r.file_id}: {t(r.state === 'ambiguous' ? 'release_lookup.audio_ambiguous' : r.state === 'not_found' ? 'release_lookup.audio_not_found' : 'release_lookup.audio_unavailable')}</div>)}
|
||||
<p className={s.note}>{t('release_lookup.audio_editions')}</p>
|
||||
</>}
|
||||
<FormError>{(report.error ?? search.error ?? apply.error) ? errorText(report.error ?? search.error ?? apply.error) : null}</FormError>
|
||||
{report.data?.state === 'ready' && report.data.candidates.length > 0 && <>
|
||||
{report.data.candidates.length > 1 && <label className={s.sessions}>
|
||||
{t('release_lookup.edition')}
|
||||
<select className={s.select} value={releaseId} disabled={apply.isPending || search.isPending} onChange={(e) => { setSelected(e.target.value); apply.reset(); }}>
|
||||
<option value="" disabled>{t('release_lookup.choose_edition')}</option>
|
||||
{report.data.candidates.map((c) => <option key={c.release.id} value={c.release.id}>{c.release.artist} — {c.release.title} · {[c.release.date, c.release.country, c.release.comment, t('release_lookup.tracks_total', { total: c.total })].filter(Boolean).join(' · ')}</option>)}
|
||||
</select>
|
||||
</label>}
|
||||
{candidate && <>
|
||||
<strong>{candidate.release.artist} — {candidate.release.title}</strong>
|
||||
<span className={s.note}>{[kind(candidate.release.type), candidate.release.date, candidate.release.country, candidate.release.comment].filter(Boolean).join(' · ')}</span>
|
||||
<a href={`https://musicbrainz.org/release/${encodeURIComponent(releaseId)}`} target="_blank" rel="noreferrer">MusicBrainz</a>
|
||||
<p className={s.note}>{t('release_lookup.confidence', { value: t(candidate.confidence === 'high' ? 'release_lookup.confidence_high' : candidate.confidence === 'medium' ? 'release_lookup.confidence_medium' : 'release_lookup.confidence_low') })}</p>
|
||||
<p className={s.note}>{t('release_lookup.coverage', { matched: candidate.matched, total: candidate.total, missing: candidate.missing.length, extra: candidate.unmatched.length })}</p>
|
||||
{candidate.existing.length > 0 && <p className={s.note}>{t('release_lookup.existing', { count: candidate.existing.length })}</p>}
|
||||
<div className={s.tableWrap}>
|
||||
<table className={s.table}>
|
||||
<thead><tr><th>#</th><th>{t('release_lookup.uploaded')}</th><th>{t('release_lookup.catalogue')}</th></tr></thead>
|
||||
<tbody>{candidate.release.tracks.map((track, index) => {
|
||||
const match = candidate.matches.find((m) => m.track_index === index);
|
||||
const file = files.find((f) => f.id === match?.file_id);
|
||||
return <tr key={`${track.disc}:${track.position}:${index}`}>
|
||||
<td>{track.disc}.{track.position}</td>
|
||||
<td className={u.releaseCell}>{match ? file?.metadata ? `${file.metadata.artist} — ${file.metadata.title}` : file?.original_name ?? t('release_lookup.matched') : t('release_lookup.missing')}
|
||||
{file?.metadata && <div className={u.sub}>{[file.metadata.albumartist, file.metadata.album, file.metadata.year, `${file.metadata.disc ?? 1}.${file.metadata.track ?? '?'}`].filter(Boolean).join(' · ')}</div>}
|
||||
{match?.method === 'acoustid' && <div className={u.sub}>{t('release_lookup.audio_match')}</div>}
|
||||
{match?.confidence === 'medium' && <div className={u.warn}>{t('release_lookup.tentative')}</div>}</td>
|
||||
<td className={u.releaseCell}>{track.artist} — {track.title}</td>
|
||||
</tr>;
|
||||
})}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<label><input type="checkbox" checked={applyMetadata} disabled={apply.isPending || search.isPending || !candidate.matches.length} onChange={(e) => setApplyMetadata(e.target.checked)} /> {t('release_lookup.apply_metadata')}</label>
|
||||
<Button busy={cover.isFetching} onClick={() => setPreview(true)}>{t('release_lookup.preview_cover')}</Button>
|
||||
{preview && cover.error && <FormError>{errorText(cover.error)}</FormError>}
|
||||
{preview && cover.data && <>
|
||||
<img className={u.releaseCover} width={180} height={180} src={`data:${cover.data.mime_type};base64,${cover.data.image_base64}`} alt={t('uploads.batch_cover')} />
|
||||
<span className={s.note}>{t(cover.data.exact_release ? 'release_lookup.exact_cover' : 'release_lookup.group_cover')}</span>
|
||||
<a href={cover.data.source_url} target="_blank" rel="noreferrer">Cover Art Archive</a>
|
||||
<label><input type="checkbox" checked={applyCover} disabled={apply.isPending} onChange={(e) => setApplyCover(e.target.checked)} /> {t(group ? group.has_cover ? 'release_lookup.replace_group_cover' : 'release_lookup.apply_group_cover' : first.batch_has_cover ? 'release_lookup.replace_cover' : 'release_lookup.apply_cover')}</label>
|
||||
</>}
|
||||
{report.data.selected_release === releaseId && <p role="status" className={s.ok}>{t('release_lookup.applied')}</p>}
|
||||
</>}
|
||||
</>}
|
||||
</div>
|
||||
</Dialog>}
|
||||
</>;
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { useId, useState } from 'react';
|
||||
import { needsFormatAttention } from './files';
|
||||
import { AudioPreview } from './AudioPreview';
|
||||
import { batchSummary } from './batchSummary';
|
||||
import { BatchRelease, type ReleaseGroup } from './BatchRelease';
|
||||
import { UploadStatusBadge, useReasonText, type UploadFile } from './common';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, cx, Icon } from '../../shared/ui/ui';
|
||||
@@ -28,6 +29,7 @@ export function BatchCard({
|
||||
}) {
|
||||
const { t, i18n } = useTranslation('admin');
|
||||
const [collapsed, setCollapsed] = useState(true);
|
||||
const [releaseGroups, setReleaseGroups] = useState<ReleaseGroup[]>([]);
|
||||
const contentId = useId();
|
||||
const first = files[0]!;
|
||||
const ids = files.map((f) => f.id);
|
||||
@@ -52,6 +54,7 @@ export function BatchCard({
|
||||
{t(first.batch_has_cover ? 'uploads.batch_cover_present' : 'uploads.batch_cover_absent')}
|
||||
</span>
|
||||
<span style={{ flex: 1 }} />
|
||||
{selectable && <BatchRelease files={files} onGroupsChange={setReleaseGroups} />}
|
||||
{selectable && (
|
||||
<Button variant="ghost" onClick={() => onEditBatch(first.batch_id)}>
|
||||
{t('uploads.edit_batch')}
|
||||
@@ -60,7 +63,17 @@ export function BatchCard({
|
||||
{first.batch_comment && <span className={u.comment}>«{first.batch_comment}»</span>}
|
||||
</div>
|
||||
<div id={contentId} hidden={collapsed}>
|
||||
{files.map((f) => (
|
||||
{(releaseGroups.length > 1 ? [
|
||||
...releaseGroups.map((group) => ({ group, rows: files.filter((f) => group.file_ids.includes(f.id)) })),
|
||||
{ group: undefined, rows: files.filter((f) => !releaseGroups.some((g) => g.file_ids.includes(f.id))) },
|
||||
] : [{ group: undefined, rows: files }]).filter((part) => part.rows.length > 0).map((part) => <div key={part.group?.id ?? 'other'}>
|
||||
{part.group && <div className={u.releaseHead}>
|
||||
{selectable && <input type="checkbox" aria-label={t('release_lookup.select_group', { album: part.group.album })} checked={part.rows.every((f) => selected.has(f.id))} onChange={(e) => onToggle(part.rows.map((f) => f.id), e.target.checked)} />}
|
||||
<strong>{part.group.artist} — {part.group.album}</strong>
|
||||
<span>{t('release_lookup.tracks_total', { total: part.group.file_ids.length })}</span>
|
||||
<span>{t(part.group.has_cover ? 'uploads.batch_cover_present' : 'uploads.batch_cover_absent')}</span>
|
||||
</div>}
|
||||
{part.rows.map((f) => (
|
||||
<QueueRow
|
||||
key={f.id}
|
||||
file={f}
|
||||
@@ -72,6 +85,7 @@ export function BatchCard({
|
||||
onRetry={() => onRetry(f.id)}
|
||||
/>
|
||||
))}
|
||||
</div>)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -8,6 +8,10 @@ type SettingsPatch = paths['/api/v1/admin/settings/uploads']['patch']['requestBo
|
||||
/** The feature owns transport mapping; views only provide form values. */
|
||||
export function uploadsApi(client: ApiClient) {
|
||||
return {
|
||||
releaseReport: async (id: string, group_id?: string) => unwrap(await client.GET('/api/v1/admin/uploads/batches/{id}/releases', { params: { path: { id }, query: group_id ? { group_id } : {} } })),
|
||||
searchReleases: async (id: string, body: paths['/api/v1/admin/uploads/batches/{id}/releases']['post']['requestBody']['content']['application/json']) => unwrap(await client.POST('/api/v1/admin/uploads/batches/{id}/releases', { params: { path: { id } }, body })),
|
||||
releaseCover: async (id: string, release_id: string, group_id?: string) => unwrap(await client.GET('/api/v1/admin/uploads/batches/{id}/releases/{release_id}/cover', { params: { path: { id, release_id }, query: group_id ? { group_id } : {} } })),
|
||||
applyRelease: async (id: string, body: paths['/api/v1/admin/uploads/batches/{id}/release']['post']['requestBody']['content']['application/json']) => unwrap(await client.POST('/api/v1/admin/uploads/batches/{id}/release', { params: { path: { id } }, body })),
|
||||
list: async (status: components['schemas']['UploadStatus'][], possible: boolean, cursor?: string) => unwrap(
|
||||
await client.GET('/api/v1/admin/uploads', {
|
||||
params: { query: { status, possible_duplicate: possible || undefined, cursor, limit: 100 } },
|
||||
|
||||
@@ -222,6 +222,11 @@
|
||||
}
|
||||
|
||||
.preview { width: 300px; max-width: 100%; min-width: 0; }
|
||||
.releaseCell { min-width: 140px; max-width: 300px; white-space: normal !important; overflow-wrap: anywhere; }
|
||||
.releaseDialog { width: 900px; }
|
||||
.releaseHead { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; padding: 12px 18px; border-bottom: 1px solid var(--line); font-size: 13px; }
|
||||
.releaseHead strong { min-width: 0; overflow-wrap: anywhere; }
|
||||
.releaseCover { max-width: 100%; object-fit: contain; border-radius: 10px; }
|
||||
.preview audio { display: block; width: 100%; }
|
||||
.previewError { display: flex; flex-direction: column; align-items: flex-start; gap: 4px; margin-top: 6px; font-size: 12px; }
|
||||
|
||||
|
||||
Reference in new issue
Block a user