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,
|
||||
"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:
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue