From 8a757f5b6778579f2be46756ec3dbe3de3d9e216 Mon Sep 17 00:00:00 2001 From: kikootwo Date: Fri, 20 Mar 2026 13:32:49 -0400 Subject: [PATCH] Import: allow selecting specific audio files Add support for selecting individual audio files during manual and bulk imports and pass that selection through the scan, API, job queue, processor and organizer. Key changes: - API: scan now returns audioFiles for each discovered book and emits a new 'grouping' progress phase; execute and manual-import routes accept file lists (audioFiles / selectedFiles) and validate them. - Scanner: group loose audio files by metadata (title/author/narrator), deduplicate multi-part sets (CD1/CD2) across folders, and return audioFiles + groupingKey; add concurrency limit for ffprobe reads and merge groups post-scan. - Job queue & processor: OrganizeFiles payload now includes selectedFiles; processors forward selectedFiles to the FileOrganizer and to cleanup logic. - File organizer & cleanup: filter to only selectedFiles when organizing; cleanup now deletes only the selected files (if provided) instead of removing the whole directory. - UI: Manual import browser and bulk import wizard updated to show per-file selection, track checkedFiles, toggle all, and send selected files to the API; ConfirmPhase updated to allow checking/unchecking files and prevents starting import with no files selected. - Filesystem browse: removed expensive per-subfolder stats to keep browsing responsive (now lists subdirectories without nested stat calls). Overall this change enables finer-grained imports, reduces accidental deletion of unselected files, and improves scan grouping for multi-folder audiobooks. --- .../api/admin/bulk-import/execute/route.ts | 28 +- src/app/api/admin/bulk-import/scan/route.ts | 1 + src/app/api/admin/filesystem/browse/route.ts | 60 +--- src/app/api/admin/manual-import/route.ts | 82 ++++- src/components/admin/BulkImportWizard.tsx | 1 + .../admin/bulk-import/ScanFolderStep.tsx | 44 +-- src/components/admin/bulk-import/types.ts | 6 +- .../audiobooks/ManualImportBrowser.tsx | 58 +++- .../audiobooks/manual-import/BrowsePhase.tsx | 113 ++++--- .../audiobooks/manual-import/ConfirmPhase.tsx | 74 ++-- .../audiobooks/manual-import/types.ts | 3 - .../processors/organize-files.processor.ts | 39 ++- src/lib/services/job-queue.service.ts | 5 +- src/lib/utils/bulk-import-scanner.ts | 315 ++++++++++++++++-- src/lib/utils/file-organizer.ts | 12 +- 15 files changed, 597 insertions(+), 244 deletions(-) diff --git a/src/app/api/admin/bulk-import/execute/route.ts b/src/app/api/admin/bulk-import/execute/route.ts index 2abc2df..e051c01 100644 --- a/src/app/api/admin/bulk-import/execute/route.ts +++ b/src/app/api/admin/bulk-import/execute/route.ts @@ -31,6 +31,7 @@ const RECYCLABLE_STATUSES = [ interface ImportItem { folderPath: string; asin: string; + audioFiles?: string[]; // Specific files to import (from scanner grouping) } interface ImportResult { @@ -105,7 +106,7 @@ export async function POST(request: NextRequest) { const results: ImportResult[] = []; for (const item of imports) { - const { folderPath, asin } = item; + const { folderPath, asin, audioFiles: itemAudioFiles } = item; try { // Validate path @@ -119,7 +120,7 @@ export async function POST(request: NextRequest) { continue; } - // Verify directory exists and has audio files + // Verify directory exists try { const stat = await fs.stat(normalizedPath); if (!stat.isDirectory()) { @@ -131,10 +132,14 @@ export async function POST(request: NextRequest) { continue; } - const hasAudio = await hasAudioFiles(normalizedPath); - if (!hasAudio) { - results.push({ folderPath, asin, success: false, error: 'No audio files' }); - continue; + // Verify audio files: if specific files provided, trust the scanner; + // otherwise fall back to folder-level check + if (!itemAudioFiles || itemAudioFiles.length === 0) { + const hasAudio = await hasAudioFiles(normalizedPath); + if (!hasAudio) { + results.push({ folderPath, asin, success: false, error: 'No audio files' }); + continue; + } } // Resolve or create audiobook record @@ -250,8 +255,15 @@ export async function POST(request: NextRequest) { requestId = newReq.id; } - // Queue organize_files job - await jobQueue.addOrganizeJob(requestId, audiobookId, normalizedPath); + // Queue organize_files job (pass specific files if scanner provided them) + await jobQueue.addOrganizeJob( + requestId, + audiobookId, + normalizedPath, + undefined, + false, + itemAudioFiles && itemAudioFiles.length > 0 ? itemAudioFiles : undefined + ); results.push({ folderPath, asin, success: true, requestId }); logger.info(`Bulk import queued: asin=${asin}, path=${normalizedPath}, request=${requestId}`); diff --git a/src/app/api/admin/bulk-import/scan/route.ts b/src/app/api/admin/bulk-import/scan/route.ts index 36bf408..a74e217 100644 --- a/src/app/api/admin/bulk-import/scan/route.ts +++ b/src/app/api/admin/bulk-import/scan/route.ts @@ -209,6 +209,7 @@ export async function POST(request: NextRequest) { totalSizeBytes: book.totalSizeBytes, metadataSource: book.metadataSource, searchTerm: book.searchTerm, + audioFiles: book.audioFiles, match: match ? { asin: match.asin, diff --git a/src/app/api/admin/filesystem/browse/route.ts b/src/app/api/admin/filesystem/browse/route.ts index 4f385e2..982573a 100644 --- a/src/app/api/admin/filesystem/browse/route.ts +++ b/src/app/api/admin/filesystem/browse/route.ts @@ -17,47 +17,6 @@ const logger = RMABLogger.create('API.Admin.Filesystem.Browse'); interface DirectoryEntry { name: string; type: 'directory'; - audioFileCount: number; - subfolderCount: number; - totalSize: number; -} - -/** - * Scan immediate children of a directory to gather audio file and subfolder stats. - */ -async function getDirectoryStats( - dirPath: string -): Promise<{ audioFileCount: number; subfolderCount: number; totalSize: number }> { - const fs = await import('fs/promises'); - const pathModule = await import('path'); - - let audioFileCount = 0; - let subfolderCount = 0; - let totalSize = 0; - - try { - const children = await fs.readdir(dirPath, { withFileTypes: true }); - for (const child of children) { - if (child.isDirectory()) { - subfolderCount++; - } else if (child.isFile()) { - const ext = pathModule.extname(child.name).toLowerCase(); - if ((AUDIO_EXTENSIONS as readonly string[]).includes(ext)) { - audioFileCount++; - try { - const stat = await fs.stat(pathModule.join(dirPath, child.name)); - totalSize += stat.size; - } catch { - /* skip unreadable files */ - } - } - } - } - } catch { - /* directory not readable */ - } - - return { audioFileCount, subfolderCount, totalSize }; } /** @@ -152,20 +111,11 @@ export async function GET(request: NextRequest) { // Read directory entries const dirEntries = await fs.readdir(normalizedPath, { withFileTypes: true }); - // Gather stats for each subdirectory (parallel for performance) - const directoryEntries = dirEntries.filter((e) => e.isDirectory()); - const statsPromises = directoryEntries.map(async (entry): Promise => { - const fullPath = pathModule.join(normalizedPath, entry.name); - const stats = await getDirectoryStats(fullPath); - return { - name: entry.name, - type: 'directory', - ...stats, - }; - }); - - const entries = await Promise.all(statsPromises); - entries.sort((a, b) => a.name.localeCompare(b.name)); + // List subdirectories (no nested stat calls — keeps browsing fast) + const entries: DirectoryEntry[] = dirEntries + .filter((e) => e.isDirectory()) + .map((entry) => ({ name: entry.name, type: 'directory' as const })) + .sort((a, b) => a.name.localeCompare(b.name)); // Gather audio files in the current directory const audioFiles: Array<{ name: string; size: number }> = []; diff --git a/src/app/api/admin/manual-import/route.ts b/src/app/api/admin/manual-import/route.ts index d7bd546..2c46c85 100644 --- a/src/app/api/admin/manual-import/route.ts +++ b/src/app/api/admin/manual-import/route.ts @@ -55,9 +55,25 @@ export async function POST(request: NextRequest) { const fs = await import('fs/promises'); const body = await request.json(); - const { folderPath, asin, cleanupSource } = body; + const { folderPath, asin, cleanupSource, selectedFiles } = body; let { audiobookId } = body; + // Validate selectedFiles if provided + if (selectedFiles !== undefined) { + if (!Array.isArray(selectedFiles) || selectedFiles.length === 0) { + return NextResponse.json( + { error: 'selectedFiles must be a non-empty array of file names' }, + { status: 400 } + ); + } + if (!selectedFiles.every((f: unknown) => typeof f === 'string')) { + return NextResponse.json( + { error: 'selectedFiles must contain only strings' }, + { status: 400 } + ); + } + } + // Validate required fields if ((!audiobookId && !asin) || !folderPath) { return NextResponse.json( @@ -120,13 +136,52 @@ export async function POST(request: NextRequest) { ); } - // Verify folder contains audio files - const audioCheck = await hasAudioFiles(normalizedPath); - if (!audioCheck.found) { - return NextResponse.json( - { error: 'No audio files found in the selected directory' }, - { status: 400 } - ); + // Verify selected files exist and are audio files, or fall back to folder scan + let audioFileCount: number; + const validatedFiles: string[] = []; + + if (selectedFiles && selectedFiles.length > 0) { + for (const fileName of selectedFiles as string[]) { + // Prevent path traversal + if (fileName.includes('/') || fileName.includes('\\') || fileName === '..' || fileName === '.') { + return NextResponse.json( + { error: `Invalid file name: ${fileName}` }, + { status: 400 } + ); + } + const ext = pathModule.extname(fileName).toLowerCase(); + if (!(AUDIO_EXTENSIONS as readonly string[]).includes(ext)) { + return NextResponse.json( + { error: `Not an audio file: ${fileName}` }, + { status: 400 } + ); + } + try { + const fileStat = await fs.stat(pathModule.join(normalizedPath, fileName)); + if (!fileStat.isFile()) { + return NextResponse.json( + { error: `Not a file: ${fileName}` }, + { status: 400 } + ); + } + validatedFiles.push(fileName); + } catch { + return NextResponse.json( + { error: `File not found: ${fileName}` }, + { status: 404 } + ); + } + } + audioFileCount = validatedFiles.length; + } else { + const audioCheck = await hasAudioFiles(normalizedPath); + if (!audioCheck.found) { + return NextResponse.json( + { error: 'No audio files found in the selected directory' }, + { status: 400 } + ); + } + audioFileCount = audioCheck.count; } // Resolve audiobook by ASIN if audiobookId not provided @@ -317,9 +372,16 @@ export async function POST(request: NextRequest) { // Queue organize_files job const jobQueue = getJobQueueService(); - await jobQueue.addOrganizeJob(requestId, audiobookId, normalizedPath, undefined, cleanupSource === true); + await jobQueue.addOrganizeJob( + requestId, + audiobookId, + normalizedPath, + undefined, + cleanupSource === true, + validatedFiles.length > 0 ? validatedFiles : undefined + ); - logger.info(`Manual import queued: request=${requestId}, path=${normalizedPath}, audioFiles=${audioCheck.count}`); + logger.info(`Manual import queued: request=${requestId}, path=${normalizedPath}, audioFiles=${audioFileCount}`); return NextResponse.json({ success: true, diff --git a/src/components/admin/BulkImportWizard.tsx b/src/components/admin/BulkImportWizard.tsx index bb6bc2b..9256cd3 100644 --- a/src/components/admin/BulkImportWizard.tsx +++ b/src/components/admin/BulkImportWizard.tsx @@ -205,6 +205,7 @@ export function BulkImportWizard({ isOpen, onClose }: BulkImportWizardProps) { imports: booksToImport.map((b) => ({ folderPath: b.folderPath, asin: b.match!.asin, + audioFiles: b.audioFiles, })), }), }); diff --git a/src/components/admin/bulk-import/ScanFolderStep.tsx b/src/components/admin/bulk-import/ScanFolderStep.tsx index fb63645..f4e5593 100644 --- a/src/components/admin/bulk-import/ScanFolderStep.tsx +++ b/src/components/admin/bulk-import/ScanFolderStep.tsx @@ -18,13 +18,12 @@ import { HomeIcon, ChevronRightIcon, ArrowLeftIcon, - MusicalNoteIcon, ExclamationTriangleIcon, ArrowPathIcon, MagnifyingGlassIcon, } from '@heroicons/react/24/outline'; import { fetchWithAuth } from '@/lib/utils/api'; -import { RootEntry, DirectoryEntry, formatBytes } from './types'; +import { RootEntry, DirectoryEntry } from './types'; function SkeletonRow() { return ( @@ -149,9 +148,8 @@ export function ScanFolderStep({ onFolderSelected }: ScanFolderStepProps) { ]; })(); - // Count total audio files and subfolders in current listing - const totalSubfolders = entries.reduce((sum, e) => sum + e.subfolderCount, 0); - const totalAudioInChildren = entries.reduce((sum, e) => sum + e.audioFileCount, 0); + // Count subfolders in current listing + const totalSubfolders = entries.length; return (
@@ -248,7 +246,6 @@ export function ScanFolderStep({ onFolderSelected }: ScanFolderStepProps) { {currentPath && !isLoading && !error && entries.length > 0 && (
{entries.map((entry) => { - const hasAudio = entry.audioFileCount > 0; const isHovered = hoveredFolder === entry.name; return ( @@ -267,33 +264,9 @@ export function ScanFolderStep({ onFolderSelected }: ScanFolderStepProps) { )}
-
-

- {entry.name} -

-

- {entry.subfolderCount > 0 && ( - {entry.subfolderCount} folder{entry.subfolderCount !== 1 ? 's' : ''} - )} - {entry.subfolderCount > 0 && entry.audioFileCount > 0 && · } - {entry.audioFileCount > 0 && ( - {entry.audioFileCount} audio file{entry.audioFileCount !== 1 ? 's' : ''} - )} - {entry.totalSize > 0 && ( - · {formatBytes(entry.totalSize)} - )} - {entry.subfolderCount === 0 && entry.audioFileCount === 0 && ( - Empty - )} -

-
- - {hasAudio && ( - - - {entry.audioFileCount} - - )} +

+ {entry.name} +

@@ -325,10 +298,7 @@ export function ScanFolderStep({ onFolderSelected }: ScanFolderStepProps) {

{currentPath}

{entries.length > 0 && (

- {entries.length} subfolder{entries.length !== 1 ? 's' : ''} - {totalAudioInChildren > 0 && ( - · {totalAudioInChildren} audio files visible - )} + {totalSubfolders} subfolder{totalSubfolders !== 1 ? 's' : ''}

)}
diff --git a/src/components/admin/bulk-import/types.ts b/src/components/admin/bulk-import/types.ts index f441b6b..b68992c 100644 --- a/src/components/admin/bulk-import/types.ts +++ b/src/components/admin/bulk-import/types.ts @@ -14,9 +14,6 @@ export interface RootEntry { export interface DirectoryEntry { name: string; type: 'directory'; - audioFileCount: number; - subfolderCount: number; - totalSize: number; } /** Audible match data for a discovered audiobook. */ @@ -39,6 +36,7 @@ export interface ScannedBook { totalSizeBytes: number; metadataSource: 'tags' | 'file_name'; searchTerm: string; + audioFiles: string[]; match: AudibleMatch | null; inLibrary: boolean; hasActiveRequest: boolean; @@ -48,7 +46,7 @@ export interface ScannedBook { /** Progress event from the SSE scan stream. */ export interface ScanProgressEvent { - phase: 'discovering' | 'reading_metadata'; + phase: 'discovering' | 'reading_metadata' | 'grouping'; foldersScanned: number; audiobooksFound: number; currentFolder?: string; diff --git a/src/components/audiobooks/ManualImportBrowser.tsx b/src/components/audiobooks/ManualImportBrowser.tsx index 9d39347..b490a3d 100644 --- a/src/components/audiobooks/ManualImportBrowser.tsx +++ b/src/components/audiobooks/ManualImportBrowser.tsx @@ -47,8 +47,6 @@ export function ManualImportBrowser({ const [currentPath, setCurrentPath] = useState(null); const [entries, setEntries] = useState([]); const [selectedPath, setSelectedPath] = useState(null); - const [selectedAudioCount, setSelectedAudioCount] = useState(0); - const [selectedSize, setSelectedSize] = useState(0); const [selectedAudioFiles, setSelectedAudioFiles] = useState([]); const [currentAudioFiles, setCurrentAudioFiles] = useState([]); const [pathHistory, setPathHistory] = useState([]); @@ -62,6 +60,9 @@ export function ManualImportBrowser({ // Cleanup source toggle const [cleanupSource, setCleanupSource] = useState(false); + // File selection state (shared between BrowsePhase and ConfirmPhase) + const [checkedFiles, setCheckedFiles] = useState>(new Set()); + // Hover state for folder icon swap const [hoveredFolder, setHoveredFolder] = useState(null); @@ -96,6 +97,7 @@ export function ManualImportBrowser({ const fetchDirectory = useCallback(async (dirPath: string) => { setIsLoading(true); setError(null); + setCheckedFiles(new Set()); try { const res = await fetchWithAuth( `/api/admin/filesystem/browse?path=${encodeURIComponent(dirPath)}` @@ -105,8 +107,9 @@ export function ManualImportBrowser({ throw new Error(data.error || 'Failed to browse directory'); } const data = await res.json(); + const audioFiles: AudioFileEntry[] = data.audioFiles || []; setEntries(data.entries || []); - setCurrentAudioFiles(data.audioFiles || []); + setCurrentAudioFiles(audioFiles); setCurrentPath(data.path || dirPath); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to browse directory'); @@ -165,12 +168,38 @@ export function ManualImportBrowser({ navigateInto(fullPath); }; - const handleSelectCurrentFolder = () => { + const handleToggleFile = (fileName: string) => { + setCheckedFiles((prev) => { + const next = new Set(prev); + if (next.has(fileName)) { + next.delete(fileName); + } else { + next.add(fileName); + } + return next; + }); + }; + + const handleToggleAll = () => { + // In confirm phase, toggle against selectedAudioFiles; in browse phase, against currentAudioFiles + const files = phase === 'confirm' ? selectedAudioFiles : currentAudioFiles; + if (checkedFiles.size === files.length) { + setCheckedFiles(new Set()); + } else { + setCheckedFiles(new Set(files.map((f) => f.name))); + } + }; + + const handleSelectFiles = () => { if (!currentPath || currentAudioFiles.length === 0) return; + // No individual selection = whole folder; otherwise only checked files + const selected = checkedFiles.size > 0 + ? currentAudioFiles.filter((f) => checkedFiles.has(f.name)) + : currentAudioFiles; setSelectedPath(currentPath); - setSelectedAudioCount(currentAudioFiles.length); - setSelectedSize(currentAudioFiles.reduce((sum, f) => sum + f.size, 0)); - setSelectedAudioFiles(currentAudioFiles); + setSelectedAudioFiles(selected); + // Ensure checkedFiles reflects what we're importing for ConfirmPhase + setCheckedFiles(new Set(selected.map((f) => f.name))); setSlideDirection('right'); setPhase('confirm'); }; @@ -185,12 +214,18 @@ export function ManualImportBrowser({ setIsImporting(true); setImportError(null); try { + // Send only the files that are still checked in ConfirmPhase + const fileNames = selectedAudioFiles + .filter((f) => checkedFiles.has(f.name)) + .map((f) => f.name); + if (fileNames.length === 0) return; const res = await fetchWithAuth('/api/admin/manual-import', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ asin: audiobook.asin, folderPath: selectedPath, + selectedFiles: fileNames, cleanupSource, }), }); @@ -268,6 +303,7 @@ export function ManualImportBrowser({ currentPath={currentPath} entries={entries} currentAudioFiles={currentAudioFiles} + checkedFiles={checkedFiles} isLoading={isLoading} error={error} hoveredFolder={hoveredFolder} @@ -278,7 +314,8 @@ export function ManualImportBrowser({ onNavigateToRoot={navigateToRoot} onNavigateToBreadcrumb={navigateToBreadcrumb} onFolderClick={handleFolderClick} - onSelectCurrentFolder={handleSelectCurrentFolder} + onSelectFiles={handleSelectFiles} + onToggleFile={handleToggleFile} onHoverFolder={setHoveredFolder} onRetry={currentPath ? () => fetchDirectory(currentPath) : fetchRoots} /> @@ -286,14 +323,15 @@ export function ManualImportBrowser({ diff --git a/src/components/audiobooks/manual-import/BrowsePhase.tsx b/src/components/audiobooks/manual-import/BrowsePhase.tsx index 3cbb5a2..a9db62b 100644 --- a/src/components/audiobooks/manual-import/BrowsePhase.tsx +++ b/src/components/audiobooks/manual-import/BrowsePhase.tsx @@ -40,6 +40,7 @@ interface BrowsePhaseProps { currentPath: string | null; entries: DirectoryEntry[]; currentAudioFiles: AudioFileEntry[]; + checkedFiles: Set; isLoading: boolean; error: string | null; hoveredFolder: string | null; @@ -50,7 +51,8 @@ interface BrowsePhaseProps { onNavigateToRoot: () => void; onNavigateToBreadcrumb: (index: number) => void; onFolderClick: (entry: DirectoryEntry) => void; - onSelectCurrentFolder: () => void; + onSelectFiles: () => void; + onToggleFile: (fileName: string) => void; onHoverFolder: (name: string | null) => void; onRetry: () => void; } @@ -60,6 +62,7 @@ export function BrowsePhase({ currentPath, entries, currentAudioFiles, + checkedFiles, isLoading, error, hoveredFolder, @@ -70,10 +73,16 @@ export function BrowsePhase({ onNavigateToRoot, onNavigateToBreadcrumb, onFolderClick, - onSelectCurrentFolder, + onSelectFiles, + onToggleFile, onHoverFolder, onRetry, }: BrowsePhaseProps) { + const hasSelection = checkedFiles.size > 0; + const totalSize = currentAudioFiles.reduce((sum, f) => sum + f.size, 0); + const checkedSize = hasSelection + ? currentAudioFiles.filter((f) => checkedFiles.has(f.name)).reduce((sum, f) => sum + f.size, 0) + : totalSize; return (
{/* Breadcrumb bar */} @@ -165,7 +174,6 @@ export function BrowsePhase({
{/* Subdirectories */} {entries.map((entry) => { - const hasAudio = entry.audioFileCount > 0; const isHovered = hoveredFolder === entry.name; return ( @@ -184,33 +192,9 @@ export function BrowsePhase({ )}
-
-

- {entry.name} -

-

- {entry.subfolderCount > 0 && ( - {entry.subfolderCount} folder{entry.subfolderCount !== 1 ? 's' : ''} - )} - {entry.subfolderCount > 0 && entry.audioFileCount > 0 && · } - {entry.audioFileCount > 0 && ( - {entry.audioFileCount} audio file{entry.audioFileCount !== 1 ? 's' : ''} - )} - {entry.totalSize > 0 && ( - · {formatBytes(entry.totalSize)} - )} - {entry.subfolderCount === 0 && entry.audioFileCount === 0 && ( - Empty - )} -

-
- - {hasAudio && ( - - - {entry.audioFileCount} - - )} +

+ {entry.name} +

@@ -221,24 +205,38 @@ export function BrowsePhase({ {currentAudioFiles.length > 0 && entries.length > 0 && (

- Audio Files + Audio Files {hasSelection && `\u00B7 click to select`}

)} - {currentAudioFiles.map((file) => ( -
- - - {file.name} - - - {formatBytes(file.size)} - -
- ))} + {currentAudioFiles.map((file) => { + const isSelected = checkedFiles.has(file.name); + return ( + + ); + })}
)} @@ -258,18 +256,33 @@ export function BrowsePhase({ )} - {/* Footer: Select this folder */} + {/* Footer */} {currentPath && !isLoading && currentAudioFiles.length > 0 && (

- {currentAudioFiles.length} - {' '}audio file{currentAudioFiles.length !== 1 ? 's' : ''} in this folder + {hasSelection ? ( + <> + {checkedFiles.size} + {' '}of {currentAudioFiles.length} file{currentAudioFiles.length !== 1 ? 's' : ''} selected + + ) : ( + <> + {currentAudioFiles.length} + {' '}audio file{currentAudioFiles.length !== 1 ? 's' : ''} in this folder + + )} + {checkedSize > 0 && ( + · {formatBytes(checkedSize)} + )}

)} diff --git a/src/components/audiobooks/manual-import/ConfirmPhase.tsx b/src/components/audiobooks/manual-import/ConfirmPhase.tsx index c5c80b5..42b67b4 100644 --- a/src/components/audiobooks/manual-import/ConfirmPhase.tsx +++ b/src/components/audiobooks/manual-import/ConfirmPhase.tsx @@ -16,14 +16,15 @@ import { AudioFileEntry, formatBytes } from './types'; interface ConfirmPhaseProps { audiobook: { asin: string; title: string; author: string; coverArtUrl?: string }; selectedPath: string; - audioFileCount: number; - totalSize: number; audioFiles: AudioFileEntry[]; + checkedFiles: Set; isImporting: boolean; importError: string | null; slideClass: string; cleanupSource: boolean; onCleanupSourceChange: (value: boolean) => void; + onToggleFile: (fileName: string) => void; + onToggleAll: () => void; onBack: () => void; onStartImport: () => void; } @@ -31,17 +32,23 @@ interface ConfirmPhaseProps { export function ConfirmPhase({ audiobook, selectedPath, - audioFileCount, - totalSize, audioFiles, + checkedFiles, isImporting, importError, slideClass, cleanupSource, onCleanupSourceChange, + onToggleFile, + onToggleAll, onBack, onStartImport, }: ConfirmPhaseProps) { + const allChecked = audioFiles.length > 0 && checkedFiles.size === audioFiles.length; + const someChecked = checkedFiles.size > 0 && !allChecked; + const checkedSize = audioFiles + .filter((f) => checkedFiles.has(f.name)) + .reduce((sum, f) => sum + f.size, 0); return (
@@ -79,28 +86,51 @@ export function ConfirmPhase({ {selectedPath}

- {audioFileCount} audio file{audioFileCount !== 1 ? 's' : ''} - {totalSize > 0 ? ` \u00B7 ${formatBytes(totalSize)}` : ''} + {checkedFiles.size} of {audioFiles.length} file{audioFiles.length !== 1 ? 's' : ''} selected + {checkedSize > 0 ? ` \u00B7 ${formatBytes(checkedSize)}` : ''}

{/* Audio files to import */}
-

- Files to import -

-
- {audioFiles.map((file) => ( -
- - - {file.name} - - - {formatBytes(file.size)} - -
- ))} +
+ { if (el) el.indeterminate = someChecked; }} + onChange={onToggleAll} + disabled={isImporting} + className="w-4 h-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500 cursor-pointer" + /> +

+ Files to import +

+
+
+ {audioFiles.map((file) => { + const isChecked = checkedFiles.has(file.name); + return ( + + ); + })}
@@ -149,7 +179,7 @@ export function ConfirmPhase({