mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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 }> = [];
|
||||
|
||||
@@ -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> · </span>}
|
||||
{entry.audioFileCount > 0 && (
|
||||
<span>{entry.audioFileCount} audio file{entry.audioFileCount !== 1 ? 's' : ''}</span>
|
||||
)}
|
||||
{entry.totalSize > 0 && (
|
||||
<span> · {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> · {totalAudioInChildren} audio files visible</span>
|
||||
)}
|
||||
{totalSubfolders} subfolder{totalSubfolders !== 1 ? 's' : ''}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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> · </span>}
|
||||
{entry.audioFileCount > 0 && (
|
||||
<span>{entry.audioFileCount} audio file{entry.audioFileCount !== 1 ? 's' : ''}</span>
|
||||
)}
|
||||
{entry.totalSize > 0 && (
|
||||
<span> · {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"> · {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 →
|
||||
{hasSelection
|
||||
? `Select ${checkedFiles.size} File${checkedFiles.size !== 1 ? 's' : ''}`
|
||||
: 'Select This Folder'
|
||||
} →
|
||||
</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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user