mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 21:00:09 +00:00
1045 lines
37 KiB
TypeScript
1045 lines
37 KiB
TypeScript
/**
|
|
* 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<OrganizationResult> {
|
|
// 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<string, string>();
|
|
|
|
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<string>();
|
|
|
|
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<string[]> {
|
|
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<string> {
|
|
const counts = new Map<string, number>();
|
|
|
|
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<string>,
|
|
usedFilenames: Set<string>
|
|
): 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>): 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<void> {
|
|
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<void> {
|
|
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<ValidationResult> {
|
|
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<EbookOrganizationResult> {
|
|
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<FileOrganizer> {
|
|
// 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);
|
|
}
|