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:
kikootwo
2026-05-15 19:33:59 -04:00
parent 6f8ac86a43
commit f23afc1ba2
18 changed files with 815 additions and 7 deletions
+1
View File
@@ -113,6 +113,7 @@ export interface PathsSettings {
audiobookPathTemplate?: string;
ebookPathTemplate?: string;
metadataTaggingEnabled: boolean;
plexFormatCoercionEnabled: boolean;
chapterMergingEnabled: boolean;
fileRenameEnabled: boolean;
fileRenameTemplate?: string;
@@ -414,6 +414,30 @@ export function PathsTab({ paths, onChange, onValidationChange }: PathsTabProps)
</div>
</div>
{/* Plex Format Coercion Toggle */}
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
<div className="flex items-start gap-4">
<input
type="checkbox"
id="plex-format-coercion-settings"
checked={paths.plexFormatCoercionEnabled}
onChange={(e) => updatePath('plexFormatCoercionEnabled', e.target.checked)}
className="mt-1 h-5 w-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<div className="flex-1">
<label
htmlFor="plex-format-coercion-settings"
className="block text-sm font-medium text-gray-900 dark:text-gray-100 cursor-pointer"
>
Coerce file formats for Plex compatibility
</label>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
Rename .mp4 audiobook files (and single-file .m4a) to .m4b before Plex scans. No re-encoding.
</p>
</div>
</div>
</div>
{/* Chapter Merging Toggle */}
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
<div className="flex items-start gap-4">
+15 -1
View File
@@ -15,7 +15,7 @@ export async function PUT(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
const { downloadDir, mediaDir, audiobookPathTemplate, ebookPathTemplate, metadataTaggingEnabled, chapterMergingEnabled, fileRenameEnabled, fileRenameTemplate, fileChmod, dirChmod } = await request.json();
const { downloadDir, mediaDir, audiobookPathTemplate, ebookPathTemplate, metadataTaggingEnabled, chapterMergingEnabled, plexFormatCoercionEnabled, fileRenameEnabled, fileRenameTemplate, fileChmod, dirChmod } = await request.json();
if (!downloadDir || !mediaDir) {
return NextResponse.json(
@@ -112,6 +112,19 @@ export async function PUT(request: NextRequest) {
},
});
// Update Plex format coercion setting (issue #166: silently rename .mp4/.m4a to .m4b
// post-organize so Plex's audiobook library recognizes them without transcoding)
await prisma.configuration.upsert({
where: { key: 'plex_format_coercion_enabled' },
update: { value: String(plexFormatCoercionEnabled ?? true) },
create: {
key: 'plex_format_coercion_enabled',
value: String(plexFormatCoercionEnabled ?? true),
category: 'automation',
description: 'Rename audio files to Plex-compatible extensions (.mp4/.m4a -> .m4b) after organization to avoid silent library-scan failures',
},
});
// Update file rename setting
await prisma.configuration.upsert({
where: { key: 'file_rename_enabled' },
@@ -176,6 +189,7 @@ export async function PUT(request: NextRequest) {
configService.clearCache('ebook_path_template');
configService.clearCache('metadata_tagging_enabled');
configService.clearCache('chapter_merging_enabled');
configService.clearCache('plex_format_coercion_enabled');
configService.clearCache('file_rename_enabled');
configService.clearCache('file_rename_template');
configService.clearCache('file_chmod');
+1
View File
@@ -134,6 +134,7 @@ export async function GET(request: NextRequest) {
ebookPathTemplate: configMap.get('ebook_path_template') || configMap.get('audiobook_path_template') || '{author}/{title} {asin}',
metadataTaggingEnabled: configMap.get('metadata_tagging_enabled') === 'true',
chapterMergingEnabled: configMap.get('chapter_merging_enabled') === 'true',
plexFormatCoercionEnabled: configMap.get('plex_format_coercion_enabled') !== 'false',
fileRenameEnabled: configMap.get('file_rename_enabled') === 'true',
fileRenameTemplate: configMap.get('file_rename_template') || '{title}',
fileChmod: configMap.get('file_chmod') || '664',
+13
View File
@@ -469,6 +469,19 @@ export async function POST(request: NextRequest) {
},
});
// Plex format coercion configuration (issue #166: silently rename .mp4/.m4a to .m4b
// post-organize so Plex's audiobook library recognizes them without transcoding)
await prisma.configuration.upsert({
where: { key: 'plex_format_coercion_enabled' },
update: { value: String(paths.plex_format_coercion_enabled ?? true) },
create: {
key: 'plex_format_coercion_enabled',
value: String(paths.plex_format_coercion_enabled ?? true),
category: 'automation',
description: 'Rename audio files to Plex-compatible extensions (.mp4/.m4a -> .m4b) after organization to avoid silent library-scan failures'
},
});
// BookDate configuration (optional, global for all users)
// Note: libraryScope and customPrompt are now per-user settings, not required here
if (bookdate && bookdate.provider && bookdate.apiKey && bookdate.model) {
+4
View File
@@ -87,6 +87,7 @@ interface SetupState {
downloadDir: string;
mediaDir: string;
metadataTaggingEnabled: boolean;
plexFormatCoercionEnabled: boolean;
chapterMergingEnabled: boolean;
bookdateProvider: string;
bookdateApiKey: string;
@@ -161,6 +162,7 @@ export default function SetupWizard() {
downloadDir: '/downloads',
mediaDir: '/media/audiobooks',
metadataTaggingEnabled: true,
plexFormatCoercionEnabled: true,
chapterMergingEnabled: false,
bookdateProvider: 'openai',
bookdateApiKey: '',
@@ -237,6 +239,7 @@ export default function SetupWizard() {
download_dir: state.downloadDir,
media_dir: state.mediaDir,
metadata_tagging_enabled: state.metadataTaggingEnabled,
plex_format_coercion_enabled: state.plexFormatCoercionEnabled,
chapter_merging_enabled: state.chapterMergingEnabled,
},
bookdate: state.bookdateConfigured ? {
@@ -537,6 +540,7 @@ export default function SetupWizard() {
downloadDir={state.downloadDir}
mediaDir={state.mediaDir}
metadataTaggingEnabled={state.metadataTaggingEnabled}
plexFormatCoercionEnabled={state.plexFormatCoercionEnabled}
chapterMergingEnabled={state.chapterMergingEnabled}
pathsTested={state.pathsTested}
onUpdate={updateField}
+26
View File
@@ -13,6 +13,7 @@ interface PathsStepProps {
downloadDir: string;
mediaDir: string;
metadataTaggingEnabled: boolean;
plexFormatCoercionEnabled: boolean;
chapterMergingEnabled: boolean;
pathsTested: boolean;
onUpdate: (field: string, value: any) => void;
@@ -24,6 +25,7 @@ export function PathsStep({
downloadDir,
mediaDir,
metadataTaggingEnabled,
plexFormatCoercionEnabled,
chapterMergingEnabled,
pathsTested,
onUpdate,
@@ -246,6 +248,30 @@ export function PathsStep({
</div>
</div>
{/* Plex Format Coercion Toggle */}
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
<div className="flex items-start gap-4">
<input
type="checkbox"
id="plex-format-coercion"
checked={plexFormatCoercionEnabled}
onChange={(e) => onUpdate('plexFormatCoercionEnabled', e.target.checked)}
className="mt-1 h-5 w-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<div className="flex-1">
<label
htmlFor="plex-format-coercion"
className="block text-sm font-medium text-gray-900 dark:text-gray-100 cursor-pointer"
>
Coerce file formats for Plex compatibility
</label>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
Rename .mp4 audiobook files (and single-file .m4a) to .m4b before Plex scans. No re-encoding.
</p>
</div>
</div>
</div>
{/* Chapter Merging Toggle */}
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
<div className="flex items-start gap-4">
+36
View File
@@ -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) {
+160
View File
@@ -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 };
}