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
@@ -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