From 55b85ac28f336c6048c661a0f75f5cbee9476ebd Mon Sep 17 00:00:00 2001 From: "r.osipin" Date: Sun, 31 May 2026 23:13:07 +0300 Subject: [PATCH 1/3] Add Discogs release review UI --- src/App.discogs-release-autocomplete.test.tsx | 266 ++++++++++ .../catalog/api/catalogEntityMappers.ts | 1 + src/features/catalog/api/catalogTypes.ts | 2 + .../catalog/api/externalMetadataClient.ts | 150 ++++++ src/features/catalog/api/httpClient.ts | 12 +- src/features/catalog/api/releaseClient.ts | 6 + .../catalog/catalogApi.mutations.test.ts | 54 ++ src/features/catalog/catalogApi.ts | 1 + .../catalog/externalMetadataClient.test.ts | 189 +++++++ .../releases/DiscogsReleaseLookupPanel.tsx | 468 ++++++++++++++++++ src/features/releases/ReleaseDetail.tsx | 15 +- src/features/releases/ReleaseEntryForm.tsx | 156 +++++- .../releases/ReleaseEntryFormTypes.ts | 1 + src/features/releases/ReleasesWorkspace.tsx | 20 +- src/features/releases/release-form.css | 185 +++++++ src/features/releases/release-tracklist.css | 27 + src/features/releases/releaseSubmit.ts | 3 + src/features/releases/releasesData.ts | 6 +- .../releases/useReleaseTrackDrafts.ts | 6 + src/styles/responsive.css | 4 + 20 files changed, 1565 insertions(+), 7 deletions(-) create mode 100644 src/App.discogs-release-autocomplete.test.tsx create mode 100644 src/features/catalog/api/externalMetadataClient.ts create mode 100644 src/features/catalog/externalMetadataClient.test.ts create mode 100644 src/features/releases/DiscogsReleaseLookupPanel.tsx diff --git a/src/App.discogs-release-autocomplete.test.tsx b/src/App.discogs-release-autocomplete.test.tsx new file mode 100644 index 0000000..4f5e2c6 --- /dev/null +++ b/src/App.discogs-release-autocomplete.test.tsx @@ -0,0 +1,266 @@ +import { describe, expect, it } from 'vitest' +import * as h from './test/appTestHarness' + +h.setupAppTestHooks() + +describe('App Discogs release autocomplete', () => { + it('searches release candidates and prefills a new release only after review apply', async () => { + window.history.pushState({}, '', '/releases') + const fetchMock = h.vi.fn().mockImplementation((input) => { + const url = requestUrl(input) + + if (url.pathname === '/api/external-metadata/discogs/releases') { + return Promise.resolve( + h.jsonResponse({ + items: [ + { + source: source('249504'), + title: 'Blue Monday', + artists: ['New Order'], + year: 1983, + labels: ['Factory'], + formats: ['Vinyl', '12"'], + catalogNumber: 'FAC 73', + barcodes: ['5016839200371'], + }, + ], + limit: 25, + total: 1, + }), + ) + } + + if (url.pathname === '/api/external-metadata/discogs/releases/249504') { + return Promise.resolve(h.jsonResponse(releaseDetail())) + } + + throw new Error(`Unexpected request: ${url.pathname}`) + }) + h.vi.stubGlobal('fetch', fetchMock) + const user = h.userEvent.setup() + h.render() + + await user.click(h.screen.getByRole('button', { name: 'Add release' })) + const form = h.screen.getByRole('form', { name: 'Add release' }) + + await user.type( + h.within(form).getByLabelText('Title'), + 'Local working title', + ) + await user.click( + h.within(form).getByRole('button', { name: 'Search Discogs' }), + ) + + const lookup = h.within(form).getByRole('region', { + name: 'Discogs release lookup', + }) + await user.click( + h.within(lookup).getByRole('button', { name: 'Search Discogs releases' }), + ) + await user.click( + await h.within(lookup).findByRole('button', { + name: /review blue monday/i, + }), + ) + + expect(h.within(form).getByLabelText('Title')).toHaveValue( + 'Local working title', + ) + expect( + await h.within(lookup).findByRole('heading', { + name: 'Review Discogs candidate', + }), + ).toBeInTheDocument() + expect( + h.within(lookup).getAllByText('Data provided by Discogs.').length, + ).toBeGreaterThan(0) + expect( + h.within(lookup).getByRole('link', { name: 'Open Discogs source' }), + ).toHaveAttribute('href', 'https://www.discogs.com/release/249504') + + await user.click( + h.within(lookup).getByRole('button', { + name: 'Apply selected Discogs fields', + }), + ) + + expect(h.within(form).getByLabelText('Title')).toHaveValue('Blue Monday') + expect(h.within(form).getByText('Factory')).toBeInTheDocument() + expect(h.within(form).getByText('FAC 73')).toBeInTheDocument() + expect(h.within(form).getAllByText('Blue Monday').length).toBeGreaterThan(0) + expect( + h.within(form).getByRole('button', { name: 'Add record' }), + ).toBeDisabled() + expect( + h.screen.queryByRole('complementary', { name: 'Blue Monday' }), + ).not.toBeInTheDocument() + }) + + it('reviews an existing release update before applying selected field groups and provenance', async () => { + window.history.pushState({}, '', '/releases?release=blue-monday') + const fetchMock = h.vi.fn().mockImplementation((input) => { + const url = requestUrl(input) + + if (url.pathname === '/api/external-metadata/discogs/releases') { + return Promise.resolve( + h.jsonResponse({ + items: [ + { + source: source('249504'), + title: 'Blue Monday 12"', + artists: ['New Order'], + year: 1983, + labels: ['Factory'], + formats: ['Vinyl', '12"'], + catalogNumber: 'FAC 73', + barcodes: [], + }, + ], + limit: 25, + total: 1, + }), + ) + } + + if (url.pathname === '/api/external-metadata/discogs/releases/249504') { + return Promise.resolve( + h.jsonResponse({ + ...releaseDetail(), + title: 'Blue Monday 12"', + draft: { + ...releaseDetail().draft, + title: 'Blue Monday 12"', + }, + }), + ) + } + + throw new Error(`Unexpected request: ${url.pathname}`) + }) + h.vi.stubGlobal('fetch', fetchMock) + const user = h.userEvent.setup() + h.render() + + await user.click( + h.screen.getByRole('button', { name: 'Update via Discogs' }), + ) + const form = h.screen.getByRole('form', { name: 'Edit release' }) + const lookup = h.within(form).getByRole('region', { + name: 'Discogs release lookup', + }) + + await user.click( + h.within(lookup).getByRole('button', { name: 'Search Discogs releases' }), + ) + await user.click( + await h.within(lookup).findByRole('button', { + name: /review blue monday 12/i, + }), + ) + + expect(h.within(form).getByLabelText('Title')).toHaveValue('Blue Monday') + + await user.click(h.within(lookup).getByLabelText('Apply Core')) + await user.click(h.within(lookup).getByLabelText('Apply External Source')) + await user.click( + h.within(lookup).getByRole('button', { + name: 'Apply selected Discogs fields', + }), + ) + + expect(h.within(form).getByLabelText('Title')).toHaveValue( + 'Blue Monday 12"', + ) + + await user.click( + h.within(form).getByRole('button', { name: 'Save record' }), + ) + + const updatedRelease = h + .getInitialCatalogStateForTests() + ?.releases.find((release) => release.id === 'blue-monday') + expect(updatedRelease).toMatchObject({ + title: 'Blue Monday 12"', + externalSources: [ + { + providerName: 'discogs', + resourceType: 'release', + externalId: '249504', + sourceUrl: 'https://www.discogs.com/release/249504', + }, + ], + }) + expect(updatedRelease?.externalSources?.[0].appliedAt).toMatch( + /^\d{4}-\d{2}-\d{2}T/, + ) + }) +}) + +function requestUrl(input: Parameters[0]) { + if (typeof input === 'string' || input instanceof URL) { + return new URL(input, 'http://localhost') + } + + return new URL(input.url, 'http://localhost') +} + +function source(externalId: string) { + return { + providerName: 'discogs', + resourceType: 'release', + externalId, + sourceUrl: `https://www.discogs.com/release/${externalId}`, + attribution: 'Data provided by Discogs.', + } +} + +function releaseDetail() { + return { + source: source('249504'), + title: 'Blue Monday', + artists: ['New Order'], + year: 1983, + labels: ['Factory'], + formats: ['Vinyl', '12"'], + tracklist: [ + { + title: 'Blue Monday', + position: 'A', + durationSeconds: 449, + artists: ['New Order'], + }, + ], + identifiers: [{ type: 'Barcode', value: '5016839200371' }], + barcodes: ['5016839200371'], + catalogNumber: 'FAC 73', + credits: [{ name: 'New Order', role: 'Written-By' }], + draft: { + title: 'Blue Monday', + year: 1983, + artistCredits: [{ name: 'New Order', role: 'mainArtist' }], + labels: [ + { + name: 'Factory', + catalogNumber: 'FAC 73', + hasNoCatalogNumber: false, + }, + ], + tracklist: [ + { + title: 'Blue Monday', + position: 1, + durationSeconds: 449, + artistCredits: [{ name: 'New Order', role: 'mainArtist' }], + }, + ], + externalSources: [ + { + providerName: 'discogs', + resourceType: 'release', + externalId: '249504', + sourceUrl: 'https://www.discogs.com/release/249504', + }, + ], + }, + } +} diff --git a/src/features/catalog/api/catalogEntityMappers.ts b/src/features/catalog/api/catalogEntityMappers.ts index 2a22457..be00879 100644 --- a/src/features/catalog/api/catalogEntityMappers.ts +++ b/src/features/catalog/api/catalogEntityMappers.ts @@ -185,6 +185,7 @@ export function toReleaseRecord( coverImage: release.coverImage ? toReleaseCoverImage(release.coverImage) : undefined, + externalSources: release.externalSources ?? [], ownedCopies: [ ...ownedItems .filter( diff --git a/src/features/catalog/api/catalogTypes.ts b/src/features/catalog/api/catalogTypes.ts index 4691027..f54119a 100644 --- a/src/features/catalog/api/catalogTypes.ts +++ b/src/features/catalog/api/catalogTypes.ts @@ -8,6 +8,7 @@ import type { import type { ReleaseRecord } from '../../releases/releasesData' import type { RelationRecord } from '../../relations/relationsData' import type { TrackRecord } from '../../tracks/tracksData' +import type { ExternalSourceReference } from './externalMetadataClient' export const pageSize = 100 @@ -337,6 +338,7 @@ export type ReleaseDto = { artistCredits?: ReleaseArtistCreditDto[] labels?: ReleaseLabelDto[] tracklist?: ReleaseTracklistItemDto[] + externalSources?: ExternalSourceReference[] | null } export type ReleaseCoverImageDto = { diff --git a/src/features/catalog/api/externalMetadataClient.ts b/src/features/catalog/api/externalMetadataClient.ts new file mode 100644 index 0000000..d8dbd35 --- /dev/null +++ b/src/features/catalog/api/externalMetadataClient.ts @@ -0,0 +1,150 @@ +import { assertNoCollectionIds, CatalogApiError } from './httpClient' + +export type ExternalMetadataSourceDto = { + providerName: string + resourceType: string + externalId: string + sourceUrl: string + attribution: string +} + +export type ExternalSourceReference = { + providerName: string + resourceType: string + externalId: string + sourceUrl: string + appliedAt?: string +} + +export type DiscogsReleaseSearchParams = { + query?: string + artist?: string + title?: string + year?: string + barcode?: string + catalogNumber?: string + limit?: number +} + +export type DiscogsReleaseSearchResponse = { + items: ExternalMetadataReleaseCandidateDto[] + limit: number + total: number +} + +export type ExternalMetadataReleaseCandidateDto = { + source: ExternalMetadataSourceDto + title: string + artists: string[] + year?: number | null + labels: string[] + formats: string[] + catalogNumber?: string | null + barcodes: string[] +} + +export type ExternalMetadataReleaseDetailDto = + ExternalMetadataReleaseCandidateDto & { + tracklist: ExternalMetadataReleaseTrackDto[] + identifiers: ExternalMetadataReleaseIdentifierDto[] + credits: ExternalMetadataReleaseCreditDto[] + draft: ExternalMetadataReleaseDraftDto + } + +export type ExternalMetadataReleaseTrackDto = { + title: string + position?: string | null + durationSeconds?: number | null + artists: string[] +} + +export type ExternalMetadataReleaseIdentifierDto = { + type: string + value: string +} + +export type ExternalMetadataReleaseCreditDto = { + name: string + role: string + trackTitle?: string | null + trackPosition?: string | null +} + +export type ExternalMetadataReleaseDraftDto = { + title: string + year?: number | null + artistCredits: ExternalMetadataReleaseDraftArtistCreditDto[] + labels: ExternalMetadataReleaseDraftLabelDto[] + tracklist: ExternalMetadataReleaseDraftTrackDto[] + externalSources: ExternalSourceReference[] +} + +export type ExternalMetadataReleaseDraftArtistCreditDto = { + name: string + role: string +} + +export type ExternalMetadataReleaseDraftLabelDto = { + name: string + catalogNumber?: string | null + hasNoCatalogNumber: boolean +} + +export type ExternalMetadataReleaseDraftTrackDto = { + title: string + position: number + durationSeconds?: number | null + artistCredits: ExternalMetadataReleaseDraftArtistCreditDto[] +} + +export async function searchDiscogsReleases( + params: DiscogsReleaseSearchParams, +) { + const query = new URLSearchParams() + appendTrimmed(query, 'query', params.query) + appendTrimmed(query, 'artist', params.artist) + appendTrimmed(query, 'title', params.title) + appendTrimmed(query, 'year', params.year) + appendTrimmed(query, 'barcode', params.barcode) + appendTrimmed(query, 'catalogNumber', params.catalogNumber) + query.set('limit', String(params.limit ?? 25)) + + return getExternalMetadataJson( + `/api/external-metadata/discogs/releases?${query.toString()}`, + ) +} + +export async function getDiscogsRelease(externalId: string) { + return getExternalMetadataJson( + `/api/external-metadata/discogs/releases/${encodeURIComponent( + externalId.trim(), + )}`, + ) +} + +async function getExternalMetadataJson(path: string): Promise { + const response = await fetch(path, { + credentials: 'include', + method: 'GET', + }) + + if (!response.ok) { + throw await CatalogApiError.fromResponse(response) + } + + const body = (await response.json()) as T + assertNoCollectionIds(body) + + return body +} + +function appendTrimmed( + query: URLSearchParams, + name: string, + value: string | undefined, +) { + const trimmed = value?.trim() + if (trimmed) { + query.set(name, trimmed) + } +} diff --git a/src/features/catalog/api/httpClient.ts b/src/features/catalog/api/httpClient.ts index 7766cf1..a6388f6 100644 --- a/src/features/catalog/api/httpClient.ts +++ b/src/features/catalog/api/httpClient.ts @@ -162,11 +162,18 @@ export async function readJsonBody(response: Response): Promise { export class CatalogApiError extends Error { readonly status: number readonly code: string | null - - private constructor(status: number, code: string | null, message: string) { + readonly retryAfter: string | null + + private constructor( + status: number, + code: string | null, + message: string, + retryAfter: string | null, + ) { super(message) this.status = status this.code = code + this.retryAfter = retryAfter } static async fromResponse(response: Response) { @@ -177,6 +184,7 @@ export class CatalogApiError extends Error { body?.code ?? null, body?.message ?? `Catalog API request failed with HTTP ${response.status}.`, + response.headers.get('Retry-After'), ) } } diff --git a/src/features/catalog/api/releaseClient.ts b/src/features/catalog/api/releaseClient.ts index 61aeea0..9a716d1 100644 --- a/src/features/catalog/api/releaseClient.ts +++ b/src/features/catalog/api/releaseClient.ts @@ -71,6 +71,9 @@ export async function createRelease( releaseDate: release.releaseDate ?? null, genres: release.genres, tags: release.tags, + ...(release.externalSources === undefined + ? {} + : { externalSources: release.externalSources }), tracklist: tracks.map(toReleaseTracklistRequest), ownedCopy: release.ownedCopies[0] ? { @@ -154,6 +157,9 @@ export async function updateRelease( releaseDate: release.releaseDate ?? null, genres: release.genres, tags: release.tags, + ...(release.externalSources === undefined + ? {} + : { externalSources: release.externalSources }), ...(tracks === undefined ? {} : { tracklist: tracks.map(toReleaseTracklistRequest) }), diff --git a/src/features/catalog/catalogApi.mutations.test.ts b/src/features/catalog/catalogApi.mutations.test.ts index 0c91371..e515ff1 100644 --- a/src/features/catalog/catalogApi.mutations.test.ts +++ b/src/features/catalog/catalogApi.mutations.test.ts @@ -1,10 +1,64 @@ import { describe, expect, it, vi } from 'vitest' import * as api from './catalogApi' import * as h from './catalogApiTestHarness' +import type { ReleaseRecord } from '../releases/releasesData' h.setupCatalogApiAdapterTests() describe('catalog API adapter mutations and covers', () => { + it('sends release-level external sources on create and update', async () => { + const fetchMock = vi.fn() + fetchMock + .mockResolvedValueOnce(h.jsonResponse({ id: 'release-id' }, 201)) + .mockResolvedValueOnce(h.jsonResponse({ id: 'release-id' })) + vi.stubGlobal('fetch', fetchMock) + const release: ReleaseRecord = { + id: 'release-id', + title: 'Discogs Sourced EP', + artist: 'Source Artist', + artistCredits: [{ artist: 'Source Artist', role: 'Main artist' }], + type: 'EP', + year: '2026', + label: 'Source Label', + labels: [ + { + name: 'Source Label', + catalogNumber: 'SRC-1', + hasNoCatalogNumber: false, + }, + ], + genres: ['Electronic'], + tags: [], + releaseNotes: '', + ownedCopies: [], + externalSources: [ + { + providerName: 'discogs', + resourceType: 'release', + externalId: '249504', + sourceUrl: 'https://www.discogs.com/release/249504', + appliedAt: '2026-05-31T19:00:00.000Z', + }, + ], + } + + await api.createRelease(release, []) + await api.updateRelease(release, []) + + expect(fetchMock.mock.calls[0][0]).toBe('/api/releases') + expect(fetchMock.mock.calls[1][0]).toBe('/api/releases/release-id') + expect( + h.requestPayload>(fetchMock.mock.calls[0][1]), + ).toMatchObject({ + externalSources: release.externalSources, + }) + expect( + h.requestPayload>(fetchMock.mock.calls[1][1]), + ).toMatchObject({ + externalSources: release.externalSources, + }) + }) + it('rejects invalid rating values before sending a request', async () => { const fetchMock = vi.fn() vi.stubGlobal('fetch', fetchMock) diff --git a/src/features/catalog/catalogApi.ts b/src/features/catalog/catalogApi.ts index bdb9678..4966dad 100644 --- a/src/features/catalog/catalogApi.ts +++ b/src/features/catalog/catalogApi.ts @@ -3,6 +3,7 @@ export * from './api/catalogDefaults' export * from './api/catalogLoadClient' export * from './api/catalogTypes' export * from './api/httpClient' +export * from './api/externalMetadataClient' export * from './api/importsExportsClient' export * from './api/ownedRelationsClient' export * from './api/ownedItemsClient' diff --git a/src/features/catalog/externalMetadataClient.test.ts b/src/features/catalog/externalMetadataClient.test.ts new file mode 100644 index 0000000..26909f1 --- /dev/null +++ b/src/features/catalog/externalMetadataClient.test.ts @@ -0,0 +1,189 @@ +import { describe, expect, it, vi } from 'vitest' +import { + getDiscogsRelease, + searchDiscogsReleases, +} from './api/externalMetadataClient' +import { CatalogApiError } from './api/httpClient' +import * as h from './catalogApiTestHarness' + +h.setupCatalogApiAdapterTests() + +describe('external metadata API client', () => { + it('searches Discogs releases with trimmed collection-scoped query params', async () => { + const fetchMock = vi.fn().mockResolvedValue( + h.jsonResponse({ + items: [ + { + source: source('release', '249504'), + title: 'Blue Monday', + artists: ['New Order'], + year: 1983, + labels: ['Factory'], + formats: ['Vinyl', '12"'], + catalogNumber: 'FAC 73', + barcodes: ['5016839200371'], + }, + ], + limit: 25, + total: 1, + }), + ) + vi.stubGlobal('fetch', fetchMock) + + const result = await searchDiscogsReleases({ + query: ' factory ', + artist: ' New Order ', + title: ' Blue Monday ', + year: ' 1983 ', + barcode: ' 5016839200371 ', + catalogNumber: ' FAC 73 ', + limit: 25, + }) + + const url = requestUrl(fetchMock.mock.calls[0][0]) + expect(url.pathname).toBe('/api/external-metadata/discogs/releases') + expect(url.searchParams.get('query')).toBe('factory') + expect(url.searchParams.get('artist')).toBe('New Order') + expect(url.searchParams.get('title')).toBe('Blue Monday') + expect(url.searchParams.get('year')).toBe('1983') + expect(url.searchParams.get('barcode')).toBe('5016839200371') + expect(url.searchParams.get('catalogNumber')).toBe('FAC 73') + expect(url.searchParams.get('limit')).toBe('25') + expect(fetchMock.mock.calls[0][1]).toMatchObject({ + credentials: 'include', + method: 'GET', + }) + expect(result.items[0]).toMatchObject({ + title: 'Blue Monday', + source: { + providerName: 'discogs', + attribution: 'Data provided by Discogs.', + }, + }) + }) + + it('loads Discogs release detail draft data and rejects collection id leaks', async () => { + const fetchMock = vi.fn() + fetchMock + .mockResolvedValueOnce( + h.jsonResponse({ + source: source('release', '249504'), + title: 'Blue Monday', + artists: ['New Order'], + year: 1983, + labels: ['Factory'], + formats: ['Vinyl', '12"'], + tracklist: [ + { + title: 'Blue Monday', + position: 'A', + durationSeconds: 449, + artists: ['New Order'], + }, + ], + identifiers: [{ type: 'Barcode', value: '5016839200371' }], + barcodes: ['5016839200371'], + catalogNumber: 'FAC 73', + credits: [{ name: 'New Order', role: 'Written-By' }], + draft: { + title: 'Blue Monday', + year: 1983, + artistCredits: [{ name: 'New Order', role: 'mainArtist' }], + labels: [ + { + name: 'Factory', + catalogNumber: 'FAC 73', + hasNoCatalogNumber: false, + }, + ], + tracklist: [ + { + title: 'Blue Monday', + position: 1, + durationSeconds: 449, + artistCredits: [{ name: 'New Order', role: 'mainArtist' }], + }, + ], + externalSources: [ + { + providerName: 'discogs', + resourceType: 'release', + externalId: '249504', + sourceUrl: 'https://www.discogs.com/release/249504', + }, + ], + }, + }), + ) + .mockResolvedValueOnce( + h.jsonResponse({ + collectionId: '00000000-0000-7000-8000-000000000099', + }), + ) + vi.stubGlobal('fetch', fetchMock) + + const detail = await getDiscogsRelease('249504') + + expect(fetchMock.mock.calls[0][0]).toBe( + '/api/external-metadata/discogs/releases/249504', + ) + expect(detail.draft.tracklist[0]).toMatchObject({ + title: 'Blue Monday', + position: 1, + durationSeconds: 449, + }) + expect(detail.draft.externalSources[0]).toMatchObject({ + providerName: 'discogs', + resourceType: 'release', + externalId: '249504', + }) + await expect(getDiscogsRelease('leaky')).rejects.toThrow(/collection ids/i) + }) + + it('surfaces safe provider errors without losing retry-after', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + code: 'external_metadata.rate_limited', + message: 'External metadata provider is rate limited', + }), + { + headers: { + 'Content-Type': 'application/json', + 'Retry-After': '60', + }, + status: 429, + }, + ), + ), + ) + + await expect( + searchDiscogsReleases({ query: 'Factory' }), + ).rejects.toMatchObject({ + status: 429, + code: 'external_metadata.rate_limited', + retryAfter: '60', + } satisfies Partial) + }) +}) + +function source(resourceType: string, externalId: string) { + return { + providerName: 'discogs', + resourceType, + externalId, + sourceUrl: `https://www.discogs.com/${resourceType}/${externalId}`, + attribution: 'Data provided by Discogs.', + } +} + +function requestUrl(input: Parameters[0]) { + if (typeof input === 'string' || input instanceof URL) { + return new URL(input, 'http://localhost') + } + + return new URL(input.url, 'http://localhost') +} diff --git a/src/features/releases/DiscogsReleaseLookupPanel.tsx b/src/features/releases/DiscogsReleaseLookupPanel.tsx new file mode 100644 index 0000000..7fb8cac --- /dev/null +++ b/src/features/releases/DiscogsReleaseLookupPanel.tsx @@ -0,0 +1,468 @@ +import { Search } from 'lucide-react' +import { useEffect, useRef, useState } from 'react' +import { + CatalogApiError, + getDiscogsRelease, + searchDiscogsReleases, + type ExternalMetadataReleaseCandidateDto, + type ExternalMetadataReleaseDetailDto, +} from '../catalog/catalogApi' + +export type DiscogsApplyGroups = { + core: boolean + artists: boolean + labels: boolean + tracklist: boolean + externalSource: boolean +} + +export type DiscogsSearchSeed = { + artist: string + catalogNumber: string + title: string + year: string +} + +export type DiscogsCurrentRelease = { + artists: string + externalSourceCount: number + labels: string + title: string + trackCount: number + year: string +} + +type DiscogsReleaseLookupPanelProps = { + current: DiscogsCurrentRelease + isOpen: boolean + mode: 'create' | 'update' + searchSeed: DiscogsSearchSeed + onApplyDraft: ( + detail: ExternalMetadataReleaseDetailDto, + groups: DiscogsApplyGroups, + ) => void + onOpenChange: (isOpen: boolean) => void +} + +const emptyGroups: DiscogsApplyGroups = { + core: false, + artists: false, + labels: false, + tracklist: false, + externalSource: false, +} + +export function DiscogsReleaseLookupPanel({ + current, + isOpen, + mode, + searchSeed, + onApplyDraft, + onOpenChange, +}: DiscogsReleaseLookupPanelProps) { + const [query, setQuery] = useState('') + const [artist, setArtist] = useState(searchSeed.artist) + const [title, setTitle] = useState(searchSeed.title) + const [year, setYear] = useState(searchSeed.year) + const [barcode, setBarcode] = useState('') + const [catalogNumber, setCatalogNumber] = useState(searchSeed.catalogNumber) + const [status, setStatus] = useState('') + const [candidates, setCandidates] = useState< + ExternalMetadataReleaseCandidateDto[] + >([]) + const [selectedDetail, setSelectedDetail] = + useState(null) + const [applyGroups, setApplyGroups] = useState(() => + defaultGroups(mode), + ) + const wasOpen = useRef(false) + + useEffect(() => { + if (isOpen && !wasOpen.current) { + setArtist(searchSeed.artist) + setTitle(searchSeed.title) + setYear(searchSeed.year) + setCatalogNumber(searchSeed.catalogNumber) + } + + wasOpen.current = isOpen + }, [isOpen, searchSeed]) + + async function handleSearch() { + setStatus('Searching Discogs release candidates.') + setSelectedDetail(null) + + try { + const result = await searchDiscogsReleases({ + query, + artist, + title, + year, + barcode, + catalogNumber, + limit: 25, + }) + + setCandidates(result.items) + setStatus( + result.items.length > 0 + ? `${result.total} candidate${result.total === 1 ? '' : 's'} found.` + : 'No Discogs release candidates found.', + ) + } catch (error) { + setCandidates([]) + setStatus(externalMetadataErrorMessage(error)) + } + } + + async function reviewCandidate( + candidate: ExternalMetadataReleaseCandidateDto, + ) { + setStatus(`Loading Discogs detail for ${candidate.title}.`) + + try { + const detail = await getDiscogsRelease(candidate.source.externalId) + setSelectedDetail(detail) + setApplyGroups(defaultGroups(mode)) + setStatus(`Review loaded for ${detail.title}.`) + } catch (error) { + setSelectedDetail(null) + setStatus(externalMetadataErrorMessage(error)) + } + } + + function updateApplyGroup(group: keyof DiscogsApplyGroups, checked: boolean) { + setApplyGroups((groups) => ({ ...groups, [group]: checked })) + } + + const hasSelectedGroup = Object.values(applyGroups).some(Boolean) + + return ( +
+
+
+

Discogs

+

Search release candidates and review fields before applying.

+
+ +
+ + {isOpen ? ( + <> +
+ + + + + + + +
+ + {status ? ( +

+ {status} +

+ ) : null} + + {candidates.length > 0 ? ( +
+ {candidates.map((candidate) => ( +
+
+ {candidate.title} +

+ {candidate.artists.join(', ') || 'Unknown artist'} ·{' '} + {candidate.year ?? 'Unknown year'} ·{' '} + {candidate.labels.join(', ') || 'Unknown label'} +

+

+ {[...candidate.formats, candidate.catalogNumber] + .filter(Boolean) + .join(' · ')} +

+ {candidate.barcodes.length > 0 ? ( +

Barcodes: {candidate.barcodes.join(', ')}

+ ) : null} +

{candidate.source.attribution}

+
+
+ + Open candidate Discogs source + + +
+
+ ))} +
+ ) : null} + + {selectedDetail ? ( +
+
+
+

Review Discogs candidate

+

+ {selectedDetail.source.attribution}{' '} + + Open Discogs source + +

+
+
+ +
+ + +
+ +
+ Apply groups + updateApplyGroup('core', checked)} + /> + updateApplyGroup('artists', checked)} + /> + updateApplyGroup('labels', checked)} + /> + updateApplyGroup('tracklist', checked)} + /> + + updateApplyGroup('externalSource', checked) + } + /> +
+ + +
+ ) : null} + + ) : ( +

+ Discogs lookup is optional and never saves data until the release form + is submitted. +

+ )} +
+ ) +} + +function defaultGroups(mode: 'create' | 'update'): DiscogsApplyGroups { + return mode === 'create' + ? { + core: true, + artists: true, + labels: true, + tracklist: true, + externalSource: true, + } + : emptyGroups +} + +function externalMetadataErrorMessage(error: unknown) { + if (error instanceof CatalogApiError) { + const retry = + error.retryAfter && error.status === 429 + ? ` Retry after ${error.retryAfter} seconds.` + : '' + + return `${error.message}${retry}` + } + + return 'External metadata provider is unavailable.' +} + +function currentRows(current: DiscogsCurrentRelease) { + return [ + ['Title', current.title || 'Not recorded'], + ['Artist', current.artists || 'Not recorded'], + ['Year', current.year || 'Not recorded'], + ['Labels', current.labels || 'Not recorded'], + ['Tracklist', `${current.trackCount} rows`], + ['Sources', `${current.externalSourceCount} sources`], + ] +} + +function discogsRows(detail: ExternalMetadataReleaseDetailDto) { + return [ + ['Title', detail.draft.title || 'Not recorded'], + [ + 'Artist', + detail.draft.artistCredits.map((credit) => credit.name).join(', ') || + 'Not recorded', + ], + ['Year', detail.draft.year?.toString() ?? 'Not recorded'], + [ + 'Labels', + detail.draft.labels + .map((label) => + [label.name, label.catalogNumber].filter(Boolean).join(' '), + ) + .join(', ') || 'Not recorded', + ], + ['Tracklist', `${detail.draft.tracklist.length} rows`], + ['Formats', detail.formats.join(', ') || 'Not recorded'], + ['Barcodes', detail.barcodes.join(', ') || 'Not recorded'], + [ + 'Identifiers', + detail.identifiers + .map((identifier) => `${identifier.type}: ${identifier.value}`) + .join(', ') || 'Not recorded', + ], + [ + 'Credits', + detail.credits + .map((credit) => [credit.name, credit.role].filter(Boolean).join(' - ')) + .join(', ') || 'Not recorded', + ], + ['Sources', `${detail.draft.externalSources.length} sources`], + ] +} + +function ReviewColumn({ title, rows }: { title: string; rows: string[][] }) { + return ( +
+

{title}

+
+ {rows.map(([label, value]) => ( +
+
{label}
+
{value}
+
+ ))} +
+
+ ) +} + +function ApplyGroup({ + checked, + label, + onChange, +}: { + checked: boolean + label: string + onChange: (checked: boolean) => void +}) { + return ( + + ) +} diff --git a/src/features/releases/ReleaseDetail.tsx b/src/features/releases/ReleaseDetail.tsx index 6c68a51..445b13c 100644 --- a/src/features/releases/ReleaseDetail.tsx +++ b/src/features/releases/ReleaseDetail.tsx @@ -30,6 +30,7 @@ type ReleaseDetailProps = { onEdit?: () => void onEditLocalFiles?: (tracks: TrackRecord[]) => void onRemoveCover?: (releaseId: string) => Promise | void + onUpdateViaDiscogs?: () => void onUploadCover?: (releaseId: string, file: File) => Promise | void playlists: PlaylistRecord[] release: ReleaseRecord @@ -55,6 +56,7 @@ export function ReleaseDetail({ onEdit, onEditLocalFiles, onRemoveCover, + onUpdateViaDiscogs, onUploadCover, playlists, release, @@ -105,7 +107,9 @@ export function ReleaseDetail({

{release.title}

{release.artist}

- {onEdit || (onEditLocalFiles && localTracks.length > 0) ? ( + {onEdit || + onUpdateViaDiscogs || + (onEditLocalFiles && localTracks.length > 0) ? (
{onEdit ? ( + ) : null} {onEditLocalFiles && localTracks.length > 0 ? ( + {onUpdateViaDiscogs ? ( + + ) : null} {onDelete ? ( ([]) const [editingArtistId, setEditingArtistId] = useState('') + const [discogsLookupArtistId, setDiscogsLookupArtistId] = useState('') const artists = useMemo(() => { return [...providedArtists, ...manualArtists] }, [manualArtists, providedArtists]) @@ -112,6 +118,7 @@ export function ArtistsWorkspace({ setQuery('') selectArtist(artist.id) onManualEntryClose() + setDiscogsLookupArtistId('') } function handleUpdateArtist(artist: ArtistRecord) { @@ -128,6 +135,7 @@ export function ArtistsWorkspace({ setQuery('') selectArtist(artist.id) setEditingArtistId('') + setDiscogsLookupArtistId('') } function handleDeleteArtist(artistId: string) { @@ -141,10 +149,12 @@ export function ArtistsWorkspace({ setQuery('') setEditingArtistId('') + setDiscogsLookupArtistId('') } function handleCancelEdit() { setEditingArtistId('') + setDiscogsLookupArtistId('') } const editingArtist = artists.find((artist) => artist.id === editingArtistId) @@ -205,6 +215,9 @@ export function ArtistsWorkspace({ setEditingArtistId(selectedArtist.id)} + onEdit={() => { + setEditingArtistId(selectedArtist.id) + setDiscogsLookupArtistId('') + }} + onUpdateViaDiscogs={() => { + setEditingArtistId(selectedArtist.id) + setDiscogsLookupArtistId(selectedArtist.id) + }} onDelete={() => handleDeleteArtist(selectedArtist.id)} ratingCriteria={ratingCriteria} onDeleteRating={onDeleteRating} @@ -237,6 +257,7 @@ export function ArtistsWorkspace({ export type ArtistEntryFormProps = { artists: ArtistRecord[] initialArtist?: ArtistRecord + initialShowDiscogsLookup?: boolean onCancel: () => void onSubmit: (artist: ArtistRecord) => void } @@ -244,6 +265,7 @@ export type ArtistEntryFormProps = { export function ArtistEntryForm({ artists, initialArtist, + initialShowDiscogsLookup, onCancel, onSubmit, }: ArtistEntryFormProps) { @@ -253,6 +275,13 @@ export function ArtistEntryForm({ initialArtist?.relationHint ?? '', ) const [notes, setNotes] = useState(initialArtist?.summary ?? '') + const [externalSources, setExternalSources] = useState( + initialArtist?.externalSources, + ) + const [discogsLookupOpenPreference, setDiscogsLookupOpenPreference] = + useState(null) + const isDiscogsLookupOpen = + discogsLookupOpenPreference ?? Boolean(initialShowDiscogsLookup) const isValid = name.trim().length > 0 const duplicateArtist = artists.find( (artist) => @@ -294,9 +323,28 @@ export function ArtistEntryForm({ credits: isEditing ? (initialArtist?.credits ?? []) : [], tags: ['manual entry'], summary, + externalSources, }) } + function handleApplyDiscogsDraft( + detail: ExternalMetadataArtistDetailDto, + groups: DiscogsArtistApplyGroups, + ) { + if (groups.core) { + setName(detail.draft.name) + } + + if (groups.externalSource) { + setExternalSources( + detail.draft.externalSources.map((source) => ({ + ...source, + appliedAt: new Date().toISOString(), + })), + ) + } + } + return ( Collective + {duplicateArtist ? (

Likely duplicate artist: {duplicateArtist.name}. Submit is still diff --git a/src/features/artists/DiscogsArtistLookupPanel.tsx b/src/features/artists/DiscogsArtistLookupPanel.tsx new file mode 100644 index 0000000..8571940 --- /dev/null +++ b/src/features/artists/DiscogsArtistLookupPanel.tsx @@ -0,0 +1,342 @@ +import { Search } from 'lucide-react' +import { useEffect, useRef, useState } from 'react' +import { + CatalogApiError, + getDiscogsArtist, + searchDiscogsArtists, + type ExternalMetadataArtistCandidateDto, + type ExternalMetadataArtistDetailDto, +} from '../catalog/catalogApi' + +export type DiscogsArtistApplyGroups = { + core: boolean + externalSource: boolean +} + +export type DiscogsCurrentArtist = { + externalSourceCount: number + name: string + type: string +} + +type DiscogsArtistLookupPanelProps = { + current: DiscogsCurrentArtist + isOpen: boolean + mode: 'create' | 'update' + searchSeed: string + onApplyDraft: ( + detail: ExternalMetadataArtistDetailDto, + groups: DiscogsArtistApplyGroups, + ) => void + onOpenChange: (isOpen: boolean) => void +} + +const emptyGroups: DiscogsArtistApplyGroups = { + core: false, + externalSource: false, +} + +export function DiscogsArtistLookupPanel({ + current, + isOpen, + mode, + searchSeed, + onApplyDraft, + onOpenChange, +}: DiscogsArtistLookupPanelProps) { + const [query, setQuery] = useState(searchSeed) + const [status, setStatus] = useState('') + const [candidates, setCandidates] = useState< + ExternalMetadataArtistCandidateDto[] + >([]) + const [selectedDetail, setSelectedDetail] = + useState(null) + const [applyGroups, setApplyGroups] = useState(() => + defaultGroups(mode), + ) + const wasOpen = useRef(false) + + useEffect(() => { + if (isOpen && !wasOpen.current) { + setQuery(searchSeed) + } + + wasOpen.current = isOpen + }, [isOpen, searchSeed]) + + async function handleSearch() { + setStatus('Searching Discogs artist candidates.') + setSelectedDetail(null) + + try { + const result = await searchDiscogsArtists({ query, limit: 25 }) + setCandidates(result.items) + setStatus( + result.items.length > 0 + ? `${result.total} candidate${result.total === 1 ? '' : 's'} found.` + : 'No Discogs artist candidates found.', + ) + } catch (error) { + setCandidates([]) + setStatus(externalMetadataErrorMessage(error)) + } + } + + async function reviewCandidate( + candidate: ExternalMetadataArtistCandidateDto, + ) { + setStatus(`Loading Discogs detail for ${candidate.name}.`) + + try { + const detail = await getDiscogsArtist(candidate.source.externalId) + setSelectedDetail(detail) + setApplyGroups(defaultGroups(mode)) + setStatus(`Review loaded for ${detail.name}.`) + } catch (error) { + setSelectedDetail(null) + setStatus(externalMetadataErrorMessage(error)) + } + } + + function updateApplyGroup( + group: keyof DiscogsArtistApplyGroups, + checked: boolean, + ) { + setApplyGroups((groups) => ({ ...groups, [group]: checked })) + } + + const hasSelectedGroup = Object.values(applyGroups).some(Boolean) + + return ( +

+
+
+

Discogs

+

Search artist candidates and review fields before applying.

+
+ +
+ + {isOpen ? ( + <> +
+ + +
+ + {status ? ( +

+ {status} +

+ ) : null} + + {candidates.length > 0 ? ( +
+ {candidates.map((candidate) => ( +
+
+ {candidate.name} + {candidate.profile ?

{candidate.profile}

: null} + {candidate.nameVariations.length > 0 ? ( +

Variations: {candidate.nameVariations.join(', ')}

+ ) : null} +

{candidate.source.attribution}

+
+
+ + Open candidate Discogs artist source + + +
+
+ ))} +
+ ) : null} + + {selectedDetail ? ( +
+
+
+

Review Discogs artist

+

+ {selectedDetail.source.attribution}{' '} + + Open Discogs artist source + +

+
+
+ +
+ + +
+ +
+ Apply groups + updateApplyGroup('core', checked)} + /> + + updateApplyGroup('externalSource', checked) + } + /> +
+ + +
+ ) : null} + + ) : ( +

+ Discogs lookup is optional and never saves data until the artist form + is submitted. +

+ )} +
+ ) +} + +function defaultGroups(mode: 'create' | 'update'): DiscogsArtistApplyGroups { + return mode === 'create' + ? { + core: true, + externalSource: true, + } + : emptyGroups +} + +function externalMetadataErrorMessage(error: unknown) { + if (error instanceof CatalogApiError) { + const retry = + error.retryAfter && error.status === 429 + ? ` Retry after ${error.retryAfter} seconds.` + : '' + + return `${error.message}${retry}` + } + + return 'External metadata provider is unavailable.' +} + +function currentRows(current: DiscogsCurrentArtist) { + return [ + ['Name', current.name || 'Not recorded'], + ['Type', current.type || 'Not recorded'], + ['Sources', `${current.externalSourceCount} sources`], + ] +} + +function discogsRows(detail: ExternalMetadataArtistDetailDto) { + return [ + ['Name', detail.draft.name || 'Not recorded'], + ['Aliases', detail.aliases.join(', ') || 'Not recorded'], + ['Members', detail.members.join(', ') || 'Not recorded'], + ['Variations', detail.nameVariations.join(', ') || 'Not recorded'], + ['Profile', detail.profile ?? 'Not recorded'], + ['Sources', `${detail.draft.externalSources.length} sources`], + ] +} + +function ReviewColumn({ title, rows }: { title: string; rows: string[][] }) { + return ( +
+

{title}

+
+ {rows.map(([label, value]) => ( +
+
{label}
+
{value}
+
+ ))} +
+
+ ) +} + +function ApplyGroup({ + checked, + label, + onChange, +}: { + checked: boolean + label: string + onChange: (checked: boolean) => void +}) { + return ( + + ) +} diff --git a/src/features/artists/artistsData.ts b/src/features/artists/artistsData.ts index ef0d5ad..94e9083 100644 --- a/src/features/artists/artistsData.ts +++ b/src/features/artists/artistsData.ts @@ -1,4 +1,7 @@ -import type { EntityRating } from '../catalog/catalogApi' +import type { + EntityRating, + ExternalSourceReference, +} from '../catalog/catalogApi' export type ArtistType = 'Person' | 'Band' | 'Project' | 'Alias' | 'Collective' @@ -27,6 +30,7 @@ export type ArtistRecord = { tags: string[] summary: string ratings?: EntityRating[] + externalSources?: ExternalSourceReference[] } export const artistRecords: ArtistRecord[] = [ diff --git a/src/features/catalog/api/artistLabelClient.ts b/src/features/catalog/api/artistLabelClient.ts index 3fd958a..8aaf501 100644 --- a/src/features/catalog/api/artistLabelClient.ts +++ b/src/features/catalog/api/artistLabelClient.ts @@ -22,6 +22,9 @@ export async function createArtist(artist: ArtistRecord) { await sendJson('/api/artists', 'POST', { name: artist.name, type: toArtistTypeCode(artist.type), + ...(artist.externalSources === undefined + ? {} + : { externalSources: artist.externalSources }), }) } @@ -76,6 +79,9 @@ export async function updateArtist(artist: ArtistRecord) { await sendJson(`/api/artists/${artist.id}`, 'PUT', { name: artist.name, + ...(artist.externalSources === undefined + ? {} + : { externalSources: artist.externalSources }), }) } diff --git a/src/features/catalog/api/catalogEntityMappers.ts b/src/features/catalog/api/catalogEntityMappers.ts index be00879..ea0ad3a 100644 --- a/src/features/catalog/api/catalogEntityMappers.ts +++ b/src/features/catalog/api/catalogEntityMappers.ts @@ -122,6 +122,7 @@ export function toArtistRecord( tags: [], summary: '', ratings: targetRatings(ratingsByTarget, 'artist', artist.id), + externalSources: artist.externalSources ?? [], } } @@ -147,6 +148,10 @@ export function toReleaseRecord( artistsById.get(credit.contributorArtistId)?.name ?? credit.contributorName, role: creditRoleLabel(credit.role, dictionaries), + roles: (credit.roles && credit.roles.length > 0 + ? credit.roles + : [credit.role] + ).map((role) => creditRoleLabel(role, dictionaries)), })) const mainCredits = releaseCredits.filter((credit) => isMainArtistRole(credit.role, dictionaries), @@ -416,6 +421,7 @@ export function toTrackRecord( checksum: 'Not recorded', }, ratings: targetRatings(ratingsByTarget, 'track', track.id), + externalSources: track.externalSources ?? [], } } diff --git a/src/features/catalog/api/catalogRequestMappers.ts b/src/features/catalog/api/catalogRequestMappers.ts index 96c59d1..b81f18a 100644 --- a/src/features/catalog/api/catalogRequestMappers.ts +++ b/src/features/catalog/api/catalogRequestMappers.ts @@ -22,18 +22,26 @@ export function toReleaseTypeCode(type: ReleaseType) { } export function toReleaseArtistCreditRequest(credit: ReleaseArtistCredit) { + const roles = + credit.roles && credit.roles.length > 0 ? credit.roles : [credit.role] + return { artistId: credit.artistId, name: credit.artistId ? null : credit.artist, - role: toCreditRoleCode(credit.role), + role: toCreditRoleCode(roles[0]), + roles: roles.map((role) => toCreditRoleCode(role)), } } export function toTrackCreditRequest(credit: TrackCredit) { + const roles = + credit.roles && credit.roles.length > 0 ? credit.roles : [credit.role] + return { artistId: credit.artistId, name: credit.artistId ? null : credit.artist, - role: toCreditRoleCode(credit.role), + role: toCreditRoleCode(roles[0]), + roles: roles.map((role) => toCreditRoleCode(role)), } } @@ -73,6 +81,7 @@ export function toReleaseTracklistRequest(track: TrackRecord, index: number) { artistId: credit.artistId, artist: credit.artist, role: credit.role, + roles: credit.roles, }), ), versionNote, diff --git a/src/features/catalog/api/catalogTypes.ts b/src/features/catalog/api/catalogTypes.ts index f54119a..db1c7a5 100644 --- a/src/features/catalog/api/catalogTypes.ts +++ b/src/features/catalog/api/catalogTypes.ts @@ -316,6 +316,7 @@ export type ArtistDto = { id: string type: string name: string + externalSources?: ExternalSourceReference[] | null } export type LabelDto = { @@ -353,6 +354,7 @@ export type ReleaseArtistCreditDto = { artistId: string artistName: string role: string + roles?: string[] } export type ReleaseLabelDto = { @@ -377,6 +379,7 @@ export type TrackDto = { durationSeconds?: number | null genres: string[] tags: string[] + externalSources?: ExternalSourceReference[] | null credits?: TrackCreditDto[] releaseAppearances?: TrackReleaseAppearanceDto[] } @@ -385,6 +388,7 @@ export type TrackCreditDto = { artistId: string artistName: string role: string + roles?: string[] } export type TrackReleaseAppearanceDto = { @@ -449,6 +453,7 @@ export type CreditDto = { targetType: CatalogTargetType targetId: string role: string + roles?: string[] targetTitle?: string | null } diff --git a/src/features/catalog/api/catalogValueMappers.ts b/src/features/catalog/api/catalogValueMappers.ts index bef8e08..a1532ed 100644 --- a/src/features/catalog/api/catalogValueMappers.ts +++ b/src/features/catalog/api/catalogValueMappers.ts @@ -106,9 +106,12 @@ export function toTrackCredit( credit: CreditDto, dictionaries = activeDictionaries, ): TrackCredit { + const roles = creditRolesFromDto(credit, dictionaries) + return { artistId: credit.contributorArtistId, - role: creditRoleLabel(credit.role, dictionaries), + role: roles[0] ?? creditRoleLabel(credit.role, dictionaries), + roles, artist: credit.contributorName, scope: 'Track credit.', } @@ -118,9 +121,12 @@ export function toTrackCreditFromTrackCreditDto( credit: TrackCreditDto, dictionaries = activeDictionaries, ): TrackCredit { + const roles = creditRolesFromDto(credit, dictionaries) + return { artistId: credit.artistId, - role: creditRoleLabel(credit.role, dictionaries), + role: roles[0] ?? creditRoleLabel(credit.role, dictionaries), + roles, artist: credit.artistName, scope: 'Track credit.', } @@ -130,9 +136,12 @@ export function toTrackCreditFromReleaseCredit( credit: ReleaseArtistCreditDto, dictionaries = activeDictionaries, ): TrackCredit { + const roles = creditRolesFromDto(credit, dictionaries) + return { artistId: credit.artistId, - role: creditRoleLabel(credit.role, dictionaries), + role: roles[0] ?? creditRoleLabel(credit.role, dictionaries), + roles, artist: credit.artistName, scope: 'Tracklist credit.', } @@ -142,13 +151,27 @@ export function toReleaseArtistCredit( credit: ReleaseArtistCreditDto, dictionaries = activeDictionaries, ): ReleaseArtistCredit { + const roles = creditRolesFromDto(credit, dictionaries) + return { artistId: credit.artistId, artist: credit.artistName, - role: creditRoleLabel(credit.role, dictionaries), + role: roles[0] ?? creditRoleLabel(credit.role, dictionaries), + roles, } } +export function creditRolesFromDto( + credit: { role: string; roles?: string[] }, + dictionaries = activeDictionaries, +) { + const roleCodes = + credit.roles && credit.roles.length > 0 ? credit.roles : [credit.role] + return [ + ...new Set(roleCodes.map((role) => creditRoleLabel(role, dictionaries))), + ] +} + export function toReleaseLabel(label: ReleaseLabelDto): ReleaseLabel { return { labelId: label.labelId ?? undefined, @@ -164,8 +187,11 @@ export function releaseArtistDisplay(release: ReleaseDto) { } const credits = release.artistCredits ?? [] - const mainCredits = credits.filter( - (credit) => credit.role === mainArtistRoleCode, + const mainCredits = credits.filter((credit) => + (credit.roles && credit.roles.length > 0 + ? credit.roles + : [credit.role] + ).includes(mainArtistRoleCode), ) const visibleCredits = mainCredits.length > 0 ? mainCredits : credits @@ -207,7 +233,14 @@ export function releaseArtistCreditsFromDisplay( return [] } - return [{ artistId: release.artistId, artist, role: mainArtistRoleLabel() }] + return [ + { + artistId: release.artistId, + artist, + role: mainArtistRoleLabel(), + roles: [mainArtistRoleLabel()], + }, + ] } export function releaseLabelsFromDisplay( diff --git a/src/features/catalog/api/externalMetadataClient.ts b/src/features/catalog/api/externalMetadataClient.ts index d8dbd35..4382e47 100644 --- a/src/features/catalog/api/externalMetadataClient.ts +++ b/src/features/catalog/api/externalMetadataClient.ts @@ -26,12 +26,39 @@ export type DiscogsReleaseSearchParams = { limit?: number } +export type DiscogsArtistSearchParams = { + query?: string + limit?: number +} + +export type DiscogsTrackSearchParams = { + title?: string + artist?: string + releaseTitle?: string + year?: string + barcode?: string + catalogNumber?: string + limit?: number +} + export type DiscogsReleaseSearchResponse = { items: ExternalMetadataReleaseCandidateDto[] limit: number total: number } +export type DiscogsArtistSearchResponse = { + items: ExternalMetadataArtistCandidateDto[] + limit: number + total: number +} + +export type DiscogsTrackSearchResponse = { + items: ExternalMetadataTrackCandidateDto[] + limit: number + total: number +} + export type ExternalMetadataReleaseCandidateDto = { source: ExternalMetadataSourceDto title: string @@ -51,6 +78,59 @@ export type ExternalMetadataReleaseDetailDto = draft: ExternalMetadataReleaseDraftDto } +export type ExternalMetadataArtistCandidateDto = { + source: ExternalMetadataSourceDto + name: string + profile?: string | null + nameVariations: string[] +} + +export type ExternalMetadataArtistDetailDto = + ExternalMetadataArtistCandidateDto & { + aliases: string[] + members: string[] + draft: ExternalMetadataArtistDraftDto + } + +export type ExternalMetadataArtistDraftDto = { + name: string + externalSources: ExternalSourceReference[] +} + +export type ExternalMetadataTrackCandidateDto = { + source: ExternalMetadataSourceDto + title: string + position?: string | null + durationSeconds?: number | null + artists: string[] + release: ExternalMetadataTrackReleaseContextDto +} + +export type ExternalMetadataTrackDetailDto = + ExternalMetadataTrackCandidateDto & { + credits: ExternalMetadataTrackCreditDto[] + draft: ExternalMetadataTrackDraftDto + } + +export type ExternalMetadataTrackReleaseContextDto = { + source: ExternalMetadataSourceDto + title: string + year?: number | null + artists: string[] +} + +export type ExternalMetadataTrackCreditDto = { + name: string + role: string +} + +export type ExternalMetadataTrackDraftDto = { + title: string + durationSeconds?: number | null + artistCredits: ExternalMetadataReleaseDraftArtistCreditDto[] + externalSources: ExternalSourceReference[] +} + export type ExternalMetadataReleaseTrackDto = { title: string position?: string | null @@ -72,7 +152,10 @@ export type ExternalMetadataReleaseCreditDto = { export type ExternalMetadataReleaseDraftDto = { title: string + type?: string | null + genres: string[] year?: number | null + releaseDate?: string | null artistCredits: ExternalMetadataReleaseDraftArtistCreditDto[] labels: ExternalMetadataReleaseDraftLabelDto[] tracklist: ExternalMetadataReleaseDraftTrackDto[] @@ -114,6 +197,31 @@ export async function searchDiscogsReleases( ) } +export async function searchDiscogsArtists(params: DiscogsArtistSearchParams) { + const query = new URLSearchParams() + appendTrimmed(query, 'query', params.query) + query.set('limit', String(params.limit ?? 25)) + + return getExternalMetadataJson( + `/api/external-metadata/discogs/artists?${query.toString()}`, + ) +} + +export async function searchDiscogsTracks(params: DiscogsTrackSearchParams) { + const query = new URLSearchParams() + appendTrimmed(query, 'title', params.title) + appendTrimmed(query, 'artist', params.artist) + appendTrimmed(query, 'releaseTitle', params.releaseTitle) + appendTrimmed(query, 'year', params.year) + appendTrimmed(query, 'barcode', params.barcode) + appendTrimmed(query, 'catalogNumber', params.catalogNumber) + query.set('limit', String(params.limit ?? 25)) + + return getExternalMetadataJson( + `/api/external-metadata/discogs/tracks?${query.toString()}`, + ) +} + export async function getDiscogsRelease(externalId: string) { return getExternalMetadataJson( `/api/external-metadata/discogs/releases/${encodeURIComponent( @@ -122,6 +230,22 @@ export async function getDiscogsRelease(externalId: string) { ) } +export async function getDiscogsArtist(externalId: string) { + return getExternalMetadataJson( + `/api/external-metadata/discogs/artists/${encodeURIComponent( + externalId.trim(), + )}`, + ) +} + +export async function getDiscogsTrack(externalId: string) { + return getExternalMetadataJson( + `/api/external-metadata/discogs/tracks/${encodeURIComponent( + externalId.trim(), + )}`, + ) +} + async function getExternalMetadataJson(path: string): Promise { const response = await fetch(path, { credentials: 'include', diff --git a/src/features/catalog/api/trackClient.ts b/src/features/catalog/api/trackClient.ts index 34d9d85..7fa2336 100644 --- a/src/features/catalog/api/trackClient.ts +++ b/src/features/catalog/api/trackClient.ts @@ -31,6 +31,9 @@ async function createTrackRecord(track: TrackRecord) { durationSeconds: parseDuration(track.duration), genres: track.tags.filter((tag) => genreSet.has(tag)), tags: track.tags.filter((tag) => !genreSet.has(tag)), + ...(track.externalSources === undefined + ? {} + : { externalSources: track.externalSources }), credits: track.credits.map(toTrackCreditRequest), releaseAppearances: track.releaseAppearances .filter((appearance) => appearance.releaseId) @@ -75,6 +78,9 @@ export async function updateTrack(track: TrackRecord) { durationSeconds: parseDuration(track.duration), genres: track.tags.filter((tag) => genreSet.has(tag)), tags: track.tags.filter((tag) => !genreSet.has(tag)), + ...(track.externalSources === undefined + ? {} + : { externalSources: track.externalSources }), credits: track.credits.map(toTrackCreditRequest), releaseAppearances: track.releaseAppearances .filter((appearance) => appearance.releaseId) diff --git a/src/features/catalog/catalogApi.mutations.test.ts b/src/features/catalog/catalogApi.mutations.test.ts index e515ff1..4c45635 100644 --- a/src/features/catalog/catalogApi.mutations.test.ts +++ b/src/features/catalog/catalogApi.mutations.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it, vi } from 'vitest' import * as api from './catalogApi' import * as h from './catalogApiTestHarness' +import type { ArtistRecord } from '../artists/artistsData' import type { ReleaseRecord } from '../releases/releasesData' +import type { TrackRecord } from '../tracks/tracksData' h.setupCatalogApiAdapterTests() @@ -59,6 +61,109 @@ describe('catalog API adapter mutations and covers', () => { }) }) + it('sends artist external sources on create and update', async () => { + const fetchMock = vi.fn() + fetchMock + .mockResolvedValueOnce(h.jsonResponse({ id: 'artist-id' }, 201)) + .mockResolvedValueOnce(h.jsonResponse({ id: 'artist-id' })) + vi.stubGlobal('fetch', fetchMock) + const artist: ArtistRecord = { + id: 'artist-id', + name: 'Discogs Artist', + type: 'Person', + aliases: [], + members: [], + relationHint: '', + creditHint: '', + relations: [], + credits: [], + tags: [], + summary: '', + externalSources: [ + { + providerName: 'discogs', + resourceType: 'artist', + externalId: '5876', + sourceUrl: 'https://www.discogs.com/artist/5876', + appliedAt: '2026-05-31T19:00:00.000Z', + }, + ], + } + + await api.createArtist(artist) + await api.updateArtist(artist) + + expect( + h.requestPayload>(fetchMock.mock.calls[0][1]), + ).toMatchObject({ + externalSources: artist.externalSources, + }) + expect( + h.requestPayload>(fetchMock.mock.calls[1][1]), + ).toMatchObject({ + externalSources: artist.externalSources, + }) + }) + + it('sends track external sources on create and update', async () => { + const fetchMock = vi.fn() + fetchMock + .mockResolvedValueOnce(h.jsonResponse({ id: 'track-id' }, 201)) + .mockResolvedValueOnce(h.jsonResponse({ id: 'track-id' })) + vi.stubGlobal('fetch', fetchMock) + const track: TrackRecord = { + id: 'track-id', + title: 'Discogs Track', + artist: 'Discogs Artist', + release: { + title: 'Discogs Release', + artist: 'Discogs Artist', + year: '2026', + label: 'Source Label', + }, + trackNumber: '1', + duration: '04:29', + versionHint: '', + relationHint: '', + tags: [], + credits: [], + releaseAppearances: [], + relations: [], + fileMetadata: { + format: 'None recorded', + path: 'No file linked', + bitrate: 'Not recorded', + sampleRate: 'Not recorded', + channels: 'Not recorded', + importedAt: 'Manual entry', + checksum: 'Not recorded', + }, + externalSources: [ + { + providerName: 'discogs', + resourceType: 'track', + externalId: 'track-249504', + sourceUrl: 'https://www.discogs.com/release/249504', + appliedAt: '2026-05-31T19:00:00.000Z', + }, + ], + } + + await api.createTrack(track) + await api.updateTrack(track) + + expect( + h.requestPayload>(fetchMock.mock.calls[0][1]), + ).toMatchObject({ + externalSources: track.externalSources, + }) + expect( + h.requestPayload>(fetchMock.mock.calls[1][1]), + ).toMatchObject({ + externalSources: track.externalSources, + }) + }) + it('rejects invalid rating values before sending a request', async () => { const fetchMock = vi.fn() vi.stubGlobal('fetch', fetchMock) diff --git a/src/features/catalog/externalMetadataClient.test.ts b/src/features/catalog/externalMetadataClient.test.ts index 26909f1..cda5b26 100644 --- a/src/features/catalog/externalMetadataClient.test.ts +++ b/src/features/catalog/externalMetadataClient.test.ts @@ -1,7 +1,11 @@ import { describe, expect, it, vi } from 'vitest' import { + getDiscogsArtist, getDiscogsRelease, + getDiscogsTrack, + searchDiscogsArtists, searchDiscogsReleases, + searchDiscogsTracks, } from './api/externalMetadataClient' import { CatalogApiError } from './api/httpClient' import * as h from './catalogApiTestHarness' @@ -87,7 +91,10 @@ describe('external metadata API client', () => { credits: [{ name: 'New Order', role: 'Written-By' }], draft: { title: 'Blue Monday', + type: 'single', + genres: ['Electronic', 'Leftfield'], year: 1983, + releaseDate: '1983-03-07', artistCredits: [{ name: 'New Order', role: 'mainArtist' }], labels: [ { @@ -132,6 +139,9 @@ describe('external metadata API client', () => { position: 1, durationSeconds: 449, }) + expect(detail.draft.releaseDate).toBe('1983-03-07') + expect(detail.draft.type).toBe('single') + expect(detail.draft.genres).toEqual(['Electronic', 'Leftfield']) expect(detail.draft.externalSources[0]).toMatchObject({ providerName: 'discogs', resourceType: 'release', @@ -168,6 +178,149 @@ describe('external metadata API client', () => { retryAfter: '60', } satisfies Partial) }) + + it('searches Discogs artists with trimmed query params and parses detail drafts', async () => { + const fetchMock = vi.fn() + fetchMock + .mockResolvedValueOnce( + h.jsonResponse({ + items: [ + { + source: source('artist', '5876'), + name: 'Arthur Baker', + profile: 'Producer and remixer.', + nameVariations: ['A. Baker'], + }, + ], + limit: 25, + total: 1, + }), + ) + .mockResolvedValueOnce( + h.jsonResponse({ + source: source('artist', '5876'), + name: 'Arthur Baker', + profile: 'Producer and remixer.', + aliases: ['Arthur Baker III'], + members: ['Rockers Revenge'], + nameVariations: ['A. Baker'], + draft: { + name: 'Arthur Baker', + externalSources: [ + { + providerName: 'discogs', + resourceType: 'artist', + externalId: '5876', + sourceUrl: 'https://www.discogs.com/artist/5876', + }, + ], + }, + }), + ) + vi.stubGlobal('fetch', fetchMock) + + const result = await searchDiscogsArtists({ + query: ' Arthur Baker ', + limit: 25, + }) + const detail = await getDiscogsArtist('5876') + + const searchUrl = requestUrl(fetchMock.mock.calls[0][0]) + expect(searchUrl.pathname).toBe('/api/external-metadata/discogs/artists') + expect(searchUrl.searchParams.get('query')).toBe('Arthur Baker') + expect(searchUrl.searchParams.get('limit')).toBe('25') + expect(result.items[0]).toMatchObject({ + name: 'Arthur Baker', + source: { attribution: 'Data provided by Discogs.' }, + }) + expect(fetchMock.mock.calls[1][0]).toBe( + '/api/external-metadata/discogs/artists/5876', + ) + expect(detail.draft.externalSources[0]).toMatchObject({ + providerName: 'discogs', + resourceType: 'artist', + externalId: '5876', + }) + }) + + it('searches Discogs tracks with release context and parses selected track detail', async () => { + const fetchMock = vi.fn() + fetchMock + .mockResolvedValueOnce( + h.jsonResponse({ + items: [ + { + source: source('track', 'track-249504'), + title: 'Blue Monday', + position: 'A', + durationSeconds: 449, + artists: ['New Order'], + release: { + source: source('release', '249504'), + title: 'Blue Monday', + year: 1983, + artists: ['New Order'], + }, + }, + ], + limit: 25, + total: 1, + }), + ) + .mockResolvedValueOnce( + h.jsonResponse({ + source: source('track', 'track-249504'), + title: 'Blue Monday', + position: 'A', + durationSeconds: 449, + artists: ['New Order'], + credits: [{ name: 'Remixer Name', role: 'Remix' }], + release: { + source: source('release', '249504'), + title: 'Blue Monday', + year: 1983, + artists: ['New Order'], + }, + draft: { + title: 'Blue Monday', + durationSeconds: 449, + artistCredits: [{ name: 'New Order', role: 'mainArtist' }], + externalSources: [ + { + providerName: 'discogs', + resourceType: 'track', + externalId: 'track-249504', + sourceUrl: 'https://www.discogs.com/release/249504', + }, + ], + }, + }), + ) + vi.stubGlobal('fetch', fetchMock) + + const result = await searchDiscogsTracks({ + title: ' Blue Monday ', + artist: ' New Order ', + releaseTitle: ' Blue Monday ', + year: ' 1983 ', + barcode: ' 5016839200371 ', + catalogNumber: ' FAC 73 ', + limit: 25, + }) + const detail = await getDiscogsTrack('track-249504') + + const searchUrl = requestUrl(fetchMock.mock.calls[0][0]) + expect(searchUrl.pathname).toBe('/api/external-metadata/discogs/tracks') + expect(searchUrl.searchParams.get('title')).toBe('Blue Monday') + expect(searchUrl.searchParams.get('artist')).toBe('New Order') + expect(searchUrl.searchParams.get('releaseTitle')).toBe('Blue Monday') + expect(searchUrl.searchParams.get('catalogNumber')).toBe('FAC 73') + expect(result.items[0].release.title).toBe('Blue Monday') + expect(fetchMock.mock.calls[1][0]).toBe( + '/api/external-metadata/discogs/tracks/track-249504', + ) + expect(detail.draft.externalSources[0].resourceType).toBe('track') + }) }) function source(resourceType: string, externalId: string) { diff --git a/src/features/manualEntry/manual-entry.css b/src/features/manualEntry/manual-entry.css index e654c99..ac9802f 100644 --- a/src/features/manualEntry/manual-entry.css +++ b/src/features/manualEntry/manual-entry.css @@ -109,6 +109,18 @@ grid-column: 1 / -1; } +@media (max-width: 700px) { + .manual-entry-header, + .manual-entry-actions { + align-items: stretch; + flex-direction: column; + } + + .manual-entry-grid { + grid-template-columns: minmax(0, 1fr); + } +} + .catalog-add-entry-panel { display: grid; } diff --git a/src/features/releases/CreditRolePicker.tsx b/src/features/releases/CreditRolePicker.tsx new file mode 100644 index 0000000..99d464e --- /dev/null +++ b/src/features/releases/CreditRolePicker.tsx @@ -0,0 +1,44 @@ +type CreditRolePickerProps = { + addLabel: string + ariaLabel: string + options: string[] + onSelect: (role: string) => void +} + +export function CreditRolePicker({ + addLabel, + ariaLabel, + options, + onSelect, +}: CreditRolePickerProps) { + const hasOptions = options.length > 0 + + return ( +
+ {addLabel} + {hasOptions ? ( +
+ {options.map((role) => ( + + ))} +
+ ) : null} +
+ ) +} diff --git a/src/features/releases/DiscogsReleaseLookupPanel.tsx b/src/features/releases/DiscogsReleaseLookupPanel.tsx index 7fb8cac..0e2ba46 100644 --- a/src/features/releases/DiscogsReleaseLookupPanel.tsx +++ b/src/features/releases/DiscogsReleaseLookupPanel.tsx @@ -1,19 +1,24 @@ import { Search } from 'lucide-react' +import type { ReactNode } from 'react' import { useEffect, useRef, useState } from 'react' import { CatalogApiError, getDiscogsRelease, searchDiscogsReleases, + type CatalogDictionaries, type ExternalMetadataReleaseCandidateDto, type ExternalMetadataReleaseDetailDto, + type ExternalMetadataReleaseDraftArtistCreditDto, + type ExternalMetadataReleaseDraftTrackDto, } from '../catalog/catalogApi' +import { discogsDraftTrackRows } from './discogsReleaseTrackRows' export type DiscogsApplyGroups = { core: boolean artists: boolean + classification: boolean labels: boolean tracklist: boolean - externalSource: boolean } export type DiscogsSearchSeed = { @@ -26,7 +31,9 @@ export type DiscogsSearchSeed = { export type DiscogsCurrentRelease = { artists: string externalSourceCount: number + genres: string labels: string + releaseDate: string title: string trackCount: number year: string @@ -34,6 +41,7 @@ export type DiscogsCurrentRelease = { type DiscogsReleaseLookupPanelProps = { current: DiscogsCurrentRelease + dictionaries: CatalogDictionaries isOpen: boolean mode: 'create' | 'update' searchSeed: DiscogsSearchSeed @@ -47,13 +55,14 @@ type DiscogsReleaseLookupPanelProps = { const emptyGroups: DiscogsApplyGroups = { core: false, artists: false, + classification: false, labels: false, tracklist: false, - externalSource: false, } export function DiscogsReleaseLookupPanel({ current, + dictionaries, isOpen, mode, searchSeed, @@ -64,9 +73,9 @@ export function DiscogsReleaseLookupPanel({ const [artist, setArtist] = useState(searchSeed.artist) const [title, setTitle] = useState(searchSeed.title) const [year, setYear] = useState(searchSeed.year) - const [barcode, setBarcode] = useState('') const [catalogNumber, setCatalogNumber] = useState(searchSeed.catalogNumber) const [status, setStatus] = useState('') + const [appliedStatus, setAppliedStatus] = useState('') const [candidates, setCandidates] = useState< ExternalMetadataReleaseCandidateDto[] >([]) @@ -90,6 +99,7 @@ export function DiscogsReleaseLookupPanel({ async function handleSearch() { setStatus('Searching Discogs release candidates.') + setAppliedStatus('') setSelectedDetail(null) try { @@ -98,7 +108,6 @@ export function DiscogsReleaseLookupPanel({ artist, title, year, - barcode, catalogNumber, limit: 25, }) @@ -119,6 +128,7 @@ export function DiscogsReleaseLookupPanel({ candidate: ExternalMetadataReleaseCandidateDto, ) { setStatus(`Loading Discogs detail for ${candidate.title}.`) + setAppliedStatus('') try { const detail = await getDiscogsRelease(candidate.source.externalId) @@ -135,7 +145,21 @@ export function DiscogsReleaseLookupPanel({ setApplyGroups((groups) => ({ ...groups, [group]: checked })) } + function handleApplyDraft( + detail: ExternalMetadataReleaseDetailDto, + groups: DiscogsApplyGroups, + ) { + onApplyDraft(detail, groups) + setAppliedStatus( + `Applied Discogs ${appliedGroupLabel(groups)} to the form. Save record to persist changes.`, + ) + setCandidates([]) + setSelectedDetail(null) + onOpenChange(false) + } + const hasSelectedGroup = Object.values(applyGroups).some(Boolean) + const selectedExternalId = selectedDetail?.source.externalId ?? '' return (
setYear(event.target.value)} /> -
@@ -232,140 +248,227 @@ export function DiscogsReleaseLookupPanel({
{candidates.map((candidate) => (
-
- {candidate.title} -

- {candidate.artists.join(', ') || 'Unknown artist'} ·{' '} - {candidate.year ?? 'Unknown year'} ·{' '} - {candidate.labels.join(', ') || 'Unknown label'} -

-

- {[...candidate.formats, candidate.catalogNumber] - .filter(Boolean) - .join(' · ')} -

- {candidate.barcodes.length > 0 ? ( -

Barcodes: {candidate.barcodes.join(', ')}

- ) : null} -

{candidate.source.attribution}

-
-
- - Open candidate Discogs source - - +
+
+ {candidate.title} +

+ {candidate.artists.join(', ') || 'Unknown artist'} ·{' '} + {candidate.year ?? 'Unknown year'} +

+

+ {[...candidate.formats, candidate.catalogNumber] + .filter(Boolean) + .join(' · ')} +

+

{candidate.source.attribution}

+
+
+ + Open candidate Discogs source + + +
+ {selectedDetail?.source.externalId === + candidate.source.externalId ? ( + + ) : null}
))}
) : null} - - {selectedDetail ? ( -
-
-
-

Review Discogs candidate

-

- {selectedDetail.source.attribution}{' '} - - Open Discogs source - -

-
-
- -
- - -
- -
- Apply groups - updateApplyGroup('core', checked)} - /> - updateApplyGroup('artists', checked)} - /> - updateApplyGroup('labels', checked)} - /> - updateApplyGroup('tracklist', checked)} - /> - - updateApplyGroup('externalSource', checked) - } - /> -
- - -
- ) : null} ) : ( -

- Discogs lookup is optional and never saves data until the release form - is submitted. +

+ {appliedStatus || + 'Discogs lookup is optional and never saves data until the release form is submitted.'}

)} ) } +function appliedGroupLabel(groups: DiscogsApplyGroups) { + const labels = [ + groups.core ? 'core' : '', + groups.artists ? 'artists' : '', + groups.labels ? 'labels' : '', + groups.classification ? 'classification' : '', + groups.tracklist ? 'tracklist' : '', + ].filter(Boolean) + + if (labels.length === 0) { + return 'fields' + } + + return labels.length === 1 + ? labels[0] + : `${labels.slice(0, -1).join(', ')} and ${labels.at(-1)}` +} + +function DiscogsCandidateReview({ + applyGroups, + current, + detail, + dictionaries, + hasSelectedGroup, + onApplyDraft, + onUpdateApplyGroup, +}: { + applyGroups: DiscogsApplyGroups + current: DiscogsCurrentRelease + detail: ExternalMetadataReleaseDetailDto + dictionaries: CatalogDictionaries + hasSelectedGroup: boolean + onApplyDraft: ( + detail: ExternalMetadataReleaseDetailDto, + groups: DiscogsApplyGroups, + ) => void + onUpdateApplyGroup: ( + group: keyof DiscogsApplyGroups, + checked: boolean, + ) => void +}) { + const compilationDetected = hasCompilationTrackArtists(detail) + const reviewTracks = discogsDraftTrackRows(detail.draft.tracklist) + const draftGenres = detail.draft.genres ?? [] + + return ( +
+
+
+

Review Discogs candidate

+

+ {detail.source.attribution}{' '} + + Open Discogs source + +

+
+
+ +
+ onUpdateApplyGroup('core', checked)} + /> + onUpdateApplyGroup('artists', checked)} + > + + + onUpdateApplyGroup('labels', checked)} + /> + 0 ? draftGenres.join(', ') : 'Not recorded' + } + onChange={(checked) => onUpdateApplyGroup('classification', checked)} + /> + onUpdateApplyGroup('tracklist', checked)} + > + {compilationDetected ? ( +

+ Compilation detected: track-specific artists differ from release + artists. Applying Tracklist will mark the release as Various + Artists and write track-level artist credits. +

+ ) : null} + +
+
+ + +
+ ) +} + function defaultGroups(mode: 'create' | 'update'): DiscogsApplyGroups { return mode === 'create' ? { core: true, artists: true, + classification: true, labels: true, tracklist: true, - externalSource: true, } : emptyGroups } @@ -383,69 +486,222 @@ function externalMetadataErrorMessage(error: unknown) { return 'External metadata provider is unavailable.' } -function currentRows(current: DiscogsCurrentRelease) { - return [ - ['Title', current.title || 'Not recorded'], - ['Artist', current.artists || 'Not recorded'], - ['Year', current.year || 'Not recorded'], - ['Labels', current.labels || 'Not recorded'], - ['Tracklist', `${current.trackCount} rows`], - ['Sources', `${current.externalSourceCount} sources`], - ] +function ImpactRow({ + checked, + children, + currentValue, + group, + nextValue, + onChange, +}: { + checked: boolean + children?: ReactNode + currentValue: string + group: string + nextValue: string + onChange: (checked: boolean) => void +}) { + return ( +
+ +
{group}
+
+ Current + {currentValue} +
+
+ Discogs + {nextValue} + {children ? ( +
{children}
+ ) : null} +
+
+ ) } -function discogsRows(detail: ExternalMetadataReleaseDetailDto) { - return [ - ['Title', detail.draft.title || 'Not recorded'], - [ - 'Artist', - detail.draft.artistCredits.map((credit) => credit.name).join(', ') || - 'Not recorded', - ], - ['Year', detail.draft.year?.toString() ?? 'Not recorded'], - [ - 'Labels', - detail.draft.labels - .map((label) => - [label.name, label.catalogNumber].filter(Boolean).join(' '), - ) - .join(', ') || 'Not recorded', - ], - ['Tracklist', `${detail.draft.tracklist.length} rows`], - ['Formats', detail.formats.join(', ') || 'Not recorded'], - ['Barcodes', detail.barcodes.join(', ') || 'Not recorded'], - [ - 'Identifiers', - detail.identifiers - .map((identifier) => `${identifier.type}: ${identifier.value}`) - .join(', ') || 'Not recorded', - ], - [ - 'Credits', - detail.credits - .map((credit) => [credit.name, credit.role].filter(Boolean).join(' - ')) - .join(', ') || 'Not recorded', - ], - ['Sources', `${detail.draft.externalSources.length} sources`], - ] +function ArtistImpactList({ + credits, + dictionaries, +}: { + credits: ExternalMetadataReleaseDraftArtistCreditDto[] + dictionaries: CatalogDictionaries +}) { + if (credits.length === 0) { + return

No Discogs artist credits.

+ } + + return ( +
+ {groupDiscogsReviewCredits(credits).map((credit) => ( + + ))} +
+ ) } -function ReviewColumn({ title, rows }: { title: string; rows: string[][] }) { +function TrackImpactList({ + dictionaries, + tracks, +}: { + dictionaries: CatalogDictionaries + tracks: ExternalMetadataReleaseDraftTrackDto[] +}) { + const [showAllTracks, setShowAllTracks] = useState(false) + const previewTracks = showAllTracks ? tracks : tracks.slice(0, 4) + const hiddenCount = tracks.length - previewTracks.length + + if (tracks.length === 0) { + return

No Discogs track rows.

+ } + return ( -
-

{title}

-
- {rows.map(([label, value]) => ( -
-
{label}
-
{value}
+
+ {previewTracks.map((track) => ( +
+ + {track.position} + +
+ {track.title} +

+ {track.durationSeconds + ? formatDurationSeconds(track.durationSeconds) + : 'No duration'}{' '} + · create track +

+ {track.artistCredits.length > 0 ? ( +
+ {groupDiscogsReviewCredits(track.artistCredits).map( + (credit) => ( + + ), + )} +
+ ) : ( +

Inherits release artists.

+ )}
+
+ ))} + {hiddenCount > 0 ? ( + + ) : showAllTracks && tracks.length > 4 ? ( + + ) : null} +
+ ) +} + +function CreditImpactRow({ + credit, + dictionaries, +}: { + credit: GroupedDiscogsReviewCredit + dictionaries: CatalogDictionaries +}) { + return ( +
+ {credit.name} + + {credit.roles.map((role) => ( + + {roleLabelFromCode(role, dictionaries)} + ))} -
+
) } +type GroupedDiscogsReviewCredit = { + name: string + roles: string[] +} + +function groupDiscogsReviewCredits( + credits: ExternalMetadataReleaseDraftArtistCreditDto[], +) { + const grouped = new Map() + + credits.forEach((credit) => { + const name = credit.name.trim() + if (!name) { + return + } + + const key = name.toLowerCase() + const existing = grouped.get(key) + const roles = splitRoleLabels(credit.role) + + if (existing) { + existing.roles = [...new Set([...existing.roles, ...roles])] + } else { + grouped.set(key, { name, roles }) + } + }) + + return [...grouped.values()] +} + +function splitRoleLabels(role: string) { + const roles: string[] = [] + let depth = 0 + let current = '' + + for (const character of role) { + if (character === '[' || character === '(') { + depth += 1 + } else if ((character === ']' || character === ')') && depth > 0) { + depth -= 1 + } + + if (character === ',' && depth === 0) { + const trimmed = current.trim() + if (trimmed) { + roles.push(trimmed) + } + current = '' + } else { + current += character + } + } + + const trimmed = current.trim() + if (trimmed) { + roles.push(trimmed) + } + + return roles +} + function ApplyGroup({ checked, label, @@ -466,3 +722,74 @@ function ApplyGroup({ ) } + +function releaseLabelSummary(detail: ExternalMetadataReleaseDetailDto) { + return detail.draft.labels + .map((label) => [label.name, label.catalogNumber].filter(Boolean).join(' ')) + .join(', ') +} + +function roleLabelFromCode(role: string, dictionaries: CatalogDictionaries) { + const trimmedRole = role.trim() + + return ( + dictionaries.creditRole.find( + (entry) => entry.code === trimmedRole || entry.name === trimmedRole, + )?.name ?? trimmedRole + ) +} + +function hasCompilationTrackArtists(detail: ExternalMetadataReleaseDetailDto) { + const releaseMainArtists = normalizedSet( + detail.draft.artistCredits + .filter((credit) => normalizeText(credit.role) === 'mainartist') + .map((credit) => credit.name), + ) + const releaseArtists = + releaseMainArtists.size > 0 + ? releaseMainArtists + : normalizedSet(detail.draft.artistCredits.map((credit) => credit.name)) + + return detail.draft.tracklist.some((track) => { + const trackMainArtists = normalizedSet( + track.artistCredits + .filter((credit) => normalizeText(credit.role) === 'mainartist') + .map((credit) => credit.name), + ) + + if (trackMainArtists.size === 0) { + return false + } + + return !setsEqual(releaseArtists, trackMainArtists) + }) +} + +function normalizedSet(values: string[]) { + return new Set(values.map(normalizeText).filter(Boolean)) +} + +function normalizeText(value: string) { + return value.trim().toLowerCase() +} + +function setsEqual(left: Set, right: Set) { + if (left.size !== right.size) { + return false + } + + for (const value of left) { + if (!right.has(value)) { + return false + } + } + + return true +} + +function formatDurationSeconds(durationSeconds: number) { + const minutes = Math.floor(durationSeconds / 60) + const seconds = durationSeconds % 60 + + return `${minutes}:${String(seconds).padStart(2, '0')}` +} diff --git a/src/features/releases/ReleaseArtistCreditsSection.tsx b/src/features/releases/ReleaseArtistCreditsSection.tsx index b1b205c..7c4aad0 100644 --- a/src/features/releases/ReleaseArtistCreditsSection.tsx +++ b/src/features/releases/ReleaseArtistCreditsSection.tsx @@ -1,5 +1,6 @@ import type { Dispatch, SetStateAction } from 'react' import type { ArtistRecord } from '../artists/artistsData' +import { CreditRolePicker } from './CreditRolePicker' import type { EditableArtistCredit } from './ReleaseEntryFormTypes' import { artistCreditName } from './releaseFormHelpers' @@ -99,44 +100,34 @@ export function ReleaseArtistCreditsSection({ {artistName || 'Unnamed artist'} - + /> + + + ))} + 0 ? 'Add role' : 'Set role'} + ariaLabel={`Track role for ${artistName || 'artist'}`} + options={creditRoleOptions.filter( + (role) => !credit.roles.includes(role), + )} + onSelect={(role) => { + if (!role || credit.roles.includes(role)) { + return + } + + const roles = [...credit.roles, role] + handleTrackArtistChange(trackId, credit.id, 'roles', roles) handleTrackArtistChange( trackId, credit.id, 'role', - event.target.value, + credit.role || role, ) - } - > - - {creditRoleOptions.map((role) => ( - - ))} - - + }} + /> + + + + {isOpen ? ( + <> +
+ + + + + + +
+ + {status ? ( +

+ {status} +

+ ) : null} + + {candidates.length > 0 ? ( +
+ {candidates.map((candidate) => { + const isSelected = + selectedDetail?.source.externalId === + candidate.source.externalId + + return ( + { + void reviewCandidate(candidate) + }} + onUpdateApplyGroup={updateApplyGroup} + /> + ) + })} +
+ ) : null} + + ) : ( +

+ {appliedStatus || + 'Discogs lookup is optional and never saves data until the track form is submitted.'} +

+ )} + + ) +} + +function CandidateCard({ + applyGroups, + candidate, + current, + detail, + dictionaries, + hasSelectedGroup, + isSelected, + onApplyDraft, + onReview, + onUpdateApplyGroup, +}: { + applyGroups: DiscogsTrackApplyGroups + candidate: ExternalMetadataTrackCandidateDto + current: DiscogsCurrentTrack + detail: ExternalMetadataTrackDetailDto | null + dictionaries: CatalogDictionaries + hasSelectedGroup: boolean + isSelected: boolean + onApplyDraft: ( + detail: ExternalMetadataTrackDetailDto, + groups: DiscogsTrackApplyGroups, + ) => void + onReview: () => void + onUpdateApplyGroup: ( + group: keyof DiscogsTrackApplyGroups, + checked: boolean, + ) => void +}) { + return ( +
+
+
+ {candidate.title} +

+ {candidate.position ?? 'Unnumbered'} ·{' '} + {formatDurationSeconds(candidate.durationSeconds)} +

+

{joinOrEmpty(candidate.artists)}

+

+ {candidate.release.title} ·{' '} + {candidate.release.year ?? 'Unknown year'} ·{' '} + {joinOrEmpty(candidate.release.artists)} +

+

{candidate.source.attribution}

+
+
+ + Open candidate Discogs track source + + +
+
+ + {detail ? ( + + ) : null} +
+ ) +} + +function DiscogsTrackCandidateReview({ + applyGroups, + current, + detail, + dictionaries, + hasSelectedGroup, + onApplyDraft, + onUpdateApplyGroup, +}: { + applyGroups: DiscogsTrackApplyGroups + current: DiscogsCurrentTrack + detail: ExternalMetadataTrackDetailDto + dictionaries: CatalogDictionaries + hasSelectedGroup: boolean + onApplyDraft: ( + detail: ExternalMetadataTrackDetailDto, + groups: DiscogsTrackApplyGroups, + ) => void + onUpdateApplyGroup: ( + group: keyof DiscogsTrackApplyGroups, + checked: boolean, + ) => void +}) { + return ( +
+
+
+

Review Discogs track

+

+ {detail.source.attribution}{' '} + + Open Discogs track source + +

+
+
+ +
+ onUpdateApplyGroup('core', checked)} + /> + onUpdateApplyGroup('credits', checked)} + > + + +
+ + +
+ ) +} + +function ImpactRow({ + checked, + children, + currentValue, + group, + nextValue, + onChange, +}: { + checked: boolean + children?: ReactNode + currentValue: string + group: string + nextValue: string + onChange: (checked: boolean) => void +}) { + return ( +
+ +
{group}
+
+ Current + {currentValue} +
+
+ Discogs + {nextValue} + {children ? ( +
{children}
+ ) : null} +
+
+ ) +} + +function CreditImpactList({ + credits, + dictionaries, +}: { + credits: ExternalMetadataReleaseDraftArtistCreditDto[] + dictionaries: CatalogDictionaries +}) { + if (credits.length === 0) { + return

No Discogs artist credits.

+ } + + return ( +
+ {credits.map((credit, index) => ( + + ))} +
+ ) +} + +function CreditImpactRow({ + credit, + dictionaries, +}: { + credit: ExternalMetadataReleaseDraftArtistCreditDto + dictionaries: CatalogDictionaries +}) { + const role = roleLabelFromCode(credit.role, dictionaries) + + return ( +
+ {credit.name} + {role} +
+ ) +} + +function ApplyGroup({ + checked, + label, + onChange, +}: { + checked: boolean + label: string + onChange: (checked: boolean) => void +}) { + return ( + + ) +} + +function defaultGroups(mode: 'create' | 'update'): DiscogsTrackApplyGroups { + return mode === 'create' + ? { + core: true, + credits: true, + } + : emptyGroups +} + +function appliedGroupLabel(groups: DiscogsTrackApplyGroups) { + const labels = [ + groups.core ? 'core' : '', + groups.credits ? 'credits' : '', + ].filter(Boolean) + + if (labels.length === 0) { + return 'fields' + } + + return labels.length === 1 + ? labels[0] + : `${labels.slice(0, -1).join(', ')} and ${labels.at(-1)}` +} + +function externalMetadataErrorMessage(error: unknown) { + if (error instanceof CatalogApiError) { + const retry = + error.retryAfter && error.status === 429 + ? ` Retry after ${error.retryAfter} seconds.` + : '' + + return `${error.message}${retry}` + } + + return 'External metadata provider is unavailable.' +} + +function trackCoreLabel(title: string, duration: string) { + return [title || 'Not recorded', duration || 'Unknown duration'] + .filter(Boolean) + .join(' · ') +} + +function joinOrEmpty(values: string[]) { + return values.length > 0 ? values.join(', ') : 'Not recorded' +} + +function roleLabelFromCode(role: string, dictionaries: CatalogDictionaries) { + const trimmedRole = role.trim() + + return ( + dictionaries.creditRole.find( + (entry) => entry.code === trimmedRole || entry.name === trimmedRole, + )?.name ?? trimmedRole + ) +} + +function LookupInput({ + label, + value, + onChange, +}: { + label: string + value: string + onChange: (value: string) => void +}) { + return ( + + ) +} diff --git a/src/features/tracks/TrackDetail.tsx b/src/features/tracks/TrackDetail.tsx index b1b0aa5..fdd7b50 100644 --- a/src/features/tracks/TrackDetail.tsx +++ b/src/features/tracks/TrackDetail.tsx @@ -26,6 +26,7 @@ type TrackDetailProps = { onDelete?: () => void onEdit?: () => void onEditLocalFile?: (track: TrackRecord) => void + onUpdateViaDiscogs?: () => void playlists: PlaylistRecord[] ratingCriteria: RatingCriterion[] relations: RelationRecord[] @@ -48,6 +49,7 @@ export function TrackDetail({ onDelete, onEdit, onEditLocalFile, + onUpdateViaDiscogs, onDeleteRating, onRateTarget, playlists, @@ -93,6 +95,15 @@ export function TrackDetail({ > Edit record + {onUpdateViaDiscogs ? ( + + ) : null} {onDelete ? ( - {credit.role} + {(credit.roles && credit.roles.length > 0 + ? credit.roles + : [credit.role] + ).map((role) => ( + + {role} + + ))} {credit.artistId ? ( void releases: ReleaseRecord[] tracks: TrackRecord[] @@ -41,6 +49,7 @@ export function TrackEntryForm({ artists, dictionaries, initialTrack, + initialShowDiscogsLookup, onCancel, tracks, onSubmit, @@ -58,12 +67,21 @@ export function TrackEntryForm({ const [credits, setCredits] = useState(() => (initialTrack?.credits ?? []).map((credit, index) => ({ ...credit, + roles: + credit.roles && credit.roles.length > 0 ? credit.roles : [credit.role], id: createManualRecordId( 'track-credit', `${initialTrack?.id ?? 'new'}-${index}`, ), })), ) + const [externalSources, setExternalSources] = useState( + initialTrack?.externalSources, + ) + const [discogsLookupOpenPreference, setDiscogsLookupOpenPreference] = + useState(null) + const isDiscogsLookupOpen = + discogsLookupOpenPreference ?? Boolean(initialShowDiscogsLookup) const appearances = useMemo( () => (initialTrack ? trackReleaseAppearances(initialTrack) : []), [initialTrack], @@ -76,10 +94,10 @@ export function TrackEntryForm({ .filter((tag) => !trackGenreOptions.includes(tag)) .join(', ') ?? '', ) - const hasInvalidCredit = credits.some((credit) => credit.role.length === 0) + const hasInvalidCredit = credits.some((credit) => credit.roles.length === 0) const isValid = title.trim().length > 0 && !hasInvalidCredit const candidateArtist = ( - credits.find((credit) => credit.role === 'Main artist')?.artist ?? + credits.find(hasMainArtistRole)?.artist ?? credits[0]?.artist ?? '' ).toLowerCase() @@ -128,6 +146,7 @@ export function TrackEntryForm({ artistId: existingArtist?.id, artist: existingArtist?.name ?? artistName, role: 'Main artist', + roles: ['Main artist'], scope: 'Track-level credit.', }, ]) @@ -151,8 +170,7 @@ export function TrackEntryForm({ versionNote: appearance.versionNote, })) const primaryAppearance = normalizedAppearances[0] - const primaryCredit = - credits.find((credit) => credit.role === 'Main artist') ?? credits[0] + const primaryCredit = credits.find(hasMainArtistRole) ?? credits[0] const existingFileMetadata = initialTrack?.fileMetadata const note = primaryAppearance?.versionNote.trim() || '' const tags = uniqueValues([ @@ -191,10 +209,11 @@ export function TrackEntryForm({ 'Manual track draft with incomplete metadata.', ), tags: tags.length > 0 ? tags : ['manual entry'], - credits: credits.map(({ artistId, artist, role, scope }) => ({ + credits: credits.map(({ artistId, artist, role, roles, scope }) => ({ artistId, artist, - role: toCreditRole(role), + role: toCreditRole(roles[0] ?? role), + roles: roles.map(toCreditRole), scope, })), releaseAppearances: normalizedAppearances, @@ -217,7 +236,104 @@ export function TrackEntryForm({ importedAt: existingFileMetadata?.importedAt ?? 'Manual entry', checksum: existingFileMetadata?.checksum ?? 'Not recorded', }, + externalSources, + }) + } + + function handleApplyDiscogsDraft( + detail: ExternalMetadataTrackDetailDto, + groups: DiscogsTrackApplyGroups, + ) { + if (groups.core) { + setTitle(detail.draft.title) + setDurationParts( + detail.draft.durationSeconds + ? durationSecondsToParts(detail.draft.durationSeconds) + : durationTextToParts(''), + ) + } + + if (groups.credits) { + setCredits(groupDiscogsCredits(detail.draft.artistCredits)) + } + + setExternalSources( + detail.draft.externalSources.map((source) => ({ + ...source, + appliedAt: new Date().toISOString(), + })), + ) + } + + function groupDiscogsCredits( + artistCredits: ReadonlyArray<{ name: string; role: string }>, + ) { + const grouped = new Map< + string, + ReturnType + >() + + artistCredits.forEach((credit, index) => { + const editableCredit = editableCreditFromDiscogsCredit( + credit.name, + credit.role, + index, + ) + const key = + editableCredit.artistId || + editableCredit.artist.trim().toLowerCase() || + editableCredit.id + const existing = grouped.get(key) + + if (existing) { + existing.roles = [ + ...new Set([...existing.roles, ...editableCredit.roles]), + ] + existing.role = existing.roles[0] ?? '' + } else { + grouped.set(key, editableCredit) + } }) + + return [...grouped.values()] + } + + function editableCreditFromDiscogsCredit( + artistName: string, + role: string, + index: number, + ) { + const normalizedArtistName = artistName.trim() + const existingArtist = artists.find( + (record) => + record.name.toLowerCase() === normalizedArtistName.toLowerCase(), + ) + + const roles = roleLabelsFromCode(role) + + return { + id: createManualRecordId( + 'track-credit', + `${normalizedArtistName}-${role}-${index}`, + ), + artistId: existingArtist?.id, + artist: existingArtist?.name ?? normalizedArtistName, + role: roles[0] ?? '', + roles, + scope: 'Suggested by Discogs track detail.', + } + } + + function roleLabelsFromCode(role: string) { + return [ + ...new Set( + role + .split(',') + .map((part) => part.trim()) + .filter(Boolean) + .map((part) => toCreditRole(part)), + ), + ] } return ( @@ -323,6 +439,31 @@ export function TrackEntryForm({ this session.

) : null} + credit.artist).join(', '), + duration: durationPartsToText(durationParts), + title, + }} + dictionaries={dictionaries} + isOpen={isDiscogsLookupOpen} + mode={initialTrack ? 'update' : 'create'} + searchSeed={{ + artist: + credits.find(hasMainArtistRole)?.artist ?? + credits[0]?.artist ?? + '', + catalogNumber: initialTrack?.release.catalogNumber ?? '', + releaseTitle: + appearances[0]?.releaseTitle ?? + initialTrack?.release.title ?? + '', + title, + year: appearances[0]?.year ?? initialTrack?.release.year ?? '', + }} + onApplyDraft={handleApplyDiscogsDraft} + onOpenChange={setDiscogsLookupOpenPreference} + />
@@ -358,35 +499,63 @@ export function TrackEntryForm({ {credit.artist} - + }} + /> + +
+ ) +} + +function ImpactRow({ + checked, + children, + currentValue, + group, + nextValue, + onChange, +}: { + checked: boolean + children?: ReactNode + currentValue: string + group: string + nextValue: string + onChange: (checked: boolean) => void +}) { + return ( +
+ +
{group}
+
+ Current + {currentValue} +
+
+ Discogs + {nextValue} + {children ? ( +
{children}
+ ) : null} +
+
+ ) +} + +function ArtistImpactList({ + credits, + dictionaries, +}: { + credits: ExternalMetadataReleaseDetailDto['draft']['artistCredits'] + dictionaries: CatalogDictionaries +}) { + if (credits.length === 0) { + return

No Discogs artist credits.

+ } + + return ( +
+ {groupDiscogsReviewCredits(credits).map((credit) => ( + + ))} +
+ ) +} + +function TrackImpactList({ + dictionaries, + tracks, +}: { + dictionaries: CatalogDictionaries + tracks: ExternalMetadataReleaseDraftTrackDto[] +}) { + const [showAllTracks, setShowAllTracks] = useState(false) + const previewTracks = showAllTracks ? tracks : tracks.slice(0, 4) + const hiddenCount = tracks.length - previewTracks.length + + if (tracks.length === 0) { + return

No Discogs track rows.

+ } + + return ( +
+ {previewTracks.map((track) => ( +
+ + {track.position} + +
+ {track.title} +

+ {track.durationSeconds + ? formatDurationSeconds(track.durationSeconds) + : 'No duration'}{' '} + · create track +

+ {track.artistCredits.length > 0 ? ( +
+ {groupDiscogsReviewCredits(track.artistCredits).map( + (credit) => ( + + ), + )} +
+ ) : ( +

Inherits release artists.

+ )} +
+
+ ))} + {hiddenCount > 0 ? ( + + ) : showAllTracks && tracks.length > 4 ? ( + + ) : null} +
+ ) +} + +function CreditImpactRow({ + credit, + dictionaries, +}: { + credit: GroupedDiscogsReviewCredit + dictionaries: CatalogDictionaries +}) { + return ( +
+ {credit.name} + + {credit.roles.map((role) => ( + + {discogsRoleLabelFromCode(role, dictionaries)} + + ))} + +
+ ) +} + +function ApplyGroup({ + checked, + label, + onChange, +}: { + checked: boolean + label: string + onChange: (checked: boolean) => void +}) { + return ( + + ) +} + +function releaseLabelSummary(detail: ExternalMetadataReleaseDetailDto) { + return detail.draft.labels + .map((label) => [label.name, label.catalogNumber].filter(Boolean).join(' ')) + .join(', ') +} + +function formatDurationSeconds(durationSeconds: number) { + const minutes = Math.floor(durationSeconds / 60) + const seconds = durationSeconds % 60 + + return `${minutes}:${String(seconds).padStart(2, '0')}` +} diff --git a/src/features/releases/DiscogsReleaseLookupPanel.tsx b/src/features/releases/DiscogsReleaseLookupPanel.tsx index 0e2ba46..7cd0102 100644 --- a/src/features/releases/DiscogsReleaseLookupPanel.tsx +++ b/src/features/releases/DiscogsReleaseLookupPanel.tsx @@ -1,5 +1,4 @@ import { Search } from 'lucide-react' -import type { ReactNode } from 'react' import { useEffect, useRef, useState } from 'react' import { CatalogApiError, @@ -8,10 +7,8 @@ import { type CatalogDictionaries, type ExternalMetadataReleaseCandidateDto, type ExternalMetadataReleaseDetailDto, - type ExternalMetadataReleaseDraftArtistCreditDto, - type ExternalMetadataReleaseDraftTrackDto, } from '../catalog/catalogApi' -import { discogsDraftTrackRows } from './discogsReleaseTrackRows' +import { DiscogsCandidateReview } from './DiscogsCandidateReview' export type DiscogsApplyGroups = { core: boolean @@ -339,128 +336,6 @@ function appliedGroupLabel(groups: DiscogsApplyGroups) { : `${labels.slice(0, -1).join(', ')} and ${labels.at(-1)}` } -function DiscogsCandidateReview({ - applyGroups, - current, - detail, - dictionaries, - hasSelectedGroup, - onApplyDraft, - onUpdateApplyGroup, -}: { - applyGroups: DiscogsApplyGroups - current: DiscogsCurrentRelease - detail: ExternalMetadataReleaseDetailDto - dictionaries: CatalogDictionaries - hasSelectedGroup: boolean - onApplyDraft: ( - detail: ExternalMetadataReleaseDetailDto, - groups: DiscogsApplyGroups, - ) => void - onUpdateApplyGroup: ( - group: keyof DiscogsApplyGroups, - checked: boolean, - ) => void -}) { - const compilationDetected = hasCompilationTrackArtists(detail) - const reviewTracks = discogsDraftTrackRows(detail.draft.tracklist) - const draftGenres = detail.draft.genres ?? [] - - return ( -
- - -
- onUpdateApplyGroup('core', checked)} - /> - onUpdateApplyGroup('artists', checked)} - > - - - onUpdateApplyGroup('labels', checked)} - /> - 0 ? draftGenres.join(', ') : 'Not recorded' - } - onChange={(checked) => onUpdateApplyGroup('classification', checked)} - /> - onUpdateApplyGroup('tracklist', checked)} - > - {compilationDetected ? ( -

- Compilation detected: track-specific artists differ from release - artists. Applying Tracklist will mark the release as Various - Artists and write track-level artist credits. -

- ) : null} - -
-
- - -
- ) -} - function defaultGroups(mode: 'create' | 'update'): DiscogsApplyGroups { return mode === 'create' ? { @@ -485,311 +360,3 @@ function externalMetadataErrorMessage(error: unknown) { return 'External metadata provider is unavailable.' } - -function ImpactRow({ - checked, - children, - currentValue, - group, - nextValue, - onChange, -}: { - checked: boolean - children?: ReactNode - currentValue: string - group: string - nextValue: string - onChange: (checked: boolean) => void -}) { - return ( -
- -
{group}
-
- Current - {currentValue} -
-
- Discogs - {nextValue} - {children ? ( -
{children}
- ) : null} -
-
- ) -} - -function ArtistImpactList({ - credits, - dictionaries, -}: { - credits: ExternalMetadataReleaseDraftArtistCreditDto[] - dictionaries: CatalogDictionaries -}) { - if (credits.length === 0) { - return

No Discogs artist credits.

- } - - return ( -
- {groupDiscogsReviewCredits(credits).map((credit) => ( - - ))} -
- ) -} - -function TrackImpactList({ - dictionaries, - tracks, -}: { - dictionaries: CatalogDictionaries - tracks: ExternalMetadataReleaseDraftTrackDto[] -}) { - const [showAllTracks, setShowAllTracks] = useState(false) - const previewTracks = showAllTracks ? tracks : tracks.slice(0, 4) - const hiddenCount = tracks.length - previewTracks.length - - if (tracks.length === 0) { - return

No Discogs track rows.

- } - - return ( -
- {previewTracks.map((track) => ( -
- - {track.position} - -
- {track.title} -

- {track.durationSeconds - ? formatDurationSeconds(track.durationSeconds) - : 'No duration'}{' '} - · create track -

- {track.artistCredits.length > 0 ? ( -
- {groupDiscogsReviewCredits(track.artistCredits).map( - (credit) => ( - - ), - )} -
- ) : ( -

Inherits release artists.

- )} -
-
- ))} - {hiddenCount > 0 ? ( - - ) : showAllTracks && tracks.length > 4 ? ( - - ) : null} -
- ) -} - -function CreditImpactRow({ - credit, - dictionaries, -}: { - credit: GroupedDiscogsReviewCredit - dictionaries: CatalogDictionaries -}) { - return ( -
- {credit.name} - - {credit.roles.map((role) => ( - - {roleLabelFromCode(role, dictionaries)} - - ))} - -
- ) -} - -type GroupedDiscogsReviewCredit = { - name: string - roles: string[] -} - -function groupDiscogsReviewCredits( - credits: ExternalMetadataReleaseDraftArtistCreditDto[], -) { - const grouped = new Map() - - credits.forEach((credit) => { - const name = credit.name.trim() - if (!name) { - return - } - - const key = name.toLowerCase() - const existing = grouped.get(key) - const roles = splitRoleLabels(credit.role) - - if (existing) { - existing.roles = [...new Set([...existing.roles, ...roles])] - } else { - grouped.set(key, { name, roles }) - } - }) - - return [...grouped.values()] -} - -function splitRoleLabels(role: string) { - const roles: string[] = [] - let depth = 0 - let current = '' - - for (const character of role) { - if (character === '[' || character === '(') { - depth += 1 - } else if ((character === ']' || character === ')') && depth > 0) { - depth -= 1 - } - - if (character === ',' && depth === 0) { - const trimmed = current.trim() - if (trimmed) { - roles.push(trimmed) - } - current = '' - } else { - current += character - } - } - - const trimmed = current.trim() - if (trimmed) { - roles.push(trimmed) - } - - return roles -} - -function ApplyGroup({ - checked, - label, - onChange, -}: { - checked: boolean - label: string - onChange: (checked: boolean) => void -}) { - return ( - - ) -} - -function releaseLabelSummary(detail: ExternalMetadataReleaseDetailDto) { - return detail.draft.labels - .map((label) => [label.name, label.catalogNumber].filter(Boolean).join(' ')) - .join(', ') -} - -function roleLabelFromCode(role: string, dictionaries: CatalogDictionaries) { - const trimmedRole = role.trim() - - return ( - dictionaries.creditRole.find( - (entry) => entry.code === trimmedRole || entry.name === trimmedRole, - )?.name ?? trimmedRole - ) -} - -function hasCompilationTrackArtists(detail: ExternalMetadataReleaseDetailDto) { - const releaseMainArtists = normalizedSet( - detail.draft.artistCredits - .filter((credit) => normalizeText(credit.role) === 'mainartist') - .map((credit) => credit.name), - ) - const releaseArtists = - releaseMainArtists.size > 0 - ? releaseMainArtists - : normalizedSet(detail.draft.artistCredits.map((credit) => credit.name)) - - return detail.draft.tracklist.some((track) => { - const trackMainArtists = normalizedSet( - track.artistCredits - .filter((credit) => normalizeText(credit.role) === 'mainartist') - .map((credit) => credit.name), - ) - - if (trackMainArtists.size === 0) { - return false - } - - return !setsEqual(releaseArtists, trackMainArtists) - }) -} - -function normalizedSet(values: string[]) { - return new Set(values.map(normalizeText).filter(Boolean)) -} - -function normalizeText(value: string) { - return value.trim().toLowerCase() -} - -function setsEqual(left: Set, right: Set) { - if (left.size !== right.size) { - return false - } - - for (const value of left) { - if (!right.has(value)) { - return false - } - } - - return true -} - -function formatDurationSeconds(durationSeconds: number) { - const minutes = Math.floor(durationSeconds / 60) - const seconds = durationSeconds % 60 - - return `${minutes}:${String(seconds).padStart(2, '0')}` -} diff --git a/src/features/releases/ReleaseArtistCreditsSection.tsx b/src/features/releases/ReleaseArtistCreditsSection.tsx index 7c4aad0..2335b6b 100644 --- a/src/features/releases/ReleaseArtistCreditsSection.tsx +++ b/src/features/releases/ReleaseArtistCreditsSection.tsx @@ -101,8 +101,11 @@ export function ReleaseArtistCreditsSection({ {artistName || 'Unnamed artist'} - {credit.roles.map((role) => ( - + {credit.roles.map((role, index) => ( + {role}