Compare commits

...

2 Commits

Author SHA1 Message Date
kikootwo 54b54d343a Bump package version to 1.1.7
Update package.json version from 1.1.6 to 1.1.7 to publish a new patch release.
2026-03-20 13:33:09 -04:00
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
16 changed files with 598 additions and 245 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "readmeabook",
"version": "1.1.6",
"version": "1.1.7",
"private": true,
"scripts": {
"dev": "next dev",
+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,
@@ -205,6 +205,7 @@ export function BulkImportWizard({ isOpen, onClose }: BulkImportWizardProps) {
imports: booksToImport.map((b) => ({
folderPath: b.folderPath,
asin: b.match!.asin,
audioFiles: b.audioFiles,
})),
}),
});
@@ -18,13 +18,12 @@ import {
HomeIcon,
ChevronRightIcon,
ArrowLeftIcon,
MusicalNoteIcon,
ExclamationTriangleIcon,
ArrowPathIcon,
MagnifyingGlassIcon,
} from '@heroicons/react/24/outline';
import { fetchWithAuth } from '@/lib/utils/api';
import { RootEntry, DirectoryEntry, formatBytes } from './types';
import { RootEntry, DirectoryEntry } from './types';
function SkeletonRow() {
return (
@@ -149,9 +148,8 @@ export function ScanFolderStep({ onFolderSelected }: ScanFolderStepProps) {
];
})();
// Count total audio files and subfolders in current listing
const totalSubfolders = entries.reduce((sum, e) => sum + e.subfolderCount, 0);
const totalAudioInChildren = entries.reduce((sum, e) => sum + e.audioFileCount, 0);
// Count subfolders in current listing
const totalSubfolders = entries.length;
return (
<div className="flex flex-col h-full">
@@ -248,7 +246,6 @@ export function ScanFolderStep({ onFolderSelected }: ScanFolderStepProps) {
{currentPath && !isLoading && !error && entries.length > 0 && (
<div className="divide-y divide-gray-100 dark:divide-gray-800">
{entries.map((entry) => {
const hasAudio = entry.audioFileCount > 0;
const isHovered = hoveredFolder === entry.name;
return (
@@ -267,33 +264,9 @@ export function ScanFolderStep({ onFolderSelected }: ScanFolderStepProps) {
)}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
{entry.name}
</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>
)}
<p className="flex-1 min-w-0 text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
{entry.name}
</p>
<ChevronRightIcon className="w-4 h-4 text-gray-300 dark:text-gray-600 flex-shrink-0" />
</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>
{entries.length > 0 && (
<p className="mt-0.5">
{entries.length} subfolder{entries.length !== 1 ? 's' : ''}
{totalAudioInChildren > 0 && (
<span> &middot; {totalAudioInChildren} audio files visible</span>
)}
{totalSubfolders} subfolder{totalSubfolders !== 1 ? 's' : ''}
</p>
)}
</div>
+2 -4
View File
@@ -14,9 +14,6 @@ export interface RootEntry {
export interface DirectoryEntry {
name: string;
type: 'directory';
audioFileCount: number;
subfolderCount: number;
totalSize: number;
}
/** Audible match data for a discovered audiobook. */
@@ -39,6 +36,7 @@ export interface ScannedBook {
totalSizeBytes: number;
metadataSource: 'tags' | 'file_name';
searchTerm: string;
audioFiles: string[];
match: AudibleMatch | null;
inLibrary: boolean;
hasActiveRequest: boolean;
@@ -48,7 +46,7 @@ export interface ScannedBook {
/** Progress event from the SSE scan stream. */
export interface ScanProgressEvent {
phase: 'discovering' | 'reading_metadata';
phase: 'discovering' | 'reading_metadata' | 'grouping';
foldersScanned: number;
audiobooksFound: number;
currentFolder?: string;
@@ -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}
/>
@@ -40,6 +40,7 @@ interface BrowsePhaseProps {
currentPath: string | null;
entries: DirectoryEntry[];
currentAudioFiles: AudioFileEntry[];
checkedFiles: Set<string>;
isLoading: boolean;
error: string | null;
hoveredFolder: string | null;
@@ -50,7 +51,8 @@ interface BrowsePhaseProps {
onNavigateToRoot: () => void;
onNavigateToBreadcrumb: (index: number) => void;
onFolderClick: (entry: DirectoryEntry) => void;
onSelectCurrentFolder: () => void;
onSelectFiles: () => void;
onToggleFile: (fileName: string) => void;
onHoverFolder: (name: string | null) => void;
onRetry: () => void;
}
@@ -60,6 +62,7 @@ export function BrowsePhase({
currentPath,
entries,
currentAudioFiles,
checkedFiles,
isLoading,
error,
hoveredFolder,
@@ -70,10 +73,16 @@ export function BrowsePhase({
onNavigateToRoot,
onNavigateToBreadcrumb,
onFolderClick,
onSelectCurrentFolder,
onSelectFiles,
onToggleFile,
onHoverFolder,
onRetry,
}: 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 (
<div className="flex flex-col h-full">
{/* Breadcrumb bar */}
@@ -165,7 +174,6 @@ export function BrowsePhase({
<div className="divide-y divide-gray-100 dark:divide-gray-800">
{/* Subdirectories */}
{entries.map((entry) => {
const hasAudio = entry.audioFileCount > 0;
const isHovered = hoveredFolder === entry.name;
return (
@@ -184,33 +192,9 @@ export function BrowsePhase({
)}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
{entry.name}
</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>
)}
<p className="flex-1 min-w-0 text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
{entry.name}
</p>
<ChevronRightIcon className="w-4 h-4 text-gray-300 dark:text-gray-600 flex-shrink-0" />
</button>
@@ -221,24 +205,38 @@ export function BrowsePhase({
{currentAudioFiles.length > 0 && entries.length > 0 && (
<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">
Audio Files
Audio Files {hasSelection && `\u00B7 click to select`}
</p>
</div>
)}
{currentAudioFiles.map((file) => (
<div
key={`file-${file.name}`}
className="flex items-center gap-3 px-4 py-2.5"
>
<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>
</div>
))}
{currentAudioFiles.map((file) => {
const isSelected = checkedFiles.has(file.name);
return (
<button
key={`file-${file.name}`}
onClick={() => onToggleFile(file.name)}
className={`w-full flex items-center gap-3 px-4 py-2.5 text-left transition-colors ${
isSelected
? 'bg-blue-50 dark:bg-blue-900/20 border-l-2 border-blue-500'
: 'hover:bg-gray-50 dark:hover:bg-gray-800/50 border-l-2 border-transparent'
}`}
>
<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>
)}
@@ -258,18 +256,33 @@ export function BrowsePhase({
)}
</div>
{/* Footer: Select this folder */}
{/* Footer */}
{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">
<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>
{' '}audio file{currentAudioFiles.length !== 1 ? 's' : ''} in this folder
{hasSelection ? (
<>
<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>
<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"
>
Select This Folder &rarr;
{hasSelection
? `Select ${checkedFiles.size} File${checkedFiles.size !== 1 ? 's' : ''}`
: 'Select This Folder'
} &rarr;
</button>
</div>
)}
@@ -16,14 +16,15 @@ import { AudioFileEntry, formatBytes } from './types';
interface ConfirmPhaseProps {
audiobook: { asin: string; title: string; author: string; coverArtUrl?: string };
selectedPath: string;
audioFileCount: number;
totalSize: number;
audioFiles: AudioFileEntry[];
checkedFiles: Set<string>;
isImporting: boolean;
importError: string | null;
slideClass: string;
cleanupSource: boolean;
onCleanupSourceChange: (value: boolean) => void;
onToggleFile: (fileName: string) => void;
onToggleAll: () => void;
onBack: () => void;
onStartImport: () => void;
}
@@ -31,17 +32,23 @@ interface ConfirmPhaseProps {
export function ConfirmPhase({
audiobook,
selectedPath,
audioFileCount,
totalSize,
audioFiles,
checkedFiles,
isImporting,
importError,
slideClass,
cleanupSource,
onCleanupSourceChange,
onToggleFile,
onToggleAll,
onBack,
onStartImport,
}: 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 (
<div className={`flex flex-col h-full ${slideClass}`}>
<div className="flex-1 overflow-y-auto p-6 space-y-6">
@@ -79,28 +86,51 @@ export function ConfirmPhase({
{selectedPath}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1.5">
{audioFileCount} audio file{audioFileCount !== 1 ? 's' : ''}
{totalSize > 0 ? ` \u00B7 ${formatBytes(totalSize)}` : ''}
{checkedFiles.size} of {audioFiles.length} file{audioFiles.length !== 1 ? 's' : ''} selected
{checkedSize > 0 ? ` \u00B7 ${formatBytes(checkedSize)}` : ''}
</p>
</div>
{/* Audio files to import */}
<div>
<h4 className="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-3">
Files to import
</h4>
<div className="rounded-xl border border-gray-200 dark:border-gray-700 divide-y divide-gray-100 dark:divide-gray-800 overflow-hidden">
{audioFiles.map((file) => (
<div key={file.name} className="flex items-center gap-3 px-3.5 py-2.5">
<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>
</div>
))}
<div className="flex items-center gap-3 mb-3">
<input
type="checkbox"
checked={allChecked}
ref={(el) => { if (el) el.indeterminate = someChecked; }}
onChange={onToggleAll}
disabled={isImporting}
className="w-4 h-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500 cursor-pointer"
/>
<h4 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
Files to import
</h4>
</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>
@@ -149,7 +179,7 @@ export function ConfirmPhase({
</button>
<button
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"
>
{isImporting ? (
@@ -12,9 +12,6 @@ export interface RootEntry {
export interface DirectoryEntry {
name: string;
type: 'directory';
audioFileCount: number;
subfolderCount: number;
totalSize: number;
}
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
*/
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');
@@ -212,7 +212,8 @@ export async function processOrganizeFiles(payload: OrganizeFilesPayload): Promi
},
template,
jobId ? { jobId, context: 'FileOrganizer' } : undefined,
renameConfig
renameConfig,
selectedFiles
);
if (!result.success) {
@@ -322,7 +323,7 @@ export async function processOrganizeFiles(payload: OrganizeFilesPayload): Promi
// Cleanup source files if requested (manual import feature)
if (cleanupSource) {
await cleanupSourceAfterOrganize(downloadPath, configService, jobId, logger);
await cleanupSourceAfterOrganize(downloadPath, configService, jobId, logger, selectedFiles);
}
return {
@@ -1132,20 +1133,38 @@ async function cleanupSourceAfterOrganize(
downloadPath: string,
configService: any,
jobId: string | undefined,
logger: RMABLogger
logger: RMABLogger,
selectedFiles?: string[]
): Promise<void> {
try {
const fs = await import('fs/promises');
const pathModule = await import('path');
logger.info(`Cleaning up source files: ${downloadPath}`);
const stats = await fs.stat(downloadPath);
if (stats.isDirectory()) {
await fs.rm(downloadPath, { recursive: true, force: true });
logger.info(`Removed source directory: ${downloadPath}`);
if (selectedFiles && selectedFiles.length > 0) {
// Only delete the specific files that were imported, not the entire directory
for (const fileName of selectedFiles) {
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 {
await fs.unlink(downloadPath);
logger.info(`Removed source file: ${downloadPath}`);
// No file filter — delete entire source path (original behavior)
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
+4 -1
View File
@@ -76,6 +76,7 @@ export interface OrganizeFilesPayload extends JobPayload {
downloadPath: string;
targetPath?: string; // Optional - not used by processor (reads from database config)
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 {
@@ -644,7 +645,8 @@ export class JobQueueService {
audiobookId: string,
downloadPath: string,
targetPath?: string,
cleanupSource?: boolean
cleanupSource?: boolean,
selectedFiles?: string[]
): Promise<string> {
return await this.addJob(
'organize_files',
@@ -654,6 +656,7 @@ export class JobQueueService {
downloadPath,
targetPath, // Not used by processor
cleanupSource,
selectedFiles,
} as OrganizeFilesPayload,
{
priority: 8,
+283 -32
View File
@@ -3,7 +3,8 @@
* Documentation: documentation/features/bulk-import.md
*
* 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';
@@ -17,6 +18,9 @@ const execPromise = promisify(exec);
/** Maximum recursion depth for folder scanning. */
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. */
export interface AudioFileMetadata {
title?: string; // From 'album' tag (book title)
@@ -36,11 +40,13 @@ export interface DiscoveredAudiobook {
metadata: AudioFileMetadata;
searchTerm: string; // Constructed search query for Audible
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. */
export interface ScanProgress {
phase: 'discovering' | 'reading_metadata';
phase: 'discovering' | 'reading_metadata' | 'grouping';
foldersScanned: number;
audiobooksFound: number;
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.
*/
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.
* Once a folder is classified as an audiobook, its subfolders are NOT scanned
* further (the audio-containing folder is the audiobook boundary).
* Scans every folder for audio files. When audio files are found, they are
* grouped by metadata (title + author + narrator) — each group becomes a
* 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 onProgress - Optional callback for progress updates
@@ -242,38 +471,58 @@ export async function discoverAudiobooks(
const audioResult = await scanDirectoryForAudio(currentPath);
if (audioResult) {
// This is an audiobook folder — read metadata and add to results
const firstFile = path.join(currentPath, audioResult.audioFiles[0]);
const metadata = await readAudioMetadata(firstFile);
// Build size lookup for grouping
const audioSizes = new Map<string, number>();
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?.({
phase: 'reading_metadata',
foldersScanned,
audiobooksFound: results.length + 1,
audiobooksFound: results.length,
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 {
const children = await fs.readdir(currentPath, { withFileTypes: true });
const subdirs = children
@@ -290,5 +539,7 @@ export async function discoverAudiobooks(
}
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,
template: string,
loggerConfig?: LoggerConfig,
renameConfig?: { enabled: boolean; template: string }
renameConfig?: { enabled: boolean; template: string },
selectedFiles?: string[]
): Promise<OrganizationResult> {
// Create logger if config provided
const logger = loggerConfig ? RMABLogger.forJob(loggerConfig.jobId, loggerConfig.context) : null;
@@ -99,7 +100,14 @@ export class FileOrganizer {
await logger?.info(`Organizing: ${downloadPath}`);
// 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) {
throw new Error('No audiobook files found in download');