diff --git a/.gitea/workflows/ci-db.yml b/.gitea/workflows/ci-db.yml index 54bd5a1..3b8a6c4 100644 --- a/.gitea/workflows/ci-db.yml +++ b/.gitea/workflows/ci-db.yml @@ -48,6 +48,7 @@ jobs: TEST_REDIS_URL: redis://redis:6379/0 TEST_MEILI_URL: http://meilisearch:7700 TEST_MEILI_KEY: ci-master-key-0123456789 + MUSICBRAINZ_ENABLED: "false" steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 @@ -73,7 +74,7 @@ jobs: working-directory: services/api # These suites share one database; dbtest also rolls all migrations down # and cleans orphan catalog rows. Do not run the packages concurrently. - run: go test -p 1 -tags integration -count=1 -v ./internal/dbtest/ ./internal/social/ + run: go test -p 1 -tags integration -count=1 -v ./internal/dbtest/ ./internal/social/ ./internal/uploads/ - name: Go — сквозной сценарий пользователя (newApp на Postgres, Redis, Meilisearch) working-directory: services/api run: go test -tags integration -count=1 -v -run TestUserJourney ./cmd/api/ diff --git a/api/openapi.yaml b/api/openapi.yaml index f62cc8e..b036a78 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -1100,6 +1100,105 @@ paths: $ref: "#/components/schemas/UploadFile" default: $ref: "#/components/responses/Problem" + /api/v1/admin/uploads/batches/{id}/releases: + parameters: + - $ref: "#/components/parameters/EntityId" + get: + operationId: getUploadReleases + parameters: + - name: group_id + in: query + schema: {type: string, format: uuid} + tags: [uploads] + summary: Состояние фонового сопоставления пачки с изданиями MusicBrainz + responses: + "200": + description: Состояние поиска и кандидаты; текущие файлы не изменяются + content: + application/json: + schema: {$ref: "#/components/schemas/UploadReleaseReport"} + default: {$ref: "#/components/responses/Problem"} + post: + operationId: searchUploadReleases + tags: [uploads] + summary: Повторить поиск релиза, при необходимости с уточнённым запросом + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + artist: {type: string, maxLength: 500} + album: {type: string, maxLength: 500} + group_id: {type: string, format: uuid} + release_id: {type: string, format: uuid} + audio: + type: boolean + description: Явно распознать до 50 редактируемых треков через AcoustID; теги и аудио не отправляются. Требуется ключ приложения на сервере. + responses: + "202": + description: Поиск поставлен в фоновую очередь + content: + application/json: + schema: {$ref: "#/components/schemas/UploadReleaseReport"} + default: {$ref: "#/components/responses/Problem"} + /api/v1/admin/uploads/batches/{id}/releases/{release_id}/cover: + parameters: + - $ref: "#/components/parameters/EntityId" + - name: release_id + in: path + required: true + schema: {type: string, format: uuid} + get: + operationId: getUploadReleaseCover + parameters: + - name: group_id + in: query + schema: {type: string, format: uuid} + tags: [uploads] + summary: Предпросмотр обложки выбранного кандидата из Cover Art Archive + responses: + "200": + description: Ограниченное по размеру изображение; не меняет обложку пачки + content: + application/json: + schema: + type: object + required: [image_base64, mime_type, source_url, exact_release] + properties: + image_base64: {type: string} + mime_type: {type: string} + source_url: {type: string} + exact_release: {type: boolean} + default: {$ref: "#/components/responses/Problem"} + /api/v1/admin/uploads/batches/{id}/release: + parameters: + - $ref: "#/components/parameters/EntityId" + post: + operationId: applyUploadRelease + tags: [uploads] + summary: Явно применить метаданные и/или обложку выбранного издания + description: Только к совпавшим редактируемым файлам. Изменившаяся пачка или повторный поиск приводят к 409; одобрение и квота не меняются. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [release_id, apply_metadata, apply_cover] + properties: + group_id: {type: string, format: uuid} + release_id: {type: string, format: uuid} + apply_metadata: {type: boolean} + apply_cover: {type: boolean} + responses: + "200": + description: Применено к текущей пачке + content: + application/json: + schema: {$ref: "#/components/schemas/UploadReleaseReport"} + default: {$ref: "#/components/responses/Problem"} /api/v1/admin/uploads/review: post: operationId: reviewUploads @@ -2975,6 +3074,100 @@ components: target_source_id: type: string format: uuid + ReleaseTrack: + type: object + required: [recording_id, title, artist, disc, position, duration_ms] + properties: + recording_id: {type: string} + title: {type: string} + artist: {type: string} + disc: {type: integer} + position: {type: integer} + duration_ms: {type: integer} + ReleaseInfo: + type: object + required: [id, group_id, title, artist, type, date, country, comment, has_cover, tracks] + properties: + id: {type: string} + group_id: {type: string} + title: {type: string} + artist: {type: string} + type: {type: string} + date: {type: string} + country: {type: string} + comment: {type: string} + has_cover: {type: boolean} + tracks: + type: array + items: {$ref: "#/components/schemas/ReleaseTrack"} + ReleaseCandidate: + type: object + required: [release, matches, missing, existing, unmatched, matched, total, complete, confidence] + properties: + release: {$ref: "#/components/schemas/ReleaseInfo"} + matches: + type: array + items: + type: object + required: [file_id, track_index, confidence] + properties: + file_id: {type: string, format: uuid} + track_index: {type: integer} + confidence: {type: string} + method: {type: string, enum: [metadata, acoustid]} + missing: + type: array + items: {$ref: "#/components/schemas/ReleaseTrack"} + existing: + type: array + description: Совпадения отсутствующих дорожек с библиотекой по метаданным и длительности; не подтверждают полную загруженную пачку + items: {$ref: "#/components/schemas/ReleaseTrack"} + unmatched: + type: array + items: {type: string, format: uuid} + matched: {type: integer} + total: {type: integer} + complete: {type: boolean} + confidence: {type: string} + UploadReleaseGroup: + type: object + required: [id, artist, album, file_ids, has_cover, state] + properties: + id: {type: string, format: uuid} + artist: {type: string} + album: {type: string} + file_ids: + type: array + items: {type: string, format: uuid} + has_cover: {type: boolean} + state: {type: string} + UploadReleaseReport: + type: object + required: [state, input_hash, candidates, selected_release] + properties: + group_id: {type: string, format: uuid} + groups: + type: array + items: {$ref: "#/components/schemas/UploadReleaseGroup"} + audio_available: {type: boolean} + audio_results: + type: array + items: + type: object + required: [file_id, state] + properties: + file_id: {type: string, format: uuid} + state: {type: string, enum: [matched, ambiguous, not_found, unavailable]} + recording_id: {type: string, format: uuid} + score: {type: number, minimum: 0, maximum: 1} + state: + type: string + enum: [disabled, queued, searching, ready, not_found, needs_input, error] + input_hash: {type: string} + candidates: + type: array + items: {$ref: "#/components/schemas/ReleaseCandidate"} + selected_release: {type: string, nullable: true} UploadBatchPatch: type: object description: Пустые поля не меняют файлы. cover_base64 null снимает обложку пачки. diff --git a/apps/web/e2e/upload-review.spec.ts b/apps/web/e2e/upload-review.spec.ts index 8083831..0092bc1 100644 --- a/apps/web/e2e/upload-review.spec.ts +++ b/apps/web/e2e/upload-review.spec.ts @@ -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}'); +}); diff --git a/apps/web/src/features/uploads/BatchRelease.tsx b/apps/web/src/features/uploads/BatchRelease.tsx new file mode 100644 index 0000000..e504fb0 --- /dev/null +++ b/apps/web/src/features/uploads/BatchRelease.tsx @@ -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([]); + 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 <> + + {open && setOpen(false)} footer={ + + }> +
+

{t('release_lookup.intro')}

+ {groups.length > 1 && <> +

{t('release_lookup.groups_hint')}

+ + {group &&

{t(group.has_cover ? 'uploads.batch_cover_present' : 'uploads.batch_cover_absent')}

} + } +
+ + +
+ + + +

{t(report.data?.audio_available ? 'release_lookup.audio_privacy' : 'release_lookup.audio_setup')}

+

{stateText}

+ {!!report.data?.audio_results?.length && <> +

{t('release_lookup.audio_summary', { matched: report.data.audio_results.filter((r) => r.state === 'matched').length, total: report.data.audio_results.length })}

+ {report.data.audio_results.filter((r) => r.state !== 'matched').map((r) =>
{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')}
)} +

{t('release_lookup.audio_editions')}

+ } + {(report.error ?? search.error ?? apply.error) ? errorText(report.error ?? search.error ?? apply.error) : null} + {report.data?.state === 'ready' && report.data.candidates.length > 0 && <> + {report.data.candidates.length > 1 && } + {candidate && <> + {candidate.release.artist} — {candidate.release.title} + {[kind(candidate.release.type), candidate.release.date, candidate.release.country, candidate.release.comment].filter(Boolean).join(' · ')} + MusicBrainz +

{t('release_lookup.confidence', { value: t(candidate.confidence === 'high' ? 'release_lookup.confidence_high' : candidate.confidence === 'medium' ? 'release_lookup.confidence_medium' : 'release_lookup.confidence_low') })}

+

{t('release_lookup.coverage', { matched: candidate.matched, total: candidate.total, missing: candidate.missing.length, extra: candidate.unmatched.length })}

+ {candidate.existing.length > 0 &&

{t('release_lookup.existing', { count: candidate.existing.length })}

} +
+ + + {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 + + + + ; + })} +
#{t('release_lookup.uploaded')}{t('release_lookup.catalogue')}
{track.disc}.{track.position}{match ? file?.metadata ? `${file.metadata.artist} — ${file.metadata.title}` : file?.original_name ?? t('release_lookup.matched') : t('release_lookup.missing')} + {file?.metadata &&
{[file.metadata.albumartist, file.metadata.album, file.metadata.year, `${file.metadata.disc ?? 1}.${file.metadata.track ?? '?'}`].filter(Boolean).join(' · ')}
} + {match?.method === 'acoustid' &&
{t('release_lookup.audio_match')}
} + {match?.confidence === 'medium' &&
{t('release_lookup.tentative')}
}
{track.artist} — {track.title}
+
+ + + {preview && cover.error && {errorText(cover.error)}} + {preview && cover.data && <> + {t('uploads.batch_cover')} + {t(cover.data.exact_release ? 'release_lookup.exact_cover' : 'release_lookup.group_cover')} + Cover Art Archive + + } + {report.data.selected_release === releaseId &&

{t('release_lookup.applied')}

} + } + } +
+
} + ; +} diff --git a/apps/web/src/features/uploads/QueueRows.tsx b/apps/web/src/features/uploads/QueueRows.tsx index ca5a8d5..ba3c16f 100644 --- a/apps/web/src/features/uploads/QueueRows.tsx +++ b/apps/web/src/features/uploads/QueueRows.tsx @@ -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([]); 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')} + {selectable && } {selectable && (