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

This commit is contained in:
mrleo1nid committed 2026-10-29 20:44:59 +03:00
1 parent 1e3a149aef
commit 39bf7d4b70
45 files changed
+4792 -417

No files matched your search

+178 -4
View File
@@ -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>}
</>;
}
+15 -1
View File
@@ -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>
);
+4
View File
@@ -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; }