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:
parent
2a7c03f398
commit
9da8251e43
9 changed files with 211 additions and 38 deletions
|
|
@ -18,7 +18,9 @@ describe('shellRouteManifest', () => {
|
||||||
expect(resolveShellPageFromPath('/watchlist')).toBe('watchlist');
|
expect(resolveShellPageFromPath('/watchlist')).toBe('watchlist');
|
||||||
expect(resolveShellPageFromPath('/active-downloads')).toBe('active-downloads');
|
expect(resolveShellPageFromPath('/active-downloads')).toBe('active-downloads');
|
||||||
expect(resolveShellPageFromPath('/artist-detail')).toBeNull();
|
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();
|
expect(resolveShellPageFromPath('/artists')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,19 @@ import type {
|
||||||
|
|
||||||
export const IMPORT_PLACEHOLDER_IMAGE = '/static/placeholder.png';
|
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 {
|
export function getStagingFileKey(file: ImportStagingFile): string {
|
||||||
return file.full_path;
|
return file.full_path;
|
||||||
}
|
}
|
||||||
|
|
@ -27,6 +40,46 @@ export function getStagingStatsText(files: ImportStagingFile[]): string {
|
||||||
return totalSize ? `${fileLabel} - ${formatImportBytes(totalSize)}` : fileLabel;
|
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) {
|
export function getTrackDisplayInfo(match: ImportAlbumMatch, index: number) {
|
||||||
const track = match.track || match.spotify_track || {};
|
const track = match.track || match.spotify_track || {};
|
||||||
const rawTrackNumber = track.track_number ?? track.trackNumber ?? null;
|
const rawTrackNumber = track.track_number ?? track.trackNumber ?? null;
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import type {
|
||||||
ImportStagingFile,
|
ImportStagingFile,
|
||||||
ImportTrackResult,
|
ImportTrackResult,
|
||||||
} from './-import.types';
|
} from './-import.types';
|
||||||
|
|
||||||
import { getStagingFileKey } from './-import.helpers';
|
import { getStagingFileKey } from './-import.helpers';
|
||||||
|
|
||||||
export type SingleSearchState = {
|
export type SingleSearchState = {
|
||||||
|
|
@ -33,6 +34,8 @@ function createInitialWorkflowState() {
|
||||||
albumResults: null as ImportAlbumResult[] | null,
|
albumResults: null as ImportAlbumResult[] | null,
|
||||||
albumSearchError: null as string | null,
|
albumSearchError: null as string | null,
|
||||||
albumSearchLoading: false,
|
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,
|
autoGroupFilePaths: null as string[] | null,
|
||||||
selectedAlbum: null as ImportAlbumResult | null,
|
selectedAlbum: null as ImportAlbumResult | null,
|
||||||
albumMatch: null as ImportAlbumMatchPayload | null,
|
albumMatch: null as ImportAlbumMatchPayload | null,
|
||||||
|
|
@ -82,6 +85,7 @@ export const useImportWorkflowStore = create(
|
||||||
albumResults: null,
|
albumResults: null,
|
||||||
albumSearchError: null,
|
albumSearchError: null,
|
||||||
albumSearchLoading: false,
|
albumSearchLoading: false,
|
||||||
|
albumSearchLookupSource: null,
|
||||||
autoGroupFilePaths: null,
|
autoGroupFilePaths: null,
|
||||||
selectedAlbum: null,
|
selectedAlbum: null,
|
||||||
albumMatch: null,
|
albumMatch: null,
|
||||||
|
|
@ -94,12 +98,15 @@ export const useImportWorkflowStore = create(
|
||||||
setAlbumResults: (albumResults: ImportAlbumResult[] | null) => set({ albumResults }),
|
setAlbumResults: (albumResults: ImportAlbumResult[] | null) => set({ albumResults }),
|
||||||
setAlbumSearchError: (albumSearchError: string | null) => set({ albumSearchError }),
|
setAlbumSearchError: (albumSearchError: string | null) => set({ albumSearchError }),
|
||||||
setAlbumSearchLoading: (albumSearchLoading: boolean) => set({ albumSearchLoading }),
|
setAlbumSearchLoading: (albumSearchLoading: boolean) => set({ albumSearchLoading }),
|
||||||
|
setAlbumSearchLookupSource: (albumSearchLookupSource: string | null) =>
|
||||||
|
set({ albumSearchLookupSource }),
|
||||||
setAlbumSearchContext: (albumQuery: string, autoGroupFilePaths: string[] | null) => {
|
setAlbumSearchContext: (albumQuery: string, autoGroupFilePaths: string[] | null) => {
|
||||||
set({
|
set({
|
||||||
albumQuery,
|
albumQuery,
|
||||||
albumSearchLoading: true,
|
albumSearchLoading: true,
|
||||||
albumSearchError: null,
|
albumSearchError: null,
|
||||||
albumResults: null,
|
albumResults: null,
|
||||||
|
albumSearchLookupSource: null,
|
||||||
selectedAlbum: null,
|
selectedAlbum: null,
|
||||||
albumMatch: null,
|
albumMatch: null,
|
||||||
autoGroupFilePaths,
|
autoGroupFilePaths,
|
||||||
|
|
@ -219,6 +226,7 @@ export function useAlbumImportWorkflow() {
|
||||||
albumResults: state.albumResults,
|
albumResults: state.albumResults,
|
||||||
albumSearchError: state.albumSearchError,
|
albumSearchError: state.albumSearchError,
|
||||||
albumSearchLoading: state.albumSearchLoading,
|
albumSearchLoading: state.albumSearchLoading,
|
||||||
|
albumSearchLookupSource: state.albumSearchLookupSource,
|
||||||
autoGroupFilePaths: state.autoGroupFilePaths,
|
autoGroupFilePaths: state.autoGroupFilePaths,
|
||||||
clearAutoGroupFilePaths: state.clearAutoGroupFilePaths,
|
clearAutoGroupFilePaths: state.clearAutoGroupFilePaths,
|
||||||
matchOverrides: state.matchOverrides,
|
matchOverrides: state.matchOverrides,
|
||||||
|
|
@ -232,6 +240,7 @@ export function useAlbumImportWorkflow() {
|
||||||
setAlbumSearchContext: state.setAlbumSearchContext,
|
setAlbumSearchContext: state.setAlbumSearchContext,
|
||||||
setAlbumSearchError: state.setAlbumSearchError,
|
setAlbumSearchError: state.setAlbumSearchError,
|
||||||
setAlbumSearchLoading: state.setAlbumSearchLoading,
|
setAlbumSearchLoading: state.setAlbumSearchLoading,
|
||||||
|
setAlbumSearchLookupSource: state.setAlbumSearchLookupSource,
|
||||||
setMatchOverrides: state.setMatchOverrides,
|
setMatchOverrides: state.setMatchOverrides,
|
||||||
setSelectedAlbum: state.setSelectedAlbum,
|
setSelectedAlbum: state.setSelectedAlbum,
|
||||||
})),
|
})),
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,8 @@ export interface ImportAlbumResult {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
artist: string;
|
artist: string;
|
||||||
source?: string | null;
|
/** Provider that returned this result row. */
|
||||||
|
source: string;
|
||||||
image_url?: string | null;
|
image_url?: string | null;
|
||||||
total_tracks?: number | null;
|
total_tracks?: number | null;
|
||||||
release_date?: string | null;
|
release_date?: string | null;
|
||||||
|
|
@ -63,6 +64,8 @@ export interface ImportAlbumSearchPayload {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
albums?: ImportAlbumResult[];
|
albums?: ImportAlbumResult[];
|
||||||
suggestions?: ImportAlbumResult[];
|
suggestions?: ImportAlbumResult[];
|
||||||
|
/** Provider used to seed the lookup chain for this response. */
|
||||||
|
primary_source?: string | null;
|
||||||
ready?: boolean;
|
ready?: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
@ -72,7 +75,8 @@ export interface ImportTrackResult {
|
||||||
name: string;
|
name: string;
|
||||||
artist: string;
|
artist: string;
|
||||||
album?: string | null;
|
album?: string | null;
|
||||||
source?: string | null;
|
/** Provider that returned this result row. */
|
||||||
|
source: string;
|
||||||
image_url?: string | null;
|
image_url?: string | null;
|
||||||
duration_ms?: number | null;
|
duration_ms?: number | null;
|
||||||
}
|
}
|
||||||
|
|
@ -80,6 +84,8 @@ export interface ImportTrackResult {
|
||||||
export interface ImportTrackSearchPayload {
|
export interface ImportTrackSearchPayload {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
tracks?: ImportTrackResult[];
|
tracks?: ImportTrackResult[];
|
||||||
|
/** Provider used to seed the lookup chain for this response. */
|
||||||
|
primary_source?: string | null;
|
||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -87,7 +93,8 @@ export interface ImportAlbum {
|
||||||
id?: string | number | null;
|
id?: string | number | null;
|
||||||
name: string;
|
name: string;
|
||||||
artist: string;
|
artist: string;
|
||||||
source?: string | null;
|
/** Provider used to resolve this selected album. */
|
||||||
|
source: string;
|
||||||
image_url?: string | null;
|
image_url?: string | null;
|
||||||
total_tracks?: number | null;
|
total_tracks?: number | null;
|
||||||
release_date?: string | null;
|
release_date?: string | null;
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,13 @@ import { createMemoryHistory } from '@tanstack/react-router';
|
||||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
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 { HttpResponse, http, server } from '@/test/msw';
|
||||||
import { createShellBridge } from '@/test/shell-bridge';
|
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 type { ImportStagingFile } from './-import.types';
|
||||||
|
|
||||||
import { resetImportWorkflowStore } from './-import.store';
|
import { resetImportWorkflowStore } from './-import.store';
|
||||||
|
|
||||||
function renderImportRoute(initialEntries = ['/import']) {
|
function renderImportRoute(initialEntries = ['/import']) {
|
||||||
|
|
@ -86,6 +86,7 @@ describe('import route', () => {
|
||||||
return HttpResponse.json({
|
return HttpResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
ready: true,
|
ready: true,
|
||||||
|
primary_source: 'spotify',
|
||||||
suggestions: [
|
suggestions: [
|
||||||
{
|
{
|
||||||
id: 'album-1',
|
id: 'album-1',
|
||||||
|
|
@ -101,6 +102,7 @@ describe('import route', () => {
|
||||||
http.get('/api/import/search/albums', () => {
|
http.get('/api/import/search/albums', () => {
|
||||||
return HttpResponse.json({
|
return HttpResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
|
primary_source: 'spotify',
|
||||||
albums: [
|
albums: [
|
||||||
{
|
{
|
||||||
id: 'album-1',
|
id: 'album-1',
|
||||||
|
|
@ -192,11 +194,17 @@ 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('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(history.location.pathname).toBe('/import/album'));
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(getFetchUrls().some((url) => url.includes('/api/import/staging/groups'))).toBe(true),
|
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?.showReactHost).toHaveBeenCalledWith('import');
|
||||||
expect(window.SoulSyncWebShellBridge?.setActivePageChrome).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 () => {
|
it('renders auto-import results from route search state', async () => {
|
||||||
renderImportRoute(['/import/auto?autoFilter=pending']);
|
renderImportRoute(['/import/auto?autoFilter=pending']);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { type DragEvent, type KeyboardEvent, useState } from 'react';
|
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
|
import { type DragEvent, type KeyboardEvent, useState } from 'react';
|
||||||
|
|
||||||
import { Button, TextInput } from '@/components/form/form';
|
import { Button, TextInput } from '@/components/form/form';
|
||||||
|
import { Notice } from '@/components/primitives';
|
||||||
|
|
||||||
import type { ImportAlbumResult } from '../-import.types';
|
import type { ImportAlbumResult } from '../-import.types';
|
||||||
|
|
||||||
|
|
@ -15,6 +16,8 @@ import {
|
||||||
import {
|
import {
|
||||||
getDisplayedMatchFile,
|
getDisplayedMatchFile,
|
||||||
getEffectiveAlbumMatches,
|
getEffectiveAlbumMatches,
|
||||||
|
getImportSourceBadgeText,
|
||||||
|
getImportSourceFallbackBanner,
|
||||||
getTrackDisplayInfo,
|
getTrackDisplayInfo,
|
||||||
getUnmatchedStagingFiles,
|
getUnmatchedStagingFiles,
|
||||||
IMPORT_PLACEHOLDER_IMAGE,
|
IMPORT_PLACEHOLDER_IMAGE,
|
||||||
|
|
@ -47,6 +50,7 @@ function useAlbumImportViewModel() {
|
||||||
albumResults,
|
albumResults,
|
||||||
albumSearchError,
|
albumSearchError,
|
||||||
albumSearchLoading,
|
albumSearchLoading,
|
||||||
|
albumSearchLookupSource,
|
||||||
autoGroupFilePaths,
|
autoGroupFilePaths,
|
||||||
clearAutoGroupFilePaths,
|
clearAutoGroupFilePaths,
|
||||||
matchOverrides,
|
matchOverrides,
|
||||||
|
|
@ -60,6 +64,7 @@ function useAlbumImportViewModel() {
|
||||||
setAlbumSearchContext,
|
setAlbumSearchContext,
|
||||||
setAlbumSearchError,
|
setAlbumSearchError,
|
||||||
setAlbumSearchLoading,
|
setAlbumSearchLoading,
|
||||||
|
setAlbumSearchLookupSource,
|
||||||
setMatchOverrides,
|
setMatchOverrides,
|
||||||
setSelectedAlbum,
|
setSelectedAlbum,
|
||||||
} = useAlbumImportWorkflow();
|
} = useAlbumImportWorkflow();
|
||||||
|
|
@ -68,7 +73,7 @@ function useAlbumImportViewModel() {
|
||||||
setDragOverTrack(null);
|
setDragOverTrack(null);
|
||||||
setTapSelectedChip(null);
|
setTapSelectedChip(null);
|
||||||
resetAlbumWorkflow();
|
resetAlbumWorkflow();
|
||||||
refreshStaging();
|
void refreshStaging();
|
||||||
};
|
};
|
||||||
|
|
||||||
const runAlbumSearch = async (query: string, filePaths: string[] | null = null) => {
|
const runAlbumSearch = async (query: string, filePaths: string[] | null = null) => {
|
||||||
|
|
@ -80,6 +85,7 @@ function useAlbumImportViewModel() {
|
||||||
try {
|
try {
|
||||||
const payload = await searchImportAlbums(trimmed);
|
const payload = await searchImportAlbums(trimmed);
|
||||||
setAlbumResults(payload.albums ?? []);
|
setAlbumResults(payload.albums ?? []);
|
||||||
|
setAlbumSearchLookupSource(payload.primary_source ?? null);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setAlbumSearchError(getErrorMessage(error));
|
setAlbumSearchError(getErrorMessage(error));
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -94,6 +100,8 @@ function useAlbumImportViewModel() {
|
||||||
setAlbumMatchLoading(true);
|
setAlbumMatchLoading(true);
|
||||||
|
|
||||||
try {
|
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({
|
const payload = await matchImportAlbum({
|
||||||
albumId: album.id,
|
albumId: album.id,
|
||||||
source: album.source,
|
source: album.source,
|
||||||
|
|
@ -165,6 +173,7 @@ function useAlbumImportViewModel() {
|
||||||
albumResults,
|
albumResults,
|
||||||
albumSearchError,
|
albumSearchError,
|
||||||
albumSearchLoading,
|
albumSearchLoading,
|
||||||
|
albumSearchLookupSource,
|
||||||
dragOverTrack,
|
dragOverTrack,
|
||||||
groups: groupsQuery.data?.groups ?? [],
|
groups: groupsQuery.data?.groups ?? [],
|
||||||
matchOverrides,
|
matchOverrides,
|
||||||
|
|
@ -200,6 +209,7 @@ function useAlbumImportViewModel() {
|
||||||
stagingFiles,
|
stagingFiles,
|
||||||
suggestions: suggestionsQuery.data?.suggestions ?? [],
|
suggestions: suggestionsQuery.data?.suggestions ?? [],
|
||||||
suggestionsReady: suggestionsQuery.data?.ready ?? true,
|
suggestionsReady: suggestionsQuery.data?.ready ?? true,
|
||||||
|
suggestionsLookupSource: suggestionsQuery.data?.primary_source ?? null,
|
||||||
tapSelectedChip,
|
tapSelectedChip,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -221,6 +231,7 @@ function AlbumImportPanelContent({ viewModel }: { viewModel: AlbumImportViewMode
|
||||||
albumResults,
|
albumResults,
|
||||||
albumSearchError,
|
albumSearchError,
|
||||||
albumSearchLoading,
|
albumSearchLoading,
|
||||||
|
albumSearchLookupSource,
|
||||||
groups,
|
groups,
|
||||||
onAlbumQueryChange,
|
onAlbumQueryChange,
|
||||||
onBackToSearch,
|
onBackToSearch,
|
||||||
|
|
@ -230,13 +241,25 @@ function AlbumImportPanelContent({ viewModel }: { viewModel: AlbumImportViewMode
|
||||||
selectedAlbum,
|
selectedAlbum,
|
||||||
suggestions,
|
suggestions,
|
||||||
suggestionsReady,
|
suggestionsReady,
|
||||||
|
suggestionsLookupSource,
|
||||||
} = viewModel;
|
} = viewModel;
|
||||||
|
|
||||||
const showingMatch = selectedAlbum || albumMatchLoading || albumMatchError || albumMatch;
|
const showingMatch = selectedAlbum || albumMatchLoading || albumMatchError || albumMatch;
|
||||||
|
const suggestionsFallbackBanner = getImportSourceFallbackBanner(
|
||||||
|
suggestions,
|
||||||
|
suggestionsLookupSource,
|
||||||
|
);
|
||||||
|
const albumResultsFallbackBanner = getImportSourceFallbackBanner(
|
||||||
|
albumResults,
|
||||||
|
albumSearchLookupSource,
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
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 && (
|
{albumResults === null && (
|
||||||
<>
|
<>
|
||||||
{groups.length > 0 && (
|
{groups.length > 0 && (
|
||||||
|
|
@ -266,12 +289,16 @@ function AlbumImportPanelContent({ viewModel }: { viewModel: AlbumImportViewMode
|
||||||
|
|
||||||
<div className={styles.importPageSuggestions} id="import-page-suggestions">
|
<div className={styles.importPageSuggestions} id="import-page-suggestions">
|
||||||
<div className={styles.importPageSectionLabel}>Suggested from your import folder</div>
|
<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">
|
<div className={styles.importPageAlbumGrid} id="import-page-suggestions-grid">
|
||||||
{suggestions.length > 0 ? (
|
{suggestions.length > 0 ? (
|
||||||
suggestions.map((album) => (
|
suggestions.map((album) => (
|
||||||
<AlbumCard
|
<AlbumCard
|
||||||
key={`${album.source || 'source'}-${album.id}`}
|
key={`${album.source || 'source'}-${album.id}`}
|
||||||
album={album}
|
album={album}
|
||||||
|
lookupSource={suggestionsLookupSource}
|
||||||
onSelect={onSelectAlbum}
|
onSelect={onSelectAlbum}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
|
|
@ -310,11 +337,16 @@ function AlbumImportPanelContent({ viewModel }: { viewModel: AlbumImportViewMode
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{albumResultsFallbackBanner ? (
|
||||||
|
<Notice tone="warning">{albumResultsFallbackBanner}</Notice>
|
||||||
|
) : null}
|
||||||
<div className={styles.importPageAlbumGrid} id="import-page-album-results">
|
<div className={styles.importPageAlbumGrid} id="import-page-album-results">
|
||||||
{albumSearchLoading ? (
|
{albumSearchLoading ? (
|
||||||
<div className={styles.importPageEmptyState}>Searching...</div>
|
<div className={styles.importPageEmptyState}>Searching...</div>
|
||||||
) : albumSearchError ? (
|
) : albumSearchError ? (
|
||||||
<div className={styles.importPageEmptyState}>Error: {albumSearchError}</div>
|
<Notice tone="danger" role="alert">
|
||||||
|
Error: {albumSearchError}
|
||||||
|
</Notice>
|
||||||
) : albumResults?.length === 0 ? (
|
) : albumResults?.length === 0 ? (
|
||||||
<div className={styles.importPageEmptyState}>No albums found</div>
|
<div className={styles.importPageEmptyState}>No albums found</div>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -322,6 +354,7 @@ function AlbumImportPanelContent({ viewModel }: { viewModel: AlbumImportViewMode
|
||||||
<AlbumCard
|
<AlbumCard
|
||||||
key={`${album.source || 'source'}-${album.id}`}
|
key={`${album.source || 'source'}-${album.id}`}
|
||||||
album={album}
|
album={album}
|
||||||
|
lookupSource={albumSearchLookupSource}
|
||||||
onSelect={onSelectAlbum}
|
onSelect={onSelectAlbum}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
|
|
@ -329,11 +362,16 @@ function AlbumImportPanelContent({ viewModel }: { viewModel: AlbumImportViewMode
|
||||||
</div>
|
</div>
|
||||||
</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 ? (
|
{albumMatchLoading ? (
|
||||||
<div className={styles.importPageEmptyState}>Matching files to tracklist...</div>
|
<div className={styles.importPageEmptyState}>Matching files to tracklist...</div>
|
||||||
) : albumMatchError ? (
|
) : albumMatchError ? (
|
||||||
<div className={styles.importPageEmptyState}>Error: {albumMatchError}</div>
|
<Notice tone="danger" role="alert">
|
||||||
|
Error: {albumMatchError}
|
||||||
|
</Notice>
|
||||||
) : albumMatch?.album ? (
|
) : albumMatch?.album ? (
|
||||||
<AlbumMatchPanel viewModel={viewModel} />
|
<AlbumMatchPanel viewModel={viewModel} />
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -348,13 +386,17 @@ function AlbumImportPanelContent({ viewModel }: { viewModel: AlbumImportViewMode
|
||||||
|
|
||||||
function AlbumCard({
|
function AlbumCard({
|
||||||
album,
|
album,
|
||||||
|
lookupSource,
|
||||||
onSelect,
|
onSelect,
|
||||||
}: {
|
}: {
|
||||||
album: ImportAlbumResult;
|
album: ImportAlbumResult;
|
||||||
|
lookupSource: string | null;
|
||||||
onSelect: (album: ImportAlbumResult) => void;
|
onSelect: (album: ImportAlbumResult) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const resultSourceBadge = getImportSourceBadgeText(album.source, lookupSource);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button className={styles.importPageAlbumCard} onClick={() => onSelect(album)}>
|
<button type="button" className={styles.importPageAlbumCard} onClick={() => onSelect(album)}>
|
||||||
<img
|
<img
|
||||||
src={album.image_url || IMPORT_PLACEHOLDER_IMAGE}
|
src={album.image_url || IMPORT_PLACEHOLDER_IMAGE}
|
||||||
alt={album.name}
|
alt={album.name}
|
||||||
|
|
@ -370,6 +412,9 @@ function AlbumCard({
|
||||||
<div className={styles.importPageAlbumCardMeta}>
|
<div className={styles.importPageAlbumCardMeta}>
|
||||||
{album.total_tracks || 0} tracks · {album.release_date?.substring(0, 4) || ''}
|
{album.total_tracks || 0} tracks · {album.release_date?.substring(0, 4) || ''}
|
||||||
</div>
|
</div>
|
||||||
|
{resultSourceBadge ? (
|
||||||
|
<div className={styles.importPageAlbumCardSource}>{resultSourceBadge}</div>
|
||||||
|
) : null}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -407,7 +452,9 @@ function AlbumMatchPanel({ viewModel }: { viewModel: AlbumImportViewModel }) {
|
||||||
return albumMatchLoading ? (
|
return albumMatchLoading ? (
|
||||||
<div className={styles.importPageEmptyState}>Matching files to tracklist...</div>
|
<div className={styles.importPageEmptyState}>Matching files to tracklist...</div>
|
||||||
) : albumMatchError ? (
|
) : albumMatchError ? (
|
||||||
<div className={styles.importPageEmptyState}>Error: {albumMatchError}</div>
|
<Notice tone="danger" role="alert">
|
||||||
|
Error: {albumMatchError}
|
||||||
|
</Notice>
|
||||||
) : albumMatch?.album ? (
|
) : albumMatch?.album ? (
|
||||||
<>
|
<>
|
||||||
<div className={styles.importPageAlbumHero} id="import-page-album-hero">
|
<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.importPageMatchNum}>{trackInfo.displayTrackNumber}</span>
|
||||||
<span className={styles.importPageMatchTrack}>{trackInfo.name}</span>
|
<span className={styles.importPageMatchTrack}>{trackInfo.name}</span>
|
||||||
<span className={clsx(styles.importPageMatchFile, {
|
<span
|
||||||
[styles.hasFile]: file,
|
className={clsx(styles.importPageMatchFile, {
|
||||||
})}>
|
[styles.hasFile]: file,
|
||||||
|
})}
|
||||||
|
>
|
||||||
{file ? (
|
{file ? (
|
||||||
<>
|
<>
|
||||||
<span className={styles.importPageMatchFileName}>{file.filename}</span>
|
<span className={styles.importPageMatchFileName}>{file.filename}</span>
|
||||||
|
|
|
||||||
|
|
@ -237,6 +237,13 @@
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.importPageAlbumCardSource {
|
||||||
|
font-size: 10px;
|
||||||
|
color: rgba(255, 200, 100, 0.75);
|
||||||
|
margin-top: 2px;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
/* Album hero (selected album) */
|
/* Album hero (selected album) */
|
||||||
.importPageAlbumHero {
|
.importPageAlbumHero {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -639,11 +646,11 @@
|
||||||
color: rgba(255, 255, 255, 0.4);
|
color: rgba(255, 255, 255, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.importPageSingleResultSelect {
|
.importPageSingleResultSelect {
|
||||||
padding: 4px 12px;
|
padding: 4px 12px;
|
||||||
background: rgba(var(--accent-light-rgb), 0.15);
|
background: rgba(var(--accent-light-rgb), 0.15);
|
||||||
border: 1px solid rgba(var(--accent-light-rgb), 0.3);
|
border: 1px solid rgba(var(--accent-light-rgb), 0.3);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
color: rgb(var(--accent-light-rgb));
|
color: rgb(var(--accent-light-rgb));
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
|
@ -651,14 +658,14 @@
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.importPageSingleResultSelect:hover {
|
.importPageSingleResultSelect:hover {
|
||||||
background: rgba(var(--accent-light-rgb), 0.25);
|
background: rgba(var(--accent-light-rgb), 0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Empty state */
|
/* Empty state */
|
||||||
.importPageEmptyState {
|
.importPageEmptyState {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 60px 20px;
|
padding: 60px 20px;
|
||||||
color: rgba(255, 255, 255, 0.3);
|
color: rgba(255, 255, 255, 0.3);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
@ -821,8 +828,8 @@
|
||||||
.importPageMatchRow {
|
.importPageMatchRow {
|
||||||
grid-template-columns: 28px minmax(0, 1fr) auto;
|
grid-template-columns: 28px minmax(0, 1fr) auto;
|
||||||
grid-template-areas:
|
grid-template-areas:
|
||||||
"num track track"
|
'num track track'
|
||||||
"num file unmatch";
|
'num file unmatch';
|
||||||
gap: 6px 8px;
|
gap: 6px 8px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
@ -853,8 +860,8 @@
|
||||||
.importPageSingleItem {
|
.importPageSingleItem {
|
||||||
grid-template-columns: 28px minmax(0, 1fr) auto;
|
grid-template-columns: 28px minmax(0, 1fr) auto;
|
||||||
grid-template-areas:
|
grid-template-areas:
|
||||||
"checkbox info actions"
|
'checkbox info actions'
|
||||||
"search search search";
|
'search search search';
|
||||||
align-items: start;
|
align-items: start;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -140,7 +140,8 @@ function ImportProcessingQueue() {
|
||||||
function ImportQueueItem({ entry }: { entry: ImportQueueEntry }) {
|
function ImportQueueItem({ entry }: { entry: ImportQueueEntry }) {
|
||||||
const statusText = getQueueStatusText(entry);
|
const statusText = getQueueStatusText(entry);
|
||||||
const statusClass = clsx({
|
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',
|
[styles.done]: entry.status === 'done',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import clsx from 'clsx';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
import { Badge, Button, Checkbox, TextInput } from '@/components/form/form';
|
import { Badge, Button, Checkbox, TextInput } from '@/components/form/form';
|
||||||
|
import { Notice } from '@/components/primitives';
|
||||||
|
|
||||||
import type { SingleSearchState } from '../-import.store';
|
import type { SingleSearchState } from '../-import.store';
|
||||||
import type { ImportTrackResult } from '../-import.types';
|
import type { ImportTrackResult } from '../-import.types';
|
||||||
|
|
@ -113,7 +114,7 @@ export function SinglesImportTab() {
|
||||||
});
|
});
|
||||||
|
|
||||||
clearSinglesSelection();
|
clearSinglesSelection();
|
||||||
refreshStaging();
|
void refreshStaging();
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -295,7 +296,9 @@ function SingleSearchPanel({
|
||||||
{searchState?.loading ? (
|
{searchState?.loading ? (
|
||||||
<div className={styles.importPageEmptyState}>Searching...</div>
|
<div className={styles.importPageEmptyState}>Searching...</div>
|
||||||
) : searchState?.error ? (
|
) : searchState?.error ? (
|
||||||
<div className={styles.importPageEmptyState}>Error: {searchState.error}</div>
|
<Notice tone="danger" role="alert">
|
||||||
|
Error: {searchState.error}
|
||||||
|
</Notice>
|
||||||
) : searchState?.results.length === 0 ? (
|
) : searchState?.results.length === 0 ? (
|
||||||
<div className={styles.importPageEmptyState}>No results found</div>
|
<div className={styles.importPageEmptyState}>No results found</div>
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue