diff --git a/webui/src/platform/shell/route-manifest.test.ts b/webui/src/platform/shell/route-manifest.test.ts index e4f8c978..f3a77d45 100644 --- a/webui/src/platform/shell/route-manifest.test.ts +++ b/webui/src/platform/shell/route-manifest.test.ts @@ -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(); }); diff --git a/webui/src/routes/import/-import.helpers.ts b/webui/src/routes/import/-import.helpers.ts index 9a1bd6e4..6f4f8003 100644 --- a/webui/src/routes/import/-import.helpers.ts +++ b/webui/src/routes/import/-import.helpers.ts @@ -11,6 +11,19 @@ import type { export const IMPORT_PLACEHOLDER_IMAGE = '/static/placeholder.png'; +const IMPORT_SOURCE_LABELS: Record = { + 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; diff --git a/webui/src/routes/import/-import.store.ts b/webui/src/routes/import/-import.store.ts index fe7ea66f..f6bb892b 100644 --- a/webui/src/routes/import/-import.store.ts +++ b/webui/src/routes/import/-import.store.ts @@ -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, })), diff --git a/webui/src/routes/import/-import.types.ts b/webui/src/routes/import/-import.types.ts index 9767ff24..7192336c 100644 --- a/webui/src/routes/import/-import.types.ts +++ b/webui/src/routes/import/-import.types.ts @@ -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; diff --git a/webui/src/routes/import/-route.test.tsx b/webui/src/routes/import/-route.test.tsx index 5825e38c..35566c14 100644 --- a/webui/src/routes/import/-route.test.tsx +++ b/webui/src/routes/import/-route.test.tsx @@ -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']); diff --git a/webui/src/routes/import/-ui/album-import-tab.tsx b/webui/src/routes/import/-ui/album-import-tab.tsx index f74b79ae..28602a3c 100644 --- a/webui/src/routes/import/-ui/album-import-tab.tsx +++ b/webui/src/routes/import/-ui/album-import-tab.tsx @@ -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 ( <> -
+
{albumResults === null && ( <> {groups.length > 0 && ( @@ -266,12 +289,16 @@ function AlbumImportPanelContent({ viewModel }: { viewModel: AlbumImportViewMode
Suggested from your import folder
+ {suggestionsFallbackBanner ? ( + {suggestionsFallbackBanner} + ) : null}
{suggestions.length > 0 ? ( suggestions.map((album) => ( )) @@ -310,11 +337,16 @@ function AlbumImportPanelContent({ viewModel }: { viewModel: AlbumImportViewMode
+ {albumResultsFallbackBanner ? ( + {albumResultsFallbackBanner} + ) : null}
{albumSearchLoading ? (
Searching...
) : albumSearchError ? ( -
Error: {albumSearchError}
+ + Error: {albumSearchError} + ) : albumResults?.length === 0 ? (
No albums found
) : ( @@ -322,6 +354,7 @@ function AlbumImportPanelContent({ viewModel }: { viewModel: AlbumImportViewMode )) @@ -329,11 +362,16 @@ function AlbumImportPanelContent({ viewModel }: { viewModel: AlbumImportViewMode
-
+
{albumMatchLoading ? (
Matching files to tracklist...
) : albumMatchError ? ( -
Error: {albumMatchError}
+ + Error: {albumMatchError} + ) : albumMatch?.album ? ( ) : ( @@ -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 ( -
+ {resultSourceBadge ? ( +
{resultSourceBadge}
+ ) : null} ); } @@ -407,7 +452,9 @@ function AlbumMatchPanel({ viewModel }: { viewModel: AlbumImportViewModel }) { return albumMatchLoading ? (
Matching files to tracklist...
) : albumMatchError ? ( -
Error: {albumMatchError}
+ + Error: {albumMatchError} + ) : albumMatch?.album ? ( <>
@@ -474,9 +521,11 @@ function AlbumMatchPanel({ viewModel }: { viewModel: AlbumImportViewModel }) { > {trackInfo.displayTrackNumber} {trackInfo.name} - + {file ? ( <> {file.filename} diff --git a/webui/src/routes/import/-ui/import-page.module.css b/webui/src/routes/import/-ui/import-page.module.css index 39023257..88cc6ff4 100644 --- a/webui/src/routes/import/-ui/import-page.module.css +++ b/webui/src/routes/import/-ui/import-page.module.css @@ -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; } diff --git a/webui/src/routes/import/-ui/import-page.tsx b/webui/src/routes/import/-ui/import-page.tsx index 32501f51..a9483c3e 100644 --- a/webui/src/routes/import/-ui/import-page.tsx +++ b/webui/src/routes/import/-ui/import-page.tsx @@ -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', }); diff --git a/webui/src/routes/import/-ui/singles-import-tab.tsx b/webui/src/routes/import/-ui/singles-import-tab.tsx index 30256221..a636bdee 100644 --- a/webui/src/routes/import/-ui/singles-import-tab.tsx +++ b/webui/src/routes/import/-ui/singles-import-tab.tsx @@ -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 ? (
Searching...
) : searchState?.error ? ( -
Error: {searchState.error}
+ + Error: {searchState.error} + ) : searchState?.results.length === 0 ? (
No results found
) : (