feat(webui): clarify import source metadata

- thread primary_source through album and track search payloads while keeping per-result source on the returned rows
- reuse the shared Notice primitive for fallback and error messaging in the import pages
- update the import route tests and shell route smoke coverage for the new behavior
This commit is contained in:
Antti Kettunen 2026-05-24 15:40:13 +03:00
parent 2a7c03f398
commit 9da8251e43
No known key found for this signature in database
GPG key ID: C6B2A3D250359BD7
9 changed files with 211 additions and 38 deletions

View file

@ -18,7 +18,9 @@ describe('shellRouteManifest', () => {
expect(resolveShellPageFromPath('/watchlist')).toBe('watchlist');
expect(resolveShellPageFromPath('/active-downloads')).toBe('active-downloads');
expect(resolveShellPageFromPath('/artist-detail')).toBeNull();
expect(resolveShellPageFromPath('/artist-detail/spotify/2YZyLoL8N0Wb9xBt1NhZWg')).toBe('artist-detail');
expect(resolveShellPageFromPath('/artist-detail/spotify/2YZyLoL8N0Wb9xBt1NhZWg')).toBe(
'artist-detail',
);
expect(resolveShellPageFromPath('/artists')).toBeNull();
});

View file

@ -11,6 +11,19 @@ import type {
export const IMPORT_PLACEHOLDER_IMAGE = '/static/placeholder.png';
const IMPORT_SOURCE_LABELS: Record<string, string> = {
amazon: 'Amazon Music',
deezer: 'Deezer',
discogs: 'Discogs',
hydrabase: 'Hydrabase',
itunes: 'Apple Music',
musicbrainz: 'MusicBrainz',
playlist: 'Playlist',
soulseek: 'Basic Search',
spotify: 'Spotify',
youtube_videos: 'Music Videos',
};
export function getStagingFileKey(file: ImportStagingFile): string {
return file.full_path;
}
@ -27,6 +40,46 @@ export function getStagingStatsText(files: ImportStagingFile[]): string {
return totalSize ? `${fileLabel} - ${formatImportBytes(totalSize)}` : fileLabel;
}
export function getImportSourceLabel(source: string | null | undefined): string {
if (!source) return '';
return IMPORT_SOURCE_LABELS[source.toLowerCase()] || source;
}
/**
* Label a fallback result row so it is obvious which provider actually returned it.
*/
export function getImportSourceBadgeText(
resultSource: string | null | undefined,
lookupSource: string | null | undefined,
): string {
if (!resultSource || !lookupSource) return '';
if (resultSource.toLowerCase() === lookupSource.toLowerCase()) return '';
return `via ${getImportSourceLabel(resultSource)}`;
}
/**
* Banner for a whole result set that came from a fallback provider rather than the lookup source.
*/
export function getImportSourceFallbackBanner(
results: Array<{ source: string }> | null | undefined,
lookupSource: string | null | undefined,
): string {
if (!lookupSource || !results?.length) return '';
const normalizedLookupSource = lookupSource.toLowerCase();
if (
!results.every(
(result) => result.source && result.source.toLowerCase() !== normalizedLookupSource,
)
) {
return '';
}
const resultSource = results[0]?.source;
if (!resultSource) return '';
return `Showing ${getImportSourceLabel(resultSource)} results - not from your primary source (${getImportSourceLabel(lookupSource)}).`;
}
export function getTrackDisplayInfo(match: ImportAlbumMatch, index: number) {
const track = match.track || match.spotify_track || {};
const rawTrackNumber = track.track_number ?? track.trackNumber ?? null;

View file

@ -10,6 +10,7 @@ import type {
ImportStagingFile,
ImportTrackResult,
} from './-import.types';
import { getStagingFileKey } from './-import.helpers';
export type SingleSearchState = {
@ -33,6 +34,8 @@ function createInitialWorkflowState() {
albumResults: null as ImportAlbumResult[] | null,
albumSearchError: null as string | null,
albumSearchLoading: false,
// Lookup source used to seed the album search. Result rows still carry their own `source`.
albumSearchLookupSource: null as string | null,
autoGroupFilePaths: null as string[] | null,
selectedAlbum: null as ImportAlbumResult | null,
albumMatch: null as ImportAlbumMatchPayload | null,
@ -82,6 +85,7 @@ export const useImportWorkflowStore = create(
albumResults: null,
albumSearchError: null,
albumSearchLoading: false,
albumSearchLookupSource: null,
autoGroupFilePaths: null,
selectedAlbum: null,
albumMatch: null,
@ -94,12 +98,15 @@ export const useImportWorkflowStore = create(
setAlbumResults: (albumResults: ImportAlbumResult[] | null) => set({ albumResults }),
setAlbumSearchError: (albumSearchError: string | null) => set({ albumSearchError }),
setAlbumSearchLoading: (albumSearchLoading: boolean) => set({ albumSearchLoading }),
setAlbumSearchLookupSource: (albumSearchLookupSource: string | null) =>
set({ albumSearchLookupSource }),
setAlbumSearchContext: (albumQuery: string, autoGroupFilePaths: string[] | null) => {
set({
albumQuery,
albumSearchLoading: true,
albumSearchError: null,
albumResults: null,
albumSearchLookupSource: null,
selectedAlbum: null,
albumMatch: null,
autoGroupFilePaths,
@ -219,6 +226,7 @@ export function useAlbumImportWorkflow() {
albumResults: state.albumResults,
albumSearchError: state.albumSearchError,
albumSearchLoading: state.albumSearchLoading,
albumSearchLookupSource: state.albumSearchLookupSource,
autoGroupFilePaths: state.autoGroupFilePaths,
clearAutoGroupFilePaths: state.clearAutoGroupFilePaths,
matchOverrides: state.matchOverrides,
@ -232,6 +240,7 @@ export function useAlbumImportWorkflow() {
setAlbumSearchContext: state.setAlbumSearchContext,
setAlbumSearchError: state.setAlbumSearchError,
setAlbumSearchLoading: state.setAlbumSearchLoading,
setAlbumSearchLookupSource: state.setAlbumSearchLookupSource,
setMatchOverrides: state.setMatchOverrides,
setSelectedAlbum: state.setSelectedAlbum,
})),

View file

@ -53,7 +53,8 @@ export interface ImportAlbumResult {
id: string;
name: string;
artist: string;
source?: string | null;
/** Provider that returned this result row. */
source: string;
image_url?: string | null;
total_tracks?: number | null;
release_date?: string | null;
@ -63,6 +64,8 @@ export interface ImportAlbumSearchPayload {
success: boolean;
albums?: ImportAlbumResult[];
suggestions?: ImportAlbumResult[];
/** Provider used to seed the lookup chain for this response. */
primary_source?: string | null;
ready?: boolean;
error?: string;
}
@ -72,7 +75,8 @@ export interface ImportTrackResult {
name: string;
artist: string;
album?: string | null;
source?: string | null;
/** Provider that returned this result row. */
source: string;
image_url?: string | null;
duration_ms?: number | null;
}
@ -80,6 +84,8 @@ export interface ImportTrackResult {
export interface ImportTrackSearchPayload {
success: boolean;
tracks?: ImportTrackResult[];
/** Provider used to seed the lookup chain for this response. */
primary_source?: string | null;
error?: string;
}
@ -87,7 +93,8 @@ export interface ImportAlbum {
id?: string | number | null;
name: string;
artist: string;
source?: string | null;
/** Provider used to resolve this selected album. */
source: string;
image_url?: string | null;
total_tracks?: number | null;
release_date?: string | null;

View file

@ -2,13 +2,13 @@ import { createMemoryHistory } from '@tanstack/react-router';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { createAppQueryClient } from '@/app/query-client';
import { AppRouterProvider, createAppRouter } from '@/app/router';
import { HttpResponse, http, server } from '@/test/msw';
import { createShellBridge } from '@/test/shell-bridge';
import { createAppQueryClient } from '@/app/query-client';
import { AppRouterProvider, createAppRouter } from '@/app/router';
import type { ImportStagingFile } from './-import.types';
import { resetImportWorkflowStore } from './-import.store';
function renderImportRoute(initialEntries = ['/import']) {
@ -86,6 +86,7 @@ describe('import route', () => {
return HttpResponse.json({
success: true,
ready: true,
primary_source: 'spotify',
suggestions: [
{
id: 'album-1',
@ -101,6 +102,7 @@ describe('import route', () => {
http.get('/api/import/search/albums', () => {
return HttpResponse.json({
success: true,
primary_source: 'spotify',
albums: [
{
id: 'album-1',
@ -192,11 +194,17 @@ 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('Showing Deezer results - not from your primary source (Spotify).'),
).toBeInTheDocument();
expect(screen.getByText('via Deezer')).toBeInTheDocument();
await waitFor(() => expect(history.location.pathname).toBe('/import/album'));
await waitFor(() =>
expect(getFetchUrls().some((url) => url.includes('/api/import/staging/groups'))).toBe(true),
);
expect(getFetchUrls().some((url) => url.includes('/api/import/staging/suggestions'))).toBe(true);
expect(getFetchUrls().some((url) => url.includes('/api/import/staging/suggestions'))).toBe(
true,
);
expect(window.SoulSyncWebShellBridge?.showReactHost).toHaveBeenCalledWith('import');
expect(window.SoulSyncWebShellBridge?.setActivePageChrome).toHaveBeenCalledWith('import');
});
@ -265,6 +273,40 @@ describe('import route', () => {
});
});
it('surfaces the served source when album search falls back', async () => {
server.use(
http.get('/api/import/search/albums', () => {
return HttpResponse.json({
success: true,
primary_source: 'spotify',
albums: [
{
id: 'album-2',
name: 'Album A',
artist: 'Artist A',
source: 'musicbrainz',
total_tracks: 1,
release_date: '2026-01-01',
},
],
});
}),
);
renderImportRoute();
const searchInput = await screen.findByPlaceholderText('Search for an album...');
fireEvent.change(searchInput, { target: { value: 'Album A' } });
fireEvent.click(screen.getByRole('button', { name: 'Search' }));
expect(
await screen.findByText(
'Showing MusicBrainz results - not from your primary source (Spotify).',
),
).toBeInTheDocument();
expect(screen.getByText('via MusicBrainz')).toBeInTheDocument();
});
it('renders auto-import results from route search state', async () => {
renderImportRoute(['/import/auto?autoFilter=pending']);

View file

@ -1,8 +1,9 @@
import { useQuery } from '@tanstack/react-query';
import { type DragEvent, type KeyboardEvent, useState } from 'react';
import clsx from 'clsx';
import { type DragEvent, type KeyboardEvent, useState } from 'react';
import { Button, TextInput } from '@/components/form/form';
import { Notice } from '@/components/primitives';
import type { ImportAlbumResult } from '../-import.types';
@ -15,6 +16,8 @@ import {
import {
getDisplayedMatchFile,
getEffectiveAlbumMatches,
getImportSourceBadgeText,
getImportSourceFallbackBanner,
getTrackDisplayInfo,
getUnmatchedStagingFiles,
IMPORT_PLACEHOLDER_IMAGE,
@ -47,6 +50,7 @@ function useAlbumImportViewModel() {
albumResults,
albumSearchError,
albumSearchLoading,
albumSearchLookupSource,
autoGroupFilePaths,
clearAutoGroupFilePaths,
matchOverrides,
@ -60,6 +64,7 @@ function useAlbumImportViewModel() {
setAlbumSearchContext,
setAlbumSearchError,
setAlbumSearchLoading,
setAlbumSearchLookupSource,
setMatchOverrides,
setSelectedAlbum,
} = useAlbumImportWorkflow();
@ -68,7 +73,7 @@ function useAlbumImportViewModel() {
setDragOverTrack(null);
setTapSelectedChip(null);
resetAlbumWorkflow();
refreshStaging();
void refreshStaging();
};
const runAlbumSearch = async (query: string, filePaths: string[] | null = null) => {
@ -80,6 +85,7 @@ function useAlbumImportViewModel() {
try {
const payload = await searchImportAlbums(trimmed);
setAlbumResults(payload.albums ?? []);
setAlbumSearchLookupSource(payload.primary_source ?? null);
} catch (error) {
setAlbumSearchError(getErrorMessage(error));
} finally {
@ -94,6 +100,8 @@ function useAlbumImportViewModel() {
setAlbumMatchLoading(true);
try {
// Pass the source that returned this result row so matching keeps using the
// same provider even if the search fell back from the lookup source.
const payload = await matchImportAlbum({
albumId: album.id,
source: album.source,
@ -165,6 +173,7 @@ function useAlbumImportViewModel() {
albumResults,
albumSearchError,
albumSearchLoading,
albumSearchLookupSource,
dragOverTrack,
groups: groupsQuery.data?.groups ?? [],
matchOverrides,
@ -200,6 +209,7 @@ function useAlbumImportViewModel() {
stagingFiles,
suggestions: suggestionsQuery.data?.suggestions ?? [],
suggestionsReady: suggestionsQuery.data?.ready ?? true,
suggestionsLookupSource: suggestionsQuery.data?.primary_source ?? null,
tapSelectedChip,
};
}
@ -221,6 +231,7 @@ function AlbumImportPanelContent({ viewModel }: { viewModel: AlbumImportViewMode
albumResults,
albumSearchError,
albumSearchLoading,
albumSearchLookupSource,
groups,
onAlbumQueryChange,
onBackToSearch,
@ -230,13 +241,25 @@ function AlbumImportPanelContent({ viewModel }: { viewModel: AlbumImportViewMode
selectedAlbum,
suggestions,
suggestionsReady,
suggestionsLookupSource,
} = viewModel;
const showingMatch = selectedAlbum || albumMatchLoading || albumMatchError || albumMatch;
const suggestionsFallbackBanner = getImportSourceFallbackBanner(
suggestions,
suggestionsLookupSource,
);
const albumResultsFallbackBanner = getImportSourceFallbackBanner(
albumResults,
albumSearchLookupSource,
);
return (
<>
<div id="import-page-album-search-section" className={clsx({ [styles.hidden]: showingMatch })}>
<div
id="import-page-album-search-section"
className={clsx({ [styles.hidden]: showingMatch })}
>
{albumResults === null && (
<>
{groups.length > 0 && (
@ -266,12 +289,16 @@ function AlbumImportPanelContent({ viewModel }: { viewModel: AlbumImportViewMode
<div className={styles.importPageSuggestions} id="import-page-suggestions">
<div className={styles.importPageSectionLabel}>Suggested from your import folder</div>
{suggestionsFallbackBanner ? (
<Notice tone="warning">{suggestionsFallbackBanner}</Notice>
) : null}
<div className={styles.importPageAlbumGrid} id="import-page-suggestions-grid">
{suggestions.length > 0 ? (
suggestions.map((album) => (
<AlbumCard
key={`${album.source || 'source'}-${album.id}`}
album={album}
lookupSource={suggestionsLookupSource}
onSelect={onSelectAlbum}
/>
))
@ -310,11 +337,16 @@ function AlbumImportPanelContent({ viewModel }: { viewModel: AlbumImportViewMode
</Button>
</div>
{albumResultsFallbackBanner ? (
<Notice tone="warning">{albumResultsFallbackBanner}</Notice>
) : null}
<div className={styles.importPageAlbumGrid} id="import-page-album-results">
{albumSearchLoading ? (
<div className={styles.importPageEmptyState}>Searching...</div>
) : albumSearchError ? (
<div className={styles.importPageEmptyState}>Error: {albumSearchError}</div>
<Notice tone="danger" role="alert">
Error: {albumSearchError}
</Notice>
) : albumResults?.length === 0 ? (
<div className={styles.importPageEmptyState}>No albums found</div>
) : (
@ -322,6 +354,7 @@ function AlbumImportPanelContent({ viewModel }: { viewModel: AlbumImportViewMode
<AlbumCard
key={`${album.source || 'source'}-${album.id}`}
album={album}
lookupSource={albumSearchLookupSource}
onSelect={onSelectAlbum}
/>
))
@ -329,11 +362,16 @@ function AlbumImportPanelContent({ viewModel }: { viewModel: AlbumImportViewMode
</div>
</div>
<div id="import-page-album-match-section" className={clsx({ [styles.hidden]: !showingMatch })}>
<div
id="import-page-album-match-section"
className={clsx({ [styles.hidden]: !showingMatch })}
>
{albumMatchLoading ? (
<div className={styles.importPageEmptyState}>Matching files to tracklist...</div>
) : albumMatchError ? (
<div className={styles.importPageEmptyState}>Error: {albumMatchError}</div>
<Notice tone="danger" role="alert">
Error: {albumMatchError}
</Notice>
) : albumMatch?.album ? (
<AlbumMatchPanel viewModel={viewModel} />
) : (
@ -348,13 +386,17 @@ function AlbumImportPanelContent({ viewModel }: { viewModel: AlbumImportViewMode
function AlbumCard({
album,
lookupSource,
onSelect,
}: {
album: ImportAlbumResult;
lookupSource: string | null;
onSelect: (album: ImportAlbumResult) => void;
}) {
const resultSourceBadge = getImportSourceBadgeText(album.source, lookupSource);
return (
<button className={styles.importPageAlbumCard} onClick={() => onSelect(album)}>
<button type="button" className={styles.importPageAlbumCard} onClick={() => onSelect(album)}>
<img
src={album.image_url || IMPORT_PLACEHOLDER_IMAGE}
alt={album.name}
@ -370,6 +412,9 @@ function AlbumCard({
<div className={styles.importPageAlbumCardMeta}>
{album.total_tracks || 0} tracks · {album.release_date?.substring(0, 4) || ''}
</div>
{resultSourceBadge ? (
<div className={styles.importPageAlbumCardSource}>{resultSourceBadge}</div>
) : null}
</button>
);
}
@ -407,7 +452,9 @@ function AlbumMatchPanel({ viewModel }: { viewModel: AlbumImportViewModel }) {
return albumMatchLoading ? (
<div className={styles.importPageEmptyState}>Matching files to tracklist...</div>
) : albumMatchError ? (
<div className={styles.importPageEmptyState}>Error: {albumMatchError}</div>
<Notice tone="danger" role="alert">
Error: {albumMatchError}
</Notice>
) : albumMatch?.album ? (
<>
<div className={styles.importPageAlbumHero} id="import-page-album-hero">
@ -474,9 +521,11 @@ function AlbumMatchPanel({ viewModel }: { viewModel: AlbumImportViewModel }) {
>
<span className={styles.importPageMatchNum}>{trackInfo.displayTrackNumber}</span>
<span className={styles.importPageMatchTrack}>{trackInfo.name}</span>
<span className={clsx(styles.importPageMatchFile, {
[styles.hasFile]: file,
})}>
<span
className={clsx(styles.importPageMatchFile, {
[styles.hasFile]: file,
})}
>
{file ? (
<>
<span className={styles.importPageMatchFileName}>{file.filename}</span>

View file

@ -237,6 +237,13 @@
margin-top: 4px;
}
.importPageAlbumCardSource {
font-size: 10px;
color: rgba(255, 200, 100, 0.75);
margin-top: 2px;
font-style: italic;
}
/* Album hero (selected album) */
.importPageAlbumHero {
display: flex;
@ -639,11 +646,11 @@
color: rgba(255, 255, 255, 0.4);
}
.importPageSingleResultSelect {
padding: 4px 12px;
background: rgba(var(--accent-light-rgb), 0.15);
border: 1px solid rgba(var(--accent-light-rgb), 0.3);
border-radius: 6px;
.importPageSingleResultSelect {
padding: 4px 12px;
background: rgba(var(--accent-light-rgb), 0.15);
border: 1px solid rgba(var(--accent-light-rgb), 0.3);
border-radius: 6px;
color: rgb(var(--accent-light-rgb));
font-size: 11px;
font-weight: 600;
@ -651,14 +658,14 @@
flex-shrink: 0;
}
.importPageSingleResultSelect:hover {
background: rgba(var(--accent-light-rgb), 0.25);
}
.importPageSingleResultSelect:hover {
background: rgba(var(--accent-light-rgb), 0.25);
}
/* Empty state */
.importPageEmptyState {
text-align: center;
padding: 60px 20px;
/* Empty state */
.importPageEmptyState {
text-align: center;
padding: 60px 20px;
color: rgba(255, 255, 255, 0.3);
font-size: 14px;
}
@ -821,8 +828,8 @@
.importPageMatchRow {
grid-template-columns: 28px minmax(0, 1fr) auto;
grid-template-areas:
"num track track"
"num file unmatch";
'num track track'
'num file unmatch';
gap: 6px 8px;
align-items: center;
}
@ -853,8 +860,8 @@
.importPageSingleItem {
grid-template-columns: 28px minmax(0, 1fr) auto;
grid-template-areas:
"checkbox info actions"
"search search search";
'checkbox info actions'
'search search search';
align-items: start;
gap: 8px;
}

View file

@ -140,7 +140,8 @@ function ImportProcessingQueue() {
function ImportQueueItem({ entry }: { entry: ImportQueueEntry }) {
const statusText = getQueueStatusText(entry);
const statusClass = clsx({
[styles.error]: entry.status === 'error' || (entry.status === 'done' && entry.errors.length > 0),
[styles.error]:
entry.status === 'error' || (entry.status === 'done' && entry.errors.length > 0),
[styles.done]: entry.status === 'done',
});

View file

@ -2,6 +2,7 @@ import clsx from 'clsx';
import { useEffect } from 'react';
import { Badge, Button, Checkbox, TextInput } from '@/components/form/form';
import { Notice } from '@/components/primitives';
import type { SingleSearchState } from '../-import.store';
import type { ImportTrackResult } from '../-import.types';
@ -113,7 +114,7 @@ export function SinglesImportTab() {
});
clearSinglesSelection();
refreshStaging();
void refreshStaging();
};
return (
@ -295,7 +296,9 @@ function SingleSearchPanel({
{searchState?.loading ? (
<div className={styles.importPageEmptyState}>Searching...</div>
) : searchState?.error ? (
<div className={styles.importPageEmptyState}>Error: {searchState.error}</div>
<Notice tone="danger" role="alert">
Error: {searchState.error}
</Notice>
) : searchState?.results.length === 0 ? (
<div className={styles.importPageEmptyState}>No results found</div>
) : (