feat(import): show MusicBrainz variants

- pass release metadata through album search normalization
- surface release format, country, label, and disambiguation in React import cards
- add coverage for search normalization and import route rendering
This commit is contained in:
Antti Kettunen 2026-05-24 21:29:22 +03:00
parent 400d0dd48a
commit caa6534ee8
No known key found for this signature in database
GPG key ID: C6B2A3D250359BD7
6 changed files with 109 additions and 8 deletions

View file

@ -56,6 +56,12 @@ def search_kind(client, query: str, kind: str, source_name: Optional[str] = None
"release_date": album.release_date,
"total_tracks": album.total_tracks,
"album_type": album.album_type,
"format": getattr(album, "format", None),
"country": getattr(album, "country", None),
"status": getattr(album, "status", None),
"label": getattr(album, "label", None),
"disambiguation": getattr(album, "disambiguation", None),
"release_group_id": getattr(album, "release_group_id", None),
"external_urls": album.external_urls or {},
})
except Exception as e:

View file

@ -19,7 +19,9 @@ class _Artist:
class _Album:
def __init__(self, id_, name, artists=None, image_url=None, release_date=None,
total_tracks=10, album_type='album', external_urls=None):
total_tracks=10, album_type='album', external_urls=None, format=None,
country=None, status=None, label=None, disambiguation=None,
release_group_id=None):
self.id = id_
self.name = name
self.artists = artists or []
@ -28,6 +30,12 @@ class _Album:
self.total_tracks = total_tracks
self.album_type = album_type
self.external_urls = external_urls
self.format = format
self.country = country
self.status = status
self.label = label
self.disambiguation = disambiguation
self.release_group_id = release_group_id
class _Track:
@ -99,6 +107,27 @@ def test_search_kind_albums_handles_no_artists():
assert result[0]['artist'] == 'Unknown Artist'
def test_search_kind_albums_passthrough_release_metadata():
client = _Client(albums=[_Album(
'a1',
'Variant',
artists=['Artist'],
format='CD',
country='US',
status='Official',
label='Fixture Records',
disambiguation='clean',
release_group_id='rg-1',
)])
result = sources.search_kind(client, 'v', 'albums')
assert result[0]['format'] == 'CD'
assert result[0]['country'] == 'US'
assert result[0]['status'] == 'Official'
assert result[0]['label'] == 'Fixture Records'
assert result[0]['disambiguation'] == 'clean'
assert result[0]['release_group_id'] == 'rg-1'
def test_search_kind_tracks_returns_full_shape():
client = _Client(tracks=[_Track('t1', 'Money', artists=['Pink Floyd'], album='DSOTM',
duration_ms=383000, image_url='m.jpg',

View file

@ -58,6 +58,11 @@ export interface ImportAlbumResult {
image_url?: string | null;
total_tracks?: number | null;
release_date?: string | null;
format?: string | null;
country?: string | null;
disambiguation?: string | null;
status?: string | null;
label?: string | null;
}
export interface ImportAlbumSearchPayload {
@ -98,6 +103,11 @@ export interface ImportAlbum {
image_url?: string | null;
total_tracks?: number | null;
release_date?: string | null;
format?: string | null;
country?: string | null;
disambiguation?: string | null;
status?: string | null;
label?: string | null;
}
export interface ImportAlbumTrack {

View file

@ -97,6 +97,11 @@ describe('import route', () => {
source: 'deezer',
total_tracks: 1,
release_date: '2026-01-01',
format: 'CD',
country: 'US',
disambiguation: '25th Anniversary Edition',
status: 'official',
label: 'MusicBrainz',
},
],
});
@ -113,6 +118,11 @@ describe('import route', () => {
source: 'deezer',
total_tracks: 1,
release_date: '2026-01-01',
format: 'CD',
country: 'US',
disambiguation: '25th Anniversary Edition',
status: 'official',
label: 'MusicBrainz',
},
],
});
@ -130,6 +140,11 @@ describe('import route', () => {
source: 'deezer',
total_tracks: 1,
release_date: '2026-01-01',
format: 'CD',
country: 'US',
disambiguation: '25th Anniversary Edition',
status: 'official',
label: 'MusicBrainz',
},
matches: [
{
@ -196,6 +211,10 @@ describe('import route', () => {
await waitFor(() => expect(screen.getByTestId('import-page')).toBeInTheDocument());
expect(await screen.findByText('Import Music')).toBeInTheDocument();
expect(screen.getByText('Import: /music/Staging')).toBeInTheDocument();
expect(
await screen.findByText('1 tracks · 2026 · CD · US · 25th Anniversary Edition'),
).toBeInTheDocument();
expect(screen.getByText('official · MusicBrainz')).toBeInTheDocument();
expect(
await screen.findByText('Showing Deezer results - not from your primary source (Spotify).'),
).toBeInTheDocument();

View file

@ -216,6 +216,16 @@ function useAlbumImportViewModel() {
type AlbumImportViewModel = ReturnType<typeof useAlbumImportViewModel>;
type AlbumMetaFields = {
total_tracks?: number | null;
release_date?: string | null;
format?: string | null;
country?: string | null;
disambiguation?: string | null;
status?: string | null;
label?: string | null;
};
export function AlbumImportTab() {
const viewModel = useAlbumImportViewModel();
@ -394,6 +404,8 @@ function AlbumCard({
onSelect: (album: ImportAlbumResult) => void;
}) {
const resultSourceBadge = getImportSourceBadgeText(album.source, lookupSource);
const metaParts = getAlbumMetaParts(album);
const detailParts = getAlbumDetailParts(album);
return (
<button type="button" className={styles.importPageAlbumCard} onClick={() => onSelect(album)}>
@ -409,9 +421,10 @@ function AlbumCard({
<div className={styles.importPageAlbumCardArtist} title={album.artist}>
{album.artist}
</div>
<div className={styles.importPageAlbumCardMeta}>
{album.total_tracks || 0} tracks · {album.release_date?.substring(0, 4) || ''}
</div>
<div className={styles.importPageAlbumCardMeta}>{metaParts.join(' · ')}</div>
{detailParts.length > 0 ? (
<div className={styles.importPageAlbumCardDetail}>{detailParts.join(' · ')}</div>
) : null}
{resultSourceBadge ? (
<div className={styles.importPageAlbumCardSource}>{resultSourceBadge}</div>
) : null}
@ -448,6 +461,7 @@ function AlbumMatchPanel({ viewModel }: { viewModel: AlbumImportViewModel }) {
matchOverrides,
);
const matchedCount = effectiveMatches.length;
const heroMetaParts = albumMatch?.album ? getAlbumMetaParts(albumMatch.album) : [];
return albumMatchLoading ? (
<div className={styles.importPageEmptyState}>Matching files to tracklist...</div>
@ -467,10 +481,7 @@ function AlbumMatchPanel({ viewModel }: { viewModel: AlbumImportViewModel }) {
<div className={styles.importPageAlbumHeroInfo}>
<div className={styles.importPageAlbumHeroTitle}>{albumMatch.album.name}</div>
<div className={styles.importPageAlbumHeroArtist}>{albumMatch.album.artist}</div>
<div className={styles.importPageAlbumHeroMeta}>
{albumMatch.album.total_tracks || albumMatch.matches?.length || 0} tracks ·{' '}
{albumMatch.album.release_date?.substring(0, 4) || ''}
</div>
<div className={styles.importPageAlbumHeroMeta}>{heroMetaParts.join(' · ')}</div>
</div>
</div>
@ -617,3 +628,17 @@ function AlbumMatchPanel({ viewModel }: { viewModel: AlbumImportViewModel }) {
<div className={styles.importPageEmptyState}>Select an album to start matching files.</div>
);
}
function getAlbumMetaParts(album: AlbumMetaFields) {
return [
`${album.total_tracks || 0} tracks`,
album.release_date?.substring(0, 4) || '',
album.format || '',
album.country || '',
album.disambiguation || '',
].filter(Boolean);
}
function getAlbumDetailParts(album: AlbumMetaFields) {
return [album.status || '', album.label || ''].filter(Boolean);
}

View file

@ -235,6 +235,18 @@
font-size: 10px;
color: rgba(255, 255, 255, 0.3);
margin-top: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.importPageAlbumCardDetail {
font-size: 10px;
color: rgba(255, 255, 255, 0.36);
margin-top: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.importPageAlbumCardSource {