mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 21:00:09 +00:00
a0f2ba680d
improve container startup for rootless Podman, plus related refactors and tests. Key changes: - Add/modify Audiobookshelf-related code and wiring (src/lib/services/audiobookshelf/api.ts, library service refs) and update documentation TABLEOFCONTENTS to reference ABS implementation. - Detect user namespace in docker/unified app-start.sh and redis-start.sh and skip gosu when running in rootless Podman to preserve UID mapping; improve startup logging and verification. - Add utility/service files (auth-token-cache.service.ts, credential-migration.service.ts, cleanup-helpers.ts) and corresponding tests; update chapter-merger and metadata-tagger utilities/tests. - Update many admin/auth API routes and tests to reflect changes in settings and integrations. - Remove large AI agent and Audiobookshelf implementation guide docs (AGENTS.md and the implementation guide) and add README note about AI-assisted workflow. These changes enable Audiobookshelf backend mode, improve compatibility with rootless container runtimes, and include cleanup/refactor work and unit tests.
276 lines
9.0 KiB
TypeScript
276 lines
9.0 KiB
TypeScript
/**
|
|
* 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<boolean | null> {
|
|
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;
|
|
}
|