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.
+
+
onOpenChange(!isOpen)}
+ >
+
+ {isOpen ? 'Hide Discogs' : 'Search Discogs'}
+
+
+
+ {isOpen ? (
+ <>
+
+
+ Discogs query
+ setQuery(event.target.value)}
+ />
+
+ {
+ void handleSearch()
+ }}
+ >
+
+ Search Discogs artists
+
+
+
+ {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}
+
+
+
+ ))}
+
+ ) : null}
+
+ {selectedDetail ? (
+
+
+
+
+
+
+
+
+
+ Apply groups
+ updateApplyGroup('core', checked)}
+ />
+
+ updateApplyGroup('externalSource', checked)
+ }
+ />
+
+
+
onApplyDraft(selectedDetail, applyGroups)}
+ >
+ Apply selected Discogs fields
+
+
+ ) : 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 (
+
+ onChange(event.target.checked)}
+ />
+ {label}
+
+ )
+}
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) => (
+ {
+ onSelect(role)
+ event.currentTarget.closest('details')?.removeAttribute('open')
+ }}
+ >
+ {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 (
+
+
+
+
+
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}
+
+
+
+
+
onApplyDraft(detail, applyGroups)}
+ >
+ Apply selected Discogs fields
+
+
+ )
+}
+
+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 ? (
+
setShowAllTracks(true)}
+ >
+ Show {hiddenCount} more Discogs track row
+ {hiddenCount === 1 ? '' : 's'}
+
+ ) : showAllTracks && tracks.length > 4 ? (
+
setShowAllTracks(false)}
+ >
+ Show fewer Discogs track rows
+
+ ) : 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 (
+
+ onChange(event.target.checked)}
+ />
+ {label}
+
+ )
+}
+
+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.
+
+
onOpenChange(!isOpen)}
+ >
+
+ {isOpen ? 'Hide Discogs' : 'Search Discogs'}
+
+
+
+ {isOpen ? (
+ <>
+
+
+ Discogs query
+ setQuery(event.target.value)}
+ />
+
+
+ Discogs artist
+ setArtist(event.target.value)}
+ />
+
+
+ Discogs title
+ setTitle(event.target.value)}
+ />
+
+
+ Discogs year
+ setYear(event.target.value)}
+ />
+
+
+ Discogs catalog number
+ setCatalogNumber(event.target.value)}
+ />
+
+ {
+ void handleSearch()
+ }}
+ >
+
+ Search Discogs releases
+
+
+
+ {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}
+
+
+
+ {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'}
-
-
- Role for {artistName || 'artist'}
-
-
+ {credit.roles.map((role, index) => (
+
+ {role}
+
+ removeCreditRole(credit, role, setArtistCredits)
+ }
+ >
+ ×
+
+
+ ))}
+ 0 ? 'Add role' : 'Set role'
}
- aria-hidden="true"
- >
- {credit.role || 'Set role'}
-
-
-
- setArtistCredits((credits) =>
- credits.map((currentCredit) =>
- currentCredit.id === credit.id
- ? {
- ...currentCredit,
- role: event.target.value,
- }
- : currentCredit,
- ),
- )
+ ariaLabel={`Role for ${artistName || 'artist'}`}
+ options={creditRoleOptions.filter(
+ (role) => !credit.roles.includes(role),
+ )}
+ onSelect={(role) =>
+ addCreditRole(credit, role, setArtistCredits)
}
- >
- Set role
- {creditRoleOptions.map((role) => (
- {role}
- ))}
-
-
+ />
+
)
}
+
+function addCreditRole(
+ credit: EditableArtistCredit,
+ role: string,
+ setArtistCredits: Dispatch>,
+) {
+ if (!role) {
+ return
+ }
+
+ setArtistCredits((credits) =>
+ credits.map((currentCredit) => {
+ if (
+ currentCredit.id !== credit.id ||
+ currentCredit.roles.includes(role)
+ ) {
+ return currentCredit
+ }
+
+ return {
+ ...currentCredit,
+ role: currentCredit.role || role,
+ roles: [...currentCredit.roles, role],
+ }
+ }),
+ )
+}
+
+function removeCreditRole(
+ credit: EditableArtistCredit,
+ role: string,
+ setArtistCredits: Dispatch>,
+) {
+ setArtistCredits((credits) =>
+ credits.map((currentCredit) => {
+ if (currentCredit.id !== credit.id) {
+ return currentCredit
+ }
+
+ const nextRoles = currentCredit.roles.filter(
+ (currentRole) => currentRole !== role,
+ )
+ return {
+ ...currentCredit,
+ role: nextRoles[0] ?? '',
+ roles: nextRoles,
+ }
+ }),
+ )
+}
diff --git a/src/features/releases/ReleaseCoreSection.tsx b/src/features/releases/ReleaseCoreSection.tsx
index 1a81c9c..d1148e5 100644
--- a/src/features/releases/ReleaseCoreSection.tsx
+++ b/src/features/releases/ReleaseCoreSection.tsx
@@ -4,7 +4,9 @@ import { releaseYearOptions } from './ReleaseEntryFormTypes'
type ReleaseCoreSectionProps = {
duplicateRelease?: ReleaseRecord
+ releaseDate: string
releaseTypeOptions: string[]
+ setReleaseDate: Dispatch>
setTitle: Dispatch>
setType: Dispatch>
setYear: Dispatch>
@@ -15,7 +17,9 @@ type ReleaseCoreSectionProps = {
export function ReleaseCoreSection({
duplicateRelease,
+ releaseDate,
releaseTypeOptions,
+ setReleaseDate,
setTitle,
setType,
setYear,
@@ -54,6 +58,15 @@ export function ReleaseCoreSection({
))}
+
+ Release date
+ setReleaseDate(event.target.value)}
+ />
+
Type
void
onEditLocalFiles?: (tracks: TrackRecord[]) => void
onRemoveCover?: (releaseId: string) => Promise | void
+ onUpdateViaDiscogs?: () => void
onUploadCover?: (releaseId: string, file: File) => Promise | void
playlists: PlaylistRecord[]
release: ReleaseRecord
@@ -55,6 +56,7 @@ export function ReleaseDetail({
onEdit,
onEditLocalFiles,
onRemoveCover,
+ onUpdateViaDiscogs,
onUploadCover,
playlists,
release,
@@ -105,7 +107,10 @@ export function ReleaseDetail({
{release.title}
{release.artist}
- {onEdit || (onEditLocalFiles && localTracks.length > 0) ? (
+ {onEdit ||
+ onUpdateViaDiscogs ||
+ onDelete ||
+ (onEditLocalFiles && localTracks.length > 0) ? (
{onEdit ? (
) : null}
+ {onUpdateViaDiscogs ? (
+
+ Update via Discogs
+
+ ) : null}
{onEditLocalFiles && localTracks.length > 0 ? (
- {credit.role}
+ {(credit.roles && credit.roles.length > 0
+ ? credit.roles
+ : [credit.role]
+ ).map((role) => (
+
+ {role}
+
+ ))}
{credit.artistId ? (
0
+ ? credit.roles
+ : [credit.role],
}))
}
@@ -56,6 +76,7 @@ export function ReleaseEntryForm({
artistId: initialRelease.artistId ?? '',
artist: initialRelease.artistId ? '' : initialRelease.artist,
role: 'Main artist',
+ roles: ['Main artist'],
},
]
}
@@ -66,6 +87,9 @@ export function ReleaseEntryForm({
const [draftArtist, setDraftArtist] = useState('')
const [draftArtistId, setDraftArtistId] = useState('')
const [year, setYear] = useState(initialRelease?.year ?? '')
+ const [releaseDate, setReleaseDate] = useState(
+ initialRelease?.releaseDate ?? '',
+ )
const [notOnLabel, setNotOnLabel] = useState(
Boolean(initialRelease?.notOnLabel),
)
@@ -104,6 +128,13 @@ export function ReleaseEntryForm({
const [type, setType] = useState(
initialRelease?.type ?? releaseTypeOptions[0] ?? 'Unknown',
)
+ const effectiveReleaseTypeOptions = releaseTypeOptions.includes(type)
+ ? releaseTypeOptions
+ : [...releaseTypeOptions, type]
+ const effectiveGenreOptions = [
+ ...genreOptions,
+ ...genres.filter((genre) => !genreOptions.includes(genre)),
+ ]
const [includeOwnedCopy, setIncludeOwnedCopy] = useState(Boolean(firstCopy))
const [medium, setMedium] = useState(firstCopy?.medium ?? '')
const [status, setStatus] = useState(
@@ -111,6 +142,13 @@ export function ReleaseEntryForm({
)
const [tags, setTags] = useState(initialRelease?.tags.join(', ') ?? '')
const [releaseNotes] = useState(initialRelease?.releaseNotes ?? '')
+ const [externalSources, setExternalSources] = useState(
+ initialRelease?.externalSources,
+ )
+ const [discogsLookupOpenPreference, setDiscogsLookupOpenPreference] =
+ useState(null)
+ const isDiscogsLookupOpen =
+ discogsLookupOpenPreference ?? Boolean(initialShowDiscogsLookup)
const effectiveArtistCredits = artistCredits
const draftReleaseLabel: EditableReleaseLabel | undefined =
draftLabel.trim().length > 0
@@ -126,9 +164,7 @@ export function ReleaseEntryForm({
: labels
const releaseMainArtistCredits = effectiveArtistCredits
.map((credit) => releaseArtistCreditFromEditableCredit(credit, artists))
- .filter(
- (credit) => credit.role === 'Main artist' && credit.artist.length > 0,
- )
+ .filter((credit) => hasMainArtistRole(credit) && credit.artist.length > 0)
const {
addDraftTrack,
addTrackArtist,
@@ -142,6 +178,7 @@ export function ReleaseEntryForm({
handleTrackDraftArtistChange,
removeDraftTrack,
removeTrackArtist,
+ replaceDraftTracks,
selectExistingTrack,
selectedCustomTrackCredits,
selectedDraftTrack,
@@ -177,8 +214,7 @@ export function ReleaseEntryForm({
draftTracks.some(isDraftTrackIncluded)
const hasUnsetReleaseArtistRole = artistCredits.some(
(credit) =>
- artistCreditName(credit, artists).length > 0 &&
- credit.role.trim().length === 0,
+ artistCreditName(credit, artists).length > 0 && credit.roles.length === 0,
)
const hasUnsetTrackArtistRole = draftTracks.some(
(track) =>
@@ -187,7 +223,7 @@ export function ReleaseEntryForm({
track.artistCredits.some(
(credit) =>
artistCreditName(credit, artists).length > 0 &&
- credit.role.trim().length === 0,
+ credit.roles.length === 0,
),
)
const hasInvalidVariousArtistTrack = draftTracks.some(
@@ -262,6 +298,7 @@ export function ReleaseEntryForm({
artistId: draftArtistId,
artist: draftArtistId ? '' : artistName,
role: '',
+ roles: [],
},
])
setDraftArtist('')
@@ -304,6 +341,7 @@ export function ReleaseEntryForm({
draftTracks,
effectiveArtistCredits,
effectiveLabels,
+ externalSources,
firstCopy,
genres,
includeOwnedCopy,
@@ -312,6 +350,7 @@ export function ReleaseEntryForm({
medium,
notOnLabel,
releaseNotes,
+ releaseDate,
status,
tags,
title,
@@ -323,6 +362,106 @@ export function ReleaseEntryForm({
onSubmit(release, submittedTracks)
}
+ function handleApplyDiscogsDraft(
+ detail: ExternalMetadataReleaseDetailDto,
+ groups: DiscogsApplyGroups,
+ ) {
+ const draft = detail.draft
+
+ if (groups.core) {
+ setTitle(draft.title)
+ if (draft.type) {
+ setType(releaseTypeValueFromCode(draft.type))
+ }
+ setYear(draft.year?.toString() ?? '')
+ if (draft.releaseDate) {
+ setReleaseDate(draft.releaseDate)
+ }
+ }
+
+ if (groups.artists) {
+ setIsVariousArtists(false)
+ setArtistCredits(
+ groupDiscogsCredits(
+ draft.artistCredits,
+ 'release',
+ artists,
+ dictionaries,
+ ),
+ )
+ setDraftArtist('')
+ setDraftArtistId('')
+ }
+
+ if (groups.labels) {
+ setNotOnLabel(false)
+ setDraftLabel('')
+ setDraftCatalogNumber('')
+ setDraftHasNoCatalogNumber(false)
+ setLabels(
+ draft.labels.map((label, index) => ({
+ id: createManualRecordId('release-label', `discogs-${index + 1}`),
+ label: label.name,
+ catalogNumber: label.catalogNumber ?? '',
+ hasNoCatalogNumber: label.hasNoCatalogNumber,
+ })),
+ )
+ }
+
+ if (groups.classification) {
+ setGenres(draft.genres ?? [])
+ }
+
+ if (groups.tracklist) {
+ const discogsTracks = discogsDraftTrackRows(draft.tracklist)
+
+ if (discogsTracklistNeedsVariousArtists(discogsTracks, draft)) {
+ setIsVariousArtists(true)
+ }
+
+ replaceDraftTracks(
+ discogsTracks.map(
+ (track, index): DraftTrackRow => ({
+ id: createManualRecordId(
+ 'draft-track',
+ `discogs-${track.position || index + 1}`,
+ ),
+ existingTrackQuery: '',
+ position: String(track.position || index + 1),
+ title: track.title,
+ durationParts: track.durationSeconds
+ ? durationSecondsToParts(track.durationSeconds)
+ : { ...emptyDurationParts },
+ inheritReleaseArtistCredits: track.artistCredits.length === 0,
+ artistCredits: groupDiscogsCredits(
+ track.artistCredits,
+ `track-${index + 1}`,
+ artists,
+ dictionaries,
+ ),
+ draftArtist: '',
+ draftArtistId: '',
+ versionNote: '',
+ }),
+ ),
+ )
+ }
+
+ setExternalSources(
+ draft.externalSources.map((source) => ({
+ ...source,
+ appliedAt: new Date().toISOString(),
+ })),
+ )
+ }
+
+ function releaseTypeValueFromCode(code: string) {
+ return (
+ dictionaries?.releaseType.find((entry) => entry.code === code)?.name ??
+ code
+ )
+ }
+
return (
+
+ [label.label, label.catalogNumber].filter(Boolean).join(' '),
+ )
+ .join(', '),
+ releaseDate,
+ title,
+ trackCount: draftTracks.filter(isDraftTrackIncluded).length,
+ year,
+ }}
+ dictionaries={dictionaries}
+ isOpen={isDiscogsLookupOpen}
+ mode={initialRelease ? 'update' : 'create'}
+ searchSeed={{
+ artist: releaseArtist,
+ catalogNumber:
+ labels.find((label) => label.catalogNumber.trim().length > 0)
+ ?.catalogNumber ?? draftCatalogNumber,
+ title,
+ year: /^\d{4}$/.test(year) ? year : '',
+ }}
+ onApplyDraft={handleApplyDiscogsDraft}
+ onOpenChange={setDiscogsLookupOpenPreference}
+ />
)
}
+
+function hasMainArtistRole(credit: { role: string; roles?: string[] }) {
+ return (
+ credit.roles && credit.roles.length > 0 ? credit.roles : [credit.role]
+ ).includes('Main artist')
+}
diff --git a/src/features/releases/ReleaseEntryFormTypes.ts b/src/features/releases/ReleaseEntryFormTypes.ts
index f8e50fc..7945eb8 100644
--- a/src/features/releases/ReleaseEntryFormTypes.ts
+++ b/src/features/releases/ReleaseEntryFormTypes.ts
@@ -8,6 +8,7 @@ export type ReleaseEntryFormProps = {
artists: ArtistRecord[]
dictionaries: CatalogDictionaries
initialRelease?: ReleaseRecord
+ initialShowDiscogsLookup?: boolean
releases: ReleaseRecord[]
tracks: TrackRecord[]
onCancel: () => void
@@ -33,6 +34,7 @@ export type EditableArtistCredit = {
artistId: string
artist: string
role: string
+ roles: string[]
}
export type EditableReleaseLabel = {
diff --git a/src/features/releases/ReleaseTrackArtistCreditChip.tsx b/src/features/releases/ReleaseTrackArtistCreditChip.tsx
index 1a64a1f..1c5427d 100644
--- a/src/features/releases/ReleaseTrackArtistCreditChip.tsx
+++ b/src/features/releases/ReleaseTrackArtistCreditChip.tsx
@@ -1,4 +1,5 @@
import type { ArtistRecord } from '../artists/artistsData'
+import { CreditRolePicker } from './CreditRolePicker'
import type { EditableArtistCredit } from './ReleaseEntryFormTypes'
import { artistCreditName } from './releaseFormHelpers'
@@ -10,7 +11,7 @@ type ReleaseTrackArtistCreditChipProps = {
trackId: string,
creditId: string,
field: keyof Omit,
- value: string,
+ value: string | string[],
) => void
removeTrackArtist: (trackId: string, creditId: string) => void
trackId: string
@@ -31,40 +32,52 @@ export function ReleaseTrackArtistCreditChip({
{artistName || 'Unnamed artist'}
-
-
- Track role for {artistName || 'artist'}
-
-
- {credit.role || 'Set role'}
-
-
-
+
+ {credit.roles.map((role) => (
+
+ {role}
+ {
+ const roles = credit.roles.filter(
+ (currentRole) => currentRole !== role,
+ )
+ handleTrackArtistChange(trackId, credit.id, 'roles', roles)
+ handleTrackArtistChange(
+ trackId,
+ credit.id,
+ 'role',
+ roles[0] ?? '',
+ )
+ }}
+ >
+ ×
+
+
+ ))}
+ 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,
)
- }
- >
- Set role
- {creditRoleOptions.map((role) => (
- {role}
- ))}
-
-
+ }}
+ />
+
,
- value: string,
+ value: string | string[],
) => void
handleTrackDraftArtistChange: (trackId: string, nextName: string) => void
isVariousArtists: boolean
diff --git a/src/features/releases/ReleaseTracklistSection.tsx b/src/features/releases/ReleaseTracklistSection.tsx
index c9fd266..868dc04 100644
--- a/src/features/releases/ReleaseTracklistSection.tsx
+++ b/src/features/releases/ReleaseTracklistSection.tsx
@@ -33,7 +33,7 @@ type ReleaseTracklistSectionProps = {
trackId: string,
creditId: string,
field: keyof Omit,
- value: string,
+ value: string | string[],
) => void
handleTrackDraftArtistChange: (trackId: string, nextName: string) => void
isVariousArtists: boolean
diff --git a/src/features/releases/ReleasesWorkspace.tsx b/src/features/releases/ReleasesWorkspace.tsx
index 3874a29..2bbd14a 100644
--- a/src/features/releases/ReleasesWorkspace.tsx
+++ b/src/features/releases/ReleasesWorkspace.tsx
@@ -98,6 +98,7 @@ export function ReleasesWorkspace({
})
const [manualReleases, setManualReleases] = useState([])
const [editingReleaseId, setEditingReleaseId] = useState('')
+ const [discogsLookupReleaseId, setDiscogsLookupReleaseId] = useState('')
const [localEditFiles, setLocalEditFiles] = useState([])
const [ratingColumnIds, setRatingColumnIds] = useState(() =>
readRatingColumnIds('discweave.releaseRatingColumns'),
@@ -163,6 +164,7 @@ export function ReleasesWorkspace({
setQuery('')
selectRelease(release.id)
setEditingReleaseId('')
+ setDiscogsLookupReleaseId('')
}
function handleDeleteRelease(releaseId: string) {
@@ -176,6 +178,7 @@ export function ReleasesWorkspace({
setQuery('')
setEditingReleaseId('')
+ setDiscogsLookupReleaseId('')
}
async function handleUploadReleaseCover(releaseId: string, file: File) {
@@ -319,10 +322,16 @@ export function ReleasesWorkspace({
artists={artists}
dictionaries={dictionaries}
initialRelease={editingRelease}
+ initialShowDiscogsLookup={
+ editingRelease.id === discogsLookupReleaseId
+ }
key={editingRelease.id}
releases={releases}
tracks={tracks}
- onCancel={() => setEditingReleaseId('')}
+ onCancel={() => {
+ setEditingReleaseId('')
+ setDiscogsLookupReleaseId('')
+ }}
onSubmit={handleUpdateRelease}
/>
) : null}
@@ -347,7 +356,10 @@ export function ReleasesWorkspace({
{selectedRelease ? (
setEditingReleaseId(selectedRelease.id)}
+ onEdit={() => {
+ setEditingReleaseId(selectedRelease.id)
+ setDiscogsLookupReleaseId('')
+ }}
onDelete={() => handleDeleteRelease(selectedRelease.id)}
onEditLocalFiles={
canEditLocalFiles
@@ -357,6 +369,10 @@ export function ReleasesWorkspace({
: undefined
}
onRemoveCover={handleRemoveReleaseCover}
+ onUpdateViaDiscogs={() => {
+ setEditingReleaseId(selectedRelease.id)
+ setDiscogsLookupReleaseId(selectedRelease.id)
+ }}
onUploadCover={handleUploadReleaseCover}
playlists={playlists}
release={selectedRelease}
diff --git a/src/features/releases/discogs-release-lookup.css b/src/features/releases/discogs-release-lookup.css
new file mode 100644
index 0000000..bfab10d
--- /dev/null
+++ b/src/features/releases/discogs-release-lookup.css
@@ -0,0 +1,307 @@
+.discogs-release-lookup {
+ max-width: 100%;
+ min-width: 0;
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-sm);
+ background: #fbfcfa;
+ padding: 12px;
+}
+
+.discogs-release-lookup * {
+ min-width: 0;
+}
+
+.discogs-release-lookup .button {
+ max-width: 100%;
+ flex-wrap: wrap;
+ height: auto;
+ overflow-wrap: anywhere;
+ text-align: center;
+}
+
+.discogs-release-lookup .button span {
+ min-width: 0;
+ overflow-wrap: anywhere;
+ white-space: normal;
+}
+
+.discogs-search-form {
+ display: grid;
+ grid-template-columns: repeat(5, minmax(0, 1fr)) auto;
+ gap: 8px;
+ align-items: end;
+ min-width: 0;
+}
+
+.discogs-search-form label {
+ min-width: 0;
+}
+
+.discogs-lookup-status {
+ margin: 0;
+ color: var(--color-muted);
+ font-size: 12px;
+ font-weight: 650;
+}
+
+.discogs-apply-status {
+ margin: 0;
+ border: 1px solid #c8d4c4;
+ border-radius: var(--radius-sm);
+ background: #eef3eb;
+ color: #405f42;
+ font-size: 12px;
+ font-weight: 700;
+ padding: 8px 10px;
+}
+
+.discogs-candidate-list,
+.discogs-review-panel {
+ display: grid;
+ gap: 8px;
+ min-width: 0;
+}
+
+.discogs-candidate {
+ display: grid;
+ gap: 12px;
+ min-width: 0;
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-sm);
+ background: var(--color-surface);
+ padding: 10px;
+}
+
+.discogs-candidate.is-selected {
+ border-color: #aeb6ad;
+ box-shadow: 0 0 0 1px rgba(81, 111, 82, 0.12);
+}
+
+.discogs-candidate-summary {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: 12px;
+ align-items: start;
+ min-width: 0;
+}
+
+.discogs-candidate strong,
+.discogs-impact-row strong {
+ color: var(--color-heading);
+ font-size: 13px;
+}
+
+.discogs-candidate p {
+ margin: 2px 0 0;
+ color: var(--color-muted);
+ font-size: 12px;
+}
+
+.discogs-candidate-actions {
+ display: grid;
+ justify-items: end;
+ gap: 8px;
+ min-width: 0;
+}
+
+.discogs-candidate-actions .detail-link {
+ overflow-wrap: anywhere;
+ text-align: right;
+}
+
+.discogs-review-panel {
+ gap: 10px;
+ border-top: 1px dashed var(--color-border);
+ padding-top: 10px;
+}
+
+.discogs-impact-list {
+ display: grid;
+ gap: 0;
+}
+
+.discogs-impact-row {
+ display: grid;
+ grid-template-columns:
+ minmax(120px, 150px) minmax(84px, 110px) minmax(0, 1fr)
+ minmax(0, 1.6fr);
+ gap: 12px;
+ align-items: start;
+ min-width: 0;
+ border-bottom: 1px solid var(--color-border);
+ padding: 9px 0;
+}
+
+.discogs-impact-row:last-child {
+ border-bottom: 0;
+}
+
+.discogs-impact-group,
+.discogs-impact-value > span {
+ color: var(--color-soft);
+ font-size: 10px;
+ font-weight: 780;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+}
+
+.discogs-impact-value {
+ display: grid;
+ gap: 2px;
+ min-width: 0;
+}
+
+.discogs-impact-value strong {
+ min-width: 0;
+ overflow-wrap: break-word;
+ color: var(--color-heading);
+ font-size: 12px;
+ font-weight: 650;
+}
+
+.discogs-impact-detail {
+ display: grid;
+ gap: 8px;
+ margin-top: 8px;
+ min-width: 0;
+}
+
+.discogs-credit-impact-list,
+.discogs-track-impact-list {
+ display: grid;
+ gap: 6px;
+}
+
+.discogs-credit-impact-row {
+ display: grid;
+ grid-template-columns: minmax(130px, 1fr) auto;
+ gap: 8px;
+ align-items: center;
+ min-width: 0;
+ border-top: 1px solid var(--color-border);
+ padding-top: 6px;
+}
+
+.discogs-credit-impact-row:first-child {
+ border-top: 0;
+ padding-top: 0;
+}
+
+.discogs-credit-impact-row .badge {
+ justify-self: start;
+}
+
+.discogs-credit-role-list {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: flex-end;
+ gap: 4px;
+}
+
+.discogs-track-impact-row {
+ display: grid;
+ grid-template-columns: 34px minmax(0, 1fr);
+ gap: 8px;
+ min-width: 0;
+ border-top: 1px solid var(--color-border);
+ padding-top: 7px;
+}
+
+.discogs-track-impact-row:first-child {
+ border-top: 0;
+ padding-top: 0;
+}
+
+.discogs-track-impact-position {
+ display: inline-grid;
+ width: 28px;
+ height: 24px;
+ place-items: center;
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-sm);
+ background: #f8faf6;
+ color: var(--color-muted);
+ font-size: 11px;
+ font-weight: 760;
+}
+
+.discogs-track-impact-row p,
+.discogs-impact-empty,
+.discogs-impact-warning {
+ margin: 2px 0 0;
+ color: var(--color-muted);
+ font-size: 11px;
+}
+
+.discogs-impact-warning {
+ border: 1px solid #ead19a;
+ border-radius: var(--radius-sm);
+ background: #fbf4df;
+ color: #7a5618;
+ padding: 7px 8px;
+}
+
+.discogs-track-toggle {
+ justify-self: start;
+}
+
+.discogs-apply-groups {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px 12px;
+ min-width: 0;
+ border: 0;
+ margin: 0;
+ padding: 0;
+}
+
+.discogs-apply-groups legend {
+ width: 100%;
+ color: var(--color-soft);
+ font-size: 11px;
+ font-weight: 780;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+}
+
+@media (max-width: 860px) {
+ .discogs-search-form {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
+ .discogs-search-form > .button {
+ justify-self: start;
+ }
+
+ .discogs-candidate-summary,
+ .discogs-impact-row,
+ .discogs-credit-impact-row {
+ grid-template-columns: 1fr;
+ }
+
+ .discogs-candidate-actions {
+ justify-items: start;
+ }
+
+ .discogs-candidate-actions .detail-link {
+ text-align: left;
+ }
+}
+
+@media (max-width: 560px) {
+ .discogs-search-form {
+ grid-template-columns: 1fr;
+ }
+
+ .discogs-search-form > .button,
+ .discogs-candidate-actions .button {
+ width: 100%;
+ min-width: 0;
+ white-space: normal;
+ }
+
+ .discogs-candidate-actions {
+ justify-items: stretch;
+ width: 100%;
+ }
+}
diff --git a/src/features/releases/discogsReleaseApply.ts b/src/features/releases/discogsReleaseApply.ts
new file mode 100644
index 0000000..9fa0943
--- /dev/null
+++ b/src/features/releases/discogsReleaseApply.ts
@@ -0,0 +1,64 @@
+import type { ArtistRecord } from '../artists/artistsData'
+import type { CatalogDictionaries } from '../catalog/catalogApi'
+import { createManualRecordId } from '../manualEntry/manualEntryUtils'
+import type { EditableArtistCredit } from './ReleaseEntryFormTypes'
+import { discogsRoleLabels } from './discogsRoleUtils'
+
+export function groupDiscogsCredits(
+ credits: ReadonlyArray<{ name: string; role: string }>,
+ prefix: string,
+ artists: ArtistRecord[],
+ dictionaries: CatalogDictionaries,
+): EditableArtistCredit[] {
+ const grouped = new Map()
+
+ credits.forEach((credit, index) => {
+ const editableCredit = editableCreditFromDiscogsCredit(
+ credit.name,
+ credit.role,
+ index,
+ prefix,
+ artists,
+ dictionaries,
+ )
+ const key =
+ editableCredit.artistId ||
+ editableCredit.artist.trim().toLowerCase() ||
+ editableCredit.id
+ const existing = grouped.get(key)
+
+ if (existing) {
+ existing.roles = [
+ ...new Set([...existing.roles, ...editableCredit.roles]),
+ ]
+ existing.role = existing.roles[0] ?? ''
+ } else {
+ grouped.set(key, editableCredit)
+ }
+ })
+
+ return [...grouped.values()]
+}
+
+function editableCreditFromDiscogsCredit(
+ name: string,
+ role: string,
+ index: number,
+ prefix: string,
+ artists: ArtistRecord[],
+ dictionaries: CatalogDictionaries,
+): EditableArtistCredit {
+ const trimmedName = name.trim()
+ const existingArtist = artists.find(
+ (artist) => artist.name.toLowerCase() === trimmedName.toLowerCase(),
+ )
+ const roles = discogsRoleLabels(role, dictionaries)
+
+ return {
+ id: createManualRecordId(`${prefix}-artist-credit`, `discogs-${index + 1}`),
+ artistId: existingArtist?.id ?? '',
+ artist: existingArtist ? '' : trimmedName,
+ role: roles[0] ?? '',
+ roles,
+ }
+}
diff --git a/src/features/releases/discogsReleaseTrackRows.test.ts b/src/features/releases/discogsReleaseTrackRows.test.ts
new file mode 100644
index 0000000..41a9ea8
--- /dev/null
+++ b/src/features/releases/discogsReleaseTrackRows.test.ts
@@ -0,0 +1,32 @@
+import { describe, expect, it } from 'vitest'
+import { discogsDraftTrackRows } from './discogsReleaseTrackRows'
+
+describe('discogs release track rows', () => {
+ it('drops Discogs heading rows and renumbers remaining tracks for DiscWeave', () => {
+ expect(
+ discogsDraftTrackRows([
+ {
+ title: 'Orbit Compact Disc',
+ position: 1,
+ durationSeconds: null,
+ artistCredits: [],
+ },
+ {
+ title: 'Little Fluffy Clouds',
+ position: 2,
+ durationSeconds: 269,
+ artistCredits: [],
+ },
+ {
+ title: 'Earth (Gaia)',
+ position: 3,
+ durationSeconds: 580,
+ artistCredits: [],
+ },
+ ]),
+ ).toMatchObject([
+ { title: 'Little Fluffy Clouds', position: 1 },
+ { title: 'Earth (Gaia)', position: 2 },
+ ])
+ })
+})
diff --git a/src/features/releases/discogsReleaseTrackRows.ts b/src/features/releases/discogsReleaseTrackRows.ts
new file mode 100644
index 0000000..508c0af
--- /dev/null
+++ b/src/features/releases/discogsReleaseTrackRows.ts
@@ -0,0 +1,26 @@
+import type { ExternalMetadataReleaseDraftTrackDto } from '../catalog/catalogApi'
+
+export function discogsDraftTrackRows(
+ tracks: ExternalMetadataReleaseDraftTrackDto[],
+) {
+ return tracks
+ .filter((track) => !isDiscogsNonTrackHeadingRow(track))
+ .map((track, index) => ({
+ ...track,
+ position: index + 1,
+ }))
+}
+
+function isDiscogsNonTrackHeadingRow(
+ track: ExternalMetadataReleaseDraftTrackDto,
+) {
+ const title = normalizeText(track.title)
+ return (
+ /^(disc|disk|cd|side|part)\s*[a-z0-9]*$/.test(title) ||
+ /(?:compact\s+disc|bonus\s+disc|disc\s+\d|cd\s+\d)$/.test(title)
+ )
+}
+
+function normalizeText(value: string) {
+ return value.trim().toLowerCase()
+}
diff --git a/src/features/releases/discogsRoleUtils.ts b/src/features/releases/discogsRoleUtils.ts
new file mode 100644
index 0000000..af2693e
--- /dev/null
+++ b/src/features/releases/discogsRoleUtils.ts
@@ -0,0 +1,146 @@
+import type {
+ CatalogDictionaries,
+ ExternalMetadataReleaseDetailDto,
+ ExternalMetadataReleaseDraftArtistCreditDto,
+} from '../catalog/catalogApi'
+
+export type GroupedDiscogsReviewCredit = {
+ name: string
+ roles: string[]
+}
+
+export function discogsRoleLabels(
+ role: string,
+ dictionaries: CatalogDictionaries,
+) {
+ return splitDiscogsRoleLabels(role).map((part) =>
+ discogsRoleLabelFromCode(part, dictionaries),
+ )
+}
+
+export function discogsRoleLabelFromCode(
+ role: string,
+ dictionaries: CatalogDictionaries,
+) {
+ const trimmedRole = role.trim()
+
+ return (
+ dictionaries.creditRole.find(
+ (entry) => entry.code === trimmedRole || entry.name === trimmedRole,
+ )?.name ?? trimmedRole
+ )
+}
+
+export function groupDiscogsReviewCredits(
+ credits: ExternalMetadataReleaseDraftArtistCreditDto[],
+) {
+ const grouped = new Map()
+
+ credits.forEach((credit) => {
+ const name = credit.name.trim()
+ if (!name) {
+ return
+ }
+
+ const key = name.toLowerCase()
+ const existing = grouped.get(key)
+ const roles = splitDiscogsRoleLabels(credit.role)
+
+ if (existing) {
+ existing.roles = [...new Set([...existing.roles, ...roles])]
+ } else {
+ grouped.set(key, { name, roles })
+ }
+ })
+
+ return [...grouped.values()]
+}
+
+export function splitDiscogsRoleLabels(role: string) {
+ const roles: string[] = []
+ let depth = 0
+ let current = ''
+
+ for (const character of role) {
+ if (character === '[' || character === '(') {
+ depth += 1
+ } else if ((character === ']' || character === ')') && depth > 0) {
+ depth -= 1
+ }
+
+ if (character === ',' && depth === 0) {
+ pushRole(current, roles)
+ current = ''
+ } else {
+ current += character
+ }
+ }
+
+ pushRole(current, roles)
+ return roles
+}
+
+export function hasCompilationTrackArtists(
+ detail: ExternalMetadataReleaseDetailDto,
+) {
+ return discogsTracklistNeedsVariousArtists(
+ detail.draft.tracklist,
+ detail.draft,
+ )
+}
+
+export function discogsTracklistNeedsVariousArtists(
+ tracklist: ExternalMetadataReleaseDetailDto['draft']['tracklist'],
+ draft: ExternalMetadataReleaseDetailDto['draft'],
+) {
+ const releaseMainArtists = normalizedSet(
+ draft.artistCredits
+ .filter((credit) => normalizeText(credit.role) === 'mainartist')
+ .map((credit) => credit.name),
+ )
+ const releaseArtists =
+ releaseMainArtists.size > 0
+ ? releaseMainArtists
+ : normalizedSet(draft.artistCredits.map((credit) => credit.name))
+
+ return tracklist.some((track) => {
+ const trackMainArtists = normalizedSet(
+ track.artistCredits
+ .filter((credit) => normalizeText(credit.role) === 'mainartist')
+ .map((credit) => credit.name),
+ )
+
+ return (
+ trackMainArtists.size > 0 && !setsEqual(releaseArtists, trackMainArtists)
+ )
+ })
+}
+
+function pushRole(value: string, roles: string[]) {
+ const trimmed = value.trim()
+ if (trimmed) {
+ roles.push(trimmed)
+ }
+}
+
+function normalizedSet(values: string[]) {
+ return new Set(values.map(normalizeText).filter(Boolean))
+}
+
+function normalizeText(value: string) {
+ return value.trim().toLowerCase()
+}
+
+function setsEqual(left: Set, right: Set) {
+ if (left.size !== right.size) {
+ return false
+ }
+
+ for (const value of left) {
+ if (!right.has(value)) {
+ return false
+ }
+ }
+
+ return true
+}
diff --git a/src/features/releases/release-form.css b/src/features/releases/release-form.css
index bdb2110..9320e9d 100644
--- a/src/features/releases/release-form.css
+++ b/src/features/releases/release-form.css
@@ -53,6 +53,10 @@
grid-column: 1 / -1;
}
+.release-core-type-field {
+ grid-column: 1 / -1;
+}
+
.release-owned-copy-grid {
grid-template-columns: repeat(2, minmax(160px, 1fr));
}
@@ -102,45 +106,48 @@
.release-artist-chip-list {
display: flex;
flex-wrap: wrap;
- gap: 6px;
+ gap: 8px;
min-width: 0;
}
.release-artist-chip {
- display: inline-flex;
- align-items: center;
- gap: 8px;
- max-width: 100%;
+ display: flex;
+ align-items: flex-start;
+ gap: 12px;
+ width: min(100%, 560px);
min-width: 0;
border: 1px solid #c8d4c4;
- border-radius: 999px;
+ border-radius: var(--radius-md);
background: #eef3eb;
- padding: 5px 6px 5px 14px;
+ padding: 8px 8px 8px 12px;
}
.release-artist-chip-name {
+ flex: 0 0 160px;
min-width: 0;
overflow: hidden;
color: var(--color-heading);
font-size: 13px;
font-weight: 700;
+ line-height: 1.35;
+ padding-top: 5px;
text-overflow: ellipsis;
white-space: nowrap;
}
-.release-artist-chip-role {
- position: relative;
- display: inline-grid;
- min-height: 28px;
- flex: 0 0 auto;
- align-items: center;
+.release-artist-chip-roles {
+ display: flex;
+ flex: 1 1 auto;
+ flex-wrap: wrap;
+ gap: 6px;
+ min-width: 0;
}
-.release-artist-chip-role-face {
+.release-artist-role-pill {
display: inline-flex;
- min-height: 26px;
align-items: center;
- gap: 7px;
+ gap: 6px;
+ min-height: 26px;
border: 1px solid #bdc9b9;
border-radius: 999px;
background-color: #f8faf6;
@@ -148,11 +155,11 @@
font-size: 12px;
font-weight: 700;
line-height: 1;
- padding: 0 9px 0 10px;
+ padding: 0 4px 0 10px;
white-space: nowrap;
}
-.manual-entry-grid .release-artist-chip-role-face span {
+.release-artist-role-pill span {
color: inherit;
font-size: inherit;
font-weight: inherit;
@@ -160,32 +167,117 @@
text-transform: none;
}
-.release-artist-chip-role-face-unset {
- border-color: #9da996;
+.release-artist-role-pill button {
+ display: grid;
+ width: 18px;
+ height: 18px;
+ place-items: center;
+ border: 0;
+ border-radius: 999px;
+ background: transparent;
color: var(--color-muted);
+ cursor: pointer;
+ font-size: 14px;
+ line-height: 1;
}
-.release-artist-chip-role-caret {
- width: 6px;
- height: 6px;
- border-right: 1.5px solid var(--color-muted);
- border-bottom: 1.5px solid var(--color-muted);
- transform: translateY(-1px) rotate(45deg);
+.release-artist-role-pill button:hover {
+ background: #dfe8dc;
+ color: var(--color-heading);
}
-.release-artist-chip-role-select {
+.release-artist-add-role {
+ position: relative;
+ display: inline-flex;
+ align-items: center;
+ min-height: 26px;
+ border: 1px dashed #9da996;
+ border-radius: 999px;
+ background: #f8faf6;
+ color: var(--color-muted);
+ font-size: 12px;
+ font-weight: 700;
+ line-height: 1;
+ padding: 0 10px;
+ white-space: nowrap;
+}
+
+.release-artist-add-role[open] {
+ border-style: solid;
+ color: var(--color-heading);
+ z-index: 10;
+}
+
+.release-artist-add-role summary {
+ display: inline-flex;
+ align-items: center;
+ min-height: 24px;
+ cursor: pointer;
+ list-style: none;
+}
+
+.release-artist-add-role summary::-webkit-details-marker {
+ display: none;
+}
+
+.release-artist-add-role-empty,
+.release-artist-add-role-disabled {
+ border-style: solid;
+}
+
+.release-artist-add-role-disabled {
+ cursor: default;
+ opacity: 0.65;
+}
+
+.release-artist-role-menu {
position: absolute;
- inset: 0;
+ top: calc(100% + 6px);
+ left: 0;
+ z-index: 30;
+ display: grid;
+ width: max-content;
+ min-width: 190px;
+ max-width: min(320px, calc(100vw - 48px));
+ max-height: 240px;
+ overflow-y: auto;
+ overscroll-behavior: contain;
+ border: 1px solid #4f594d;
+ border-radius: var(--radius-md);
+ background: #f8faf6;
+ box-shadow: 0 12px 28px rgba(23, 28, 22, 0.22);
+ padding: 6px;
+}
+
+.release-artist-role-menu button {
+ display: block;
width: 100%;
- height: 100%;
- min-height: 0;
- appearance: none;
border: 0;
- opacity: 0;
+ border-radius: var(--radius-sm);
+ background: transparent;
+ color: var(--color-heading);
cursor: pointer;
+ font-size: 13px;
+ font-weight: 700;
+ line-height: 1.25;
+ padding: 8px 10px;
+ text-align: left;
+ white-space: normal;
+}
+
+.release-artist-role-menu button:hover,
+.release-artist-role-menu button:focus-visible {
+ background: #dfe8dc;
+ outline: none;
+}
+
+.release-artist-add-role:hover {
+ border-style: solid;
+ color: var(--color-heading);
}
-.release-artist-chip-role:focus-within .release-artist-chip-role-face {
+.release-artist-add-role summary:focus-visible,
+.release-artist-role-pill button:focus-visible {
outline: 3px solid rgba(81, 111, 82, 0.18);
outline-offset: 2px;
}
@@ -332,3 +424,26 @@
.release-track-version-field {
min-width: 0;
}
+
+@media (max-width: 860px) {
+ .release-core-grid,
+ .release-owned-copy-grid,
+ .release-artist-composer,
+ .release-label-composer,
+ .release-artist-row,
+ .release-label-row {
+ grid-template-columns: 1fr;
+ }
+
+ .release-row-checkbox {
+ padding-top: 0;
+ }
+}
+
+@media (max-width: 560px) {
+ .release-form-section-header,
+ .release-section-actions {
+ align-items: stretch;
+ flex-direction: column;
+ }
+}
diff --git a/src/features/releases/release-tracklist.css b/src/features/releases/release-tracklist.css
index 1dd9255..76f9c87 100644
--- a/src/features/releases/release-tracklist.css
+++ b/src/features/releases/release-tracklist.css
@@ -353,3 +353,30 @@
min-height: 30px;
margin-top: 18px;
}
+
+@media (max-width: 860px) {
+ .release-tracklist-layout,
+ .release-track-detail-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .release-tracklist-toolbar,
+ .release-tracklist-detail-header {
+ align-items: stretch;
+ flex-direction: column;
+ }
+
+ .release-track-detail-actions {
+ justify-content: flex-start;
+ }
+
+ .existing-track-summary span:not(.badge),
+ .existing-track-results span {
+ overflow-wrap: anywhere;
+ white-space: normal;
+ }
+
+ .track-duration-control {
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ }
+}
diff --git a/src/features/releases/releaseFormHelpers.ts b/src/features/releases/releaseFormHelpers.ts
index 3e21597..132eb9a 100644
--- a/src/features/releases/releaseFormHelpers.ts
+++ b/src/features/releases/releaseFormHelpers.ts
@@ -74,6 +74,10 @@ export function draftTracksFromRelease(
artistId: credit.artistId ?? '',
artist: credit.artistId ? '' : credit.artist,
role: credit.role,
+ roles:
+ credit.roles && credit.roles.length > 0
+ ? credit.roles
+ : [credit.role],
})),
draftArtist: '',
draftArtistId: '',
@@ -223,6 +227,8 @@ export function editableArtistCreditFromReleaseCredit(
artistId: credit.artistId ?? '',
artist: credit.artistId ? '' : credit.artist,
role: credit.role,
+ roles:
+ credit.roles && credit.roles.length > 0 ? credit.roles : [credit.role],
}
}
@@ -232,10 +238,13 @@ export function releaseArtistCreditFromEditableCredit(
): ReleaseArtistCredit {
const existingArtist = artists.find((artist) => artist.id === credit.artistId)
+ const roles = credit.roles.length > 0 ? credit.roles : [credit.role]
+
return {
artistId: existingArtist?.id,
artist: existingArtist?.name ?? credit.artist.trim(),
- role: toCreditRole(credit.role),
+ role: toCreditRole(roles[0]),
+ roles: roles.map(toCreditRole),
}
}
diff --git a/src/features/releases/releaseSubmit.ts b/src/features/releases/releaseSubmit.ts
index 184bb5d..0db65dc 100644
--- a/src/features/releases/releaseSubmit.ts
+++ b/src/features/releases/releaseSubmit.ts
@@ -32,6 +32,7 @@ type BuildReleaseSubmissionInput = {
draftTracks: DraftTrackRow[]
effectiveArtistCredits: EditableArtistCredit[]
effectiveLabels: EditableReleaseLabel[]
+ externalSources?: ReleaseRecord['externalSources']
firstCopy?: OwnedCopy
genres: string[]
includeOwnedCopy: boolean
@@ -40,6 +41,7 @@ type BuildReleaseSubmissionInput = {
medium: string
notOnLabel: boolean
releaseNotes: string
+ releaseDate: string
status: OwnedCopy['status'] | ''
tags: string
title: string
@@ -53,6 +55,7 @@ export function buildReleaseSubmission({
draftTracks,
effectiveArtistCredits,
effectiveLabels,
+ externalSources,
firstCopy,
genres,
includeOwnedCopy,
@@ -61,6 +64,7 @@ export function buildReleaseSubmission({
medium,
notOnLabel,
releaseNotes,
+ releaseDate,
status,
tags,
title,
@@ -88,16 +92,14 @@ export function buildReleaseSubmission({
const displayArtist = isVariousArtists
? 'Various Artists'
: resolvedArtistCredits
- .filter((credit) => credit.role === 'Main artist')
+ .filter(hasMainArtistRole)
.map((credit) => credit.artist)
.join(', ') ||
resolvedArtistCredits.map((credit) => credit.artist).join(', ')
const displayLabel = notOnLabel
? 'Not On Label'
: resolvedLabels.map(releaseLabelDisplay).join(', ') || 'Unknown label'
- const firstMainArtist = resolvedArtistCredits.find(
- (credit) => credit.role === 'Main artist',
- )
+ const firstMainArtist = resolvedArtistCredits.find(hasMainArtistRole)
const copyMedium = medium.trim()
const copyStatus = status
const releaseId =
@@ -126,6 +128,7 @@ export function buildReleaseSubmission({
artistCredits: resolvedArtistCredits,
type,
year: textOrFallback(year, 'Unknown year'),
+ releaseDate: releaseDate.trim() || undefined,
label: displayLabel,
labels: resolvedLabels,
isVariousArtists,
@@ -134,6 +137,7 @@ export function buildReleaseSubmission({
tags: splitCommaList(tags),
releaseNotes,
ownedCopies,
+ externalSources,
}
const submittedTracks = draftTracks
.filter(isDraftTrackIncluded)
@@ -180,20 +184,20 @@ export function buildReleaseSubmission({
const existingArtist = artists.find(
(artist) => artist.id === credit.artistId,
)
+ const roles = credit.roles.length > 0 ? credit.roles : [credit.role]
return {
artistId: existingArtist?.id,
artist: existingArtist?.name ?? credit.artist.trim(),
- role: toCreditRole(credit.role),
+ role: toCreditRole(roles[0]),
+ roles: roles.map(toCreditRole),
}
})
.filter((credit) => credit.artist.length > 0)
const effectiveTrackCredits =
!track.inheritReleaseArtistCredits && resolvedTrackCredits.length > 0
? resolvedTrackCredits
- : resolvedArtistCredits.filter(
- (credit) => credit.role === 'Main artist',
- )
+ : resolvedArtistCredits.filter(hasMainArtistRole)
const trackArtist =
effectiveTrackCredits.map((credit) => credit.artist).join(', ') ||
displayArtist
@@ -223,6 +227,7 @@ export function buildReleaseSubmission({
credits: effectiveTrackCredits.map((credit) => ({
artistId: credit.artistId,
role: credit.role,
+ roles: credit.roles,
artist: credit.artist,
scope: '',
})),
@@ -263,3 +268,9 @@ export function buildReleaseSubmission({
return { release, submittedTracks }
}
+
+function hasMainArtistRole(credit: ReleaseArtistCredit) {
+ return (
+ credit.roles && credit.roles.length > 0 ? credit.roles : [credit.role]
+ ).includes('Main artist')
+}
diff --git a/src/features/releases/releases.css b/src/features/releases/releases.css
index 41308e2..0526ee4 100644
--- a/src/features/releases/releases.css
+++ b/src/features/releases/releases.css
@@ -1,4 +1,5 @@
@import './release-form.css';
+@import './discogs-release-lookup.css';
@import './release-tracklist.css';
@import './release-track-artists.css';
@import './release-detail.css';
diff --git a/src/features/releases/releasesData.ts b/src/features/releases/releasesData.ts
index 746ed3b..0f58ca4 100644
--- a/src/features/releases/releasesData.ts
+++ b/src/features/releases/releasesData.ts
@@ -1,5 +1,8 @@
import type { CreditRole } from '../catalog/creditRoles'
-import type { EntityRating } from '../catalog/catalogApi'
+import type {
+ EntityRating,
+ ExternalSourceReference,
+} from '../catalog/catalogApi'
export type ReleaseType = string
@@ -16,6 +19,7 @@ export type ReleaseArtistCredit = {
artistId?: string
artist: string
role: CreditRole
+ roles?: CreditRole[]
}
export type ReleaseLabel = {
@@ -51,6 +55,7 @@ export type ReleaseRecord = {
releaseNotes: string
ownedCopies: OwnedCopy[]
coverImage?: ReleaseCoverImage
+ externalSources?: ExternalSourceReference[]
ratings?: EntityRating[]
}
diff --git a/src/features/releases/useReleaseTrackDrafts.ts b/src/features/releases/useReleaseTrackDrafts.ts
index 02ea8be..47fa099 100644
--- a/src/features/releases/useReleaseTrackDrafts.ts
+++ b/src/features/releases/useReleaseTrackDrafts.ts
@@ -135,6 +135,10 @@ export function useReleaseTrackDrafts({
artistId: credit.artistId ?? '',
artist: credit.artistId ? '' : credit.artist,
role: credit.role,
+ roles:
+ credit.roles && credit.roles.length > 0
+ ? credit.roles
+ : [credit.role],
})),
draftArtist: '',
draftArtistId: '',
@@ -188,7 +192,7 @@ export function useReleaseTrackDrafts({
trackId: string,
creditId: string,
field: keyof Omit,
- value: string,
+ value: string | string[],
) {
setDraftTracks((currentTracks) =>
currentTracks.map((track) =>
@@ -196,7 +200,12 @@ export function useReleaseTrackDrafts({
? {
...track,
artistCredits: track.artistCredits.map((credit) =>
- credit.id === creditId ? { ...credit, [field]: value } : credit,
+ credit.id === creditId
+ ? {
+ ...credit,
+ [field]: value,
+ }
+ : credit,
),
}
: track,
@@ -247,6 +256,7 @@ export function useReleaseTrackDrafts({
artistId: track.draftArtistId,
artist: track.draftArtistId ? '' : artistName,
role: '',
+ roles: [],
},
],
}
@@ -354,6 +364,11 @@ export function useReleaseTrackDrafts({
}
}
+ function replaceDraftTracks(nextTracks: DraftTrackRow[]) {
+ setDraftTracks(nextTracks)
+ setSelectedDraftTrackId(nextTracks[0]?.id ?? null)
+ }
+
function draftTrackArtistSummary(track: DraftTrackRow) {
if (track.existingTrackId) {
const linkedTrack = tracks.find(
@@ -409,6 +424,7 @@ export function useReleaseTrackDrafts({
handleTrackDraftArtistChange,
removeDraftTrack,
removeTrackArtist,
+ replaceDraftTracks,
selectExistingTrack,
selectedCustomTrackCredits,
selectedDraftTrack,
diff --git a/src/features/tracks/DiscogsTrackLookupPanel.tsx b/src/features/tracks/DiscogsTrackLookupPanel.tsx
new file mode 100644
index 0000000..ea4197b
--- /dev/null
+++ b/src/features/tracks/DiscogsTrackLookupPanel.tsx
@@ -0,0 +1,594 @@
+import { Search } from 'lucide-react'
+import { type ReactNode, useEffect, useRef, useState } from 'react'
+import {
+ CatalogApiError,
+ getDiscogsTrack,
+ searchDiscogsTracks,
+ type CatalogDictionaries,
+ type ExternalMetadataReleaseDraftArtistCreditDto,
+ type ExternalMetadataTrackCandidateDto,
+ type ExternalMetadataTrackDetailDto,
+} from '../catalog/catalogApi'
+import { formatDurationSeconds } from '../catalog/durationFormat'
+
+export type DiscogsTrackApplyGroups = {
+ core: boolean
+ credits: boolean
+}
+
+export type DiscogsTrackSearchSeed = {
+ artist: string
+ catalogNumber: string
+ releaseTitle: string
+ title: string
+ year: string
+}
+
+export type DiscogsCurrentTrack = {
+ artists: string
+ duration: string
+ title: string
+}
+
+type DiscogsTrackLookupPanelProps = {
+ current: DiscogsCurrentTrack
+ dictionaries: CatalogDictionaries
+ isOpen: boolean
+ mode: 'create' | 'update'
+ searchSeed: DiscogsTrackSearchSeed
+ onApplyDraft: (
+ detail: ExternalMetadataTrackDetailDto,
+ groups: DiscogsTrackApplyGroups,
+ ) => void
+ onOpenChange: (isOpen: boolean) => void
+}
+
+const emptyGroups: DiscogsTrackApplyGroups = {
+ core: false,
+ credits: false,
+}
+
+export function DiscogsTrackLookupPanel({
+ current,
+ dictionaries,
+ isOpen,
+ mode,
+ searchSeed,
+ onApplyDraft,
+ onOpenChange,
+}: DiscogsTrackLookupPanelProps) {
+ const [title, setTitle] = useState(searchSeed.title)
+ const [artist, setArtist] = useState(searchSeed.artist)
+ const [releaseTitle, setReleaseTitle] = useState(searchSeed.releaseTitle)
+ const [year, setYear] = useState(searchSeed.year)
+ const [catalogNumber, setCatalogNumber] = useState(searchSeed.catalogNumber)
+ const [status, setStatus] = useState('')
+ const [appliedStatus, setAppliedStatus] = useState('')
+ const [candidates, setCandidates] = useState<
+ ExternalMetadataTrackCandidateDto[]
+ >([])
+ const [selectedDetail, setSelectedDetail] =
+ useState(null)
+ const [applyGroups, setApplyGroups] = useState(() =>
+ defaultGroups(mode),
+ )
+ const wasOpen = useRef(false)
+
+ useEffect(() => {
+ if (isOpen && !wasOpen.current) {
+ setTitle(searchSeed.title)
+ setArtist(searchSeed.artist)
+ setReleaseTitle(searchSeed.releaseTitle)
+ setYear(searchSeed.year)
+ setCatalogNumber(searchSeed.catalogNumber)
+ }
+
+ wasOpen.current = isOpen
+ }, [isOpen, searchSeed])
+
+ async function handleSearch() {
+ setStatus('Searching Discogs track candidates.')
+ setAppliedStatus('')
+ setSelectedDetail(null)
+
+ try {
+ const result = await searchDiscogsTracks({
+ title,
+ artist,
+ releaseTitle,
+ year,
+ catalogNumber,
+ limit: 25,
+ })
+
+ setCandidates(result.items)
+ setStatus(
+ result.items.length > 0
+ ? `${result.total} candidate${result.total === 1 ? '' : 's'} found.`
+ : 'No Discogs track candidates found.',
+ )
+ } catch (error) {
+ setCandidates([])
+ setStatus(externalMetadataErrorMessage(error))
+ }
+ }
+
+ async function reviewCandidate(candidate: ExternalMetadataTrackCandidateDto) {
+ setStatus(`Loading Discogs detail for ${candidate.title}.`)
+ setAppliedStatus('')
+
+ try {
+ const detail = await getDiscogsTrack(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 DiscogsTrackApplyGroups,
+ checked: boolean,
+ ) {
+ setApplyGroups((groups) => ({ ...groups, [group]: checked }))
+ }
+
+ function handleApplyDraft(
+ detail: ExternalMetadataTrackDetailDto,
+ groups: DiscogsTrackApplyGroups,
+ ) {
+ 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)
+
+ return (
+
+
+
+
Discogs
+
Search track candidates and review fields before applying.
+
+
onOpenChange(!isOpen)}
+ >
+
+ {isOpen ? 'Hide Discogs' : 'Search Discogs'}
+
+
+
+ {isOpen ? (
+ <>
+
+
+
+
+
+
+ {
+ void handleSearch()
+ }}
+ >
+
+ Search Discogs tracks
+
+
+
+ {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}
+
+
+
+
+ {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 (
+
+
+
+
+ onUpdateApplyGroup('core', checked)}
+ />
+ onUpdateApplyGroup('credits', checked)}
+ >
+
+
+
+
+
onApplyDraft(detail, applyGroups)}
+ >
+ Apply selected Discogs fields
+
+
+ )
+}
+
+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 (
+
+ onChange(event.target.checked)}
+ />
+ {label}
+
+ )
+}
+
+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 (
+
+ {label}
+ onChange(event.target.value)}
+ />
+
+ )
+}
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 ? (
+
+ Update via Discogs
+
+ ) : 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}
-
-
- {credit.role}
-
-
-
+
+ {credit.roles.map((role, index) => (
+
+ {role}
+
+ setCredits((currentCredits) =>
+ currentCredits.map((currentCredit) => {
+ if (currentCredit.id !== credit.id) {
+ return currentCredit
+ }
+
+ const roles = currentCredit.roles.filter(
+ (currentRole) => currentRole !== role,
+ )
+ return {
+ ...currentCredit,
+ role: roles[0] ?? '',
+ roles,
+ }
+ }),
+ )
+ }
+ >
+ ×
+
+
+ ))}
+ 0 ? 'Add role' : 'Set role'
+ }
+ ariaLabel={`Role for ${credit.artist}`}
+ options={trackCreditRoleOptions.filter(
+ (role) => !credit.roles.includes(role),
+ )}
+ onSelect={(role) => {
+ if (!role || credit.roles.includes(role)) {
+ return
+ }
+
setCredits((currentCredits) =>
currentCredits.map((currentCredit) =>
currentCredit.id === credit.id
? {
...currentCredit,
- role: toCreditRole(event.target.value),
+ role: currentCredit.role || role,
+ roles: [...currentCredit.roles, role],
}
: currentCredit,
),
)
- }
- >
- {trackCreditRoleOptions.map((role) => (
-
- {role}
-
- ))}
-
-
+ }}
+ />
+
)
}
+
+function hasMainArtistRole(credit: { role: string; roles?: string[] }) {
+ return (
+ credit.roles && credit.roles.length > 0 ? credit.roles : [credit.role]
+ ).includes('Main artist')
+}
diff --git a/src/features/tracks/TracksWorkspace.tsx b/src/features/tracks/TracksWorkspace.tsx
index f8bad9c..a6a63f0 100644
--- a/src/features/tracks/TracksWorkspace.tsx
+++ b/src/features/tracks/TracksWorkspace.tsx
@@ -89,6 +89,7 @@ export function TracksWorkspace({
})
const [manualTracks, setManualTracks] = useState([])
const [editingTrackId, setEditingTrackId] = useState('')
+ const [discogsLookupTrackId, setDiscogsLookupTrackId] = useState('')
const [localEditFiles, setLocalEditFiles] = useState([])
const [ratingColumnIds, setRatingColumnIds] = useState(() =>
readRatingColumnIds('discweave.trackRatingColumns'),
@@ -110,7 +111,12 @@ export function TracksWorkspace({
terms.every((term) => trackSearchText(track).includes(term)) &&
(!filters.format || track.fileMetadata.format === filters.format) &&
(!filters.creditRole ||
- track.credits.some((credit) => credit.role === filters.creditRole)) &&
+ track.credits.some((credit) =>
+ (credit.roles && credit.roles.length > 0
+ ? credit.roles
+ : [credit.role]
+ ).includes(filters.creditRole),
+ )) &&
(!filters.relationType ||
track.versionHint === filters.relationType ||
track.relations.some(
@@ -145,6 +151,7 @@ export function TracksWorkspace({
setQuery('')
selectTrack(track.id)
onManualEntryClose()
+ setDiscogsLookupTrackId('')
}
function handleUpdateTrack(track: TrackRecord) {
@@ -161,6 +168,7 @@ export function TracksWorkspace({
setQuery('')
selectTrack(track.id)
setEditingTrackId('')
+ setDiscogsLookupTrackId('')
}
function handleDeleteTrack(trackId: string) {
@@ -174,6 +182,7 @@ export function TracksWorkspace({
setQuery('')
setEditingTrackId('')
+ setDiscogsLookupTrackId('')
}
async function handleEditLocalFile(track: TrackRecord) {
@@ -228,7 +237,11 @@ export function TracksWorkspace({
value={filters.creditRole}
values={uniqueValues(
tracks.flatMap((track) =>
- track.credits.map((credit) => credit.role),
+ track.credits.flatMap((credit) =>
+ credit.roles && credit.roles.length > 0
+ ? credit.roles
+ : [credit.role],
+ ),
),
)}
onChange={(creditRole) =>
@@ -279,8 +292,12 @@ export function TracksWorkspace({
artists={artists}
dictionaries={dictionaries}
initialTrack={editingTrack}
+ initialShowDiscogsLookup={editingTrack.id === discogsLookupTrackId}
key={editingTrack.id}
- onCancel={() => setEditingTrackId('')}
+ onCancel={() => {
+ setEditingTrackId('')
+ setDiscogsLookupTrackId('')
+ }}
releases={releases}
tracks={tracks}
onSubmit={handleUpdateTrack}
@@ -306,7 +323,14 @@ export function TracksWorkspace({
{selectedTrack ? (
setEditingTrackId(selectedTrack.id)}
+ onEdit={() => {
+ setEditingTrackId(selectedTrack.id)
+ setDiscogsLookupTrackId('')
+ }}
+ onUpdateViaDiscogs={() => {
+ setEditingTrackId(selectedTrack.id)
+ setDiscogsLookupTrackId(selectedTrack.id)
+ }}
onDelete={() => handleDeleteTrack(selectedTrack.id)}
playlists={playlists}
relations={relations}
diff --git a/src/features/tracks/discogsTrackApply.ts b/src/features/tracks/discogsTrackApply.ts
new file mode 100644
index 0000000..0df3ed3
--- /dev/null
+++ b/src/features/tracks/discogsTrackApply.ts
@@ -0,0 +1,74 @@
+import type { ArtistRecord } from '../artists/artistsData'
+import type { CatalogDictionaries } from '../catalog/catalogApi'
+import { createManualRecordId } from '../manualEntry/manualEntryUtils'
+import { discogsRoleLabels } from '../releases/discogsRoleUtils'
+
+export type EditableTrackCredit = {
+ artist: string
+ artistId?: string
+ id: string
+ role: string
+ roles: string[]
+ scope: string
+}
+
+export function groupDiscogsTrackCredits(
+ artistCredits: ReadonlyArray<{ name: string; role: string }>,
+ artists: ArtistRecord[],
+ dictionaries: CatalogDictionaries,
+) {
+ const grouped = new Map()
+
+ artistCredits.forEach((credit, index) => {
+ const editableCredit = editableCreditFromDiscogsCredit(
+ credit.name,
+ credit.role,
+ index,
+ artists,
+ dictionaries,
+ )
+ const key =
+ editableCredit.artistId ||
+ editableCredit.artist.trim().toLowerCase() ||
+ editableCredit.id
+ const existing = grouped.get(key)
+
+ if (existing) {
+ existing.roles = [
+ ...new Set([...existing.roles, ...editableCredit.roles]),
+ ]
+ existing.role = existing.roles[0] ?? ''
+ } else {
+ grouped.set(key, editableCredit)
+ }
+ })
+
+ return [...grouped.values()]
+}
+
+function editableCreditFromDiscogsCredit(
+ artistName: string,
+ role: string,
+ index: number,
+ artists: ArtistRecord[],
+ dictionaries: CatalogDictionaries,
+): EditableTrackCredit {
+ const normalizedArtistName = artistName.trim()
+ const existingArtist = artists.find(
+ (record) =>
+ record.name.toLowerCase() === normalizedArtistName.toLowerCase(),
+ )
+ const roles = discogsRoleLabels(role, dictionaries)
+
+ return {
+ id: createManualRecordId(
+ 'track-credit',
+ `${normalizedArtistName}-${role}-${index}`,
+ ),
+ artistId: existingArtist?.id,
+ artist: existingArtist?.name ?? normalizedArtistName,
+ role: roles[0] ?? '',
+ roles,
+ scope: 'Suggested by Discogs track detail.',
+ }
+}
diff --git a/src/features/tracks/trackDisplayHelpers.ts b/src/features/tracks/trackDisplayHelpers.ts
index 4eb2abd..e799b9b 100644
--- a/src/features/tracks/trackDisplayHelpers.ts
+++ b/src/features/tracks/trackDisplayHelpers.ts
@@ -31,7 +31,12 @@ export function trackReleaseAppearances(
export function trackArtistDisplay(track: TrackRecord) {
const mainArtists = uniqueValues(
track.credits
- .filter((credit) => credit.role === 'Main artist')
+ .filter((credit) =>
+ (credit.roles && credit.roles.length > 0
+ ? credit.roles
+ : [credit.role]
+ ).includes('Main artist'),
+ )
.map((credit) => credit.artist),
)
const creditArtists = uniqueValues(
@@ -112,7 +117,9 @@ export function trackSearchText(track: TrackRecord) {
: []),
...track.tags,
...track.credits.flatMap((credit) => [
- credit.role,
+ ...(credit.roles && credit.roles.length > 0
+ ? credit.roles
+ : [credit.role]),
credit.artist,
credit.scope,
]),
diff --git a/src/features/tracks/tracks.css b/src/features/tracks/tracks.css
index f4c2009..7ba989c 100644
--- a/src/features/tracks/tracks.css
+++ b/src/features/tracks/tracks.css
@@ -42,6 +42,28 @@
align-self: end;
}
+.discogs-track-lookup .discogs-search-form {
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+}
+
+.discogs-track-lookup .discogs-search-form > .button {
+ justify-self: start;
+}
+
+.discogs-track-lookup .discogs-candidate-summary,
+.discogs-track-lookup .discogs-impact-row,
+.discogs-track-lookup .discogs-credit-impact-row {
+ grid-template-columns: minmax(0, 1fr);
+}
+
+.discogs-track-lookup .discogs-candidate-actions {
+ justify-items: start;
+}
+
+.discogs-track-lookup .discogs-candidate-actions .detail-link {
+ text-align: left;
+}
+
.track-appearance-list {
display: grid;
gap: 8px;
@@ -131,3 +153,32 @@
letter-spacing: 0.06em;
text-transform: uppercase;
}
+
+@media (max-width: 700px) {
+ .discogs-track-lookup .discogs-search-form {
+ grid-template-columns: minmax(0, 1fr);
+ }
+
+ .discogs-track-lookup .discogs-search-form > .button {
+ justify-self: stretch;
+ }
+
+ .track-entry-layout,
+ .track-core-grid,
+ .track-credit-composer,
+ .draft-track-row {
+ grid-template-columns: minmax(0, 1fr);
+ }
+
+ .track-entry-side {
+ border-top: 1px solid var(--color-border);
+ border-left: 0;
+ padding-top: 12px;
+ padding-left: 0;
+ }
+
+ .draft-track-header {
+ align-items: stretch;
+ flex-direction: column;
+ }
+}
diff --git a/src/features/tracks/tracksData.ts b/src/features/tracks/tracksData.ts
index e82472b..270fccb 100644
--- a/src/features/tracks/tracksData.ts
+++ b/src/features/tracks/tracksData.ts
@@ -1,10 +1,14 @@
import type { CreditRole } from '../catalog/creditRoles'
-import type { EntityRating } from '../catalog/catalogApi'
+import type {
+ EntityRating,
+ ExternalSourceReference,
+} from '../catalog/catalogApi'
import type { ReleaseCoverImage, ReleaseLabel } from '../releases/releasesData'
export type TrackCredit = {
artistId?: string
role: CreditRole
+ roles?: CreditRole[]
artist: string
scope: string
}
@@ -67,6 +71,7 @@ export type TrackRecord = {
relations: TrackRelation[]
fileMetadata: LocalFileMetadata
ratings?: EntityRating[]
+ externalSources?: ExternalSourceReference[]
}
export const trackRecords: TrackRecord[] = [
diff --git a/src/styles/responsive.css b/src/styles/responsive.css
index 9ba27f0..73ae9e5 100644
--- a/src/styles/responsive.css
+++ b/src/styles/responsive.css
@@ -263,6 +263,10 @@
min-width: 0;
}
+ .table-scroll > .catalog-table {
+ min-width: 0;
+ }
+
.catalog-table thead {
display: none;
}
diff --git a/src/test/appTestHarness.ts b/src/test/appTestHarness.ts
index 87540e8..44b165b 100644
--- a/src/test/appTestHarness.ts
+++ b/src/test/appTestHarness.ts
@@ -486,9 +486,11 @@ export async function addReleaseArtist(
) {
await user.type(within(form).getByLabelText('Release artist'), name)
await user.click(within(form).getByRole('button', { name: 'Add artist' }))
- await user.selectOptions(
- within(form).getByLabelText(`Role for ${name}`),
- role,
+ await user.click(within(form).getByLabelText(`Role for ${name}`))
+ await user.click(
+ within(form).getByRole('menuitem', {
+ name: role,
+ }),
)
}
diff --git a/vitest.config.ts b/vitest.config.ts
index b2cd25d..a837bfc 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -3,7 +3,9 @@ import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
environment: 'jsdom',
+ fileParallelism: false,
globals: true,
setupFiles: './src/test/setup.ts',
+ testTimeout: 10_000,
},
})