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 {
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}`);
@@ -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,
+5 -55
View File
@@ -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<DirectoryEntry> => {
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 }> = [];
+72 -10
View File
@@ -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,