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.
This commit is contained in:
kikootwo
2026-03-20 13:32:49 -04:00
parent 850e777a81
commit 8a757f5b67
15 changed files with 597 additions and 244 deletions
@@ -47,8 +47,6 @@ export function ManualImportBrowser({
const [currentPath, setCurrentPath] = useState<string | null>(null);
const [entries, setEntries] = useState<DirectoryEntry[]>([]);
const [selectedPath, setSelectedPath] = useState<string | null>(null);
const [selectedAudioCount, setSelectedAudioCount] = useState(0);
const [selectedSize, setSelectedSize] = useState(0);
const [selectedAudioFiles, setSelectedAudioFiles] = useState<AudioFileEntry[]>([]);
const [currentAudioFiles, setCurrentAudioFiles] = useState<AudioFileEntry[]>([]);
const [pathHistory, setPathHistory] = useState<string[]>([]);
@@ -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<Set<string>>(new Set());
// Hover state for folder icon swap
const [hoveredFolder, setHoveredFolder] = useState<string | null>(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({
<ConfirmPhase
audiobook={audiobook}
selectedPath={selectedPath!}
audioFileCount={selectedAudioCount}
totalSize={selectedSize}
audioFiles={selectedAudioFiles}
checkedFiles={checkedFiles}
isImporting={isImporting}
importError={importError}
slideClass={slideClass}
cleanupSource={cleanupSource}
onCleanupSourceChange={setCleanupSource}
onToggleFile={handleToggleFile}
onToggleAll={handleToggleAll}
onBack={handleBackToBrowse}
onStartImport={handleStartImport}
/>