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
+20 -8
View File
@@ -31,6 +31,7 @@ const RECYCLABLE_STATUSES = [
interface ImportItem { interface ImportItem {
folderPath: string; folderPath: string;
asin: string; asin: string;
audioFiles?: string[]; // Specific files to import (from scanner grouping)
} }
interface ImportResult { interface ImportResult {
@@ -105,7 +106,7 @@ export async function POST(request: NextRequest) {
const results: ImportResult[] = []; const results: ImportResult[] = [];
for (const item of imports) { for (const item of imports) {
const { folderPath, asin } = item; const { folderPath, asin, audioFiles: itemAudioFiles } = item;
try { try {
// Validate path // Validate path
@@ -119,7 +120,7 @@ export async function POST(request: NextRequest) {
continue; continue;
} }
// Verify directory exists and has audio files // Verify directory exists
try { try {
const stat = await fs.stat(normalizedPath); const stat = await fs.stat(normalizedPath);
if (!stat.isDirectory()) { if (!stat.isDirectory()) {
@@ -131,10 +132,14 @@ export async function POST(request: NextRequest) {
continue; continue;
} }
const hasAudio = await hasAudioFiles(normalizedPath); // Verify audio files: if specific files provided, trust the scanner;
if (!hasAudio) { // otherwise fall back to folder-level check
results.push({ folderPath, asin, success: false, error: 'No audio files' }); if (!itemAudioFiles || itemAudioFiles.length === 0) {
continue; const hasAudio = await hasAudioFiles(normalizedPath);
if (!hasAudio) {
results.push({ folderPath, asin, success: false, error: 'No audio files' });
continue;
}
} }
// Resolve or create audiobook record // Resolve or create audiobook record
@@ -250,8 +255,15 @@ export async function POST(request: NextRequest) {
requestId = newReq.id; requestId = newReq.id;
} }
// Queue organize_files job // Queue organize_files job (pass specific files if scanner provided them)
await jobQueue.addOrganizeJob(requestId, audiobookId, normalizedPath); await jobQueue.addOrganizeJob(
requestId,
audiobookId,
normalizedPath,
undefined,
false,
itemAudioFiles && itemAudioFiles.length > 0 ? itemAudioFiles : undefined
);
results.push({ folderPath, asin, success: true, requestId }); results.push({ folderPath, asin, success: true, requestId });
logger.info(`Bulk import queued: asin=${asin}, path=${normalizedPath}, request=${requestId}`); logger.info(`Bulk import queued: asin=${asin}, path=${normalizedPath}, request=${requestId}`);
@@ -209,6 +209,7 @@ export async function POST(request: NextRequest) {
totalSizeBytes: book.totalSizeBytes, totalSizeBytes: book.totalSizeBytes,
metadataSource: book.metadataSource, metadataSource: book.metadataSource,
searchTerm: book.searchTerm, searchTerm: book.searchTerm,
audioFiles: book.audioFiles,
match: match match: match
? { ? {
asin: match.asin, asin: match.asin,
+5 -55
View File
@@ -17,47 +17,6 @@ const logger = RMABLogger.create('API.Admin.Filesystem.Browse');
interface DirectoryEntry { interface DirectoryEntry {
name: string; name: string;
type: 'directory'; 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 // Read directory entries
const dirEntries = await fs.readdir(normalizedPath, { withFileTypes: true }); const dirEntries = await fs.readdir(normalizedPath, { withFileTypes: true });
// Gather stats for each subdirectory (parallel for performance) // List subdirectories (no nested stat calls — keeps browsing fast)
const directoryEntries = dirEntries.filter((e) => e.isDirectory()); const entries: DirectoryEntry[] = dirEntries
const statsPromises = directoryEntries.map(async (entry): Promise<DirectoryEntry> => { .filter((e) => e.isDirectory())
const fullPath = pathModule.join(normalizedPath, entry.name); .map((entry) => ({ name: entry.name, type: 'directory' as const }))
const stats = await getDirectoryStats(fullPath); .sort((a, b) => a.name.localeCompare(b.name));
return {
name: entry.name,
type: 'directory',
...stats,
};
});
const entries = await Promise.all(statsPromises);
entries.sort((a, b) => a.name.localeCompare(b.name));
// Gather audio files in the current directory // Gather audio files in the current directory
const audioFiles: Array<{ name: string; size: number }> = []; const audioFiles: Array<{ name: string; size: number }> = [];
+72 -10
View File
@@ -55,9 +55,25 @@ export async function POST(request: NextRequest) {
const fs = await import('fs/promises'); const fs = await import('fs/promises');
const body = await request.json(); const body = await request.json();
const { folderPath, asin, cleanupSource } = body; const { folderPath, asin, cleanupSource, selectedFiles } = body;
let { audiobookId } = 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 // Validate required fields
if ((!audiobookId && !asin) || !folderPath) { if ((!audiobookId && !asin) || !folderPath) {
return NextResponse.json( return NextResponse.json(
@@ -120,13 +136,52 @@ export async function POST(request: NextRequest) {
); );
} }
// Verify folder contains audio files // Verify selected files exist and are audio files, or fall back to folder scan
const audioCheck = await hasAudioFiles(normalizedPath); let audioFileCount: number;
if (!audioCheck.found) { const validatedFiles: string[] = [];
return NextResponse.json(
{ error: 'No audio files found in the selected directory' }, if (selectedFiles && selectedFiles.length > 0) {
{ status: 400 } 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 // Resolve audiobook by ASIN if audiobookId not provided
@@ -317,9 +372,16 @@ export async function POST(request: NextRequest) {
// Queue organize_files job // Queue organize_files job
const jobQueue = getJobQueueService(); 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({ return NextResponse.json({
success: true, success: true,
@@ -205,6 +205,7 @@ export function BulkImportWizard({ isOpen, onClose }: BulkImportWizardProps) {
imports: booksToImport.map((b) => ({ imports: booksToImport.map((b) => ({
folderPath: b.folderPath, folderPath: b.folderPath,
asin: b.match!.asin, asin: b.match!.asin,
audioFiles: b.audioFiles,
})), })),
}), }),
}); });
@@ -18,13 +18,12 @@ import {
HomeIcon, HomeIcon,
ChevronRightIcon, ChevronRightIcon,
ArrowLeftIcon, ArrowLeftIcon,
MusicalNoteIcon,
ExclamationTriangleIcon, ExclamationTriangleIcon,
ArrowPathIcon, ArrowPathIcon,
MagnifyingGlassIcon, MagnifyingGlassIcon,
} from '@heroicons/react/24/outline'; } from '@heroicons/react/24/outline';
import { fetchWithAuth } from '@/lib/utils/api'; import { fetchWithAuth } from '@/lib/utils/api';
import { RootEntry, DirectoryEntry, formatBytes } from './types'; import { RootEntry, DirectoryEntry } from './types';
function SkeletonRow() { function SkeletonRow() {
return ( return (
@@ -149,9 +148,8 @@ export function ScanFolderStep({ onFolderSelected }: ScanFolderStepProps) {
]; ];
})(); })();
// Count total audio files and subfolders in current listing // Count subfolders in current listing
const totalSubfolders = entries.reduce((sum, e) => sum + e.subfolderCount, 0); const totalSubfolders = entries.length;
const totalAudioInChildren = entries.reduce((sum, e) => sum + e.audioFileCount, 0);
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
@@ -248,7 +246,6 @@ export function ScanFolderStep({ onFolderSelected }: ScanFolderStepProps) {
{currentPath && !isLoading && !error && entries.length > 0 && ( {currentPath && !isLoading && !error && entries.length > 0 && (
<div className="divide-y divide-gray-100 dark:divide-gray-800"> <div className="divide-y divide-gray-100 dark:divide-gray-800">
{entries.map((entry) => { {entries.map((entry) => {
const hasAudio = entry.audioFileCount > 0;
const isHovered = hoveredFolder === entry.name; const isHovered = hoveredFolder === entry.name;
return ( return (
@@ -267,33 +264,9 @@ export function ScanFolderStep({ onFolderSelected }: ScanFolderStepProps) {
)} )}
</div> </div>
<div className="flex-1 min-w-0"> <p className="flex-1 min-w-0 text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
<p className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate"> {entry.name}
{entry.name} </p>
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
{entry.subfolderCount > 0 && (
<span>{entry.subfolderCount} folder{entry.subfolderCount !== 1 ? 's' : ''}</span>
)}
{entry.subfolderCount > 0 && entry.audioFileCount > 0 && <span> &middot; </span>}
{entry.audioFileCount > 0 && (
<span>{entry.audioFileCount} audio file{entry.audioFileCount !== 1 ? 's' : ''}</span>
)}
{entry.totalSize > 0 && (
<span> &middot; {formatBytes(entry.totalSize)}</span>
)}
{entry.subfolderCount === 0 && entry.audioFileCount === 0 && (
<span className="italic">Empty</span>
)}
</p>
</div>
{hasAudio && (
<span className="flex-shrink-0 inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 text-xs font-medium">
<MusicalNoteIcon className="w-3 h-3" />
{entry.audioFileCount}
</span>
)}
<ChevronRightIcon className="w-4 h-4 text-gray-300 dark:text-gray-600 flex-shrink-0" /> <ChevronRightIcon className="w-4 h-4 text-gray-300 dark:text-gray-600 flex-shrink-0" />
</button> </button>
@@ -325,10 +298,7 @@ export function ScanFolderStep({ onFolderSelected }: ScanFolderStepProps) {
<p className="font-mono text-xs text-gray-500 dark:text-gray-500 truncate">{currentPath}</p> <p className="font-mono text-xs text-gray-500 dark:text-gray-500 truncate">{currentPath}</p>
{entries.length > 0 && ( {entries.length > 0 && (
<p className="mt-0.5"> <p className="mt-0.5">
{entries.length} subfolder{entries.length !== 1 ? 's' : ''} {totalSubfolders} subfolder{totalSubfolders !== 1 ? 's' : ''}
{totalAudioInChildren > 0 && (
<span> &middot; {totalAudioInChildren} audio files visible</span>
)}
</p> </p>
)} )}
</div> </div>
+2 -4
View File
@@ -14,9 +14,6 @@ export interface RootEntry {
export interface DirectoryEntry { export interface DirectoryEntry {
name: string; name: string;
type: 'directory'; type: 'directory';
audioFileCount: number;
subfolderCount: number;
totalSize: number;
} }
/** Audible match data for a discovered audiobook. */ /** Audible match data for a discovered audiobook. */
@@ -39,6 +36,7 @@ export interface ScannedBook {
totalSizeBytes: number; totalSizeBytes: number;
metadataSource: 'tags' | 'file_name'; metadataSource: 'tags' | 'file_name';
searchTerm: string; searchTerm: string;
audioFiles: string[];
match: AudibleMatch | null; match: AudibleMatch | null;
inLibrary: boolean; inLibrary: boolean;
hasActiveRequest: boolean; hasActiveRequest: boolean;
@@ -48,7 +46,7 @@ export interface ScannedBook {
/** Progress event from the SSE scan stream. */ /** Progress event from the SSE scan stream. */
export interface ScanProgressEvent { export interface ScanProgressEvent {
phase: 'discovering' | 'reading_metadata'; phase: 'discovering' | 'reading_metadata' | 'grouping';
foldersScanned: number; foldersScanned: number;
audiobooksFound: number; audiobooksFound: number;
currentFolder?: string; currentFolder?: string;
@@ -47,8 +47,6 @@ export function ManualImportBrowser({
const [currentPath, setCurrentPath] = useState<string | null>(null); const [currentPath, setCurrentPath] = useState<string | null>(null);
const [entries, setEntries] = useState<DirectoryEntry[]>([]); const [entries, setEntries] = useState<DirectoryEntry[]>([]);
const [selectedPath, setSelectedPath] = useState<string | null>(null); const [selectedPath, setSelectedPath] = useState<string | null>(null);
const [selectedAudioCount, setSelectedAudioCount] = useState(0);
const [selectedSize, setSelectedSize] = useState(0);
const [selectedAudioFiles, setSelectedAudioFiles] = useState<AudioFileEntry[]>([]); const [selectedAudioFiles, setSelectedAudioFiles] = useState<AudioFileEntry[]>([]);
const [currentAudioFiles, setCurrentAudioFiles] = useState<AudioFileEntry[]>([]); const [currentAudioFiles, setCurrentAudioFiles] = useState<AudioFileEntry[]>([]);
const [pathHistory, setPathHistory] = useState<string[]>([]); const [pathHistory, setPathHistory] = useState<string[]>([]);
@@ -62,6 +60,9 @@ export function ManualImportBrowser({
// Cleanup source toggle // Cleanup source toggle
const [cleanupSource, setCleanupSource] = useState(false); 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 // Hover state for folder icon swap
const [hoveredFolder, setHoveredFolder] = useState<string | null>(null); const [hoveredFolder, setHoveredFolder] = useState<string | null>(null);
@@ -96,6 +97,7 @@ export function ManualImportBrowser({
const fetchDirectory = useCallback(async (dirPath: string) => { const fetchDirectory = useCallback(async (dirPath: string) => {
setIsLoading(true); setIsLoading(true);
setError(null); setError(null);
setCheckedFiles(new Set());
try { try {
const res = await fetchWithAuth( const res = await fetchWithAuth(
`/api/admin/filesystem/browse?path=${encodeURIComponent(dirPath)}` `/api/admin/filesystem/browse?path=${encodeURIComponent(dirPath)}`
@@ -105,8 +107,9 @@ export function ManualImportBrowser({
throw new Error(data.error || 'Failed to browse directory'); throw new Error(data.error || 'Failed to browse directory');
} }
const data = await res.json(); const data = await res.json();
const audioFiles: AudioFileEntry[] = data.audioFiles || [];
setEntries(data.entries || []); setEntries(data.entries || []);
setCurrentAudioFiles(data.audioFiles || []); setCurrentAudioFiles(audioFiles);
setCurrentPath(data.path || dirPath); setCurrentPath(data.path || dirPath);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Failed to browse directory'); setError(err instanceof Error ? err.message : 'Failed to browse directory');
@@ -165,12 +168,38 @@ export function ManualImportBrowser({
navigateInto(fullPath); 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; 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); setSelectedPath(currentPath);
setSelectedAudioCount(currentAudioFiles.length); setSelectedAudioFiles(selected);
setSelectedSize(currentAudioFiles.reduce((sum, f) => sum + f.size, 0)); // Ensure checkedFiles reflects what we're importing for ConfirmPhase
setSelectedAudioFiles(currentAudioFiles); setCheckedFiles(new Set(selected.map((f) => f.name)));
setSlideDirection('right'); setSlideDirection('right');
setPhase('confirm'); setPhase('confirm');
}; };
@@ -185,12 +214,18 @@ export function ManualImportBrowser({
setIsImporting(true); setIsImporting(true);
setImportError(null); setImportError(null);
try { 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', { const res = await fetchWithAuth('/api/admin/manual-import', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
asin: audiobook.asin, asin: audiobook.asin,
folderPath: selectedPath, folderPath: selectedPath,
selectedFiles: fileNames,
cleanupSource, cleanupSource,
}), }),
}); });
@@ -268,6 +303,7 @@ export function ManualImportBrowser({
currentPath={currentPath} currentPath={currentPath}
entries={entries} entries={entries}
currentAudioFiles={currentAudioFiles} currentAudioFiles={currentAudioFiles}
checkedFiles={checkedFiles}
isLoading={isLoading} isLoading={isLoading}
error={error} error={error}
hoveredFolder={hoveredFolder} hoveredFolder={hoveredFolder}
@@ -278,7 +314,8 @@ export function ManualImportBrowser({
onNavigateToRoot={navigateToRoot} onNavigateToRoot={navigateToRoot}
onNavigateToBreadcrumb={navigateToBreadcrumb} onNavigateToBreadcrumb={navigateToBreadcrumb}
onFolderClick={handleFolderClick} onFolderClick={handleFolderClick}
onSelectCurrentFolder={handleSelectCurrentFolder} onSelectFiles={handleSelectFiles}
onToggleFile={handleToggleFile}
onHoverFolder={setHoveredFolder} onHoverFolder={setHoveredFolder}
onRetry={currentPath ? () => fetchDirectory(currentPath) : fetchRoots} onRetry={currentPath ? () => fetchDirectory(currentPath) : fetchRoots}
/> />
@@ -286,14 +323,15 @@ export function ManualImportBrowser({
<ConfirmPhase <ConfirmPhase
audiobook={audiobook} audiobook={audiobook}
selectedPath={selectedPath!} selectedPath={selectedPath!}
audioFileCount={selectedAudioCount}
totalSize={selectedSize}
audioFiles={selectedAudioFiles} audioFiles={selectedAudioFiles}
checkedFiles={checkedFiles}
isImporting={isImporting} isImporting={isImporting}
importError={importError} importError={importError}
slideClass={slideClass} slideClass={slideClass}
cleanupSource={cleanupSource} cleanupSource={cleanupSource}
onCleanupSourceChange={setCleanupSource} onCleanupSourceChange={setCleanupSource}
onToggleFile={handleToggleFile}
onToggleAll={handleToggleAll}
onBack={handleBackToBrowse} onBack={handleBackToBrowse}
onStartImport={handleStartImport} onStartImport={handleStartImport}
/> />
@@ -40,6 +40,7 @@ interface BrowsePhaseProps {
currentPath: string | null; currentPath: string | null;
entries: DirectoryEntry[]; entries: DirectoryEntry[];
currentAudioFiles: AudioFileEntry[]; currentAudioFiles: AudioFileEntry[];
checkedFiles: Set<string>;
isLoading: boolean; isLoading: boolean;
error: string | null; error: string | null;
hoveredFolder: string | null; hoveredFolder: string | null;
@@ -50,7 +51,8 @@ interface BrowsePhaseProps {
onNavigateToRoot: () => void; onNavigateToRoot: () => void;
onNavigateToBreadcrumb: (index: number) => void; onNavigateToBreadcrumb: (index: number) => void;
onFolderClick: (entry: DirectoryEntry) => void; onFolderClick: (entry: DirectoryEntry) => void;
onSelectCurrentFolder: () => void; onSelectFiles: () => void;
onToggleFile: (fileName: string) => void;
onHoverFolder: (name: string | null) => void; onHoverFolder: (name: string | null) => void;
onRetry: () => void; onRetry: () => void;
} }
@@ -60,6 +62,7 @@ export function BrowsePhase({
currentPath, currentPath,
entries, entries,
currentAudioFiles, currentAudioFiles,
checkedFiles,
isLoading, isLoading,
error, error,
hoveredFolder, hoveredFolder,
@@ -70,10 +73,16 @@ export function BrowsePhase({
onNavigateToRoot, onNavigateToRoot,
onNavigateToBreadcrumb, onNavigateToBreadcrumb,
onFolderClick, onFolderClick,
onSelectCurrentFolder, onSelectFiles,
onToggleFile,
onHoverFolder, onHoverFolder,
onRetry, onRetry,
}: BrowsePhaseProps) { }: 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 ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
{/* Breadcrumb bar */} {/* Breadcrumb bar */}
@@ -165,7 +174,6 @@ export function BrowsePhase({
<div className="divide-y divide-gray-100 dark:divide-gray-800"> <div className="divide-y divide-gray-100 dark:divide-gray-800">
{/* Subdirectories */} {/* Subdirectories */}
{entries.map((entry) => { {entries.map((entry) => {
const hasAudio = entry.audioFileCount > 0;
const isHovered = hoveredFolder === entry.name; const isHovered = hoveredFolder === entry.name;
return ( return (
@@ -184,33 +192,9 @@ export function BrowsePhase({
)} )}
</div> </div>
<div className="flex-1 min-w-0"> <p className="flex-1 min-w-0 text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
<p className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate"> {entry.name}
{entry.name} </p>
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
{entry.subfolderCount > 0 && (
<span>{entry.subfolderCount} folder{entry.subfolderCount !== 1 ? 's' : ''}</span>
)}
{entry.subfolderCount > 0 && entry.audioFileCount > 0 && <span> &middot; </span>}
{entry.audioFileCount > 0 && (
<span>{entry.audioFileCount} audio file{entry.audioFileCount !== 1 ? 's' : ''}</span>
)}
{entry.totalSize > 0 && (
<span> &middot; {formatBytes(entry.totalSize)}</span>
)}
{entry.subfolderCount === 0 && entry.audioFileCount === 0 && (
<span className="italic">Empty</span>
)}
</p>
</div>
{hasAudio && (
<span className="flex-shrink-0 inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 text-xs font-medium">
<MusicalNoteIcon className="w-3 h-3" />
{entry.audioFileCount}
</span>
)}
<ChevronRightIcon className="w-4 h-4 text-gray-300 dark:text-gray-600 flex-shrink-0" /> <ChevronRightIcon className="w-4 h-4 text-gray-300 dark:text-gray-600 flex-shrink-0" />
</button> </button>
@@ -221,24 +205,38 @@ export function BrowsePhase({
{currentAudioFiles.length > 0 && entries.length > 0 && ( {currentAudioFiles.length > 0 && entries.length > 0 && (
<div className="px-4 py-2 bg-gray-50/50 dark:bg-gray-800/20"> <div className="px-4 py-2 bg-gray-50/50 dark:bg-gray-800/20">
<p className="text-xs font-medium text-gray-400 dark:text-gray-500 uppercase tracking-wider"> <p className="text-xs font-medium text-gray-400 dark:text-gray-500 uppercase tracking-wider">
Audio Files Audio Files {hasSelection && `\u00B7 click to select`}
</p> </p>
</div> </div>
)} )}
{currentAudioFiles.map((file) => ( {currentAudioFiles.map((file) => {
<div const isSelected = checkedFiles.has(file.name);
key={`file-${file.name}`} return (
className="flex items-center gap-3 px-4 py-2.5" <button
> key={`file-${file.name}`}
<MusicalNoteIcon className="w-4 h-4 text-blue-500 dark:text-blue-400 flex-shrink-0" /> onClick={() => onToggleFile(file.name)}
<span className="flex-1 min-w-0 text-sm text-gray-700 dark:text-gray-300 truncate"> className={`w-full flex items-center gap-3 px-4 py-2.5 text-left transition-colors ${
{file.name} isSelected
</span> ? 'bg-blue-50 dark:bg-blue-900/20 border-l-2 border-blue-500'
<span className="text-xs text-gray-400 dark:text-gray-500 flex-shrink-0"> : 'hover:bg-gray-50 dark:hover:bg-gray-800/50 border-l-2 border-transparent'
{formatBytes(file.size)} }`}
</span> >
</div> <MusicalNoteIcon className={`w-4 h-4 flex-shrink-0 ${
))} isSelected ? 'text-blue-600 dark:text-blue-400' : 'text-blue-500/50 dark:text-blue-400/50'
}`} />
<span className={`flex-1 min-w-0 text-sm truncate ${
isSelected
? 'text-blue-900 dark:text-blue-100 font-medium'
: 'text-gray-700 dark:text-gray-300'
}`}>
{file.name}
</span>
<span className="text-xs text-gray-400 dark:text-gray-500 flex-shrink-0">
{formatBytes(file.size)}
</span>
</button>
);
})}
</div> </div>
)} )}
@@ -258,18 +256,33 @@ export function BrowsePhase({
)} )}
</div> </div>
{/* Footer: Select this folder */} {/* Footer */}
{currentPath && !isLoading && currentAudioFiles.length > 0 && ( {currentPath && !isLoading && currentAudioFiles.length > 0 && (
<div className="px-5 py-3.5 border-t border-gray-200 dark:border-gray-700/50 bg-gray-50/50 dark:bg-gray-800/30 flex items-center justify-between gap-4"> <div className="px-5 py-3.5 border-t border-gray-200 dark:border-gray-700/50 bg-gray-50/50 dark:bg-gray-800/30 flex items-center justify-between gap-4">
<p className="text-sm text-gray-600 dark:text-gray-400"> <p className="text-sm text-gray-600 dark:text-gray-400">
<span className="font-medium text-gray-900 dark:text-gray-100">{currentAudioFiles.length}</span> {hasSelection ? (
{' '}audio file{currentAudioFiles.length !== 1 ? 's' : ''} in this folder <>
<span className="font-medium text-gray-900 dark:text-gray-100">{checkedFiles.size}</span>
{' '}of {currentAudioFiles.length} file{currentAudioFiles.length !== 1 ? 's' : ''} selected
</>
) : (
<>
<span className="font-medium text-gray-900 dark:text-gray-100">{currentAudioFiles.length}</span>
{' '}audio file{currentAudioFiles.length !== 1 ? 's' : ''} in this folder
</>
)}
{checkedSize > 0 && (
<span className="text-gray-400 dark:text-gray-500"> &middot; {formatBytes(checkedSize)}</span>
)}
</p> </p>
<button <button
onClick={onSelectCurrentFolder} onClick={onSelectFiles}
className="flex-shrink-0 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-xl transition-colors" className="flex-shrink-0 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-xl transition-colors"
> >
Select This Folder &rarr; {hasSelection
? `Select ${checkedFiles.size} File${checkedFiles.size !== 1 ? 's' : ''}`
: 'Select This Folder'
} &rarr;
</button> </button>
</div> </div>
)} )}
@@ -16,14 +16,15 @@ import { AudioFileEntry, formatBytes } from './types';
interface ConfirmPhaseProps { interface ConfirmPhaseProps {
audiobook: { asin: string; title: string; author: string; coverArtUrl?: string }; audiobook: { asin: string; title: string; author: string; coverArtUrl?: string };
selectedPath: string; selectedPath: string;
audioFileCount: number;
totalSize: number;
audioFiles: AudioFileEntry[]; audioFiles: AudioFileEntry[];
checkedFiles: Set<string>;
isImporting: boolean; isImporting: boolean;
importError: string | null; importError: string | null;
slideClass: string; slideClass: string;
cleanupSource: boolean; cleanupSource: boolean;
onCleanupSourceChange: (value: boolean) => void; onCleanupSourceChange: (value: boolean) => void;
onToggleFile: (fileName: string) => void;
onToggleAll: () => void;
onBack: () => void; onBack: () => void;
onStartImport: () => void; onStartImport: () => void;
} }
@@ -31,17 +32,23 @@ interface ConfirmPhaseProps {
export function ConfirmPhase({ export function ConfirmPhase({
audiobook, audiobook,
selectedPath, selectedPath,
audioFileCount,
totalSize,
audioFiles, audioFiles,
checkedFiles,
isImporting, isImporting,
importError, importError,
slideClass, slideClass,
cleanupSource, cleanupSource,
onCleanupSourceChange, onCleanupSourceChange,
onToggleFile,
onToggleAll,
onBack, onBack,
onStartImport, onStartImport,
}: ConfirmPhaseProps) { }: 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 ( return (
<div className={`flex flex-col h-full ${slideClass}`}> <div className={`flex flex-col h-full ${slideClass}`}>
<div className="flex-1 overflow-y-auto p-6 space-y-6"> <div className="flex-1 overflow-y-auto p-6 space-y-6">
@@ -79,28 +86,51 @@ export function ConfirmPhase({
{selectedPath} {selectedPath}
</p> </p>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1.5"> <p className="text-xs text-gray-500 dark:text-gray-400 mt-1.5">
{audioFileCount} audio file{audioFileCount !== 1 ? 's' : ''} {checkedFiles.size} of {audioFiles.length} file{audioFiles.length !== 1 ? 's' : ''} selected
{totalSize > 0 ? ` \u00B7 ${formatBytes(totalSize)}` : ''} {checkedSize > 0 ? ` \u00B7 ${formatBytes(checkedSize)}` : ''}
</p> </p>
</div> </div>
{/* Audio files to import */} {/* Audio files to import */}
<div> <div>
<h4 className="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-3"> <div className="flex items-center gap-3 mb-3">
Files to import <input
</h4> type="checkbox"
<div className="rounded-xl border border-gray-200 dark:border-gray-700 divide-y divide-gray-100 dark:divide-gray-800 overflow-hidden"> checked={allChecked}
{audioFiles.map((file) => ( ref={(el) => { if (el) el.indeterminate = someChecked; }}
<div key={file.name} className="flex items-center gap-3 px-3.5 py-2.5"> onChange={onToggleAll}
<MusicalNoteIcon className="w-4 h-4 text-blue-500 dark:text-blue-400 flex-shrink-0" /> disabled={isImporting}
<span className="flex-1 min-w-0 text-sm text-gray-700 dark:text-gray-300 truncate"> className="w-4 h-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500 cursor-pointer"
{file.name} />
</span> <h4 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
<span className="text-xs text-gray-400 dark:text-gray-500 flex-shrink-0"> Files to import
{formatBytes(file.size)} </h4>
</span> </div>
</div> <div className="rounded-xl border border-gray-200 dark:border-gray-700 divide-y divide-gray-100 dark:divide-gray-800 overflow-hidden max-h-48 overflow-y-auto">
))} {audioFiles.map((file) => {
const isChecked = checkedFiles.has(file.name);
return (
<label
key={file.name}
className={`flex items-center gap-3 px-3.5 py-2.5 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors ${!isChecked ? 'opacity-50' : ''}`}
>
<input
type="checkbox"
checked={isChecked}
onChange={() => onToggleFile(file.name)}
disabled={isImporting}
className="w-4 h-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500 cursor-pointer"
/>
<MusicalNoteIcon className="w-4 h-4 text-blue-500 dark:text-blue-400 flex-shrink-0" />
<span className="flex-1 min-w-0 text-sm text-gray-700 dark:text-gray-300 truncate">
{file.name}
</span>
<span className="text-xs text-gray-400 dark:text-gray-500 flex-shrink-0">
{formatBytes(file.size)}
</span>
</label>
);
})}
</div> </div>
</div> </div>
@@ -149,7 +179,7 @@ export function ConfirmPhase({
</button> </button>
<button <button
onClick={onStartImport} onClick={onStartImport}
disabled={isImporting} disabled={isImporting || checkedFiles.size === 0}
className="px-5 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-xl transition-colors disabled:opacity-70 flex items-center gap-2" className="px-5 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-xl transition-colors disabled:opacity-70 flex items-center gap-2"
> >
{isImporting ? ( {isImporting ? (
@@ -12,9 +12,6 @@ export interface RootEntry {
export interface DirectoryEntry { export interface DirectoryEntry {
name: string; name: string;
type: 'directory'; type: 'directory';
audioFileCount: number;
subfolderCount: number;
totalSize: number;
} }
export interface AudioFileEntry { export interface AudioFileEntry {
+29 -10
View File
@@ -23,7 +23,7 @@ import { getAudibleService } from '../integrations/audible.service';
* Handles both audiobook and ebook request types with appropriate branching * Handles both audiobook and ebook request types with appropriate branching
*/ */
export async function processOrganizeFiles(payload: OrganizeFilesPayload): Promise<any> { export async function processOrganizeFiles(payload: OrganizeFilesPayload): Promise<any> {
const { requestId, audiobookId, downloadPath, jobId, cleanupSource } = payload; const { requestId, audiobookId, downloadPath, jobId, cleanupSource, selectedFiles } = payload;
const logger = RMABLogger.forJob(jobId, 'OrganizeFiles'); const logger = RMABLogger.forJob(jobId, 'OrganizeFiles');
@@ -212,7 +212,8 @@ export async function processOrganizeFiles(payload: OrganizeFilesPayload): Promi
}, },
template, template,
jobId ? { jobId, context: 'FileOrganizer' } : undefined, jobId ? { jobId, context: 'FileOrganizer' } : undefined,
renameConfig renameConfig,
selectedFiles
); );
if (!result.success) { if (!result.success) {
@@ -322,7 +323,7 @@ export async function processOrganizeFiles(payload: OrganizeFilesPayload): Promi
// Cleanup source files if requested (manual import feature) // Cleanup source files if requested (manual import feature)
if (cleanupSource) { if (cleanupSource) {
await cleanupSourceAfterOrganize(downloadPath, configService, jobId, logger); await cleanupSourceAfterOrganize(downloadPath, configService, jobId, logger, selectedFiles);
} }
return { return {
@@ -1132,20 +1133,38 @@ async function cleanupSourceAfterOrganize(
downloadPath: string, downloadPath: string,
configService: any, configService: any,
jobId: string | undefined, jobId: string | undefined,
logger: RMABLogger logger: RMABLogger,
selectedFiles?: string[]
): Promise<void> { ): Promise<void> {
try { try {
const fs = await import('fs/promises'); const fs = await import('fs/promises');
const pathModule = await import('path');
logger.info(`Cleaning up source files: ${downloadPath}`); logger.info(`Cleaning up source files: ${downloadPath}`);
const stats = await fs.stat(downloadPath); if (selectedFiles && selectedFiles.length > 0) {
if (stats.isDirectory()) { // Only delete the specific files that were imported, not the entire directory
await fs.rm(downloadPath, { recursive: true, force: true }); for (const fileName of selectedFiles) {
logger.info(`Removed source directory: ${downloadPath}`); const filePath = pathModule.join(downloadPath, fileName);
try {
await fs.unlink(filePath);
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
logger.warn(`Failed to delete source file: ${filePath}`);
}
}
}
logger.info(`Removed ${selectedFiles.length} selected source files from ${downloadPath}`);
} else { } else {
await fs.unlink(downloadPath); // No file filter — delete entire source path (original behavior)
logger.info(`Removed source file: ${downloadPath}`); const stats = await fs.stat(downloadPath);
if (stats.isDirectory()) {
await fs.rm(downloadPath, { recursive: true, force: true });
logger.info(`Removed source directory: ${downloadPath}`);
} else {
await fs.unlink(downloadPath);
logger.info(`Removed source file: ${downloadPath}`);
}
} }
// Determine boundary path based on download path prefix // Determine boundary path based on download path prefix
+4 -1
View File
@@ -76,6 +76,7 @@ export interface OrganizeFilesPayload extends JobPayload {
downloadPath: string; downloadPath: string;
targetPath?: string; // Optional - not used by processor (reads from database config) targetPath?: string; // Optional - not used by processor (reads from database config)
cleanupSource?: boolean; // If true, delete source files after successful import cleanupSource?: boolean; // If true, delete source files after successful import
selectedFiles?: string[]; // If set, only import these specific files from downloadPath
} }
export interface ScanPlexPayload extends JobPayload { export interface ScanPlexPayload extends JobPayload {
@@ -644,7 +645,8 @@ export class JobQueueService {
audiobookId: string, audiobookId: string,
downloadPath: string, downloadPath: string,
targetPath?: string, targetPath?: string,
cleanupSource?: boolean cleanupSource?: boolean,
selectedFiles?: string[]
): Promise<string> { ): Promise<string> {
return await this.addJob( return await this.addJob(
'organize_files', 'organize_files',
@@ -654,6 +656,7 @@ export class JobQueueService {
downloadPath, downloadPath,
targetPath, // Not used by processor targetPath, // Not used by processor
cleanupSource, cleanupSource,
selectedFiles,
} as OrganizeFilesPayload, } as OrganizeFilesPayload,
{ {
priority: 8, priority: 8,
+283 -32
View File
@@ -3,7 +3,8 @@
* Documentation: documentation/features/bulk-import.md * Documentation: documentation/features/bulk-import.md
* *
* Recursively discovers audiobook folders, reads embedded metadata via ffprobe, * Recursively discovers audiobook folders, reads embedded metadata via ffprobe,
* and prepares search terms for Audible matching. Used by the bulk import API. * groups loose audio files by metadata, and prepares search terms for Audible
* matching. Used by the bulk import API.
*/ */
import { exec } from 'child_process'; import { exec } from 'child_process';
@@ -17,6 +18,9 @@ const execPromise = promisify(exec);
/** Maximum recursion depth for folder scanning. */ /** Maximum recursion depth for folder scanning. */
export const MAX_SCAN_DEPTH = 10; export const MAX_SCAN_DEPTH = 10;
/** Maximum concurrent ffprobe calls for metadata reads. */
const METADATA_CONCURRENCY = 10;
/** Metadata extracted from an audio file via ffprobe. */ /** Metadata extracted from an audio file via ffprobe. */
export interface AudioFileMetadata { export interface AudioFileMetadata {
title?: string; // From 'album' tag (book title) title?: string; // From 'album' tag (book title)
@@ -36,11 +40,13 @@ export interface DiscoveredAudiobook {
metadata: AudioFileMetadata; metadata: AudioFileMetadata;
searchTerm: string; // Constructed search query for Audible searchTerm: string; // Constructed search query for Audible
metadataSource: 'tags' | 'file_name'; // Where the search term came from metadataSource: 'tags' | 'file_name'; // Where the search term came from
audioFiles: string[]; // File names (relative to folderPath) belonging to this book
groupingKey: string; // Normalized key for cross-folder deduplication
} }
/** Progress callback for streaming updates to the caller. */ /** Progress callback for streaming updates to the caller. */
export interface ScanProgress { export interface ScanProgress {
phase: 'discovering' | 'reading_metadata'; phase: 'discovering' | 'reading_metadata' | 'grouping';
foldersScanned: number; foldersScanned: number;
audiobooksFound: number; audiobooksFound: number;
currentFolder?: string; currentFolder?: string;
@@ -173,7 +179,25 @@ export function buildSearchTerm(
} }
/** /**
* Scan a single directory for audio files. * Build a normalized grouping key from metadata.
* Used to determine which files belong to the same book.
* Returns null if metadata has no title (ungroupable).
*/
function buildGroupingKey(metadata: AudioFileMetadata): string | null {
if (!metadata.title) return null;
const normalize = (s?: string) =>
(s || '').toLowerCase().replace(/[^a-z0-9]/g, '');
return [
normalize(metadata.title),
normalize(metadata.author),
normalize(metadata.narrator),
].join('|');
}
/**
* Scan a single directory for audio files (immediate children only).
* Returns audio file names and total size, or null if no audio files found. * Returns audio file names and total size, or null if no audio files found.
*/ */
async function scanDirectoryForAudio( async function scanDirectoryForAudio(
@@ -206,11 +230,216 @@ async function scanDirectoryForAudio(
} }
/** /**
* Recursively discover audiobook folders starting from a root path. * Run async tasks with a concurrency limit.
*/
async function asyncPool<T, R>(
items: T[],
concurrency: number,
fn: (item: T) => Promise<R>
): Promise<R[]> {
const results: R[] = [];
let index = 0;
async function worker() {
while (index < items.length) {
const i = index++;
results[i] = await fn(items[i]);
}
}
const workers = Array.from(
{ length: Math.min(concurrency, items.length) },
() => worker()
);
await Promise.all(workers);
return results;
}
/**
* Group audio files in a directory by their metadata.
* Reads metadata from all files using a concurrency pool, then groups them
* by a normalized key of title + author + narrator.
* Files with no metadata title each become their own group.
*/
async function groupAudioFilesByMetadata(
dirPath: string,
audioFiles: string[],
audioSizes: Map<string, number>
): Promise<Array<{
files: string[];
totalSize: number;
metadata: AudioFileMetadata;
metadataSource: 'tags' | 'file_name';
searchTerm: string;
groupingKey: string;
}>> {
// Read metadata from all files with concurrency limit
const metadataResults = await asyncPool(
audioFiles,
METADATA_CONCURRENCY,
async (fileName) => {
const filePath = path.join(dirPath, fileName);
const metadata = await readAudioMetadata(filePath);
return { fileName, metadata };
}
);
// Group by metadata key
const groups = new Map<string, {
files: string[];
totalSize: number;
metadata: AudioFileMetadata;
}>();
let ungroupedCounter = 0;
for (const { fileName, metadata } of metadataResults) {
const key = buildGroupingKey(metadata);
const fileSize = audioSizes.get(fileName) || 0;
if (key) {
// Has metadata — group with others sharing the same key
const existing = groups.get(key);
if (existing) {
existing.files.push(fileName);
existing.totalSize += fileSize;
} else {
groups.set(key, {
files: [fileName],
totalSize: fileSize,
metadata,
});
}
} else {
// No title metadata — treat as individual book
const uniqueKey = `__ungrouped_${ungroupedCounter++}`;
groups.set(uniqueKey, {
files: [fileName],
totalSize: fileSize,
metadata,
});
}
}
// Build result with search terms
return Array.from(groups.entries()).map(([groupingKey, group]) => {
group.files.sort((a, b) => a.localeCompare(b));
const { searchTerm, source } = buildSearchTerm(group.metadata, group.files[0]);
return {
files: group.files,
totalSize: group.totalSize,
metadata: group.metadata,
metadataSource: source,
searchTerm,
groupingKey,
};
});
}
/**
* Merge discoveries that share the same grouping key across different folders.
* Handles the multi-CD case (e.g., CD1/ and CD2/ with same metadata).
*/
function deduplicateDiscoveries(
discoveries: DiscoveredAudiobook[]
): DiscoveredAudiobook[] {
const byKey = new Map<string, DiscoveredAudiobook[]>();
for (const disc of discoveries) {
// Skip ungrouped entries (each is unique)
if (disc.groupingKey.startsWith('__ungrouped_')) {
const key = `${disc.folderPath}::${disc.groupingKey}`;
byKey.set(key, [disc]);
continue;
}
const existing = byKey.get(disc.groupingKey);
if (existing) {
existing.push(disc);
} else {
byKey.set(disc.groupingKey, [disc]);
}
}
const merged: DiscoveredAudiobook[] = [];
for (const group of byKey.values()) {
if (group.length === 1) {
merged.push(group[0]);
continue;
}
// Merge multiple discoveries with the same key
// Use the common parent directory as the folder path
const allPaths = group.map((d) => d.folderPath);
const commonParent = findCommonParent(allPaths);
const first = group[0];
// Combine audio files with relative paths from the common parent
const combinedFiles: string[] = [];
let combinedSize = 0;
let combinedCount = 0;
for (const disc of group) {
const relPrefix = path.relative(commonParent, disc.folderPath).replace(/\\/g, '/');
for (const file of disc.audioFiles) {
combinedFiles.push(relPrefix ? `${relPrefix}/${file}` : file);
}
combinedSize += disc.totalSizeBytes;
combinedCount += disc.audioFileCount;
}
merged.push({
folderPath: commonParent,
folderName: path.basename(commonParent),
relativePath: first.relativePath.split('/').slice(0, -1).join('/') || path.basename(commonParent),
audioFileCount: combinedCount,
totalSizeBytes: combinedSize,
metadata: first.metadata,
searchTerm: first.searchTerm,
metadataSource: first.metadataSource,
audioFiles: combinedFiles,
groupingKey: first.groupingKey,
});
}
return merged;
}
/**
* Find the longest common parent directory among a set of paths.
*/
function findCommonParent(paths: string[]): string {
if (paths.length === 0) return '';
if (paths.length === 1) return paths[0];
const normalized = paths.map((p) => p.replace(/\\/g, '/'));
const parts = normalized.map((p) => p.split('/'));
const minLen = Math.min(...parts.map((p) => p.length));
let commonParts = 0;
for (let i = 0; i < minLen; i++) {
if (parts.every((p) => p[i] === parts[0][i])) {
commonParts = i + 1;
} else {
break;
}
}
return parts[0].slice(0, commonParts).join('/');
}
/**
* Recursively discover audiobooks starting from a root path.
* *
* A folder is classified as an "audiobook folder" if it contains audio files. * Scans every folder for audio files. When audio files are found, they are
* Once a folder is classified as an audiobook, its subfolders are NOT scanned * grouped by metadata (title + author + narrator) — each group becomes a
* further (the audio-containing folder is the audiobook boundary). * separate discovered audiobook. Files with no metadata are treated as
* individual books. Scanning ALWAYS recurses into subfolders regardless of
* whether the current folder has audio files.
*
* After the full walk, discoveries sharing the same grouping key across
* different folders (e.g., CD1/ and CD2/) are merged.
* *
* @param rootPath - The root directory to scan * @param rootPath - The root directory to scan
* @param onProgress - Optional callback for progress updates * @param onProgress - Optional callback for progress updates
@@ -242,38 +471,58 @@ export async function discoverAudiobooks(
const audioResult = await scanDirectoryForAudio(currentPath); const audioResult = await scanDirectoryForAudio(currentPath);
if (audioResult) { if (audioResult) {
// This is an audiobook folder — read metadata and add to results // Build size lookup for grouping
const firstFile = path.join(currentPath, audioResult.audioFiles[0]); const audioSizes = new Map<string, number>();
const metadata = await readAudioMetadata(firstFile); for (const fileName of audioResult.audioFiles) {
try {
const stat = await fs.stat(path.join(currentPath, fileName));
audioSizes.set(fileName, stat.size);
} catch {
audioSizes.set(fileName, 0);
}
}
onProgress?.({
phase: 'grouping',
foldersScanned,
audiobooksFound: results.length,
currentFolder: path.basename(currentPath),
});
// Group audio files by metadata
const groups = await groupAudioFilesByMetadata(
currentPath,
audioResult.audioFiles,
audioSizes
);
const folderName = path.basename(currentPath);
const relativePath = path.relative(rootPath, currentPath).replace(/\\/g, '/');
for (const group of groups) {
results.push({
folderPath: currentPath.replace(/\\/g, '/'),
folderName,
relativePath: relativePath || folderName,
audioFileCount: group.files.length,
totalSizeBytes: group.totalSize,
metadata: group.metadata,
searchTerm: group.searchTerm,
metadataSource: group.metadataSource,
audioFiles: group.files,
groupingKey: group.groupingKey,
});
}
onProgress?.({ onProgress?.({
phase: 'reading_metadata', phase: 'reading_metadata',
foldersScanned, foldersScanned,
audiobooksFound: results.length + 1, audiobooksFound: results.length,
currentFolder: path.basename(currentPath), currentFolder: path.basename(currentPath),
}); });
const folderName = path.basename(currentPath);
const relativePath = path.relative(rootPath, currentPath).replace(/\\/g, '/');
const firstFileName = audioResult.audioFiles[0];
const { searchTerm, source } = buildSearchTerm(metadata, firstFileName);
results.push({
folderPath: currentPath.replace(/\\/g, '/'),
folderName,
relativePath: relativePath || folderName,
audioFileCount: audioResult.audioFiles.length,
totalSizeBytes: audioResult.totalSize,
metadata,
searchTerm,
metadataSource: source,
});
// Do NOT recurse into subfolders of audiobook folders
return;
} }
// No audio files here — recurse into subfolders // Always recurse into subfolders
try { try {
const children = await fs.readdir(currentPath, { withFileTypes: true }); const children = await fs.readdir(currentPath, { withFileTypes: true });
const subdirs = children const subdirs = children
@@ -290,5 +539,7 @@ export async function discoverAudiobooks(
} }
await walk(rootPath, 0); await walk(rootPath, 0);
return results;
// Post-scan: merge discoveries with the same grouping key across folders
return deduplicateDiscoveries(results);
} }
+10 -2
View File
@@ -82,7 +82,8 @@ export class FileOrganizer {
audiobook: AudiobookMetadata, audiobook: AudiobookMetadata,
template: string, template: string,
loggerConfig?: LoggerConfig, loggerConfig?: LoggerConfig,
renameConfig?: { enabled: boolean; template: string } renameConfig?: { enabled: boolean; template: string },
selectedFiles?: string[]
): Promise<OrganizationResult> { ): Promise<OrganizationResult> {
// Create logger if config provided // Create logger if config provided
const logger = loggerConfig ? RMABLogger.forJob(loggerConfig.jobId, loggerConfig.context) : null; const logger = loggerConfig ? RMABLogger.forJob(loggerConfig.jobId, loggerConfig.context) : null;
@@ -99,7 +100,14 @@ export class FileOrganizer {
await logger?.info(`Organizing: ${downloadPath}`); await logger?.info(`Organizing: ${downloadPath}`);
// Find audiobook files // Find audiobook files
const { audioFiles, coverFile, isFile } = await this.findAudiobookFiles(downloadPath); let { audioFiles, coverFile, isFile } = await this.findAudiobookFiles(downloadPath);
// Filter to only selected files if specified
if (selectedFiles && selectedFiles.length > 0) {
const selectedSet = new Set(selectedFiles);
audioFiles = audioFiles.filter((f) => selectedSet.has(f));
await logger?.info(`Filtered to ${audioFiles.length} selected files`);
}
if (audioFiles.length === 0) { if (audioFiles.length === 0) {
throw new Error('No audiobook files found in download'); throw new Error('No audiobook files found in download');