import { useQuery } from '@tanstack/react-query'; 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'; import { importStagingGroupsQueryOptions, importStagingSuggestionsQueryOptions, matchImportAlbum, searchImportAlbums, } from '../-import.api'; import { getDisplayedMatchFile, getEffectiveAlbumMatches, getImportSourceBadgeText, getImportSourceFallbackBanner, getTrackDisplayInfo, getUnmatchedStagingFiles, IMPORT_PLACEHOLDER_IMAGE, } from '../-import.helpers'; import { useAlbumImportWorkflow } from '../-import.store'; import styles from './import-page.module.css'; import { fallbackImage, getErrorMessage, useImportQueueActions, useImportStaging, } from './import-shared'; function useAlbumImportViewModel() { const { refreshStaging, stagingFiles } = useImportStaging(); const [dragOverTrack, setDragOverTrack] = useState(null); const [tapSelectedChip, setTapSelectedChip] = useState(null); const groupsQuery = useQuery({ ...importStagingGroupsQueryOptions(), }); const suggestionsQuery = useQuery({ ...importStagingSuggestionsQueryOptions(), }); const { addQueueJob } = useImportQueueActions(); const { albumMatch, albumMatchError, albumMatchLoading, albumQuery, albumResults, albumSearchError, albumSearchLoading, albumSearchLookupSource, autoGroupFilePaths, clearAutoGroupFilePaths, matchOverrides, resetAlbumWorkflow, selectedAlbum, setAlbumMatch, setAlbumMatchError, setAlbumMatchLoading, setAlbumQuery, setAlbumResults, setAlbumSearchContext, setAlbumSearchError, setAlbumSearchLoading, setAlbumSearchLookupSource, setMatchOverrides, setSelectedAlbum, } = useAlbumImportWorkflow(); const resetAlbumSearch = () => { setDragOverTrack(null); setTapSelectedChip(null); resetAlbumWorkflow(); void refreshStaging(); }; const runAlbumSearch = async (query: string, filePaths: string[] | null = null) => { const trimmed = query.trim(); if (!trimmed) return; setAlbumSearchContext(trimmed, filePaths); try { const payload = await searchImportAlbums(trimmed); setAlbumResults(payload.albums ?? []); setAlbumSearchLookupSource(payload.primary_source ?? null); } catch (error) { setAlbumSearchError(getErrorMessage(error)); } finally { setAlbumSearchLoading(false); } }; const selectAlbum = async (album: ImportAlbumResult) => { setSelectedAlbum(album); setAlbumMatch(null); setAlbumMatchError(null); 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, albumName: album.name, albumArtist: album.artist, filePaths: autoGroupFilePaths, }); setAlbumMatch(payload); setMatchOverrides({}); setTapSelectedChip(null); setDragOverTrack(null); } catch (error) { setAlbumMatchError(getErrorMessage(error)); } finally { clearAutoGroupFilePaths(); setAlbumMatchLoading(false); } }; const assignMatchFile = (trackIndex: number, stagingFileIndex: number) => { setMatchOverrides((current) => { const next = { ...current }; for (const [key, value] of Object.entries(next)) { if (value === stagingFileIndex) { delete next[Number(key)]; } } next[trackIndex] = stagingFileIndex; return next; }); setTapSelectedChip(null); }; const unmatchTrack = (trackIndex: number) => { setMatchOverrides((current) => { const next = { ...current }; delete next[trackIndex]; if (albumMatch?.matches?.[trackIndex]?.staging_file) { next[trackIndex] = -1; } return next; }); }; const processAlbum = () => { const album = albumMatch?.album; const matches = albumMatch?.matches ?? []; if (!album || matches.length === 0) return; const effectiveMatches = getEffectiveAlbumMatches(matches, stagingFiles, matchOverrides); if (effectiveMatches.length === 0) return; addQueueJob({ type: 'album', label: album.name, sublabel: `${album.artist} - ${effectiveMatches.length} tracks`, imageUrl: album.image_url, items: effectiveMatches, albumData: album, }); resetAlbumSearch(); }; return { albumMatch, albumMatchError, albumMatchLoading, albumQuery, albumResults, albumSearchError, albumSearchLoading, albumSearchLookupSource, dragOverTrack, groups: groupsQuery.data?.groups ?? [], matchOverrides, onAlbumQueryChange: setAlbumQuery, onAutoRematch: () => { setMatchOverrides({}); setTapSelectedChip(null); setDragOverTrack(null); }, onBackToSearch: resetAlbumSearch, onDragOverTrack: setDragOverTrack, onProcessAlbum: processAlbum, onRunGroupSearch: (group: { album: string; artist: string; file_count: number; file_paths: string[]; }) => { void runAlbumSearch(`${group.artist} ${group.album}`, group.file_paths); }, onRunSearch: () => { void runAlbumSearch(albumQuery); }, onSelectAlbum: (album: ImportAlbumResult) => { void selectAlbum(album); }, onTapAssign: assignMatchFile, onTapSelectChip: (index: number) => { setTapSelectedChip((current) => (current === index ? null : index)); }, onUnmatchTrack: unmatchTrack, selectedAlbum, stagingFiles, suggestions: suggestionsQuery.data?.suggestions ?? [], suggestionsReady: suggestionsQuery.data?.ready ?? true, suggestionsLookupSource: suggestionsQuery.data?.primary_source ?? null, tapSelectedChip, }; } type AlbumImportViewModel = ReturnType; 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(); return ; } function AlbumImportPanelContent({ viewModel }: { viewModel: AlbumImportViewModel }) { const { albumMatch, albumMatchError, albumMatchLoading, albumQuery, albumResults, albumSearchError, albumSearchLoading, albumSearchLookupSource, groups, onAlbumQueryChange, onBackToSearch, onRunGroupSearch, onRunSearch, onSelectAlbum, 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 && (
Auto-Detected Albums
{groups.map((group, index) => ( ))}
)}
Suggested from your import folder
{suggestionsFallbackBanner ? ( {suggestionsFallbackBanner} ) : null}
{suggestions.length > 0 ? ( suggestions.map((album) => ( )) ) : suggestionsReady ? null : (
Loading suggestions...
)}
)}
onAlbumQueryChange(event.target.value)} onKeyDown={(event) => { if (event.key === 'Enter') onRunSearch(); }} />
{albumResultsFallbackBanner ? ( {albumResultsFallbackBanner} ) : null}
{albumSearchLoading ? (
Searching...
) : albumSearchError ? ( Error: {albumSearchError} ) : albumResults?.length === 0 ? (
No albums found
) : ( albumResults?.map((album) => ( )) )}
{albumMatchLoading ? (
Matching files to tracklist...
) : albumMatchError ? ( Error: {albumMatchError} ) : albumMatch?.album ? ( ) : (
Select an album to start matching files.
)}
); } function AlbumCard({ album, lookupSource, onSelect, }: { album: ImportAlbumResult; lookupSource: string | null; onSelect: (album: ImportAlbumResult) => void; }) { const resultSourceBadge = getImportSourceBadgeText(album.source, lookupSource); const metaParts = getAlbumMetaParts(album); const detailParts = getAlbumDetailParts(album); return ( ); } function AlbumMatchPanel({ viewModel }: { viewModel: AlbumImportViewModel }) { const { albumMatch, albumMatchError, albumMatchLoading, dragOverTrack, matchOverrides, onAutoRematch, onBackToSearch, onDragOverTrack, onProcessAlbum, onTapAssign, onTapSelectChip, onUnmatchTrack, stagingFiles, tapSelectedChip, } = viewModel; const effectiveMatches = getEffectiveAlbumMatches( albumMatch?.matches ?? [], stagingFiles, matchOverrides, ); const unmatchedFiles = getUnmatchedStagingFiles( albumMatch?.matches ?? [], stagingFiles, matchOverrides, ); const matchedCount = effectiveMatches.length; const heroMetaParts = albumMatch?.album ? getAlbumMetaParts(albumMatch.album) : []; return albumMatchLoading ? (
Matching files to tracklist...
) : albumMatchError ? ( Error: {albumMatchError} ) : albumMatch?.album ? ( <>
{albumMatch.album.name}
{albumMatch.album.name}
{albumMatch.album.artist}
{heroMetaParts.join(' · ')}

Track Matching

{(albumMatch.matches ?? []).map((match, index) => { const trackInfo = getTrackDisplayInfo(match, index); const { confidence, file } = getDisplayedMatchFile( match, index, stagingFiles, matchOverrides, ); const confidencePercent = Math.round(confidence * 100); return (
{ if (tapSelectedChip !== null) onTapAssign(index, tapSelectedChip); }} onDragOver={(event) => { event.preventDefault(); event.dataTransfer.dropEffect = 'move'; onDragOverTrack(index); }} onDragLeave={() => onDragOverTrack(null)} onDrop={(event) => { event.preventDefault(); onDragOverTrack(null); const stagingFileIndex = Number(event.dataTransfer.getData('text/plain')); if (Number.isFinite(stagingFileIndex)) onTapAssign(index, stagingFileIndex); }} > {trackInfo.displayTrackNumber} {trackInfo.name} {file ? ( <> {file.filename} {confidencePercent}% ) : ( Drop a file here )} {file ? ( ) : null}
); })}
Unmatched Files ({unmatchedFiles.length})
{unmatchedFiles.length === 0 ? ( All files matched ) : ( unmatchedFiles.map(({ file, index }) => ( { event.stopPropagation(); onTapSelectChip(index); }} onDragStart={(event: DragEvent) => { event.dataTransfer.setData('text/plain', String(index)); event.dataTransfer.effectAllowed = 'move'; }} onKeyDown={(event: KeyboardEvent) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); onTapSelectChip(index); } }} > {file.filename} )) )}
{matchedCount} of {albumMatch.matches?.length ?? 0} tracks matched
) : (
Select an album to start matching files.
); } 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); }