Files
ReadMeABook/src/components/audiobooks/ManualImportBrowser.tsx
T
kikootwo 8a757f5b67 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.
2026-03-20 13:32:49 -04:00

347 lines
11 KiB
TypeScript

/**
* Component: Manual Import File Browser
* Documentation: documentation/features/manual-import.md
*
* Two-phase modal for browsing server directories and importing audiobook files.
* Phase 1 (BrowsePhase): Directory navigation with audio file detection.
* Phase 2 (ConfirmPhase): Review and start import.
*
* Sub-components: manual-import/BrowsePhase.tsx, manual-import/ConfirmPhase.tsx
*/
'use client';
import React, { useState, useEffect, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { fetchWithAuth } from '@/lib/utils/api';
import { FolderArrowDownIcon, XMarkIcon } from '@heroicons/react/24/outline';
import { RootEntry, DirectoryEntry, AudioFileEntry, SlideDirection } from './manual-import/types';
import { BrowsePhase } from './manual-import/BrowsePhase';
import { ConfirmPhase } from './manual-import/ConfirmPhase';
interface ManualImportBrowserProps {
isOpen: boolean;
onClose: () => void;
onSuccess: () => void;
audiobook: {
asin: string;
title: string;
author: string;
coverArtUrl?: string;
};
}
type Phase = 'browse' | 'confirm';
export function ManualImportBrowser({
isOpen,
onClose,
onSuccess,
audiobook,
}: ManualImportBrowserProps) {
const [phase, setPhase] = useState<Phase>('browse');
const [slideDirection, setSlideDirection] = useState<SlideDirection>('right');
// Browse state
const [roots, setRoots] = useState<RootEntry[]>([]);
const [currentPath, setCurrentPath] = useState<string | null>(null);
const [entries, setEntries] = useState<DirectoryEntry[]>([]);
const [selectedPath, setSelectedPath] = useState<string | null>(null);
const [selectedAudioFiles, setSelectedAudioFiles] = useState<AudioFileEntry[]>([]);
const [currentAudioFiles, setCurrentAudioFiles] = useState<AudioFileEntry[]>([]);
const [pathHistory, setPathHistory] = useState<string[]>([]);
// Loading/error state
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isImporting, setIsImporting] = useState(false);
const [importError, setImportError] = useState<string | null>(null);
// Cleanup source toggle
const [cleanupSource, setCleanupSource] = useState(false);
// File selection state (shared between BrowsePhase and ConfirmPhase)
const [checkedFiles, setCheckedFiles] = useState<Set<string>>(new Set());
// Hover state for folder icon swap
const [hoveredFolder, setHoveredFolder] = useState<string | null>(null);
// Fetch roots on open
useEffect(() => {
if (!isOpen) return;
setPhase('browse');
setCurrentPath(null);
setSelectedPath(null);
setPathHistory([]);
fetchRoots();
}, [isOpen]);
const fetchRoots = async () => {
setIsLoading(true);
setError(null);
try {
const res = await fetchWithAuth('/api/admin/filesystem/browse');
if (!res.ok) {
const data = await res.json().catch(() => ({ error: 'Failed to load' }));
throw new Error(data.error || 'Failed to load directories');
}
const data = await res.json();
setRoots(data.roots || []);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load directories');
} finally {
setIsLoading(false);
}
};
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)}`
);
if (!res.ok) {
const data = await res.json().catch(() => ({ error: 'Failed to load' }));
throw new Error(data.error || 'Failed to browse directory');
}
const data = await res.json();
const audioFiles: AudioFileEntry[] = data.audioFiles || [];
setEntries(data.entries || []);
setCurrentAudioFiles(audioFiles);
setCurrentPath(data.path || dirPath);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to browse directory');
} finally {
setIsLoading(false);
}
}, []);
const navigateInto = (dirPath: string) => {
setSlideDirection('right');
if (currentPath) {
setPathHistory((prev) => [...prev, currentPath]);
}
setSelectedPath(null);
fetchDirectory(dirPath);
};
const navigateBack = () => {
setSlideDirection('left');
setSelectedPath(null);
if (pathHistory.length > 0) {
const prevPath = pathHistory[pathHistory.length - 1];
setPathHistory((prev) => prev.slice(0, -1));
fetchDirectory(prevPath);
} else {
setCurrentPath(null);
setEntries([]);
}
};
const navigateToRoot = () => {
setSlideDirection('left');
setSelectedPath(null);
setCurrentPath(null);
setEntries([]);
setCurrentAudioFiles([]);
setPathHistory([]);
};
const navigateToBreadcrumb = (index: number) => {
if (!currentPath) return;
setSlideDirection('left');
setSelectedPath(null);
const allPaths = [...pathHistory, currentPath];
const targetPath = allPaths[index];
if (targetPath) {
setPathHistory(allPaths.slice(0, index));
fetchDirectory(targetPath);
} else {
navigateToRoot();
}
};
const handleFolderClick = (entry: DirectoryEntry) => {
const fullPath = currentPath + '/' + entry.name;
navigateInto(fullPath);
};
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);
setSelectedAudioFiles(selected);
// Ensure checkedFiles reflects what we're importing for ConfirmPhase
setCheckedFiles(new Set(selected.map((f) => f.name)));
setSlideDirection('right');
setPhase('confirm');
};
const handleBackToBrowse = () => {
setSlideDirection('left');
setPhase('browse');
};
const handleStartImport = async () => {
if (!selectedPath) return;
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,
}),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || 'Import failed');
}
onSuccess();
onClose();
} catch (err) {
setImportError(err instanceof Error ? err.message : 'Import failed');
} finally {
setIsImporting(false);
}
};
// Build breadcrumb segments
const breadcrumbs = (() => {
if (!currentPath) return [];
const allPaths = [...pathHistory, currentPath];
return allPaths.map((p) => {
const parts = p.replace(/\\/g, '/').split('/');
return parts[parts.length - 1] || p;
});
})();
const visibleBreadcrumbs = (() => {
if (breadcrumbs.length <= 3) return breadcrumbs.map((b, i) => ({ label: b, index: i }));
return [
{ label: breadcrumbs[0], index: 0 },
{ label: '...', index: -1 },
{ label: breadcrumbs[breadcrumbs.length - 1], index: breadcrumbs.length - 1 },
];
})();
if (!isOpen) return null;
const slideClass =
slideDirection === 'right'
? 'animate-[slideRight_200ms_ease-out]'
: 'animate-[slideLeft_200ms_ease-out]';
const modalContent = (
<div
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/60 backdrop-blur-sm"
style={{ height: '100dvh' }}
onClick={onClose}
>
<div
className="relative w-full max-w-2xl bg-white dark:bg-gray-900 rounded-2xl shadow-2xl overflow-hidden flex flex-col"
style={{ height: 'min(640px, 85vh)' }}
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-200 dark:border-gray-700/50">
<div className="flex items-center gap-2.5">
<FolderArrowDownIcon className="w-5 h-5 text-blue-600 dark:text-blue-400" />
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
{phase === 'browse' ? 'Manual Import' : 'Confirm Import'}
</h2>
</div>
<button
onClick={onClose}
className="p-1.5 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
>
<XMarkIcon className="w-5 h-5 text-gray-500 dark:text-gray-400" />
</button>
</div>
{/* Content */}
<div className="flex-1 min-h-0 overflow-hidden">
{phase === 'browse' ? (
<BrowsePhase
roots={roots}
currentPath={currentPath}
entries={entries}
currentAudioFiles={currentAudioFiles}
checkedFiles={checkedFiles}
isLoading={isLoading}
error={error}
hoveredFolder={hoveredFolder}
breadcrumbs={visibleBreadcrumbs}
slideClass={slideClass}
onNavigateInto={navigateInto}
onNavigateBack={navigateBack}
onNavigateToRoot={navigateToRoot}
onNavigateToBreadcrumb={navigateToBreadcrumb}
onFolderClick={handleFolderClick}
onSelectFiles={handleSelectFiles}
onToggleFile={handleToggleFile}
onHoverFolder={setHoveredFolder}
onRetry={currentPath ? () => fetchDirectory(currentPath) : fetchRoots}
/>
) : (
<ConfirmPhase
audiobook={audiobook}
selectedPath={selectedPath!}
audioFiles={selectedAudioFiles}
checkedFiles={checkedFiles}
isImporting={isImporting}
importError={importError}
slideClass={slideClass}
cleanupSource={cleanupSource}
onCleanupSourceChange={setCleanupSource}
onToggleFile={handleToggleFile}
onToggleAll={handleToggleAll}
onBack={handleBackToBrowse}
onStartImport={handleStartImport}
/>
)}
</div>
</div>
</div>
);
return createPortal(modalContent, document.body);
}