/** * Component: File Organization System * Documentation: documentation/phase3/file-organization.md */ import fs from 'fs/promises'; import path from 'path'; import axios from 'axios'; import { tagMultipleFiles, checkFfmpegAvailable } from './metadata-tagger'; import { RMABLogger } from './logger'; import { copyFile } from './copy-file'; const moduleLogger = RMABLogger.create('FileOrganizer'); import { detectChapterFiles, analyzeChapterFiles, mergeChapters, formatDuration, estimateOutputSize, checkDiskSpace, } from './chapter-merger'; import { prisma } from '../db'; import { substituteTemplate, buildRenamedFilename, type TemplateVariables } from './path-template.util'; import { AUDIO_EXTENSIONS } from '../constants/audio-formats'; export interface AudiobookMetadata { title: string; author: string; narrator?: string; year?: number; coverArtUrl?: string; asin?: string; series?: string; seriesPart?: string; } export interface OrganizationResult { success: boolean; targetPath: string; filesMovedCount: number; errors: string[]; audioFiles: string[]; coverArtFile?: string; } export interface EbookOrganizationResult { success: boolean; targetPath: string; errors: string[]; format?: string; } export interface ValidationResult { isValid: boolean; issues: string[]; path: string; } export interface LoggerConfig { jobId: string; context: string; } export class FileOrganizer { private mediaDir: string; private tempDir: string; private fileMode: number; private dirMode: number; constructor(mediaDir: string = '/media/audiobooks', tempDir: string = '/tmp/readmeabook', fileMode: number = 0o664, dirMode: number = 0o775) { this.mediaDir = mediaDir; this.tempDir = tempDir; this.fileMode = fileMode; this.dirMode = dirMode; } /** * Organize completed download into proper directory structure */ async organize( downloadPath: string, audiobook: AudiobookMetadata, template: string, loggerConfig?: LoggerConfig, renameConfig?: { enabled: boolean; template: string }, selectedFiles?: string[] ): Promise { // Create logger if config provided const logger = loggerConfig ? RMABLogger.forJob(loggerConfig.jobId, loggerConfig.context) : null; const result: OrganizationResult = { success: false, targetPath: '', filesMovedCount: 0, errors: [], audioFiles: [], }; try { await logger?.info(`Organizing: ${downloadPath}`); // Find audiobook files 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'); } await logger?.info(`Found ${audioFiles.length} audio files`); // Determine base path for source files const baseSourcePath = isFile ? path.dirname(downloadPath) : downloadPath; // Track if we created a merged file that needs cleanup let tempMergedFile: string | null = null; // Check for chapter merging if multiple files if (audioFiles.length > 1) { await logger?.info(`Multiple audio files detected (${audioFiles.length} files) - checking chapter merge settings...`); try { const chapterMergingConfig = await prisma.configuration.findUnique({ where: { key: 'chapter_merging_enabled' }, }); const chapterMergingEnabled = chapterMergingConfig?.value === 'true'; if (!chapterMergingEnabled) { await logger?.info(`Chapter merging disabled in settings - organizing ${audioFiles.length} files individually`); } else { await logger?.info(`Chapter merging enabled - analyzing files...`); // Build full paths to source files const sourceFilePaths = audioFiles.map((audioFile) => isFile ? downloadPath : path.join(downloadPath, audioFile) ); const isChapterDownload = await detectChapterFiles(sourceFilePaths, logger ?? undefined); if (isChapterDownload) { // Check disk space const estimatedSize = await estimateOutputSize(sourceFilePaths); const availableSpace = await checkDiskSpace(this.tempDir); if (availableSpace !== null && availableSpace < estimatedSize) { await logger?.warn(`Insufficient disk space for merge (need ${Math.round(estimatedSize / 1024 / 1024)}MB, have ${Math.round(availableSpace / 1024 / 1024)}MB). Organizing files individually.`); } else { // Log disk space check passed if (availableSpace !== null) { await logger?.info(`Disk space check passed: ${Math.round(availableSpace / 1024 / 1024)}MB available, ${Math.round(estimatedSize / 1024 / 1024)}MB needed`); } // Analyze and order chapter files const chapters = await analyzeChapterFiles(sourceFilePaths, logger ?? undefined); // Validate that we have valid ordering if (chapters.length === 0) { await logger?.warn(`Chapter analysis failed: No valid chapters found. Organizing files individually.`); } else { // Create output path in temp directory const outputFilename = `${this.sanitizePath(audiobook.title)}.m4b`; const outputPath = path.join(this.tempDir, outputFilename); // Perform merge const mergeResult = await mergeChapters( chapters, { title: audiobook.title, author: audiobook.author, narrator: audiobook.narrator, year: audiobook.year, asin: audiobook.asin, outputPath, dirMode: this.dirMode, }, logger ?? undefined ); if (mergeResult.success && mergeResult.outputPath) { // Replace audioFiles array with single merged file audioFiles.length = 0; audioFiles.push(mergeResult.outputPath); // Mark for cleanup after copy tempMergedFile = mergeResult.outputPath; await logger?.info(`Chapter merge complete - organizing single M4B file`); // Update isFile flag since we now have a single file path // (not in the download directory structure) } else { await logger?.warn(`Chapter merge failed: ${mergeResult.error}. Organizing ${audioFiles.length} files individually.`); result.errors.push(`Chapter merge failed: ${mergeResult.error}`); // Continue with original audioFiles array } } } } else { // detectChapterFiles already logged the reason for skipping await logger?.info(`Organizing ${audioFiles.length} files individually`); } } } catch (error) { await logger?.error(`Chapter merging error: ${error instanceof Error ? error.message : 'Unknown error'}`); result.errors.push(`Chapter merging error: ${error instanceof Error ? error.message : 'Unknown error'}`); await logger?.warn(`Falling back to organizing ${audioFiles.length} files individually`); // Continue with original audioFiles array } } else { await logger?.info(`Single audio file detected - no chapter merging needed`); } // Tag metadata BEFORE moving files (prevents Plex race condition) // Map from original file path to tagged file path (for successful tags) const taggedFileMap = new Map(); try { const config = await prisma.configuration.findUnique({ where: { key: 'metadata_tagging_enabled' }, }); const metadataTaggingEnabled = config?.value === 'true'; if (metadataTaggingEnabled && audioFiles.length > 0) { await logger?.info(`Metadata tagging enabled, checking ffmpeg availability...`); const ffmpegAvailable = await checkFfmpegAvailable(); if (ffmpegAvailable) { await logger?.info(`Tagging ${audioFiles.length} audio files with metadata (before move)...`); // Build full paths to source files for tagging // Handle merged files (absolute paths) vs original files (relative paths) const sourceFilePaths = audioFiles.map((audioFile) => path.isAbsolute(audioFile) ? audioFile // Merged file - use path directly : isFile ? downloadPath : path.join(downloadPath, audioFile) ); const taggingResults = await tagMultipleFiles(sourceFilePaths, { title: audiobook.title, author: audiobook.author, narrator: audiobook.narrator, year: audiobook.year, asin: audiobook.asin, series: audiobook.series, seriesPart: audiobook.seriesPart, }); const successCount = taggingResults.filter((r) => r.success).length; const failCount = taggingResults.filter((r) => !r.success).length; if (successCount > 0) { await logger?.info(`Successfully tagged ${successCount} file(s) with metadata`); } if (failCount > 0) { await logger?.warn(`Failed to tag ${failCount} file(s): ${ taggingResults .filter((r) => !r.success) .map((r) => `${path.basename(r.filePath)}: ${r.error}`) .join(', ') }`); result.errors.push(`Failed to tag ${failCount} file(s) with metadata`); } // Build map of successfully tagged files for (const tagResult of taggingResults) { if (tagResult.success && tagResult.taggedFilePath) { taggedFileMap.set(tagResult.filePath, tagResult.taggedFilePath); } } } else { await logger?.warn(`Metadata tagging enabled but ffmpeg not available - skipping tagging`); result.errors.push('Metadata tagging skipped: ffmpeg not available'); } } else { await logger?.info(`Metadata tagging disabled or no audio files to tag`); } } catch (error) { await logger?.error(`Metadata tagging failed: ${error instanceof Error ? error.message : 'Unknown error'}`); result.errors.push(`Metadata tagging failed: ${error instanceof Error ? error.message : 'Unknown error'}`); // Don't fail the whole operation if metadata tagging fails - continue with copying files } // Build target directory const targetPath = this.buildTargetPath( this.mediaDir, template, audiobook.author, audiobook.title, audiobook.narrator, audiobook.asin, audiobook.year, audiobook.series, audiobook.seriesPart ); await logger?.info(`Target path: ${targetPath}`); // Create target directory await fs.mkdir(targetPath, { recursive: true, mode: this.dirMode }); // Determine if file renaming should be applied const shouldRename = renameConfig?.enabled && renameConfig.template; const isMultiFile = audioFiles.length > 1; const duplicateBasenames = this.findDuplicateBasenames(audioFiles); const usedTargetFilenames = new Set(); if (shouldRename) { await logger?.info(`File renaming enabled with template: ${renameConfig.template}${isMultiFile ? ` (${audioFiles.length} files, indices will be appended)` : ''}`); } else if (duplicateBasenames.size > 0) { await logger?.info(`Detected ${duplicateBasenames.size} duplicate source filename(s); applying folder-aware naming to avoid collisions`); } // Copy audio files (do NOT delete originals - needed for seeding) for (let i = 0; i < audioFiles.length; i++) { const audioFile = audioFiles[i]; // Handle merged files (absolute paths) vs original files (relative paths) const isAbsolutePath = path.isAbsolute(audioFile); const originalSourcePath = isAbsolutePath ? audioFile // Merged file - use path directly : isFile ? downloadPath : path.join(downloadPath, audioFile); // Determine target filename (apply rename template if enabled) let filename: string; if (shouldRename) { const ext = path.extname(audioFile); const variables: TemplateVariables = { author: audiobook.author, title: audiobook.title, narrator: audiobook.narrator, asin: audiobook.asin, year: audiobook.year, series: audiobook.series, seriesPart: audiobook.seriesPart, }; filename = buildRenamedFilename( renameConfig.template, variables, ext, isMultiFile ? i + 1 : undefined, ); filename = this.makeUniqueFilename(filename, usedTargetFilenames); } else { filename = this.buildSourceAwareFilename( audioFile, duplicateBasenames, usedTargetFilenames ); } const targetFilePath = path.join(targetPath, filename); // Check if we have a tagged version of this file const taggedFilePath = taggedFileMap.get(originalSourcePath); const sourcePath = taggedFilePath || originalSourcePath; // Use tagged version if available, otherwise use original // Check if source exists try { await fs.access(sourcePath, fs.constants.R_OK); } catch { moduleLogger.warn(`Source file not found or not readable: ${sourcePath}`); result.errors.push(`Source file not found: ${audioFile}`); continue; } // Check if target already exists (skip if already copied) try { await fs.access(targetFilePath); moduleLogger.debug(`File already exists, skipping: ${filename}`); result.audioFiles.push(targetFilePath); // Clean up tagged temp file if it exists if (taggedFilePath) { try { await fs.unlink(taggedFilePath); await logger?.info(`Cleaned up temp file: ${path.basename(taggedFilePath)}`); } catch { // Ignore cleanup errors } } continue; } catch { // File doesn't exist, continue with copy } // Copy file (do NOT delete original - needed for seeding) try { // Copy file via streams (avoids copy_file_range EPERM on NFS/FUSE) await copyFile(sourcePath, targetFilePath); // Set explicit permissions after copy await fs.chmod(targetFilePath, this.fileMode); result.audioFiles.push(targetFilePath); result.filesMovedCount++; if (taggedFilePath) { await logger?.info(`Copied tagged file: ${filename}`); // Clean up the tagged temp file after successful copy try { await fs.unlink(taggedFilePath); await logger?.info(`Cleaned up temp file: ${path.basename(taggedFilePath)}`); } catch (cleanupError) { await logger?.warn(`Failed to clean up temp file: ${path.basename(taggedFilePath)}`); } } else { await logger?.info(`Copied: ${filename}`); } } catch (error) { const errorMsg = error instanceof Error ? error.message : 'Unknown error'; await logger?.error(`Failed to copy ${filename}: ${errorMsg}`); // If the tagged temp file failed to copy, clean it up and try the original untagged file if (taggedFilePath) { // Clean up the tagged temp file that failed to copy try { await fs.unlink(taggedFilePath); await logger?.info(`Cleaned up temp file after copy failure: ${path.basename(taggedFilePath)}`); } catch { // Ignore cleanup errors } // Fallback: attempt to copy the original untagged file instead await logger?.info(`Attempting fallback copy of original (untagged) file: ${filename}`); try { await fs.access(originalSourcePath, fs.constants.R_OK); await copyFile(originalSourcePath, targetFilePath); await fs.chmod(targetFilePath, this.fileMode); result.audioFiles.push(targetFilePath); result.filesMovedCount++; await logger?.info(`Fallback copy succeeded (without metadata tags): ${filename}`); result.errors.push(`Tagged copy failed for ${filename}, copied original without metadata tags`); continue; } catch (fallbackError) { const fallbackMsg = fallbackError instanceof Error ? fallbackError.message : 'Unknown error'; await logger?.error(`Fallback copy of original file also failed: ${fallbackMsg}`); } } result.errors.push(`Failed to copy ${audioFile}: ${errorMsg}`); // Continue with other files instead of throwing } } // Clean up temp merged file after successful copy if (tempMergedFile) { try { await fs.unlink(tempMergedFile); await logger?.info(`Cleaned up temp merged file: ${path.basename(tempMergedFile)}`); } catch (cleanupError) { await logger?.warn(`Failed to clean up temp merged file: ${path.basename(tempMergedFile)}`); } } // Handle cover art if (coverFile) { const sourcePath = path.join(baseSourcePath, coverFile); const targetCoverPath = path.join(targetPath, 'cover.jpg'); try { // Copy cover art (do NOT delete original) await copyFile(sourcePath, targetCoverPath); await fs.chmod(targetCoverPath, this.fileMode); result.coverArtFile = targetCoverPath; result.filesMovedCount++; await logger?.info(`Copied cover art`); } catch (error) { await logger?.warn(`Failed to copy cover art: ${error instanceof Error ? error.message : 'Unknown error'}`); result.errors.push('Failed to copy cover art'); } } else if (audiobook.coverArtUrl) { // Download cover art from Audible if not in torrent try { await this.downloadCoverArt(audiobook.coverArtUrl, targetPath); result.coverArtFile = path.join(targetPath, 'cover.jpg'); await logger?.info(`Downloaded cover art from Audible`); } catch (error) { await logger?.warn(`Failed to download cover art: ${error instanceof Error ? error.message : 'Unknown error'}`); result.errors.push('Failed to download cover art'); } } // NOTE: E-book downloads are now handled via first-class ebook requests // The createEbookRequestIfEnabled() function in organize-files.processor.ts // creates a separate ebook request that goes through the full job queue flow. // This replaces the old inline ebook sidecar download that happened here. result.targetPath = targetPath; // Only mark as success if at least one audio file was placed in the target directory // (either freshly copied or already existed from a previous attempt) if (result.audioFiles.length > 0) { result.success = true; } else { result.errors.push('No audio files were successfully copied to the target directory'); await logger?.error(`Organization failed: no audio files copied despite ${audioFiles.length} file(s) found`); } // DO NOT clean up download directory - files needed for seeding // Cleanup will be handled by the seeding cleanup job after seeding requirements are met await logger?.info(`Organization complete: ${result.filesMovedCount} files copied (originals kept for seeding)`); return result; } catch (error) { await logger?.error(`Organization failed: ${error instanceof Error ? error.message : 'Unknown error'}`); result.errors.push(error instanceof Error ? error.message : 'Unknown error'); return result; } } /** * Find audiobook files in download directory or single file */ private async findAudiobookFiles( downloadPath: string ): Promise<{ audioFiles: string[]; coverFile?: string; isFile: boolean }> { const audioExtensions: readonly string[] = AUDIO_EXTENSIONS; const coverPatterns = [ /cover\.(jpg|jpeg|png)$/i, /folder\.(jpg|jpeg|png)$/i, /art\.(jpg|jpeg|png)$/i, ]; const audioFiles: string[] = []; let coverFile: string | undefined; let isFile = false; try { // Check if downloadPath is a file or directory const stats = await fs.stat(downloadPath); if (stats.isFile()) { // Handle single file case isFile = true; const ext = path.extname(downloadPath).toLowerCase(); if (audioExtensions.includes(ext)) { // Return just the filename (not full path) audioFiles.push(path.basename(downloadPath)); } } else { // Handle directory case const files = await this.walkDirectory(downloadPath); for (const file of files) { const ext = path.extname(file).toLowerCase(); // Check if it's an audio file if (audioExtensions.includes(ext)) { audioFiles.push(file); } // Check if it's cover art const basename = path.basename(file); if (coverPatterns.some((pattern) => pattern.test(basename))) { coverFile = file; } } } } catch (error) { moduleLogger.error('Error reading directory', { error: error instanceof Error ? error.message : String(error) }); throw error; } return { audioFiles, coverFile, isFile }; } /** * Recursively walk directory to find all files */ private async walkDirectory(dir: string, baseDir: string = ''): Promise { const files: string[] = []; try { const entries = await fs.readdir(dir, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(dir, entry.name); const relativePath = baseDir ? path.join(baseDir, entry.name) : entry.name; if (entry.isDirectory()) { const subFiles = await this.walkDirectory(fullPath, relativePath); files.push(...subFiles); } else { files.push(relativePath); } } } catch (error) { moduleLogger.error(`Error reading directory ${dir}`, { error: error instanceof Error ? error.message : String(error) }); } return files; } /** * Build target path using template-based path building * Uses the path template engine to substitute variables and sanitize paths */ private buildTargetPath( baseDir: string, template: string, author: string, title: string, narrator?: string, asin?: string, year?: number, series?: string, seriesPart?: string ): string { const variables: TemplateVariables = { author, title, narrator, asin, year, series, seriesPart, }; const relativePath = substituteTemplate(template, variables); return path.join(baseDir, relativePath); } /** * Sanitize path component (remove invalid characters) */ private sanitizePath(name: string): string { return ( name // Remove invalid filename characters .replace(/[<>:"/\\|?*]/g, '') // Remove leading/trailing dots and spaces .trim() .replace(/^\.+/, '') .replace(/\.+$/, '') // Collapse multiple spaces .replace(/\s+/g, ' ') // Limit length (255 chars max for most filesystems) .slice(0, 200) ); } private findDuplicateBasenames(files: string[]): Set { const counts = new Map(); for (const file of files) { const basename = path.basename(file); counts.set(basename, (counts.get(basename) || 0) + 1); } return new Set( Array.from(counts.entries()) .filter(([, count]) => count > 1) .map(([basename]) => basename) ); } private buildSourceAwareFilename( sourcePath: string, duplicateBasenames: Set, usedFilenames: Set ): string { const basename = path.basename(sourcePath); const ext = path.extname(basename); const stem = path.basename(basename, ext); let candidate = basename; // Preserve folder context for duplicate track names (e.g. CD1/Track01.mp3, // CD2/Track01.mp3) so each file keeps a unique target name. if (duplicateBasenames.has(basename) && !path.isAbsolute(sourcePath)) { const folder = path.dirname(sourcePath); if (folder !== '.') { const folderPrefix = folder .split(path.sep) .filter(Boolean) .map((segment) => this.sanitizePath(segment)) .join('-'); if (folderPrefix) { candidate = `${folderPrefix}-${stem}${ext}`; } } } return this.makeUniqueFilename(candidate, usedFilenames); } private makeUniqueFilename(filename: string, usedFilenames: Set): string { if (!usedFilenames.has(filename)) { usedFilenames.add(filename); return filename; } const ext = path.extname(filename); const stem = path.basename(filename, ext); let suffix = 2; while (true) { const candidate = `${stem} (${suffix})${ext}`; if (!usedFilenames.has(candidate)) { usedFilenames.add(candidate); return candidate; } suffix++; } } /** * Download cover art from URL or copy from local cache */ private async downloadCoverArt(url: string, targetDir: string): Promise { const targetPath = path.join(targetDir, 'cover.jpg'); try { // Check if this is a cached thumbnail (local file) if (url.startsWith('/api/cache/thumbnails/')) { // Extract filename from the API path const filename = url.replace('/api/cache/thumbnails/', ''); const cachedPath = path.join('/app/cache/thumbnails', filename); // Copy from local cache instead of downloading await copyFile(cachedPath, targetPath); await fs.chmod(targetPath, this.fileMode); moduleLogger.debug(`Copied cover art from cache: ${filename}`); } else { // Download from external URL (e.g., Audible CDN) const response = await axios.get(url, { responseType: 'arraybuffer', timeout: 30000, }); await fs.writeFile(targetPath, response.data); moduleLogger.debug(`Downloaded cover art from URL`); } } catch (error) { moduleLogger.error('Failed to download cover art', { error: error instanceof Error ? error.message : String(error) }); throw error; } } /** * Clean up download directory */ async cleanup(downloadPath: string): Promise { try { // Remove download directory and all remaining files await fs.rm(downloadPath, { recursive: true, force: true }); moduleLogger.debug(`Cleaned up: ${downloadPath}`); } catch (error) { moduleLogger.error(`Cleanup failed for ${downloadPath}`, { error: error instanceof Error ? error.message : String(error) }); // Don't throw - cleanup is non-critical } } /** * Validate directory structure */ async validate(basePath: string): Promise { const result: ValidationResult = { isValid: true, issues: [], path: basePath, }; try { // Check if base path exists await fs.access(basePath); // Check if it's a directory const stats = await fs.stat(basePath); if (!stats.isDirectory()) { result.isValid = false; result.issues.push('Path is not a directory'); } // Check if writable try { const testFile = path.join(basePath, '.test-write'); await fs.writeFile(testFile, 'test'); await fs.unlink(testFile); } catch { result.isValid = false; result.issues.push('Directory is not writable'); } } catch (error) { result.isValid = false; result.issues.push(`Path does not exist or is not accessible: ${basePath}`); } return result; } /** * Organize ebook file into proper directory structure * Simplified compared to audiobooks - no metadata tagging, cover art, or chapter merging * Supports both direct file paths (Anna's Archive) and directories (indexer downloads) */ async organizeEbook( downloadPath: string, metadata: { title: string; author: string; narrator?: string; asin?: string; year?: number; series?: string; seriesPart?: string }, template: string, loggerConfig?: LoggerConfig, isIndexerDownload: boolean = false, renameConfig?: { enabled: boolean; template: string } ): Promise { const logger = loggerConfig ? RMABLogger.forJob(loggerConfig.jobId, loggerConfig.context) : null; const result: EbookOrganizationResult = { success: false, targetPath: '', errors: [], }; try { await logger?.info(`Organizing ebook: ${downloadPath}`); const ebookFormats = ['epub', 'pdf', 'mobi', 'azw', 'azw3', 'fb2', 'cbz', 'cbr']; // Find ebook file (handle both file and directory cases) const { ebookFile, baseSourcePath, isFile } = await this.findEbookFile(downloadPath, ebookFormats); if (!ebookFile) { throw new Error(`No ebook files found in download (looking for: ${ebookFormats.join(', ')})`); } // Build full path to source file const sourceFilePath = isFile ? downloadPath : path.join(baseSourcePath, ebookFile); await logger?.info(`Found ebook file: ${ebookFile}`); // Detect format from extension const ext = path.extname(ebookFile).toLowerCase().slice(1); result.format = ext; await logger?.info(`Detected ebook format: ${ext}`); // Build target directory using same template as audiobooks const targetDir = this.buildTargetPath( this.mediaDir, template, metadata.author, metadata.title, metadata.narrator, metadata.asin, metadata.year, metadata.series, metadata.seriesPart ); await logger?.info(`Target directory: ${targetDir}`); // Create target directory await fs.mkdir(targetDir, { recursive: true, mode: this.dirMode }); // Build target filename (apply rename template if enabled, otherwise sanitize source filename) const sourceFilename = path.basename(ebookFile); let targetFilename: string; if (renameConfig?.enabled && renameConfig.template) { const originalExt = path.extname(ebookFile); const variables: TemplateVariables = { author: metadata.author, title: metadata.title, narrator: metadata.narrator, asin: metadata.asin, year: metadata.year, series: metadata.series, seriesPart: metadata.seriesPart, }; targetFilename = buildRenamedFilename(renameConfig.template, variables, originalExt); await logger?.info(`Renamed ebook file: ${sourceFilename} -> ${targetFilename}`); } else { targetFilename = this.sanitizePath(sourceFilename); } const targetPath = path.join(targetDir, targetFilename); // Check if target already exists try { await fs.access(targetPath); await logger?.info(`Ebook already exists at target, skipping copy: ${targetFilename}`); result.success = true; result.targetPath = targetDir; return result; } catch { // File doesn't exist, continue with copy } // Copy ebook file (do NOT delete original - may need for seeding or retry) await copyFile(sourceFilePath, targetPath); await fs.chmod(targetPath, this.fileMode); await logger?.info(`Copied ebook: ${targetFilename}`); // Clean up source file ONLY for direct HTTP downloads (not indexer downloads which need to seed) if (!isIndexerDownload && isFile) { try { await fs.unlink(sourceFilePath); await logger?.info(`Cleaned up source file: ${sourceFilename}`); } catch { // Ignore cleanup errors } } else if (isIndexerDownload) { await logger?.info(`Keeping source file for seeding: ${sourceFilename}`); } result.success = true; result.targetPath = targetDir; await logger?.info(`Ebook organization complete: ${targetFilename}`); return result; } catch (error) { await logger?.error(`Ebook organization failed: ${error instanceof Error ? error.message : 'Unknown error'}`); result.errors.push(error instanceof Error ? error.message : 'Unknown error'); return result; } } /** * Find ebook file in download path (handles both single file and directory) */ private async findEbookFile( downloadPath: string, ebookFormats: string[] ): Promise<{ ebookFile: string | null; baseSourcePath: string; isFile: boolean }> { let ebookFile: string | null = null; let isFile = false; try { const stats = await fs.stat(downloadPath); if (stats.isFile()) { // Handle single file case isFile = true; const ext = path.extname(downloadPath).toLowerCase().slice(1); if (ebookFormats.includes(ext)) { ebookFile = path.basename(downloadPath); } } else { // Handle directory case - find ebook files inside const files = await this.walkDirectory(downloadPath); // Filter to ebook files and sort by preference (epub > pdf > others) const ebookFiles = files.filter(file => { const ext = path.extname(file).toLowerCase().slice(1); return ebookFormats.includes(ext); }); if (ebookFiles.length > 0) { // Sort by format preference ebookFiles.sort((a, b) => { const extA = path.extname(a).toLowerCase().slice(1); const extB = path.extname(b).toLowerCase().slice(1); const priorityOrder = ['epub', 'pdf', 'mobi', 'azw3', 'azw', 'fb2', 'cbz', 'cbr']; return priorityOrder.indexOf(extA) - priorityOrder.indexOf(extB); }); ebookFile = ebookFiles[0]; } } } catch { // Path doesn't exist or inaccessible } return { ebookFile, baseSourcePath: downloadPath, isFile, }; } } /** * Get FileOrganizer instance configured from database settings * Reads media_dir, file_chmod, dir_chmod from database configuration */ export async function getFileOrganizer(): Promise { // Read media_dir from database config const config = await prisma.configuration.findUnique({ where: { key: 'media_dir' }, }); const mediaDir = config?.value || process.env.MEDIA_DIR || '/media/audiobooks'; const tempDir = process.env.TEMP_DIR || '/tmp/readmeabook'; // Read file/directory permission settings const { getConfigService } = await import('../services/config.service'); const configService = getConfigService(); const fileChmodStr = await configService.get('file_chmod') || '664'; const dirChmodStr = await configService.get('dir_chmod') || '775'; const fileMode = parseInt(fileChmodStr, 8); const dirMode = parseInt(dirChmodStr, 8); return new FileOrganizer(mediaDir, tempDir, fileMode, dirMode); } /** * Build audiobook path using template-based path building * Standalone function for use by other modules (e.g., fetch-ebook route, request-delete service) * * @param baseDir - Base directory for audiobooks (e.g., /media/audiobooks) * @param template - Path template string (e.g., "{author}/{title} {asin}") * @param variables - Object containing variable values (author, title, narrator, asin) * @returns Full path to audiobook directory * * @example * ```typescript * const path = buildAudiobookPath( * '/media/audiobooks', * '{author}/{title} {asin}', * { author: 'Brandon Sanderson', title: 'Mistborn', asin: 'B002UZMLXM' } * ); * // Returns: "/media/audiobooks/Brandon Sanderson/Mistborn B002UZMLXM" * ``` */ export function buildAudiobookPath( baseDir: string, template: string, variables: { author: string; title: string; narrator?: string; asin?: string; year?: number; series?: string; seriesPart?: string } ): string { const templateVars: TemplateVariables = { author: variables.author, title: variables.title, narrator: variables.narrator, asin: variables.asin, year: variables.year, series: variables.series, seriesPart: variables.seriesPart, }; const relativePath = substituteTemplate(template, templateVars); return path.join(baseDir, relativePath); }