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:
parent
400d0dd48a
commit
caa6534ee8
6 changed files with 109 additions and 8 deletions
|
|
@ -56,6 +56,12 @@ def search_kind(client, query: str, kind: str, source_name: Optional[str] = None
|
||||||
"release_date": album.release_date,
|
"release_date": album.release_date,
|
||||||
"total_tracks": album.total_tracks,
|
"total_tracks": album.total_tracks,
|
||||||
"album_type": album.album_type,
|
"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 {},
|
"external_urls": album.external_urls or {},
|
||||||
})
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,9 @@ class _Artist:
|
||||||
|
|
||||||
class _Album:
|
class _Album:
|
||||||
def __init__(self, id_, name, artists=None, image_url=None, release_date=None,
|
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.id = id_
|
||||||
self.name = name
|
self.name = name
|
||||||
self.artists = artists or []
|
self.artists = artists or []
|
||||||
|
|
@ -28,6 +30,12 @@ class _Album:
|
||||||
self.total_tracks = total_tracks
|
self.total_tracks = total_tracks
|
||||||
self.album_type = album_type
|
self.album_type = album_type
|
||||||
self.external_urls = external_urls
|
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:
|
class _Track:
|
||||||
|
|
@ -99,6 +107,27 @@ def test_search_kind_albums_handles_no_artists():
|
||||||
assert result[0]['artist'] == 'Unknown Artist'
|
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():
|
def test_search_kind_tracks_returns_full_shape():
|
||||||
client = _Client(tracks=[_Track('t1', 'Money', artists=['Pink Floyd'], album='DSOTM',
|
client = _Client(tracks=[_Track('t1', 'Money', artists=['Pink Floyd'], album='DSOTM',
|
||||||
duration_ms=383000, image_url='m.jpg',
|
duration_ms=383000, image_url='m.jpg',
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,11 @@ export interface ImportAlbumResult {
|
||||||
image_url?: string | null;
|
image_url?: string | null;
|
||||||
total_tracks?: number | null;
|
total_tracks?: number | null;
|
||||||
release_date?: string | null;
|
release_date?: string | null;
|
||||||
|
format?: string | null;
|
||||||
|
country?: string | null;
|
||||||
|
disambiguation?: string | null;
|
||||||
|
status?: string | null;
|
||||||
|
label?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ImportAlbumSearchPayload {
|
export interface ImportAlbumSearchPayload {
|
||||||
|
|
@ -98,6 +103,11 @@ export interface ImportAlbum {
|
||||||
image_url?: string | null;
|
image_url?: string | null;
|
||||||
total_tracks?: number | null;
|
total_tracks?: number | null;
|
||||||
release_date?: string | null;
|
release_date?: string | null;
|
||||||
|
format?: string | null;
|
||||||
|
country?: string | null;
|
||||||
|
disambiguation?: string | null;
|
||||||
|
status?: string | null;
|
||||||
|
label?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ImportAlbumTrack {
|
export interface ImportAlbumTrack {
|
||||||
|
|
|
||||||
|
|
@ -97,6 +97,11 @@ describe('import route', () => {
|
||||||
source: 'deezer',
|
source: 'deezer',
|
||||||
total_tracks: 1,
|
total_tracks: 1,
|
||||||
release_date: '2026-01-01',
|
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',
|
source: 'deezer',
|
||||||
total_tracks: 1,
|
total_tracks: 1,
|
||||||
release_date: '2026-01-01',
|
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',
|
source: 'deezer',
|
||||||
total_tracks: 1,
|
total_tracks: 1,
|
||||||
release_date: '2026-01-01',
|
release_date: '2026-01-01',
|
||||||
|
format: 'CD',
|
||||||
|
country: 'US',
|
||||||
|
disambiguation: '25th Anniversary Edition',
|
||||||
|
status: 'official',
|
||||||
|
label: 'MusicBrainz',
|
||||||
},
|
},
|
||||||
matches: [
|
matches: [
|
||||||
{
|
{
|
||||||
|
|
@ -196,6 +211,10 @@ describe('import route', () => {
|
||||||
await waitFor(() => expect(screen.getByTestId('import-page')).toBeInTheDocument());
|
await waitFor(() => expect(screen.getByTestId('import-page')).toBeInTheDocument());
|
||||||
expect(await screen.findByText('Import Music')).toBeInTheDocument();
|
expect(await screen.findByText('Import Music')).toBeInTheDocument();
|
||||||
expect(screen.getByText('Import: /music/Staging')).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(
|
expect(
|
||||||
await screen.findByText('Showing Deezer results - not from your primary source (Spotify).'),
|
await screen.findByText('Showing Deezer results - not from your primary source (Spotify).'),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
|
|
|
||||||
|
|
@ -216,6 +216,16 @@ function useAlbumImportViewModel() {
|
||||||
|
|
||||||
type AlbumImportViewModel = ReturnType<typeof 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() {
|
export function AlbumImportTab() {
|
||||||
const viewModel = useAlbumImportViewModel();
|
const viewModel = useAlbumImportViewModel();
|
||||||
|
|
||||||
|
|
@ -394,6 +404,8 @@ function AlbumCard({
|
||||||
onSelect: (album: ImportAlbumResult) => void;
|
onSelect: (album: ImportAlbumResult) => void;
|
||||||
}) {
|
}) {
|
||||||
const resultSourceBadge = getImportSourceBadgeText(album.source, lookupSource);
|
const resultSourceBadge = getImportSourceBadgeText(album.source, lookupSource);
|
||||||
|
const metaParts = getAlbumMetaParts(album);
|
||||||
|
const detailParts = getAlbumDetailParts(album);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button type="button" className={styles.importPageAlbumCard} onClick={() => onSelect(album)}>
|
<button type="button" className={styles.importPageAlbumCard} onClick={() => onSelect(album)}>
|
||||||
|
|
@ -409,9 +421,10 @@ function AlbumCard({
|
||||||
<div className={styles.importPageAlbumCardArtist} title={album.artist}>
|
<div className={styles.importPageAlbumCardArtist} title={album.artist}>
|
||||||
{album.artist}
|
{album.artist}
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.importPageAlbumCardMeta}>
|
<div className={styles.importPageAlbumCardMeta}>{metaParts.join(' · ')}</div>
|
||||||
{album.total_tracks || 0} tracks · {album.release_date?.substring(0, 4) || ''}
|
{detailParts.length > 0 ? (
|
||||||
</div>
|
<div className={styles.importPageAlbumCardDetail}>{detailParts.join(' · ')}</div>
|
||||||
|
) : null}
|
||||||
{resultSourceBadge ? (
|
{resultSourceBadge ? (
|
||||||
<div className={styles.importPageAlbumCardSource}>{resultSourceBadge}</div>
|
<div className={styles.importPageAlbumCardSource}>{resultSourceBadge}</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
@ -448,6 +461,7 @@ function AlbumMatchPanel({ viewModel }: { viewModel: AlbumImportViewModel }) {
|
||||||
matchOverrides,
|
matchOverrides,
|
||||||
);
|
);
|
||||||
const matchedCount = effectiveMatches.length;
|
const matchedCount = effectiveMatches.length;
|
||||||
|
const heroMetaParts = albumMatch?.album ? getAlbumMetaParts(albumMatch.album) : [];
|
||||||
|
|
||||||
return albumMatchLoading ? (
|
return albumMatchLoading ? (
|
||||||
<div className={styles.importPageEmptyState}>Matching files to tracklist...</div>
|
<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.importPageAlbumHeroInfo}>
|
||||||
<div className={styles.importPageAlbumHeroTitle}>{albumMatch.album.name}</div>
|
<div className={styles.importPageAlbumHeroTitle}>{albumMatch.album.name}</div>
|
||||||
<div className={styles.importPageAlbumHeroArtist}>{albumMatch.album.artist}</div>
|
<div className={styles.importPageAlbumHeroArtist}>{albumMatch.album.artist}</div>
|
||||||
<div className={styles.importPageAlbumHeroMeta}>
|
<div className={styles.importPageAlbumHeroMeta}>{heroMetaParts.join(' · ')}</div>
|
||||||
{albumMatch.album.total_tracks || albumMatch.matches?.length || 0} tracks ·{' '}
|
|
||||||
{albumMatch.album.release_date?.substring(0, 4) || ''}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -617,3 +628,17 @@ function AlbumMatchPanel({ viewModel }: { viewModel: AlbumImportViewModel }) {
|
||||||
<div className={styles.importPageEmptyState}>Select an album to start matching files.</div>
|
<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);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -235,6 +235,18 @@
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: rgba(255, 255, 255, 0.3);
|
color: rgba(255, 255, 255, 0.3);
|
||||||
margin-top: 4px;
|
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 {
|
.importPageAlbumCardSource {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue