/** * Cleanup Helpers Utility * Documentation: documentation/phase3/sabnzbd.md * * Provides utilities for cleaning up after file organization, * including removal of empty parent directories. */ import * as fs from 'fs/promises'; import * as path from 'path'; import { RMABLogger } from './logger'; const logger = RMABLogger.create('CleanupHelpers'); /** * Options for removeEmptyParentDirectories */ export interface RemoveEmptyParentOptions { /** The boundary path - will never delete this directory or its parents */ boundaryPath: string; /** Optional logger context for job-aware logging */ logContext?: { jobId: string; context: string }; } /** * Removes empty parent directories after a file/directory has been deleted. * * This function walks up the directory tree from the deleted path, removing * any empty directories until it encounters a non-empty directory or reaches * the configured boundary path. * * Use case: SABnzbd downloads to /downloads/readmeabook/My.Audiobook.Name/ * After deleting the download folder, the category folder (readmeabook) may * be left empty. This function cleans up those empty parent folders. * * Safety features: * - Will NEVER delete the boundary path itself (e.g., download_dir) * - Will NEVER delete above the boundary path * - Gracefully handles ENOENT (already deleted) * - Gracefully handles permission errors (logs warning, continues) * - Stops immediately when a non-empty directory is encountered * * @param deletedPath - The path that was just deleted (file or directory) * @param options - Configuration options including boundary path * @returns Object with details about what was cleaned up * * @example * // After deleting /downloads/readmeabook/My.Audiobook.Name * await removeEmptyParentDirectories( * '/downloads/readmeabook/My.Audiobook.Name', * { boundaryPath: '/downloads' } * ); * // This will remove /downloads/readmeabook if it's empty * // but will never touch /downloads */ export async function removeEmptyParentDirectories( deletedPath: string, options: RemoveEmptyParentOptions ): Promise<{ success: boolean; removedDirectories: string[]; stoppedAt?: string; stoppedReason?: 'non_empty' | 'boundary_reached' | 'root_reached' | 'error'; error?: string; }> { const log = options.logContext ? RMABLogger.forJob(options.logContext.jobId, options.logContext.context) : logger; const removedDirectories: string[] = []; try { // Normalize paths for consistent comparison const normalizedBoundary = normalizePath(options.boundaryPath); let currentPath = normalizePath(path.dirname(deletedPath)); log.debug('Starting empty parent directory cleanup', { deletedPath, boundaryPath: options.boundaryPath, normalizedBoundary, startingFrom: currentPath, }); // Walk up the directory tree while (true) { // Safety check: Have we reached the filesystem root? const parentPath = normalizePath(path.dirname(currentPath)); if (parentPath === currentPath) { log.debug('Reached filesystem root, stopping cleanup'); return { success: true, removedDirectories, stoppedAt: currentPath, stoppedReason: 'root_reached', }; } // Safety check: Have we reached or passed the boundary? if (!isPathBelowBoundary(currentPath, normalizedBoundary)) { log.debug('Reached boundary path, stopping cleanup', { currentPath, boundaryPath: normalizedBoundary, }); return { success: true, removedDirectories, stoppedAt: currentPath, stoppedReason: 'boundary_reached', }; } // Check if the directory is empty const isEmpty = await isDirectoryEmpty(currentPath); if (isEmpty === null) { // Directory doesn't exist (ENOENT) - move to parent log.debug(`Directory does not exist, moving to parent: ${currentPath}`); currentPath = parentPath; continue; } if (!isEmpty) { // Directory is not empty - stop here log.debug(`Directory not empty, stopping cleanup: ${currentPath}`); return { success: true, removedDirectories, stoppedAt: currentPath, stoppedReason: 'non_empty', }; } // Directory is empty - try to remove it try { await fs.rmdir(currentPath); removedDirectories.push(currentPath); log.info(`Removed empty directory: ${currentPath}`); } catch (removeError) { const errorCode = (removeError as NodeJS.ErrnoException).code; if (errorCode === 'ENOENT') { // Already deleted (race condition) - continue to parent log.debug(`Directory already deleted: ${currentPath}`); } else if (errorCode === 'ENOTEMPTY') { // Directory became non-empty (race condition) - stop log.debug(`Directory became non-empty: ${currentPath}`); return { success: true, removedDirectories, stoppedAt: currentPath, stoppedReason: 'non_empty', }; } else if (errorCode === 'EACCES' || errorCode === 'EPERM') { // Permission error - log warning and stop log.warn(`Permission denied removing directory: ${currentPath}`, { error: removeError instanceof Error ? removeError.message : String(removeError), }); return { success: true, // Partial success - we cleaned what we could removedDirectories, stoppedAt: currentPath, stoppedReason: 'error', error: `Permission denied: ${currentPath}`, }; } else { // Unexpected error - log and stop log.error(`Failed to remove directory: ${currentPath}`, { error: removeError instanceof Error ? removeError.message : String(removeError), errorCode, }); return { success: false, removedDirectories, stoppedAt: currentPath, stoppedReason: 'error', error: removeError instanceof Error ? removeError.message : String(removeError), }; } } // Move to parent directory currentPath = parentPath; } } catch (error) { log.error('Unexpected error during empty parent cleanup', { error: error instanceof Error ? error.message : String(error), deletedPath, boundaryPath: options.boundaryPath, }); return { success: false, removedDirectories, stoppedReason: 'error', error: error instanceof Error ? error.message : String(error), }; } } /** * Checks if a directory is empty * * @param dirPath - Path to the directory * @returns true if empty, false if not empty, null if directory doesn't exist */ async function isDirectoryEmpty(dirPath: string): Promise { try { const entries = await fs.readdir(dirPath); return entries.length === 0; } catch (error) { const errorCode = (error as NodeJS.ErrnoException).code; if (errorCode === 'ENOENT') { // Directory doesn't exist return null; } if (errorCode === 'ENOTDIR') { // Path is a file, not a directory return null; } // Re-throw other errors throw error; } } /** * Checks if a path is strictly below (inside) the boundary path * * A path is below the boundary if: * - It's longer than the boundary path * - It starts with the boundary path followed by a path separator * * @param testPath - The path to test (must be normalized) * @param boundaryPath - The boundary path (must be normalized) * @returns true if testPath is strictly below boundaryPath */ function isPathBelowBoundary(testPath: string, boundaryPath: string): boolean { // Ensure both paths don't have trailing slashes for comparison const normalizedTest = testPath.replace(/\/+$/, ''); const normalizedBoundary = boundaryPath.replace(/\/+$/, ''); // Path must be strictly below boundary, not equal to it if (normalizedTest === normalizedBoundary) { return false; } // Check if test path is under boundary path // Must start with boundary + separator to avoid matching /downloads2 when boundary is /downloads return normalizedTest.startsWith(normalizedBoundary + '/'); } /** * Normalizes a file path for consistent comparison * * @param filePath - Path to normalize * @returns Normalized path with forward slashes and no trailing slash */ function normalizePath(filePath: string): string { // Convert backslashes to forward slashes let normalized = filePath.replace(/\\/g, '/'); // Use path.normalize to handle redundant separators and .. normalized = path.normalize(normalized); // Convert backslashes again (path.normalize might add them on Windows) normalized = normalized.replace(/\\/g, '/'); // Remove trailing slash (except for root '/') if (normalized.length > 1 && normalized.endsWith('/')) { normalized = normalized.slice(0, -1); } return normalized; }