mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +00:00
Add Plex format coercion (.mp4 → .m4b)
Implement Plex-compatible file-extension coercion to avoid Plex silently ignoring .mp4 (and single-file .m4a) audiobooks (issue #166). Adds a DB migration and configuration key (plex_format_coercion_enabled, default true), exposes a toggle in the setup wizard and Admin Paths settings, and persists/reads the setting in the admin/setup APIs. Introduces src/lib/utils/format-coercion.ts (coerceToPlexCompatible) and related constants in src/lib/constants/audio-formats.ts (PLEX_COMPATIBLE_EXTENSIONS, COERCION_RENAME_MAP, DRM_EXTENSIONS, TRANSCODE_REQUIRED_EXTENSIONS). The organize-files processor now runs coercion after organizing/tagging and before generating the filesHash and triggering scans; coercion is idempotent, never overwrites existing targets, logs warnings on DRM/transcode/permission errors, and is non-fatal. Adds unit tests for the coercion util and updates processor & setup UI tests. Updates documentation (TABLEOFCONTENTS, file-organization, fixes/file-hash-matching, settings-pages) describing behavior, config, and constraints.
This commit is contained in:
@@ -68,6 +68,42 @@ export type TorrentTitleFormat = (typeof TORRENT_TITLE_FORMATS)[number];
|
||||
*/
|
||||
export type AudioFormat = TorrentTitleFormat | 'OTHER';
|
||||
|
||||
/**
|
||||
* Plex audiobook-library recognized extensions.
|
||||
* Used by Plex format coercion to determine which formats are silently passed through.
|
||||
* Research-grounded — see issue #166 for context on Plex's silent-failure behavior.
|
||||
* Note: includes formats not yet in `AUDIO_EXTENSIONS` (.aac/.wav/.alac) for future-proofing.
|
||||
*/
|
||||
export const PLEX_COMPATIBLE_EXTENSIONS = [
|
||||
'.m4b',
|
||||
'.m4a',
|
||||
'.mp3',
|
||||
'.flac',
|
||||
'.aac',
|
||||
'.wav',
|
||||
'.alac',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Unambiguous rename targets for Plex format coercion.
|
||||
* `.mp4` → `.m4b` always. `.m4a` → `.m4b` is conditional (single-file only) and handled in coercion logic.
|
||||
*/
|
||||
export const COERCION_RENAME_MAP: Record<string, string> = {
|
||||
'.mp4': '.m4b',
|
||||
};
|
||||
|
||||
/**
|
||||
* DRM-protected formats that cannot be decoded without keys.
|
||||
* Plex format coercion logs a warning and skips these.
|
||||
*/
|
||||
export const DRM_EXTENSIONS = ['.aa', '.aax'] as const;
|
||||
|
||||
/**
|
||||
* Formats that would require a full transcode to become Plex-compatible.
|
||||
* Out of scope for v1 Plex format coercion — logs a warning and skips.
|
||||
*/
|
||||
export const TRANSCODE_REQUIRED_EXTENSIONS = ['.ogg', '.opus', '.wma'] as const;
|
||||
|
||||
/**
|
||||
* All supported ebook file extensions for ebook detection and file serving.
|
||||
*/
|
||||
|
||||
@@ -222,6 +222,25 @@ export async function processOrganizeFiles(payload: OrganizeFilesPayload): Promi
|
||||
|
||||
logger.info(`Successfully moved ${result.filesMovedCount} files to ${result.targetPath}`);
|
||||
|
||||
const coercionConfig = await prisma.configuration.findUnique({
|
||||
where: { key: 'plex_format_coercion_enabled' },
|
||||
});
|
||||
const coercionEnabled = coercionConfig?.value !== 'false';
|
||||
|
||||
if (coercionEnabled && result.audioFiles.length > 0) {
|
||||
try {
|
||||
const { coerceToPlexCompatible } = await import('../utils/format-coercion');
|
||||
const coercion = await coerceToPlexCompatible(result.audioFiles, logger);
|
||||
if (coercion.renamed.length > 0) {
|
||||
logger.info(`Plex format coercion: renamed ${coercion.renamed.length} file(s)`);
|
||||
result.audioFiles = coercion.finalAudioFiles;
|
||||
}
|
||||
coercion.warnings.forEach(w => logger.warn(`Plex format coercion: ${w}`));
|
||||
} catch (err) {
|
||||
logger.warn(`Plex format coercion failed (non-fatal): ${err instanceof Error ? err.message : 'unknown'}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Generate hash from organized audio files for library matching
|
||||
const filesHash = generateFilesHash(result.audioFiles);
|
||||
if (filesHash) {
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* Component: Plex Format Coercion
|
||||
* Documentation: documentation/phase3/file-organization.md
|
||||
*
|
||||
* Renames audio files in-place after organization to formats Plex's audiobook
|
||||
* library recognizes silently. No transcoding — extension-swap only.
|
||||
*
|
||||
* Behavior (issue #166):
|
||||
* - `.mp4` → `.m4b` (always)
|
||||
* - `.m4a` → `.m4b` only when it's the only audio file in its directory (single-file audiobook)
|
||||
* - DRM (`.aa`/`.aax`) → warn + skip (cannot decode without keys)
|
||||
* - Transcode-required (`.ogg`/`.opus`/`.wma`) → warn + skip (out of scope for v1)
|
||||
* - Already Plex-compatible or unrecognized → silent no-op
|
||||
* - Target path already exists → no-op + info log (never overwrite)
|
||||
*
|
||||
* Idempotency signal is the file extension itself — no marker files, no DB column.
|
||||
* Failure mode: any per-file error is captured as a warning and the original path
|
||||
* is retained in `finalAudioFiles`; never throws.
|
||||
*/
|
||||
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import {
|
||||
DRM_EXTENSIONS,
|
||||
PLEX_COMPATIBLE_EXTENSIONS,
|
||||
TRANSCODE_REQUIRED_EXTENSIONS,
|
||||
} from '../constants/audio-formats';
|
||||
import type { RMABLogger } from './logger';
|
||||
|
||||
/**
|
||||
* Result of a coercion pass over a set of audio file paths.
|
||||
*
|
||||
* - `renamed`: every successful rename, in input order
|
||||
* - `warnings`: human-readable reasons for non-rename outcomes (DRM, transcode, collision, EPERM, ...)
|
||||
* - `errors`: reserved for future hard-error reporting; currently unused (we degrade to warnings)
|
||||
* - `finalAudioFiles`: the post-coercion path list, 1:1 with the input order.
|
||||
* Always populated — caller can blindly assign `result.audioFiles = coercion.finalAudioFiles`.
|
||||
*/
|
||||
export interface CoercionResult {
|
||||
renamed: Array<{ from: string; to: string }>;
|
||||
warnings: string[];
|
||||
errors: string[];
|
||||
finalAudioFiles: string[];
|
||||
}
|
||||
|
||||
const DRM_SET: ReadonlySet<string> = new Set(DRM_EXTENSIONS as readonly string[]);
|
||||
const TRANSCODE_SET: ReadonlySet<string> = new Set(
|
||||
TRANSCODE_REQUIRED_EXTENSIONS as readonly string[],
|
||||
);
|
||||
const PLEX_COMPATIBLE_SET: ReadonlySet<string> = new Set(
|
||||
PLEX_COMPATIBLE_EXTENSIONS as readonly string[],
|
||||
);
|
||||
|
||||
/**
|
||||
* Coerce the given audio files to Plex-compatible formats by extension rename.
|
||||
*
|
||||
* Never throws. Per-file failures are recorded in `warnings` and the original
|
||||
* path is preserved in `finalAudioFiles` at the same index.
|
||||
*
|
||||
* @param audioFilePaths Absolute paths to audio files (already organized into the target media dir).
|
||||
* @param logger Optional `RMABLogger` for per-file and per-warning visibility.
|
||||
* @returns Structured `CoercionResult`. Caller should overwrite its audio-file list with `finalAudioFiles`.
|
||||
*/
|
||||
export async function coerceToPlexCompatible(
|
||||
audioFilePaths: string[],
|
||||
logger?: RMABLogger,
|
||||
): Promise<CoercionResult> {
|
||||
const renamed: Array<{ from: string; to: string }> = [];
|
||||
const warnings: string[] = [];
|
||||
const errors: string[] = [];
|
||||
const finalAudioFiles: string[] = [];
|
||||
|
||||
if (!Array.isArray(audioFilePaths) || audioFilePaths.length === 0) {
|
||||
return { renamed, warnings, errors, finalAudioFiles };
|
||||
}
|
||||
|
||||
// Count `.m4a` siblings per directory so we can distinguish single-file
|
||||
// (rename to .m4b) from multi-file (leave alone) m4a books.
|
||||
const m4aCountByDir = new Map<string, number>();
|
||||
for (const filePath of audioFilePaths) {
|
||||
if (path.extname(filePath).toLowerCase() === '.m4a') {
|
||||
const dir = path.dirname(filePath);
|
||||
m4aCountByDir.set(dir, (m4aCountByDir.get(dir) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
for (const originalPath of audioFilePaths) {
|
||||
const ext = path.extname(originalPath).toLowerCase();
|
||||
let targetExt: string | null = null;
|
||||
|
||||
if (ext === '.mp4') {
|
||||
targetExt = '.m4b';
|
||||
} else if (ext === '.m4a') {
|
||||
const dir = path.dirname(originalPath);
|
||||
const siblingCount = m4aCountByDir.get(dir) ?? 0;
|
||||
if (siblingCount === 1) {
|
||||
targetExt = '.m4b';
|
||||
} else {
|
||||
// Multi-file .m4a audiobook — leave alone.
|
||||
finalAudioFiles.push(originalPath);
|
||||
continue;
|
||||
}
|
||||
} else if (DRM_SET.has(ext)) {
|
||||
const msg = `DRM format ${ext} cannot be decoded; Plex will not import "${path.basename(originalPath)}"`;
|
||||
warnings.push(msg);
|
||||
logger?.warn(`Plex format coercion: ${msg}`);
|
||||
finalAudioFiles.push(originalPath);
|
||||
continue;
|
||||
} else if (TRANSCODE_SET.has(ext)) {
|
||||
const msg = `Format ${ext} requires transcode; not supported in this version (file: "${path.basename(originalPath)}")`;
|
||||
warnings.push(msg);
|
||||
logger?.warn(`Plex format coercion: ${msg}`);
|
||||
finalAudioFiles.push(originalPath);
|
||||
continue;
|
||||
} else if (PLEX_COMPATIBLE_SET.has(ext)) {
|
||||
// Already Plex-compatible — silent no-op.
|
||||
finalAudioFiles.push(originalPath);
|
||||
continue;
|
||||
} else {
|
||||
// Unknown extension — leave alone. Not our job to filter detection.
|
||||
finalAudioFiles.push(originalPath);
|
||||
continue;
|
||||
}
|
||||
|
||||
// We have a rename target. Compose the new path by swapping just the extension.
|
||||
const dir = path.dirname(originalPath);
|
||||
const base = path.basename(originalPath, path.extname(originalPath));
|
||||
const targetPath = path.join(dir, `${base}${targetExt}`);
|
||||
|
||||
// Pre-rename collision check — never overwrite.
|
||||
try {
|
||||
await fs.access(targetPath);
|
||||
const msg = `target already exists, skipping rename: "${path.basename(targetPath)}"`;
|
||||
warnings.push(msg);
|
||||
logger?.info(`Plex format coercion: ${msg}`);
|
||||
finalAudioFiles.push(originalPath);
|
||||
continue;
|
||||
} catch {
|
||||
// Target does not exist — proceed with rename.
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.rename(originalPath, targetPath);
|
||||
renamed.push({ from: originalPath, to: targetPath });
|
||||
finalAudioFiles.push(targetPath);
|
||||
logger?.info(
|
||||
`Plex format coercion: renamed "${path.basename(originalPath)}" → "${path.basename(targetPath)}"`,
|
||||
);
|
||||
} catch (err) {
|
||||
const reason = err instanceof Error ? err.message : 'unknown error';
|
||||
const msg = `failed to rename "${path.basename(originalPath)}" → "${path.basename(targetPath)}": ${reason}`;
|
||||
warnings.push(msg);
|
||||
logger?.warn(`Plex format coercion: ${msg}`);
|
||||
finalAudioFiles.push(originalPath);
|
||||
}
|
||||
}
|
||||
|
||||
return { renamed, warnings, errors, finalAudioFiles };
|
||||
}
|
||||
Reference in New Issue
Block a user