mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-04 13:20:11 +00:00
8a757f5b67
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.
347 lines
11 KiB
TypeScript
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);
|
|
}
|