diff --git a/.superdesign/design-system.md b/.superdesign/design-system.md index c206a99..a714e59 100644 --- a/.superdesign/design-system.md +++ b/.superdesign/design-system.md @@ -21,3 +21,17 @@ DiscWeave is a dense personal music archive for collectors. The UI should stay c - Standard fields should be offered as curated options. - Custom field should be an explicit text input with validation hint, not hidden behind a select. - Compatibility copy must clarify that custom fields are best-effort and format-specific. + +## Discogs Release Review Requirements + +- Discogs lookup must stay inside the release form, not in a modal. +- Keep the flow dense and review-oriented: search, candidates, selected review, group apply, then normal form save. +- Do not show barcode search fields, barcode values, or raw identifier lists in release lookup/review UI unless a future product task makes them first-class fields. +- Candidate rows should prioritize title, artists, year, labels, formats, catalog number, source link, and Discogs attribution. +- Selecting a candidate must reveal the review immediately near the selected candidate or in a clearly visible review rail; users should not need to discover it by scrolling. +- Review must make apply consequences explicit for Core, Artists, Labels, and Tracklist. External source provenance is applied with the selected Discogs draft after the user applies fields and saves. +- Artist role suggestions that are not already in local dictionaries should be presented as roles that will be added/accepted with the release update, not as unexplained raw Discogs strings. +- Multiple Discogs artist credits must be shown as separate rows grouped under Artists, with each artist name, one or more roles, and a clear existing-role/new-role status. Do not collapse mixed artist roles into a single comma-separated sentence. +- Tracklist review must clearly state whether tracks will be created, replaced, preserved, or updated before the user applies a group. +- Tracklist impact must show representative per-track rows, not only a count. Each row should show position, title, duration when available, track artist credits, and whether those artists/roles are matched, new, or accepted. +- Compilation and Various Artists cases must be explicit: when Discogs track rows have track-specific artists different from release artists, review should show that the release will be treated as Various Artists or that track-level artist credits will be applied. Do not hide compilation behavior behind a generic "Tracklist" checkbox. diff --git a/src/App.auth.test.tsx b/src/App.auth.test.tsx index 0d06121..a40adc8 100644 --- a/src/App.auth.test.tsx +++ b/src/App.auth.test.tsx @@ -192,7 +192,7 @@ describe('App auth', () => { ]) }) - it('opens artist records after login through server search without full catalog hydration', async () => { + it('opens artist records after login through the editable catalog workspace', async () => { h.clearCatalogForTests() h.clearAuthSessionForTests() window.history.pushState({}, '', '/artists') @@ -208,7 +208,7 @@ describe('App auth', () => { email: 'collector@discweave.local', roles: ['Admin'], }), - h.emptySearchResponse(), + ...h.emptyCatalogLoadResponses(), ) const user = h.userEvent.setup() h.render() @@ -230,12 +230,8 @@ describe('App auth', () => { const urls = requestUrls(fetchMock) expect(urls.slice(0, 2)).toEqual(['/api/auth/session', '/api/auth/login']) - expect( - h - .searchRequestUrls(fetchMock) - .some((url) => url.searchParams.get('entityType') === 'artist'), - ).toBe(true) - expect(urls.some((url) => url.startsWith('/api/artists?'))).toBe(false) + expect(urls.some((url) => url.startsWith('/api/artists?'))).toBe(true) + expect(h.searchRequestUrls(fetchMock)).toHaveLength(0) }) it('shows an accessible error after invalid login', async () => { @@ -328,16 +324,31 @@ describe('App auth', () => { ).toBeEnabled() }) - it('shows a server search error for entity workspaces', async () => { + it('shows a catalog load error for entity workspaces', async () => { h.clearCatalogForTests() window.history.pushState({}, '', '/tracks') - const fetchMock = h.mockFetch( - h.jsonResponse( - { code: 'catalog.server_error', message: 'Catalog unavailable' }, - 500, - ), - ...h.emptyCatalogLoadResponses().slice(1), - ) + const fetchMock = h.vi.fn(async (input) => { + const url = typeof input === 'string' ? input : (input as Request).url + await Promise.resolve() + + if (url.startsWith('/api/tracks?')) { + return h.jsonResponse( + { code: 'catalog.server_error', message: 'Catalog unavailable' }, + 500, + ) + } + + if (url.startsWith('/api/settings/dictionaries?')) { + return h.defaultDictionaryListResponse() + } + + if (url.startsWith('/api/rating-criteria?')) { + return h.defaultRatingCriteriaListResponse() + } + + return h.emptyCatalogListResponse() + }) + h.vi.stubGlobal('fetch', fetchMock) h.render() @@ -345,11 +356,11 @@ describe('App auth', () => { await h.screen.findByRole('heading', { name: 'Tracks' }), ).toBeInTheDocument() expect(await h.screen.findByRole('alert')).toHaveTextContent( - 'Catalog unavailable', + 'Catalog request failed. Try again.', ) expect( requestUrls(fetchMock).some((url) => url.startsWith('/api/tracks?')), - ).toBe(false) + ).toBe(true) }) it('returns to sign in when a catalog mutation expires the session', async () => { @@ -359,17 +370,20 @@ describe('App auth', () => { const url = typeof input === 'string' ? input : (input as Request).url await Promise.resolve() - if (url.startsWith('/api/search?')) { - return h.emptySearchResponse() - } if (url === '/api/artists') { return h.jsonResponse( { code: 'auth.unauthenticated', message: 'Session expired' }, 401, ) } + if (url.startsWith('/api/settings/dictionaries?')) { + return h.defaultDictionaryListResponse() + } + if (url.startsWith('/api/rating-criteria?')) { + return h.defaultRatingCriteriaListResponse() + } - return h.emptySearchResponse() + return h.emptyCatalogListResponse() }) h.vi.stubGlobal('fetch', fetchMock) const user = h.userEvent.setup() @@ -390,19 +404,43 @@ describe('App auth', () => { it('keeps the loaded workspace available when a catalog refresh fails after a mutation', async () => { h.clearCatalogForTests() window.history.pushState({}, '', '/labels') - h.mockFetch( - h.emptySearchResponse(), - ...h.emptyCatalogLoadResponses(), - h.jsonResponse({ - id: '00000000-0000-7000-8000-000000000010', - name: 'Refresh Failure Label', + let artistListRequests = 0 + h.vi.stubGlobal( + 'fetch', + h.vi.fn(async (input, init) => { + const url = typeof input === 'string' ? input : (input as Request).url + await Promise.resolve() + + if (url === '/api/labels' && init?.method === 'POST') { + return h.jsonResponse({ + id: '00000000-0000-7000-8000-000000000010', + name: 'Refresh Failure Label', + }) + } + + if (url.startsWith('/api/artists?')) { + artistListRequests += 1 + if (artistListRequests === 2) { + return h.jsonResponse( + { + code: 'catalog.server_error', + message: 'Catalog refresh failed', + }, + 500, + ) + } + } + + if (url.startsWith('/api/settings/dictionaries?')) { + return h.defaultDictionaryListResponse() + } + + if (url.startsWith('/api/rating-criteria?')) { + return h.defaultRatingCriteriaListResponse() + } + + return h.emptyCatalogListResponse() }), - h.jsonResponse( - { code: 'catalog.server_error', message: 'Catalog refresh failed' }, - 500, - ), - ...h.emptyCatalogLoadResponses().slice(1), - ...h.emptyCatalogLoadResponses(), ) const user = h.userEvent.setup() h.render() diff --git a/src/App.catalog-actions.cases.ts b/src/App.catalog-actions.cases.ts new file mode 100644 index 0000000..72c5ba3 --- /dev/null +++ b/src/App.catalog-actions.cases.ts @@ -0,0 +1,97 @@ +import type { AppRoutePath } from './app/routes' + +export type ManualEntryCase = { + action: string + detailName: string + form: string + heading: string + path: AppRoutePath + requiredLabel: string + rowName: RegExp + searchLabel: string + secondaryRequiredLabel?: string + secondaryValue?: string + value: string +} + +export const manualEntryCases: ManualEntryCase[] = [ + { + path: '/artists', + heading: 'Artists', + action: 'Add artist', + form: 'Add artist', + requiredLabel: 'Name', + value: 'Coil Archive Test Artist', + searchLabel: 'Search artists', + rowName: /coil archive test artist/i, + detailName: 'Coil Archive Test Artist', + }, + { + path: '/releases', + heading: 'Releases', + action: 'Add release', + form: 'Add release', + requiredLabel: 'Title', + value: 'Silent Dub Test Pressing', + searchLabel: 'Search releases', + rowName: /silent dub test pressing/i, + detailName: 'Silent Dub Test Pressing', + }, + { + path: '/tracks', + heading: 'Tracks', + action: 'Add track', + form: 'Add track', + requiredLabel: 'Title', + value: 'Unlabeled Field Recording', + searchLabel: 'Search tracks', + rowName: /unlabeled field recording/i, + detailName: 'Unlabeled Field Recording', + }, + { + path: '/labels', + heading: 'Labels', + action: 'Add label', + form: 'Add label', + requiredLabel: 'Name', + value: 'Basement White Label', + searchLabel: 'Search labels', + rowName: /basement white label/i, + detailName: 'Basement White Label', + }, + { + path: '/owned-items', + heading: 'Owned Items', + action: 'Add owned item', + form: 'Add owned item', + requiredLabel: 'Item name', + value: 'Basement Tape Reference Copy', + searchLabel: 'Search owned items', + rowName: /basement tape reference copy/i, + detailName: 'Basement Tape Reference Copy', + }, + { + path: '/relations', + heading: 'Relations', + action: 'Add relation', + form: 'Add relation', + requiredLabel: 'Source', + secondaryRequiredLabel: 'Target', + value: 'Archive Source Person', + secondaryValue: 'Archive Target Project', + searchLabel: 'Search relations', + rowName: /archive source person archive target project/i, + detailName: 'Archive Source Person to Archive Target Project', + }, + { + path: '/playlists', + heading: 'Playlists', + action: 'Add playlist', + form: 'Add playlist', + requiredLabel: 'Name', + value: 'Listening Desk Checks', + searchLabel: 'Search playlists', + rowName: /listening desk checks/i, + detailName: 'Listening Desk Checks', + }, +] diff --git a/src/App.catalog-actions.test.tsx b/src/App.catalog-actions.test.tsx index 1335cee..78559b1 100644 --- a/src/App.catalog-actions.test.tsx +++ b/src/App.catalog-actions.test.tsx @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import * as h from './test/appTestHarness' +import { manualEntryCases } from './App.catalog-actions.cases' h.setupAppTestHooks() @@ -154,8 +155,22 @@ describe('App catalog actions', () => { if (url === '/api/catalog-graph/label/label-1') { return h.graphResponseForLabel() } + if (url.startsWith('/api/labels?')) { + return h.jsonResponse({ + items: [{ id: 'label-1', name: 'Factory Records' }], + limit: 100, + offset: 0, + total: 1, + }) + } + if (url.startsWith('/api/settings/dictionaries?')) { + return h.defaultDictionaryListResponse() + } + if (url.startsWith('/api/rating-criteria?')) { + return h.defaultRatingCriteriaListResponse() + } - return h.emptySearchResponse() + return h.emptyCatalogListResponse() }) h.vi.stubGlobal('fetch', fetchMock) const user = h.userEvent.setup() @@ -178,12 +193,15 @@ describe('App catalog actions', () => { expect( await h.screen.findByRole('complementary', { name: 'Factory Records' }), ).toBeInTheDocument() + expect( + h.screen.getByRole('button', { name: 'Edit record' }), + ).toBeInTheDocument() expect( fetchMock.mock.calls.some( ([input]) => typeof input === 'string' && input.startsWith('/api/labels?'), ), - ).toBe(false) + ).toBe(true) }) it('keeps label owned coverage tied to release ids instead of shared titles', async () => { @@ -439,87 +457,7 @@ describe('App catalog actions', () => { }) }) - it.each([ - { - path: '/artists', - heading: 'Artists', - action: 'Add artist', - form: 'Add artist', - requiredLabel: 'Name', - value: 'Coil Archive Test Artist', - searchLabel: 'Search artists', - rowName: /coil archive test artist/i, - detailName: 'Coil Archive Test Artist', - }, - { - path: '/releases', - heading: 'Releases', - action: 'Add release', - form: 'Add release', - requiredLabel: 'Title', - value: 'Silent Dub Test Pressing', - searchLabel: 'Search releases', - rowName: /silent dub test pressing/i, - detailName: 'Silent Dub Test Pressing', - }, - { - path: '/tracks', - heading: 'Tracks', - action: 'Add track', - form: 'Add track', - requiredLabel: 'Title', - value: 'Unlabeled Field Recording', - searchLabel: 'Search tracks', - rowName: /unlabeled field recording/i, - detailName: 'Unlabeled Field Recording', - }, - { - path: '/labels', - heading: 'Labels', - action: 'Add label', - form: 'Add label', - requiredLabel: 'Name', - value: 'Basement White Label', - searchLabel: 'Search labels', - rowName: /basement white label/i, - detailName: 'Basement White Label', - }, - { - path: '/owned-items', - heading: 'Owned Items', - action: 'Add owned item', - form: 'Add owned item', - requiredLabel: 'Item name', - value: 'Basement Tape Reference Copy', - searchLabel: 'Search owned items', - rowName: /basement tape reference copy/i, - detailName: 'Basement Tape Reference Copy', - }, - { - path: '/relations', - heading: 'Relations', - action: 'Add relation', - form: 'Add relation', - requiredLabel: 'Source', - secondaryRequiredLabel: 'Target', - value: 'Archive Source Person', - secondaryValue: 'Archive Target Project', - searchLabel: 'Search relations', - rowName: /archive source person archive target project/i, - detailName: 'Archive Source Person to Archive Target Project', - }, - { - path: '/playlists', - heading: 'Playlists', - action: 'Add playlist', - form: 'Add playlist', - requiredLabel: 'Name', - value: 'Listening Desk Checks', - searchLabel: 'Search playlists', - rowName: /listening desk checks/i, - detailName: 'Listening Desk Checks', - }, - ])( + it.each(manualEntryCases)( 'supports required-only manual entry from the header in $heading', async ({ path, diff --git a/src/App.discogs-artist-autocomplete.test.tsx b/src/App.discogs-artist-autocomplete.test.tsx new file mode 100644 index 0000000..b655b7e --- /dev/null +++ b/src/App.discogs-artist-autocomplete.test.tsx @@ -0,0 +1,211 @@ +import { describe, expect, it } from 'vitest' +import * as h from './test/appTestHarness' + +h.setupAppTestHooks() + +describe('App Discogs artist autocomplete', () => { + it('searches artist candidates and prefills a new artist only after review apply', async () => { + window.history.pushState({}, '', '/artists') + const fetchMock = h.vi.fn().mockImplementation((input) => { + const url = requestUrl(input) + + if (url.pathname === '/api/external-metadata/discogs/artists') { + return Promise.resolve( + h.jsonResponse({ + items: [ + { + source: source('5876'), + name: 'Arthur Baker', + profile: 'Producer and remixer.', + nameVariations: ['A. Baker'], + }, + ], + limit: 25, + total: 1, + }), + ) + } + + if (url.pathname === '/api/external-metadata/discogs/artists/5876') { + return Promise.resolve(h.jsonResponse(artistDetail())) + } + + 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 artist' })) + const form = h.screen.getByRole('form', { name: 'Add artist' }) + + await user.type(h.within(form).getByLabelText('Name'), 'Local Artist') + await user.click( + h.within(form).getByRole('button', { name: 'Search Discogs' }), + ) + + const lookup = h.within(form).getByRole('region', { + name: 'Discogs artist lookup', + }) + await user.click( + h.within(lookup).getByRole('button', { name: 'Search Discogs artists' }), + ) + await user.click( + await h.within(lookup).findByRole('button', { + name: /review arthur baker/i, + }), + ) + + expect(h.within(form).getByLabelText('Name')).toHaveValue('Local Artist') + expect( + await h.within(lookup).findByRole('heading', { + name: 'Review Discogs artist', + }), + ).toBeInTheDocument() + expect(h.within(lookup).getByText('Arthur Baker III')).toBeInTheDocument() + expect( + h + .within(lookup) + .getByRole('link', { name: 'Open Discogs artist source' }), + ).toHaveAttribute('href', 'https://www.discogs.com/artist/5876') + + await user.click( + h.within(lookup).getByRole('button', { + name: 'Apply selected Discogs fields', + }), + ) + + expect(h.within(form).getByLabelText('Name')).toHaveValue('Arthur Baker') + await user.click(h.within(form).getByRole('button', { name: 'Add record' })) + + const createdArtist = h + .getInitialCatalogStateForTests() + ?.artists.find((artist) => artist.name === 'Arthur Baker') + expect(createdArtist?.externalSources?.[0]).toMatchObject({ + providerName: 'discogs', + resourceType: 'artist', + externalId: '5876', + sourceUrl: 'https://www.discogs.com/artist/5876', + }) + expect(createdArtist?.externalSources?.[0].appliedAt).toMatch( + /^\d{4}-\d{2}-\d{2}T/, + ) + }) + + it('reviews an existing artist update before applying selected groups', async () => { + window.history.pushState({}, '', '/artists?artist=new-order') + const fetchMock = h.vi.fn().mockImplementation((input) => { + const url = requestUrl(input) + + if (url.pathname === '/api/external-metadata/discogs/artists') { + return Promise.resolve( + h.jsonResponse({ + items: [ + { + source: source('3909'), + name: 'New Order', + profile: 'English rock band.', + nameVariations: ['N.O.'], + }, + ], + limit: 25, + total: 1, + }), + ) + } + + if (url.pathname === '/api/external-metadata/discogs/artists/3909') { + return Promise.resolve( + h.jsonResponse({ + ...artistDetail('3909', 'New Order'), + profile: 'English rock band.', + aliases: ['NO'], + members: ['Bernard Sumner'], + nameVariations: ['N.O.'], + }), + ) + } + + 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 artist' }) + const lookup = h.within(form).getByRole('region', { + name: 'Discogs artist lookup', + }) + + await user.click( + h.within(lookup).getByRole('button', { name: 'Search Discogs artists' }), + ) + await user.click( + await h.within(lookup).findByRole('button', { + name: /review new order/i, + }), + ) + 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', + }), + ) + await user.click( + h.within(form).getByRole('button', { name: 'Save record' }), + ) + + const updatedArtist = h + .getInitialCatalogStateForTests() + ?.artists.find((artist) => artist.id === 'new-order') + expect(updatedArtist?.externalSources?.[0]).toMatchObject({ + providerName: 'discogs', + resourceType: 'artist', + externalId: '3909', + }) + }) +}) + +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: 'artist', + externalId, + sourceUrl: `https://www.discogs.com/artist/${externalId}`, + attribution: 'Data provided by Discogs.', + } +} + +function artistDetail(externalId = '5876', name = 'Arthur Baker') { + return { + source: source(externalId), + name, + profile: 'Producer and remixer.', + aliases: ['Arthur Baker III'], + members: ['Rockers Revenge'], + nameVariations: ['A. Baker'], + draft: { + name, + externalSources: [ + { + providerName: 'discogs', + resourceType: 'artist', + externalId, + sourceUrl: `https://www.discogs.com/artist/${externalId}`, + }, + ], + }, + } +} diff --git a/src/App.discogs-release-autocomplete.test.tsx b/src/App.discogs-release-autocomplete.test.tsx new file mode 100644 index 0000000..ce03c39 --- /dev/null +++ b/src/App.discogs-release-autocomplete.test.tsx @@ -0,0 +1,499 @@ +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' }), + ) + const searchUrl = fetchMock.mock.calls + .map(([input]) => requestUrl(input)) + .find((url) => url.pathname === '/api/external-metadata/discogs/releases') + expect(searchUrl?.searchParams.get('barcode')).toBeNull() + expect(h.within(lookup).queryByLabelText('Discogs barcode')).toBeNull() + expect(h.within(lookup).queryByText('5016839200371')).toBeNull() + expect(h.within(lookup).queryByText('Factory')).toBeNull() + + 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') + expect(h.within(lookup).queryByText(/Identifiers/i)).toBeNull() + expect(h.within(lookup).queryByText(/Barcodes/i)).toBeNull() + expect(h.within(lookup).queryByText(/External source/i)).toBeNull() + expect(h.within(lookup).getByText('Written-By')).toBeInTheDocument() + expect(h.within(lookup).queryByText('New role accepted')).toBeNull() + expect(h.within(lookup).queryByText('Matched local artist')).toBeNull() + + await user.click( + h.within(lookup).getByRole('button', { + name: 'Apply selected Discogs fields', + }), + ) + + expect( + h.within(lookup).queryByRole('heading', { + name: 'Review Discogs candidate', + }), + ).not.toBeInTheDocument() + expect( + h + .within(lookup) + .getByText( + 'Applied Discogs core, artists, labels, classification and tracklist to the form. Save record to persist changes.', + ), + ).toBeInTheDocument() + expect(h.within(form).getByLabelText('Title')).toHaveValue('Blue Monday') + expect(h.within(form).getByLabelText('Release date')).toHaveValue( + '1983-03-07', + ) + expect(h.within(form).getByLabelText('Type')).toHaveValue('single') + expect(h.within(form).getByLabelText('Genre Electronic')).toBeChecked() + expect(h.within(form).getByLabelText('Genre Leftfield')).toBeChecked() + 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' }), + ).toBeEnabled() + 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')) + expect( + h.within(lookup).queryByLabelText('Apply External Source'), + ).toBeNull() + await user.click( + h.within(lookup).getByRole('button', { + name: 'Apply selected Discogs fields', + }), + ) + + expect( + h + .within(lookup) + .getByText( + 'Applied Discogs core to the form. Save record to persist changes.', + ), + ).toBeInTheDocument() + 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"', + releaseDate: '1983-03-07', + 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/, + ) + }) + + it('shows track artist and compilation impact before applying a Discogs tracklist', 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('orb-1991'), + title: "The Orb's Adventures Beyond The Ultraworld", + artists: ['The Orb'], + year: 1991, + labels: ['Big Life'], + formats: ['CD', 'Album'], + catalogNumber: 'BLRCD 5', + barcodes: ['042284796323'], + }, + ], + limit: 25, + total: 1, + }), + ) + } + + if (url.pathname === '/api/external-metadata/discogs/releases/orb-1991') { + return Promise.resolve(h.jsonResponse(compilationReleaseDetail())) + } + + 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'), + "The Orb's Adventures", + ) + 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 the orb's adventures/i, + }), + ) + + expect( + h.within(lookup).getByText(/Compilation detected/i), + ).toBeInTheDocument() + expect(h.within(lookup).queryByText(/Discogs heading row/i)).toBeNull() + expect(h.within(lookup).queryByText('Orbit Compact Disc')).toBeNull() + expect( + h.within(lookup).getByText('Little Fluffy Clouds'), + ).toBeInTheDocument() + expect(h.within(lookup).getByText('Earth (Gaia)')).toBeInTheDocument() + expect(h.within(lookup).getByText('Perpetual Dawn')).toBeInTheDocument() + expect(h.within(lookup).getAllByText('Main artist').length).toBeGreaterThan( + 0, + ) + expect(h.within(lookup).getByText('Engineer')).toBeInTheDocument() + expect(h.within(lookup).getByText('Guitar')).toBeInTheDocument() + expect(h.within(lookup).getByText('Producer')).toBeInTheDocument() + expect(h.within(lookup).getAllByText('Steve Hillage')).toHaveLength(1) + expect(h.within(lookup).queryByText('New role accepted')).toBeNull() + expect(h.within(lookup).queryByText('Matched local artist')).toBeNull() + expect(h.within(lookup).queryByText('042284796323')).toBeNull() + + await user.click( + h.within(lookup).getByRole('button', { + name: 'Show 1 more Discogs track row', + }), + ) + expect( + h.within(lookup).getByText('Into The Fourth Dimension'), + ).toBeInTheDocument() + + await user.click( + h.within(lookup).getByRole('button', { + name: 'Apply selected Discogs fields', + }), + ) + + expect(h.within(form).getByLabelText('Various Artists')).toBeChecked() + expect( + h.within(form).getByText('Track rows must include their own artists.'), + ).toBeInTheDocument() + }) +}) + +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', + type: 'single', + genres: ['Electronic', 'Leftfield'], + year: 1983, + releaseDate: '1983-03-07', + 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' }, + { name: 'New Order', role: 'Written-By' }, + ], + }, + ], + externalSources: [ + { + providerName: 'discogs', + resourceType: 'release', + externalId: '249504', + sourceUrl: 'https://www.discogs.com/release/249504', + }, + ], + }, + } +} + +function compilationReleaseDetail() { + return { + source: source('orb-1991'), + title: "The Orb's Adventures Beyond The Ultraworld", + artists: ['The Orb'], + year: 1991, + labels: ['Big Life'], + formats: ['CD', 'Album'], + tracklist: [], + identifiers: [{ type: 'Barcode', value: '042284796323' }], + barcodes: ['042284796323'], + catalogNumber: 'BLRCD 5', + credits: [], + draft: { + title: "The Orb's Adventures Beyond The Ultraworld", + genres: ['Electronic'], + year: 1991, + artistCredits: [{ name: 'The Orb', role: 'mainArtist' }], + labels: [ + { + name: 'Big Life', + catalogNumber: 'BLRCD 5', + hasNoCatalogNumber: false, + }, + ], + tracklist: [ + { + title: 'Orbit Compact Disc', + position: 1, + durationSeconds: null, + artistCredits: [ + { name: 'Heading Credit', role: 'Design [Not a track]' }, + ], + }, + { + title: 'Little Fluffy Clouds', + position: 2, + durationSeconds: 269, + artistCredits: [{ name: 'The Orb', role: 'mainArtist' }], + }, + { + title: 'Earth (Gaia)', + position: 3, + durationSeconds: 580, + artistCredits: [ + { name: 'The Orb', role: 'mainArtist' }, + { name: 'Andy Falconer', role: 'engineer' }, + ], + }, + { + title: 'Perpetual Dawn', + position: 4, + durationSeconds: 568, + artistCredits: [ + { name: 'The Orb', role: 'mainArtist' }, + { name: 'Steve Hillage', role: 'Guitar' }, + { name: 'Steve Hillage', role: 'Producer' }, + ], + }, + { + title: 'Back Side Of The Moon', + position: 5, + durationSeconds: 826, + artistCredits: [{ name: 'Thomas Fehlmann', role: 'mainArtist' }], + }, + { + title: 'Into The Fourth Dimension', + position: 6, + durationSeconds: 572, + artistCredits: [{ name: 'The Orb', role: 'mainArtist' }], + }, + ], + externalSources: [ + { + providerName: 'discogs', + resourceType: 'release', + externalId: 'orb-1991', + sourceUrl: 'https://www.discogs.com/release/orb-1991', + }, + ], + }, + } +} diff --git a/src/App.discogs-track-autocomplete.test.tsx b/src/App.discogs-track-autocomplete.test.tsx new file mode 100644 index 0000000..ecebd2a --- /dev/null +++ b/src/App.discogs-track-autocomplete.test.tsx @@ -0,0 +1,299 @@ +import { describe, expect, it } from 'vitest' +import * as h from './test/appTestHarness' + +h.setupAppTestHooks() + +describe('App Discogs track autocomplete', () => { + it('searches track candidates and prefills a new track only after review apply', async () => { + window.history.pushState({}, '', '/tracks') + const fetchMock = h.vi.fn().mockImplementation((input) => { + const url = requestUrl(input) + + if (url.pathname === '/api/external-metadata/discogs/tracks') { + return Promise.resolve( + h.jsonResponse({ + items: [trackCandidate()], + limit: 25, + total: 1, + }), + ) + } + + if ( + url.pathname === + `/api/external-metadata/discogs/tracks/${encodeURIComponent(trackId)}` + ) { + return Promise.resolve(h.jsonResponse(trackDetail())) + } + + 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 track' })) + const form = h.screen.getByRole('form', { name: 'Add track' }) + + await user.type(h.within(form).getByLabelText('Title'), 'Working track') + await user.click( + h.within(form).getByRole('button', { name: 'Search Discogs' }), + ) + + const lookup = h.within(form).getByRole('region', { + name: 'Discogs track lookup', + }) + expect(h.within(lookup).queryByLabelText('Discogs barcode')).toBeNull() + await user.click( + h.within(lookup).getByRole('button', { name: 'Search Discogs tracks' }), + ) + expect( + requestUrl(fetchMock.mock.calls[0]?.[0]).searchParams.has('barcode'), + ).toBe(false) + await user.click( + await h.within(lookup).findByRole('button', { + name: /review blue monday/i, + }), + ) + + expect(h.within(form).getByLabelText('Title')).toHaveValue('Working track') + expect( + await h.within(lookup).findByRole('heading', { + name: 'Review Discogs track', + }), + ).toBeInTheDocument() + expect(h.within(lookup).getAllByText('New Order').length).toBeGreaterThan(0) + expect( + h.within(lookup).getAllByText('Data provided by Discogs.').length, + ).toBeGreaterThan(0) + expect( + h.within(lookup).getByRole('link', { name: 'Open Discogs track source' }), + ).toHaveAttribute('href', 'https://www.discogs.com/release/249504') + expect(h.within(lookup).getByText('Producer')).toBeInTheDocument() + expect( + h.within(lookup).queryByLabelText('Apply External Source'), + ).toBeNull() + + 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).getByLabelText('Track duration minutes')).toHaveValue( + 7, + ) + expect(h.within(form).getByLabelText('Track duration seconds')).toHaveValue( + 29, + ) + expect( + h + .within(lookup) + .getByText(/Applied Discogs core and credits to the form/i), + ).toBeInTheDocument() + expect( + h.within(lookup).queryByRole('heading', { + name: 'Review Discogs track', + }), + ).toBeNull() + + await user.click(h.within(form).getByRole('button', { name: 'Add record' })) + + const createdTrack = h + .getInitialCatalogStateForTests() + ?.tracks.filter((track) => track.title === 'Blue Monday') + .slice(-1)[0] + expect(createdTrack?.externalSources?.[0]).toMatchObject({ + providerName: 'discogs', + resourceType: 'track', + externalId: trackId, + sourceUrl: 'https://www.discogs.com/release/249504', + }) + expect(createdTrack?.externalSources?.[0].appliedAt).toMatch( + /^\d{4}-\d{2}-\d{2}T/, + ) + expect(createdTrack?.credits).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + artist: 'New Order', + role: 'Main artist', + roles: ['Main artist', 'Producer'], + }), + ]), + ) + }) + + it('reviews an existing track update before applying selected groups', async () => { + window.history.pushState({}, '', '/tracks?track=blue-monday') + const fetchMock = h.vi.fn().mockImplementation((input) => { + const url = requestUrl(input) + + if (url.pathname === '/api/external-metadata/discogs/tracks') { + return Promise.resolve( + h.jsonResponse({ + items: [ + { + ...trackCandidate(), + title: 'Blue Monday (Factory Mix)', + }, + ], + limit: 25, + total: 1, + }), + ) + } + + if ( + url.pathname === + `/api/external-metadata/discogs/tracks/${encodeURIComponent(trackId)}` + ) { + return Promise.resolve( + h.jsonResponse({ + ...trackDetail(), + title: 'Blue Monday (Factory Mix)', + draft: { + ...trackDetail().draft, + title: 'Blue Monday (Factory Mix)', + }, + }), + ) + } + + 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 track' }) + const lookup = h.within(form).getByRole('region', { + name: 'Discogs track lookup', + }) + + await user.click( + h.within(lookup).getByRole('button', { name: 'Search Discogs tracks' }), + ) + await user.click( + await h.within(lookup).findByRole('button', { + name: /review blue monday/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 Credits')) + expect( + h.within(lookup).queryByLabelText('Apply External Source'), + ).toBeNull() + await user.click( + h.within(lookup).getByRole('button', { + name: 'Apply selected Discogs fields', + }), + ) + expect( + h + .within(lookup) + .getByText(/Applied Discogs core and credits to the form/i), + ).toBeInTheDocument() + await user.click( + h.within(form).getByRole('button', { name: 'Save record' }), + ) + + const updatedTrack = h + .getInitialCatalogStateForTests() + ?.tracks.find((track) => track.id === 'blue-monday') + expect(updatedTrack).toMatchObject({ + title: 'Blue Monday (Factory Mix)', + credits: [ + { + artist: 'New Order', + role: 'Main artist', + roles: ['Main artist', 'Producer'], + }, + ], + externalSources: [ + { + providerName: 'discogs', + resourceType: 'track', + externalId: trackId, + sourceUrl: 'https://www.discogs.com/release/249504', + }, + ], + }) + }) +}) + +const trackId = '249504:A:Blue-Monday' + +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, resourceType = 'track') { + return { + providerName: 'discogs', + resourceType, + externalId, + sourceUrl: 'https://www.discogs.com/release/249504', + attribution: 'Data provided by Discogs.', + } +} + +function trackCandidate() { + return { + source: source(trackId), + title: 'Blue Monday', + position: 'A', + durationSeconds: 449, + artists: ['New Order'], + release: { + source: source('249504', 'release'), + title: 'Blue Monday', + year: 1983, + artists: ['New Order'], + }, + } +} + +function trackDetail() { + return { + ...trackCandidate(), + credits: [ + { + name: 'New Order', + role: 'Producer', + }, + ], + draft: { + title: 'Blue Monday', + durationSeconds: 449, + artistCredits: [ + { + name: 'New Order', + role: 'Main artist', + }, + { + name: 'New Order', + role: 'Producer', + }, + ], + externalSources: [ + { + providerName: 'discogs', + resourceType: 'track', + externalId: trackId, + sourceUrl: 'https://www.discogs.com/release/249504', + }, + ], + }, + } +} diff --git a/src/App.owned-items-inventory.test.tsx b/src/App.owned-items-inventory.test.tsx index ded2e7e..1cb6c10 100644 --- a/src/App.owned-items-inventory.test.tsx +++ b/src/App.owned-items-inventory.test.tsx @@ -4,21 +4,94 @@ import * as h from './test/appTestHarness' h.setupAppTestHooks() describe('App owned item workspace', () => { - it('loads owned item inventory through the paged server endpoint by default', async () => { + it('loads owned items into the editable workspace by default', async () => { window.history.pushState({}, '', '/owned-items') h.clearCatalogForTests() - const fetchMock = h.mockFetch(h.emptyCatalogListResponse()) + const fetchMock = h.vi.fn(async (input) => { + const url = typeof input === 'string' ? input : (input as Request).url + await Promise.resolve() + + if (url.startsWith('/api/releases?')) { + return h.jsonResponse({ + items: [ + { + id: 'release-blue-monday', + title: 'Blue Monday', + type: 'single', + year: 1983, + releaseDate: null, + genres: [], + tags: [], + artistCredits: [], + labels: [], + tracklist: [], + }, + ], + limit: 100, + offset: 0, + total: 1, + }) + } + if (url.startsWith('/api/owned-items?')) { + return h.jsonResponse({ + items: [ + { + id: 'owned-blue-monday-vinyl', + targetType: 'release', + targetId: 'release-blue-monday', + target: { + type: 'release', + id: 'release-blue-monday', + title: 'Blue Monday', + subtitle: 'New Order', + releaseId: 'release-blue-monday', + releaseTitle: 'Blue Monday', + }, + status: 'owned', + medium: { + type: 'vinyl', + description: null, + path: null, + format: null, + discCount: null, + }, + condition: 'veryGood', + storageLocation: 'Shelf A3', + inventorySignals: ['physicalWithoutDigital'], + }, + ], + limit: 100, + offset: 0, + total: 1, + }) + } + if (url.startsWith('/api/settings/dictionaries?')) { + return h.defaultDictionaryListResponse() + } + if (url.startsWith('/api/rating-criteria?')) { + return h.defaultRatingCriteriaListResponse() + } + + return h.emptyCatalogListResponse() + }) + h.vi.stubGlobal('fetch', fetchMock) h.render() expect( - await h.screen.findByRole('heading', { name: 'Owned item inventory' }), + await h.screen.findByRole('heading', { name: 'Owned item records' }), + ).toBeInTheDocument() + expect( + await h.screen.findByRole('complementary', { name: 'Blue Monday' }), + ).toBeInTheDocument() + expect( + h.screen.getByRole('button', { name: 'Edit record' }), ).toBeInTheDocument() const urls = requestUrls(fetchMock) expect(urls).toContain('/api/owned-items?limit=100&offset=0') - expect(urls.some((url) => url.startsWith('/api/releases?'))).toBe(false) - expect(urls.some((url) => url.startsWith('/api/tracks?'))).toBe(false) + expect(urls.some((url) => url.startsWith('/api/releases?'))).toBe(true) + expect(urls.some((url) => url.startsWith('/api/tracks?'))).toBe(true) }) it('renders release and track target links from owned item summaries', async () => { diff --git a/src/App.relation-credit-navigation.test.tsx b/src/App.relation-credit-navigation.test.tsx index 62cba78..d841ce8 100644 --- a/src/App.relation-credit-navigation.test.tsx +++ b/src/App.relation-credit-navigation.test.tsx @@ -87,11 +87,44 @@ describe('App relation and credit navigation', () => { if (url === '/api/catalog-graph/artist/artist-1') { return f.graphResponseForArtistNavigation() } - if (url === '/api/artist-relations/artist-relation-1') { - return f.artistRelationDetailResponse() + if (url.startsWith('/api/artists?')) { + return h.jsonResponse({ + items: [ + { id: 'artist-1', name: 'Arthur Baker', type: 'person' }, + { id: 'artist-2', name: 'New Order', type: 'group' }, + ], + limit: 100, + offset: 0, + total: 2, + }) + } + if (url.startsWith('/api/artist-relations?')) { + return h.jsonResponse({ + items: [ + { + id: 'artist-relation-1', + sourceArtistId: 'artist-1', + targetArtistId: 'artist-2', + type: 'collaboration', + startYear: 1983, + endYear: null, + sourceArtistName: 'Arthur Baker', + targetArtistName: 'New Order', + }, + ], + limit: 100, + offset: 0, + total: 1, + }) + } + if (url.startsWith('/api/settings/dictionaries?')) { + return h.defaultDictionaryListResponse() + } + if (url.startsWith('/api/rating-criteria?')) { + return h.defaultRatingCriteriaListResponse() } - return h.emptySearchResponse() + return h.emptyCatalogListResponse() }) h.vi.stubGlobal('fetch', fetchMock) const user = h.userEvent.setup() @@ -113,9 +146,21 @@ describe('App relation and credit navigation', () => { }), ).toBeInTheDocument() expect( - h - .searchRequestUrls(fetchMock) - .some((url) => url.searchParams.get('savedView') === 'credits'), + h.screen.getByRole('button', { name: 'Edit record' }), + ).toBeInTheDocument() + expect( + fetchMock.mock.calls.some( + ([input]) => + typeof input === 'string' && + input.startsWith('/api/artist-relations?'), + ), ).toBe(true) + expect( + fetchMock.mock.calls.some( + ([input]) => + typeof input === 'string' && + input === '/api/artist-relations/artist-relation-1', + ), + ).toBe(false) }) }) diff --git a/src/App.release-entry-basics.test.tsx b/src/App.release-entry-basics.test.tsx index 6259524..f99f00e 100644 --- a/src/App.release-entry-basics.test.tsx +++ b/src/App.release-entry-basics.test.tsx @@ -60,9 +60,11 @@ describe('App release entry basics', () => { h.within(form).getByRole('button', { name: 'Add record' }), ).toBeDisabled() - await user.selectOptions( + await user.click( h.within(form).getByLabelText('Role for Unset Role Artist'), - 'Main artist', + ) + await user.click( + h.within(form).getByRole('menuitem', { name: 'Main artist' }), ) expect( diff --git a/src/App.release-entry-tracklist.test.tsx b/src/App.release-entry-tracklist.test.tsx index 28c5c0c..3f4e482 100644 --- a/src/App.release-entry-tracklist.test.tsx +++ b/src/App.release-entry-tracklist.test.tsx @@ -389,9 +389,11 @@ describe('App release entry tracklists', () => { 'Set a role for each track artist.', ) - await user.selectOptions( + await user.click( h.within(form).getByLabelText('Track role for Track Artist'), - 'Main artist', + ) + await user.click( + h.within(form).getByRole('menuitem', { name: 'Main artist' }), ) expect(h.screen.getByRole('button', { name: 'Add record' })).toBeEnabled() diff --git a/src/App.search-v1.test.tsx b/src/App.search-v1.test.tsx index 36b4a5e..8ffa988 100644 --- a/src/App.search-v1.test.tsx +++ b/src/App.search-v1.test.tsx @@ -94,14 +94,54 @@ describe('App search v1 UI', () => { if (url === '/api/catalog-graph/track/track-producer') { return graphResponseWithRelation() } - if (url === '/api/artist-relations/private-relation') { - return h.jsonResponse({}, 404) + if (url.startsWith('/api/tracks?')) { + return h.jsonResponse({ + items: [ + { + id: 'track-producer', + title: 'Archive Producer Cut', + durationSeconds: null, + genres: [], + tags: [], + }, + { + id: 'track-private-dub', + title: 'Private Dub', + durationSeconds: null, + genres: [], + tags: [], + }, + ], + limit: 100, + offset: 0, + total: 2, + }) + } + if (url.startsWith('/api/track-relations?')) { + return h.jsonResponse({ + items: [ + { + id: 'private-relation', + sourceTrackId: 'track-producer', + targetTrackId: 'track-private-dub', + type: 'remixOf', + sourceTrackTitle: 'Archive Producer Cut', + targetTrackTitle: 'Private Dub', + }, + ], + limit: 100, + offset: 0, + total: 1, + }) + } + if (url.startsWith('/api/settings/dictionaries?')) { + return h.defaultDictionaryListResponse() } - if (url === '/api/track-relations/private-relation') { - return trackRelationDetailResponse() + if (url.startsWith('/api/rating-criteria?')) { + return h.defaultRatingCriteriaListResponse() } - return h.emptySearchResponse() + return h.emptyCatalogListResponse() }) h.vi.stubGlobal('fetch', fetchMock) const user = h.userEvent.setup() @@ -117,19 +157,29 @@ describe('App search v1 UI', () => { await h.waitFor(() => { expect(window.location.pathname).toBe('/relations') }) - await h.waitFor(() => { - expect( - h - .searchRequestUrls(fetchMock) - .some((url) => url.searchParams.get('savedView') === 'credits'), - ).toBe(true) - }) + expect( + fetchMock.mock.calls.some( + ([input]) => + typeof input === 'string' && + input.startsWith('/api/track-relations?'), + ), + ).toBe(true) + expect( + fetchMock.mock.calls.some( + ([input]) => + typeof input === 'string' && + input === '/api/track-relations/private-relation', + ), + ).toBe(false) expect( await h.screen.findByRole('heading', { name: 'Archive Producer Cut to Private Dub', }), ).toBeInTheDocument() + expect( + h.screen.getByRole('button', { name: 'Edit record' }), + ).toBeInTheDocument() }) }) @@ -246,14 +296,3 @@ function graphResponseWithRelation() { collectorSignals: ['Physical media without digital copy'], }) } - -function trackRelationDetailResponse() { - return h.jsonResponse({ - id: 'private-relation', - sourceTrackId: 'track-producer', - targetTrackId: 'track-private-dub', - type: 'remixOf', - sourceTrackTitle: 'Archive Producer Cut', - targetTrackTitle: 'Private Dub', - }) -} diff --git a/src/App.server-navigation.test.tsx b/src/App.server-navigation.test.tsx index 7e09218..0704277 100644 --- a/src/App.server-navigation.test.tsx +++ b/src/App.server-navigation.test.tsx @@ -35,10 +35,64 @@ describe('App workspace navigation', () => { }) }) - it('renders releases through paged server search without hydrating the full catalog', async () => { + it('renders releases as an editable release workspace after hydrating the catalog', async () => { window.history.pushState({}, '', '/releases') h.clearCatalogForTests() - const fetchMock = h.mockFetch(h.emptySearchResponse()) + const fetchMock = h.vi.fn(async (input) => { + const url = typeof input === 'string' ? input : (input as Request).url + await Promise.resolve() + + if (url.startsWith('/api/artists?')) { + return listResponse([ + { id: 'artist-orb', type: 'group', name: 'The Orb' }, + ]) + } + + if (url.startsWith('/api/labels?')) { + return listResponse([{ id: 'label-big-life', name: 'Big Life' }]) + } + + if (url.startsWith('/api/releases?')) { + return listResponse([ + { + id: 'release-orb', + title: "The Orb's Adventures Beyond the Ultraworld", + type: 'album', + year: 1991, + releaseDate: null, + genres: ['Electronic'], + tags: [], + artistCredits: [ + { + artistId: 'artist-orb', + artistName: 'The Orb', + role: 'mainArtist', + }, + ], + labels: [ + { + labelId: 'label-big-life', + name: 'Big Life', + catalogNumber: 'BLRCD 5', + hasNoCatalogNumber: false, + }, + ], + tracklist: [], + }, + ]) + } + + if (url.startsWith('/api/settings/dictionaries?')) { + return h.defaultDictionaryListResponse() + } + + if (url.startsWith('/api/rating-criteria?')) { + return h.defaultRatingCriteriaListResponse() + } + + return h.emptyCatalogListResponse() + }) + h.vi.stubGlobal('fetch', fetchMock) h.render() @@ -46,122 +100,103 @@ describe('App workspace navigation', () => { await h.screen.findByRole('heading', { name: 'Releases' }), ).toBeInTheDocument() expect( - h.screen.getByRole('heading', { name: 'Catalog results' }), + await h.screen.findByRole('heading', { name: 'Release records' }), ).toBeInTheDocument() - - const urls = requestUrls(fetchMock) - expect(searchUrls(fetchMock)).toHaveLength(1) - expect(searchUrls(fetchMock)[0].searchParams.get('entityType')).toBe( - 'release', - ) - expect(searchUrls(fetchMock)[0].searchParams.get('limit')).toBe('100') - expect(searchUrls(fetchMock)[0].searchParams.get('offset')).toBe('0') - expect(urls.some((url) => url.startsWith('/api/releases?'))).toBe(false) - expect(urls.some((url) => url.startsWith('/api/tracks?'))).toBe(false) - expect(urls.some((url) => url.startsWith('/api/owned-items?'))).toBe(false) - }) - - it('keeps entity navigation on server search without full catalog hydration', async () => { - h.clearCatalogForTests() - const fetchMock = h.mockFetch( - h.emptySearchResponse(), - h.emptySearchResponse(), - h.emptySearchResponse(), - ) - const user = h.userEvent.setup() - h.render() - - await h.screen.findByText('No matching catalog entries.') - await user.click(h.screen.getByRole('link', { name: 'Releases' })) - expect( - await h.screen.findByRole('heading', { name: 'Catalog results' }), + h.screen.queryByRole('heading', { name: 'Catalog results' }), + ).not.toBeInTheDocument() + expect( + h.screen.getByRole('columnheader', { name: 'Catalog #' }), ).toBeInTheDocument() - - await user.click(h.screen.getByRole('link', { name: 'Tracks' })) - expect( - await h.screen.findByRole('heading', { name: 'Catalog results' }), + h.screen.getByRole('row', { + name: /The Orb's Adventures Beyond the Ultraworld/i, + }), + ).toBeInTheDocument() + expect( + h.screen.getByRole('button', { name: 'Edit record' }), ).toBeInTheDocument() const urls = requestUrls(fetchMock) + expect(urls.some((url) => url.startsWith('/api/releases?'))).toBe(true) expect( - searchUrls(fetchMock).map((url) => url.searchParams.get('entityType')), - ).toEqual([null, 'release', 'track']) - expect(urls.some((url) => url.startsWith('/api/releases?'))).toBe(false) - expect(urls.some((url) => url.startsWith('/api/tracks?'))).toBe(false) - expect(urls.some((url) => url.startsWith('/api/owned-items?'))).toBe(false) + searchUrls(fetchMock).some( + (url) => url.searchParams.get('entityType') === 'release', + ), + ).toBe(false) }) - it('requests the next release page from the server instead of preloading all releases', async () => { - window.history.pushState({}, '', '/releases') + it('hydrates the full catalog when navigating from catalog search into entity workspaces', async () => { h.clearCatalogForTests() const fetchMock = h.vi.fn(async (input) => { const url = typeof input === 'string' ? input : (input as Request).url await Promise.resolve() if (url.startsWith('/api/search?')) { - const params = new URL(url, window.location.origin).searchParams - - return params.get('offset') === '100' - ? releaseSearchResponse({ - id: 'release-101', - title: 'Seed Release 00101', - offset: 100, - }) - : releaseSearchResponse({ - id: 'release-1', - title: 'Seed Release 00001', - }) + return h.emptySearchResponse() } - if (url === '/api/catalog-graph/release/release-101') { - return graphResponseForRelease('release-101', 'Seed Release 00101') + + if (url.startsWith('/api/settings/dictionaries?')) { + return h.defaultDictionaryListResponse() } - if (url === '/api/catalog-graph/release/release-1') { - return graphResponseForRelease('release-1', 'Seed Release 00001') + + if (url.startsWith('/api/rating-criteria?')) { + return h.defaultRatingCriteriaListResponse() } - return h.emptySearchResponse() + return h.emptyCatalogListResponse() }) h.vi.stubGlobal('fetch', fetchMock) const user = h.userEvent.setup() - h.render() + await h.screen.findByText('No matching catalog entries.') + await user.click(h.screen.getByRole('link', { name: 'Releases' })) + expect( - await h.screen.findByRole('row', { name: /Seed Release 00001/i }), + await h.screen.findByRole('heading', { name: 'Release records' }), ).toBeInTheDocument() - await h.waitFor(() => { - expect(requestUrls(fetchMock)).toContain( - '/api/catalog-graph/release/release-1', - ) - }) - await user.click(h.screen.getByRole('button', { name: 'Next page' })) + await user.click(h.screen.getByRole('link', { name: 'Tracks' })) expect( - await h.screen.findByRole('row', { name: /Seed Release 00101/i }), + await h.screen.findByRole('heading', { name: 'Track records' }), ).toBeInTheDocument() - const urls = searchUrls(fetchMock) - expect(urls.at(-1)?.searchParams.get('entityType')).toBe('release') - expect(urls.at(-1)?.searchParams.get('limit')).toBe('100') - expect(urls.at(-1)?.searchParams.get('offset')).toBe('100') + const urls = requestUrls(fetchMock) expect( - requestUrls(fetchMock).some((url) => url.startsWith('/api/releases?')), - ).toBe(false) + searchUrls(fetchMock).map((url) => url.searchParams.get('entityType')), + ).toEqual([null]) + expect(urls.some((url) => url.startsWith('/api/releases?'))).toBe(true) + expect(urls.some((url) => url.startsWith('/api/tracks?'))).toBe(true) + expect(urls.some((url) => url.startsWith('/api/owned-items?'))).toBe(true) }) it('shows mutation errors in editable workspaces', async () => { window.history.pushState({}, '', '/artists') h.clearCatalogForTests() - const fetchMock = h.mockFetch( - h.emptyCatalogListResponse(), - h.jsonResponse( - { code: 'catalog.server_error', message: 'Save failed' }, - 500, - ), - ) + const fetchMock = h.vi.fn(async (input, init) => { + const url = typeof input === 'string' ? input : (input as Request).url + await Promise.resolve() + + if (url === '/api/artists' && init?.method === 'POST') { + return h.jsonResponse( + { code: 'catalog.server_error', message: 'Save failed' }, + 500, + ) + } + + if (url.startsWith('/api/settings/dictionaries?')) { + return h.defaultDictionaryListResponse() + } + + if (url.startsWith('/api/rating-criteria?')) { + return h.defaultRatingCriteriaListResponse() + } + + return h.emptyCatalogListResponse() + }) + h.vi.stubGlobal('fetch', fetchMock) const user = h.userEvent.setup() h.render() @@ -193,62 +228,11 @@ function searchUrls(fetchMock: ReturnType) { .map((url) => new URL(url, window.location.origin)) } -function releaseSearchResponse({ - id, - title, - offset = 0, -}: { - id: string - title: string - offset?: number -}) { +function listResponse(items: unknown[]) { return h.jsonResponse({ - items: [ - { - id, - type: 'release', - title, - subtitle: 'Various Artists', - summary: 'Album', - matchedFields: ['title'], - snippets: [title], - facets: { - roles: [], - media: ['digital'], - statuses: ['owned'], - tags: [], - labelId: null, - collectorSignals: [], - }, - rank: 1, - }, - ], + items, limit: 100, - offset, - total: 150, - }) -} - -function graphResponseForRelease(id: string, title: string) { - return h.jsonResponse({ - entity: { - id, - type: 'release', - title, - subtitle: 'Various Artists', - summary: 'Album', - }, - sections: { - artists: [], - releases: [], - tracks: [], - ownedCopies: [], - labels: [], - playlists: [], - credits: [], - relations: [], - media: [], - }, - collectorSignals: [], + offset: 0, + total: items.length, }) } diff --git a/src/app/AuthenticatedApp.tsx b/src/app/AuthenticatedApp.tsx index 3884b2a..7dc1fb9 100644 --- a/src/app/AuthenticatedApp.tsx +++ b/src/app/AuthenticatedApp.tsx @@ -591,8 +591,6 @@ export function AuthenticatedApp({ ) } -const fullCatalogRoutes = new Set() - function routeRequiresFullCatalog(path: AppRoutePath) { - return fullCatalogRoutes.has(path) + return manualEntryRoutes.has(path) } diff --git a/src/app/renderWorkspace.tsx b/src/app/renderWorkspace.tsx index 3957237..8ed2ff7 100644 --- a/src/app/renderWorkspace.tsx +++ b/src/app/renderWorkspace.tsx @@ -2,10 +2,8 @@ import { resolveRoute, type AppRoutePath } from './routes' import type { ReactNode } from 'react' import type { ArtistRecord } from '../features/artists/artistsData' import { ArtistsWorkspace } from '../features/artists/ArtistsWorkspace' -import { ServerArtistsWorkspace } from '../features/artists/ServerArtistsWorkspace' import { CatalogAddEntryFlow } from '../features/catalog/CatalogAddEntryFlow' import { CatalogWorkspace } from '../features/catalog/CatalogWorkspace' -import { ServerEntityWorkspace } from '../features/catalog/ServerEntityWorkspace' import type { CatalogState, DictionaryEntry, @@ -15,7 +13,6 @@ import type { RatingCriterionRequest, RatingCriterionUpdateRequest, RatingTargetType, - SearchEntityType, } from '../features/catalog/catalogApi' import { ExportsWorkspace } from '../features/exports/ExportsWorkspace' import { ImportsWorkspace } from '../features/imports/ImportsWorkspace' @@ -23,7 +20,6 @@ import type { LabelRecord } from '../features/labels/labelsData' import { LabelsWorkspace } from '../features/labels/LabelsWorkspace' import type { OwnedItemRecord } from '../features/ownedItems/ownedItemsData' import { OwnedItemsWorkspace } from '../features/ownedItems/OwnedItemsWorkspace' -import { ServerOwnedItemsWorkspace } from '../features/ownedItems/ServerOwnedItemsWorkspace' import type { PlaylistRecord } from '../features/playlists/playlistsData' import { PlaylistsWorkspace } from '../features/playlists/PlaylistsWorkspace' import type { ReleaseRecord } from '../features/releases/releasesData' @@ -46,62 +42,6 @@ export const manualEntryRoutes = new Set([ '/playlists', ]) -type ServerEntityWorkspaceConfig = { - ariaLabel: string - entityType?: SearchEntityType - placeholder: string - queryParam: string - savedView?: string - searchLabel: string -} - -const serverEntityWorkspaceConfigs: Partial< - Record -> = { - '/releases': { - ariaLabel: 'Releases workspace', - entityType: 'release', - placeholder: 'Release, artist, label, year, medium, tag or status', - queryParam: 'release', - searchLabel: 'Search releases', - }, - '/tracks': { - ariaLabel: 'Tracks workspace', - entityType: 'track', - placeholder: 'Track, artist, release, role, medium, tag or status', - queryParam: 'track', - searchLabel: 'Search tracks', - }, - '/labels': { - ariaLabel: 'Labels workspace', - entityType: 'label', - placeholder: 'Label, release, artist, catalog number, tag or status', - queryParam: 'label', - searchLabel: 'Search labels', - }, - '/playlists': { - ariaLabel: 'Playlists workspace', - entityType: 'playlist', - placeholder: 'Playlist, rule, tag, media, ownership status or note', - queryParam: 'playlist', - searchLabel: 'Search playlists', - }, - '/owned-items': { - ariaLabel: 'Owned items workspace', - entityType: 'ownedItem', - placeholder: 'Owned copy, release, track, medium, storage, tag or status', - queryParam: 'ownedItem', - searchLabel: 'Search owned items', - }, - '/relations': { - ariaLabel: 'Relations workspace', - placeholder: 'Artist, track, role, relation, remix or version', - queryParam: 'relation', - savedView: 'credits', - searchLabel: 'Search relations', - }, -} - export function renderWorkspace( path: AppRoutePath, isManualEntryOpen: boolean, @@ -183,19 +123,6 @@ export function renderWorkspace( ) { const shouldUseServerWorkspace = catalogState.serverBackedCatalog && !catalogState.hasLoadedFullCatalog - const serverEntityConfig = serverEntityWorkspaceConfigs[path] - const serverEntityWorkspace = serverEntityConfig ? ( - - ) : null switch (path) { case '/catalog': @@ -239,16 +166,7 @@ export function renderWorkspace( /> ) case '/artists': - return catalogState.serverBackedCatalog && - !catalogState.hasLoadedFullCatalog ? ( - - ) : ( + return ( ) case '/releases': - return shouldUseServerWorkspace && serverEntityWorkspace ? ( - serverEntityWorkspace - ) : ( + return ( ) case '/tracks': - return shouldUseServerWorkspace && serverEntityWorkspace ? ( - serverEntityWorkspace - ) : ( + return ( ) case '/playlists': - return shouldUseServerWorkspace && serverEntityWorkspace ? ( - serverEntityWorkspace - ) : ( + return ( ) case '/labels': - return shouldUseServerWorkspace && serverEntityWorkspace ? ( - serverEntityWorkspace - ) : ( + return ( ) case '/owned-items': - return shouldUseServerWorkspace ? ( - - ) : ( + return ( ) case '/relations': - return shouldUseServerWorkspace && serverEntityWorkspace ? ( - serverEntityWorkspace - ) : ( + return ( void onEdit?: () => void + onUpdateViaDiscogs?: () => void ratingCriteria: RatingCriterion[] onDeleteRating?: ( targetType: RatingTargetType, @@ -32,6 +33,7 @@ export function ArtistDetail({ catalogData, onDelete, onEdit, + onUpdateViaDiscogs, ratingCriteria, onDeleteRating, onRateTarget, @@ -70,6 +72,15 @@ export function ArtistDetail({ > Edit record + {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..e44b941 --- /dev/null +++ b/src/features/artists/DiscogsArtistLookupPanel.tsx @@ -0,0 +1,353 @@ +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() { + const trimmedQuery = query.trim() + if (!trimmedQuery) { + setCandidates([]) + setSelectedDetail(null) + setStatus('Enter an artist name to search.') + return + } + + setStatus('Searching Discogs artist candidates.') + setSelectedDetail(null) + + try { + const result = await searchDiscogsArtists({ + query: trimmedQuery, + 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 2a22457..d56e480 100644 --- a/src/features/catalog/api/catalogEntityMappers.ts +++ b/src/features/catalog/api/catalogEntityMappers.ts @@ -9,6 +9,7 @@ import type { RelationRecord } from '../../relations/relationsData' import type { TrackRecord } from '../../tracks/tracksData' import { conditionLabel, + creditRolesFromDto, creditRoleLabel, formatDuration, isDigitalFileMedium, @@ -122,6 +123,7 @@ export function toArtistRecord( tags: [], summary: '', ratings: targetRatings(ratingsByTarget, 'artist', artist.id), + externalSources: artist.externalSources ?? [], } } @@ -147,6 +149,7 @@ export function toReleaseRecord( artistsById.get(credit.contributorArtistId)?.name ?? credit.contributorName, role: creditRoleLabel(credit.role, dictionaries), + roles: creditRolesFromDto(credit, dictionaries), })) const mainCredits = releaseCredits.filter((credit) => isMainArtistRole(credit.role, dictionaries), @@ -185,6 +188,7 @@ export function toReleaseRecord( coverImage: release.coverImage ? toReleaseCoverImage(release.coverImage) : undefined, + externalSources: release.externalSources ?? [], ownedCopies: [ ...ownedItems .filter( @@ -415,6 +419,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 4691027..cb64f65 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 @@ -315,6 +316,7 @@ export type ArtistDto = { id: string type: string name: string + externalSources?: ExternalSourceReference[] | null } export type LabelDto = { @@ -337,6 +339,7 @@ export type ReleaseDto = { artistCredits?: ReleaseArtistCreditDto[] labels?: ReleaseLabelDto[] tracklist?: ReleaseTracklistItemDto[] + externalSources?: ExternalSourceReference[] | null } export type ReleaseCoverImageDto = { @@ -350,7 +353,9 @@ export type ReleaseCoverImageDto = { export type ReleaseArtistCreditDto = { artistId: string artistName: string - role: string + primaryRole?: string + role?: string + roles?: string[] } export type ReleaseLabelDto = { @@ -375,6 +380,7 @@ export type TrackDto = { durationSeconds?: number | null genres: string[] tags: string[] + externalSources?: ExternalSourceReference[] | null credits?: TrackCreditDto[] releaseAppearances?: TrackReleaseAppearanceDto[] } @@ -383,6 +389,7 @@ export type TrackCreditDto = { artistId: string artistName: string role: string + roles?: string[] } export type TrackReleaseAppearanceDto = { @@ -447,6 +454,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..8e60e51 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(primaryCreditRole(credit), dictionaries), + roles, artist: credit.artistName, scope: 'Tracklist credit.', } @@ -142,13 +151,33 @@ 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(primaryCreditRole(credit), dictionaries), + roles, } } +export function creditRolesFromDto( + credit: { primaryRole?: string; role?: string; roles?: string[] }, + dictionaries = activeDictionaries, +) { + const roleCodes = + credit.roles && credit.roles.length > 0 + ? credit.roles + : [primaryCreditRole(credit)] + return [ + ...new Set(roleCodes.map((role) => creditRoleLabel(role, dictionaries))), + ] +} + +function primaryCreditRole(credit: { primaryRole?: string; role?: string }) { + return credit.primaryRole ?? credit.role ?? '' +} + export function toReleaseLabel(label: ReleaseLabelDto): ReleaseLabel { return { labelId: label.labelId ?? undefined, @@ -164,8 +193,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 + : [primaryCreditRole(credit)] + ).includes(mainArtistRoleCode), ) const visibleCredits = mainCredits.length > 0 ? mainCredits : credits @@ -207,7 +239,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 new file mode 100644 index 0000000..4382e47 --- /dev/null +++ b/src/features/catalog/api/externalMetadataClient.ts @@ -0,0 +1,274 @@ +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 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 + 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 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 + 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 + type?: string | null + genres: string[] + year?: number | null + releaseDate?: string | 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 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( + externalId.trim(), + )}`, + ) +} + +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', + 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/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 0c91371..4c45635 100644 --- a/src/features/catalog/catalogApi.mutations.test.ts +++ b/src/features/catalog/catalogApi.mutations.test.ts @@ -1,10 +1,169 @@ 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() 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('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/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..cda5b26 --- /dev/null +++ b/src/features/catalog/externalMetadataClient.test.ts @@ -0,0 +1,342 @@ +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' + +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', + type: 'single', + genres: ['Electronic', 'Leftfield'], + year: 1983, + releaseDate: '1983-03-07', + 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.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', + 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) + }) + + 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) { + 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/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/DiscogsCandidateReview.tsx b/src/features/releases/DiscogsCandidateReview.tsx new file mode 100644 index 0000000..9f449f6 --- /dev/null +++ b/src/features/releases/DiscogsCandidateReview.tsx @@ -0,0 +1,331 @@ +import type { ReactNode } from 'react' +import { useState } from 'react' +import type { + CatalogDictionaries, + ExternalMetadataReleaseDetailDto, + ExternalMetadataReleaseDraftTrackDto, +} from '../catalog/catalogApi' +import { discogsDraftTrackRows } from './discogsReleaseTrackRows' +import type { + DiscogsApplyGroups, + DiscogsCurrentRelease, +} from './DiscogsReleaseLookupPanel' +import { + discogsRoleLabelFromCode, + groupDiscogsReviewCredits, + hasCompilationTrackArtists, + type GroupedDiscogsReviewCredit, +} from './discogsRoleUtils' + +type DiscogsCandidateReviewProps = { + applyGroups: DiscogsApplyGroups + current: DiscogsCurrentRelease + detail: ExternalMetadataReleaseDetailDto + dictionaries: CatalogDictionaries + hasSelectedGroup: boolean + onApplyDraft: ( + detail: ExternalMetadataReleaseDetailDto, + groups: DiscogsApplyGroups, + ) => void + onUpdateApplyGroup: ( + group: keyof DiscogsApplyGroups, + checked: boolean, + ) => void +} + +export function DiscogsCandidateReview({ + applyGroups, + current, + detail, + dictionaries, + hasSelectedGroup, + onApplyDraft, + onUpdateApplyGroup, +}: DiscogsCandidateReviewProps) { + 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 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 new file mode 100644 index 0000000..7cd0102 --- /dev/null +++ b/src/features/releases/DiscogsReleaseLookupPanel.tsx @@ -0,0 +1,362 @@ +import { Search } from 'lucide-react' +import { useEffect, useRef, useState } from 'react' +import { + CatalogApiError, + getDiscogsRelease, + searchDiscogsReleases, + type CatalogDictionaries, + type ExternalMetadataReleaseCandidateDto, + type ExternalMetadataReleaseDetailDto, +} from '../catalog/catalogApi' +import { DiscogsCandidateReview } from './DiscogsCandidateReview' + +export type DiscogsApplyGroups = { + core: boolean + artists: boolean + classification: boolean + labels: boolean + tracklist: boolean +} + +export type DiscogsSearchSeed = { + artist: string + catalogNumber: string + title: string + year: string +} + +export type DiscogsCurrentRelease = { + artists: string + externalSourceCount: number + genres: string + labels: string + releaseDate: string + title: string + trackCount: number + year: string +} + +type DiscogsReleaseLookupPanelProps = { + current: DiscogsCurrentRelease + dictionaries: CatalogDictionaries + isOpen: boolean + mode: 'create' | 'update' + searchSeed: DiscogsSearchSeed + onApplyDraft: ( + detail: ExternalMetadataReleaseDetailDto, + groups: DiscogsApplyGroups, + ) => void + onOpenChange: (isOpen: boolean) => void +} + +const emptyGroups: DiscogsApplyGroups = { + core: false, + artists: false, + classification: false, + labels: false, + tracklist: false, +} + +export function DiscogsReleaseLookupPanel({ + current, + dictionaries, + 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 [catalogNumber, setCatalogNumber] = useState(searchSeed.catalogNumber) + const [status, setStatus] = useState('') + const [appliedStatus, setAppliedStatus] = 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.') + setAppliedStatus('') + setSelectedDetail(null) + + try { + const result = await searchDiscogsReleases({ + query, + artist, + title, + year, + 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}.`) + setAppliedStatus('') + + 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 })) + } + + 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 ( +
+
+
+

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.formats, candidate.catalogNumber] + .filter(Boolean) + .join(' · ')} +

+

{candidate.source.attribution}

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

+ {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 defaultGroups(mode: 'create' | 'update'): DiscogsApplyGroups { + return mode === 'create' + ? { + core: true, + artists: true, + classification: true, + labels: true, + tracklist: 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.' +} diff --git a/src/features/releases/ReleaseArtistCreditsSection.tsx b/src/features/releases/ReleaseArtistCreditsSection.tsx index b1b205c..2335b6b 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,37 @@ 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 +50,7 @@ export function TrackEntryForm({ artists, dictionaries, initialTrack, + initialShowDiscogsLookup, onCancel, tracks, onSubmit, @@ -58,12 +68,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 +95,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 +147,7 @@ export function TrackEntryForm({ artistId: existingArtist?.id, artist: existingArtist?.name ?? artistName, role: 'Main artist', + roles: ['Main artist'], scope: 'Track-level credit.', }, ]) @@ -151,8 +171,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 +210,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,9 +237,41 @@ 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( + groupDiscogsTrackCredits( + detail.draft.artistCredits, + artists, + dictionaries, + ), + ) + } + + setExternalSources( + detail.draft.externalSources.map((source) => ({ + ...source, + appliedAt: new Date().toISOString(), + })), + ) + } + return ( ) : 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 +435,66 @@ export function TrackEntryForm({ {credit.artist} - + }} + /> +