From f23afc1ba290f6e35d3fed9fc09623f7feac6f37 Mon Sep 17 00:00:00 2001 From: kikootwo Date: Fri, 15 May 2026 19:33:59 -0400 Subject: [PATCH] =?UTF-8?q?Add=20Plex=20format=20coercion=20(.mp4=20?= =?UTF-8?q?=E2=86=92=20.m4b)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- documentation/TABLEOFCONTENTS.md | 1 + documentation/fixes/file-hash-matching.md | 6 + documentation/phase3/file-organization.md | 65 +++++- documentation/settings-pages.md | 23 +- .../migration.sql | 18 ++ src/app/admin/settings/lib/types.ts | 1 + .../admin/settings/tabs/PathsTab/PathsTab.tsx | 24 ++ src/app/api/admin/settings/paths/route.ts | 16 +- src/app/api/admin/settings/route.ts | 1 + src/app/api/setup/complete/route.ts | 13 ++ src/app/setup/page.tsx | 4 + src/app/setup/steps/PathsStep.tsx | 26 +++ src/lib/constants/audio-formats.ts | 36 +++ .../processors/organize-files.processor.ts | 19 ++ src/lib/utils/format-coercion.ts | 160 +++++++++++++ tests/app/setup/steps/PathsStep.test.tsx | 21 +- .../organize-files.processor.test.ts | 169 ++++++++++++++ tests/utils/format-coercion.test.ts | 219 ++++++++++++++++++ 18 files changed, 815 insertions(+), 7 deletions(-) create mode 100644 prisma/migrations/20260515000000_add_plex_format_coercion_config/migration.sql create mode 100644 src/lib/utils/format-coercion.ts create mode 100644 tests/utils/format-coercion.test.ts diff --git a/documentation/TABLEOFCONTENTS.md b/documentation/TABLEOFCONTENTS.md index 5a5ecb9..b74b97a 100644 --- a/documentation/TABLEOFCONTENTS.md +++ b/documentation/TABLEOFCONTENTS.md @@ -69,6 +69,7 @@ - **qBittorrent integration (torrents)** → [phase3/qbittorrent.md](phase3/qbittorrent.md) - **SABnzbd integration (Usenet/NZB)** → [phase3/sabnzbd.md](phase3/sabnzbd.md) - **File organization, seeding** → [phase3/file-organization.md](phase3/file-organization.md) +- **Plex-compatible format coercion (.mp4 → .m4b)** → [phase3/file-organization.md](phase3/file-organization.md#plex-format-coercion) - **Chapter merging (auto-merge to M4B)** → [features/chapter-merging.md](features/chapter-merging.md) ## Background Jobs diff --git a/documentation/fixes/file-hash-matching.md b/documentation/fixes/file-hash-matching.md index e42eccb..f6984b4 100644 --- a/documentation/fixes/file-hash-matching.md +++ b/documentation/fixes/file-hash-matching.md @@ -154,6 +154,12 @@ model Audiobook { - Hash generated AFTER merging - **Works correctly:** Hash reflects final organized state +### Coerced Files (Plex Format Coercion) +- Files renamed from `.mp4` → `.m4b` (or single-file `.m4a` → `.m4b`) by Plex format coercion +- Hash generated AFTER coercion → reflects post-coercion filenames +- **Works correctly going forward:** ABS sees post-coercion names, hash matches +- **Pre-existing library entries** hashed before coercion was enabled will NOT match post-coercion files — retroactive library sweep is out of scope (see issue #166) + ### Multiple Downloads (Same Book) - User re-downloads same audiobook (different edition/request) - Multiple records with same `filesHash` diff --git a/documentation/phase3/file-organization.md b/documentation/phase3/file-organization.md index 21686aa..d02c402 100644 --- a/documentation/phase3/file-organization.md +++ b/documentation/phase3/file-organization.md @@ -44,10 +44,11 @@ Result: Douglas Adams/Stephen Fry/The Hitchhiker's Guide to the Galaxy/ 5. **Copy** files (not move - originals stay for seeding) 6. **Tag metadata** (if enabled) - writes correct title, author, narrator, ASIN to audio files 7. Copy cover art if found, else download from Audible -8. **Generate file hash** - SHA256 of sorted audio filenames for library matching (see: [fixes/file-hash-matching.md](../fixes/file-hash-matching.md)) -9. Update request status to `downloaded` and store file hash in `audiobooks.files_hash` -10. **Trigger filesystem scan** (if enabled) - tells Plex/ABS to scan for new files -11. Originals remain until seeding requirements met +8. **Coerce file formats** (if enabled) - rename .mp4 → .m4b and single-file .m4a → .m4b for Plex compatibility (see: Plex Format Coercion below) +9. **Generate file hash** - SHA256 of sorted audio filenames for library matching (see: [fixes/file-hash-matching.md](../fixes/file-hash-matching.md)) +10. Update request status to `downloaded` and store file hash in `audiobooks.files_hash` +11. **Trigger filesystem scan** (if enabled) - tells Plex/ABS to scan for new files +12. Originals remain until seeding requirements met ## Filesystem Scan Triggering @@ -150,6 +151,61 @@ exiftool "audiobook.m4b" | grep -i asin - Multi-container: `docker exec readmeabook ffmpeg -version` - Unified: `docker exec readmeabook-unified ffmpeg -version` +## Plex Format Coercion + +**Status:** ✅ Implemented | Issue #166 + +**Purpose:** Rename audiobook files to Plex-recognized extensions before the library scan. Plex silently ignores `.mp4` files in audiobook libraries; this step prevents that silent-failure mode. Rename-only — no transcoding. + +**When:** After file organization and metadata tagging, before file-hash generation and before library scan trigger. + +**Scope:** Audio path only. Not applied to ebook organization. + +**Coercion Table:** + +| Source ext | Action | +|---|---| +| `.mp4` | Rename to `.m4b` | +| `.m4a` (single audio file in folder) | Rename to `.m4b` | +| `.m4a` (multi-file folder) | No-op | +| `.m4b`, `.mp3`, `.flac`, `.aac`, `.wav`, `.alac` | No-op | +| `.aa`, `.aax` | No-op + warn ("DRM, Plex cannot import") | +| `.ogg`, `.opus`, `.wma`, other | No-op + warn ("requires transcode, not supported in v1") | + +**Configuration:** +- Key: `plex_format_coercion_enabled` (Configuration table) +- Default: `true` +- Read contract: `value !== 'false'` enables (default-on semantics) +- Configurable in: Setup wizard (Paths step), Admin settings (Paths tab) + +**Behavior:** +- Each audio file evaluated independently (mixed-format folders supported). +- Pre-rename collision check: if target exists → no-op + info log. Never overwrites. +- Idempotent: re-running on already-coerced folder is a no-op (extension is the signal — no marker files). +- Operates on `targetPath` (organized library files) only — never touches `/downloads` (seeding-safe). + +**Failure Isolation:** +- Coercion wrapped in try/catch at processor level. +- Any failure (e.g., EPERM) logs a warning; request remains organized; original file untouched. +- A failed rename never regresses the request to "stuck." + +**Tech Stack:** +- `src/lib/utils/format-coercion.ts` — coercion module +- `src/lib/constants/audio-formats.ts` — `PLEX_COMPATIBLE_EXTENSIONS`, `COERCION_RENAME_MAP`, `DRM_EXTENSIONS`, `TRANSCODE_REQUIRED_EXTENSIONS` +- Invoked from `src/lib/processors/organize-files.processor.ts` between file organization and `generateFilesHash` +- `fs.rename` (same filesystem — no cross-mount issues) + +**Hash Interaction:** +- File hash (`audiobooks.files_hash`) is generated AFTER coercion → reflects post-coercion filenames. +- See: [fixes/file-hash-matching.md](../fixes/file-hash-matching.md) for hash semantics. + +**Out of Scope (v1):** +- Transcoding (`.ogg`, `.opus`, `.wma`) +- DRM decoding (`.aa`, `.aax`) +- FLAC → M4B (already Plex-recognized) +- Per-request override UI +- Retroactive library sweep (new downloads only) + ## Seeding Support **Config:** `seeding_time_minutes` (0 = unlimited, never cleanup) @@ -203,6 +259,7 @@ async function organize( - **Path template:** Read from database config key `audiobook_path_template` (default: `{author}/{title} {asin}`) - **Metadata tagging:** `metadata_tagging_enabled` (boolean, default: true) - **Chapter merging:** `chapter_merging_enabled` (boolean, default: false) +- **Plex format coercion:** `plex_format_coercion_enabled` (boolean, default: true) - **Fallback:** `/media/audiobooks` if media_dir not configured - **Temp directory:** `/tmp/readmeabook` (or `TEMP_DIR` env var) diff --git a/documentation/settings-pages.md b/documentation/settings-pages.md index 57ba0b5..6804cc2 100644 --- a/documentation/settings-pages.md +++ b/documentation/settings-pages.md @@ -68,7 +68,7 @@ src/app/admin/settings/ 2. **Audiobookshelf** - URL, API token (masked), library ID, Audible region, filesystem scan trigger toggle 3. **Prowlarr** - URL, API key (masked), indexer selection with priority, seeding time, RSS monitoring toggle, **audiobook/ebook categories per indexer** 4. **Download Client** - Type (qBittorrent, Transmission, SABnzbd), URL, credentials (masked), custom download path (per-client relative sub-path with live preview) -5. **Paths** - Download + media directories, audiobook organization template, metadata tagging toggle, chapter merging toggle +5. **Paths** - Download + media directories, audiobook organization template, metadata tagging toggle, chapter merging toggle, Plex format coercion toggle 6. **E-book Sidecar** - Multi-source ebook downloads (Anna's Archive + Indexer Search), preferred format 7. **BookDate** - AI provider, API key (encrypted), model selection, library scope, custom prompt, swipe history 8. **Notifications** - Multiple backends (Discord, Pushover), event subscriptions, test functionality @@ -222,6 +222,27 @@ src/app/admin/settings/ - When disabled: User relies on media server's filesystem watcher or manual scans - Error handling: Scan failures logged but don't fail organize job (graceful degradation) +## Plex Format Coercion + +**Purpose:** Rename audiobook files to Plex-recognized extensions (`.mp4` → `.m4b`, single-file `.m4a` → `.m4b`) before the library scan. Prevents Plex silently ignoring `.mp4` audiobooks. Rename-only — no transcoding. See: [phase3/file-organization.md](phase3/file-organization.md#plex-format-coercion). + +**Configuration:** +- Key: `plex_format_coercion_enabled` (boolean, default: `true`) +- Read contract: `value !== 'false'` enables (default-on) +- Configurable in: Setup wizard (Paths step), Admin settings (Paths tab) + +**UI:** +- Checkbox toggle in PathsTab, between metadata tagging and chapter merging +- Default: Checked (enabled) +- Label: "Coerce file formats for Plex compatibility" +- Sub-text: "Rename .mp4 audiobook files (and single-file .m4a) to .m4b before Plex scans. No re-encoding." + +**Behavior:** +- When enabled: After organize, rename files per coercion table before scan trigger +- When disabled: Files left as-is (Plex may silently skip `.mp4`) +- Failure isolation: Rename errors logged but don't fail organize job +- Universal (Plex + ABS) — rename is lossless, no per-backend distinction + ## Validation Flow **Plex, Download Client, Paths:** diff --git a/prisma/migrations/20260515000000_add_plex_format_coercion_config/migration.sql b/prisma/migrations/20260515000000_add_plex_format_coercion_config/migration.sql new file mode 100644 index 0000000..a1546d1 --- /dev/null +++ b/prisma/migrations/20260515000000_add_plex_format_coercion_config/migration.sql @@ -0,0 +1,18 @@ +-- Add Plex format coercion configuration +-- This allows admin to enable/disable post-organization file-extension rename to Plex-compatible formats +-- Motivation: issue #166 — Plex silently fails to import .mp4 (and some .m4a) audiobook files +-- Coercion is extension-swap only — no re-encoding, no metadata changes + +-- Insert default configuration for Plex format coercion (enabled by default) +INSERT INTO configuration (id, key, value, encrypted, category, description, created_at, updated_at) +VALUES ( + gen_random_uuid(), + 'plex_format_coercion_enabled', + 'true', + false, + 'automation', + 'Rename audio files to Plex-compatible extensions after organization (e.g., .mp4 → .m4b). No re-encoding. Prevents the silent-import failure described in issue #166.', + NOW(), + NOW() +) +ON CONFLICT (key) DO NOTHING; diff --git a/src/app/admin/settings/lib/types.ts b/src/app/admin/settings/lib/types.ts index f531b51..6fc568f 100644 --- a/src/app/admin/settings/lib/types.ts +++ b/src/app/admin/settings/lib/types.ts @@ -113,6 +113,7 @@ export interface PathsSettings { audiobookPathTemplate?: string; ebookPathTemplate?: string; metadataTaggingEnabled: boolean; + plexFormatCoercionEnabled: boolean; chapterMergingEnabled: boolean; fileRenameEnabled: boolean; fileRenameTemplate?: string; diff --git a/src/app/admin/settings/tabs/PathsTab/PathsTab.tsx b/src/app/admin/settings/tabs/PathsTab/PathsTab.tsx index a1000f9..ad6daeb 100644 --- a/src/app/admin/settings/tabs/PathsTab/PathsTab.tsx +++ b/src/app/admin/settings/tabs/PathsTab/PathsTab.tsx @@ -414,6 +414,30 @@ export function PathsTab({ paths, onChange, onValidationChange }: PathsTabProps) + {/* Plex Format Coercion Toggle */} +
+
+ updatePath('plexFormatCoercionEnabled', e.target.checked)} + className="mt-1 h-5 w-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500" + /> +
+ +

+ Rename .mp4 audiobook files (and single-file .m4a) to .m4b before Plex scans. No re-encoding. +

+
+
+
+ {/* Chapter Merging Toggle */}
diff --git a/src/app/api/admin/settings/paths/route.ts b/src/app/api/admin/settings/paths/route.ts index 9683702..b168a94 100644 --- a/src/app/api/admin/settings/paths/route.ts +++ b/src/app/api/admin/settings/paths/route.ts @@ -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'); diff --git a/src/app/api/admin/settings/route.ts b/src/app/api/admin/settings/route.ts index 59ccf3f..08163d2 100644 --- a/src/app/api/admin/settings/route.ts +++ b/src/app/api/admin/settings/route.ts @@ -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', diff --git a/src/app/api/setup/complete/route.ts b/src/app/api/setup/complete/route.ts index 637e0da..1cc4228 100644 --- a/src/app/api/setup/complete/route.ts +++ b/src/app/api/setup/complete/route.ts @@ -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) { diff --git a/src/app/setup/page.tsx b/src/app/setup/page.tsx index 067228c..b980c9c 100644 --- a/src/app/setup/page.tsx +++ b/src/app/setup/page.tsx @@ -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} diff --git a/src/app/setup/steps/PathsStep.tsx b/src/app/setup/steps/PathsStep.tsx index fb3b4cf..58a2df7 100644 --- a/src/app/setup/steps/PathsStep.tsx +++ b/src/app/setup/steps/PathsStep.tsx @@ -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({
+ {/* Plex Format Coercion Toggle */} +
+
+ onUpdate('plexFormatCoercionEnabled', e.target.checked)} + className="mt-1 h-5 w-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500" + /> +
+ +

+ Rename .mp4 audiobook files (and single-file .m4a) to .m4b before Plex scans. No re-encoding. +

+
+
+
+ {/* Chapter Merging Toggle */}
diff --git a/src/lib/constants/audio-formats.ts b/src/lib/constants/audio-formats.ts index e62772e..c32e0e0 100644 --- a/src/lib/constants/audio-formats.ts +++ b/src/lib/constants/audio-formats.ts @@ -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 = { + '.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. */ diff --git a/src/lib/processors/organize-files.processor.ts b/src/lib/processors/organize-files.processor.ts index 6fd9923..4a1283d 100644 --- a/src/lib/processors/organize-files.processor.ts +++ b/src/lib/processors/organize-files.processor.ts @@ -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) { diff --git a/src/lib/utils/format-coercion.ts b/src/lib/utils/format-coercion.ts new file mode 100644 index 0000000..b984630 --- /dev/null +++ b/src/lib/utils/format-coercion.ts @@ -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 = new Set(DRM_EXTENSIONS as readonly string[]); +const TRANSCODE_SET: ReadonlySet = new Set( + TRANSCODE_REQUIRED_EXTENSIONS as readonly string[], +); +const PLEX_COMPATIBLE_SET: ReadonlySet = 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 { + 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(); + 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 }; +} diff --git a/tests/app/setup/steps/PathsStep.test.tsx b/tests/app/setup/steps/PathsStep.test.tsx index e210016..ff2dc42 100644 --- a/tests/app/setup/steps/PathsStep.test.tsx +++ b/tests/app/setup/steps/PathsStep.test.tsx @@ -23,6 +23,7 @@ const PathsHarness = ({ downloadDir: '/downloads', mediaDir: '/media/audiobooks', metadataTaggingEnabled: true, + plexFormatCoercionEnabled: true, chapterMergingEnabled: false, ...initialState, }); @@ -84,7 +85,11 @@ describe('PathsStep', () => { ); @@ -97,4 +102,18 @@ describe('PathsStep', () => { expect(metadataToggle).toBeChecked(); expect(chapterToggle).toBeChecked(); }); + + it('renders plex format coercion toggle with default checked and toggles state', async () => { + render(); + + const coercionToggle = screen.getByLabelText('Coerce file formats for Plex compatibility'); + + expect(coercionToggle).toBeChecked(); + + fireEvent.click(coercionToggle); + expect(coercionToggle).not.toBeChecked(); + + fireEvent.click(coercionToggle); + expect(coercionToggle).toBeChecked(); + }); }); diff --git a/tests/processors/organize-files.processor.test.ts b/tests/processors/organize-files.processor.test.ts index 0ff695d..b17bee5 100644 --- a/tests/processors/organize-files.processor.test.ts +++ b/tests/processors/organize-files.processor.test.ts @@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { createPrismaMock } from '../helpers/prisma'; +import { generateFilesHash } from '@/lib/utils/files-hash'; const prismaMock = createPrismaMock(); const organizerMock = vi.hoisted(() => ({ organize: vi.fn() })); @@ -16,6 +17,9 @@ const configMock = vi.hoisted(() => ({ getBackendMode: vi.fn(), get: vi.fn(), })); +const formatCoercionMock = vi.hoisted(() => ({ + coerceToPlexCompatible: vi.fn(), +})); vi.mock('@/lib/db', () => ({ prisma: prismaMock, @@ -37,6 +41,8 @@ vi.mock('@/lib/services/job-queue.service', () => ({ getJobQueueService: () => jobQueueMock, })); +vi.mock('@/lib/utils/format-coercion', () => formatCoercionMock); + describe('processOrganizeFiles', () => { beforeEach(() => { vi.clearAllMocks(); @@ -46,6 +52,13 @@ describe('processOrganizeFiles', () => { type: 'audiobook', // Default to audiobook type user: { plexUsername: 'testuser' }, }); + // Default passthrough for Plex format coercion (issue #166): leave audio files unchanged + formatCoercionMock.coerceToPlexCompatible.mockImplementation(async (paths: string[]) => ({ + renamed: [], + warnings: [], + errors: [], + finalAudioFiles: paths, + })); }); it('organizes files and triggers filesystem scan when enabled', async () => { @@ -398,6 +411,162 @@ describe('processOrganizeFiles', () => { ); }); + it('calls Plex format coercion when enabled (default)', async () => { + prismaMock.audiobook.findUnique.mockResolvedValue({ + id: 'a-coerce-on', + title: 'Book', + author: 'Author', + narrator: null, + coverArtUrl: null, + audibleAsin: 'ASIN-CO1', + }); + // configuration.findUnique returns undefined (no setting persisted) -> default-on + prismaMock.configuration.findUnique.mockResolvedValue(undefined); + organizerMock.organize.mockResolvedValue({ + success: true, + targetPath: '/media/Author/Book', + filesMovedCount: 1, + errors: [], + audioFiles: ['/media/Author/Book/Book.mp4'], + }); + configMock.getBackendMode.mockResolvedValue('plex'); + configMock.get.mockResolvedValue('false'); + + const { processOrganizeFiles } = await import('@/lib/processors/organize-files.processor'); + const result = await processOrganizeFiles({ + requestId: 'req-coerce-on', + audiobookId: 'a-coerce-on', + downloadPath: '/downloads/book', + jobId: 'job-coerce-on', + }); + + expect(result.success).toBe(true); + expect(formatCoercionMock.coerceToPlexCompatible).toHaveBeenCalledWith( + ['/media/Author/Book/Book.mp4'], + expect.anything() + ); + }); + + it('skips Plex format coercion when disabled', async () => { + prismaMock.audiobook.findUnique.mockResolvedValue({ + id: 'a-coerce-off', + title: 'Book', + author: 'Author', + narrator: null, + coverArtUrl: null, + audibleAsin: 'ASIN-CO2', + }); + prismaMock.configuration.findUnique.mockImplementation(async (args: any) => { + if (args?.where?.key === 'plex_format_coercion_enabled') { + return { key: 'plex_format_coercion_enabled', value: 'false' }; + } + return undefined; + }); + organizerMock.organize.mockResolvedValue({ + success: true, + targetPath: '/media/Author/Book', + filesMovedCount: 1, + errors: [], + audioFiles: ['/media/Author/Book/Book.mp4'], + }); + configMock.getBackendMode.mockResolvedValue('plex'); + configMock.get.mockResolvedValue('false'); + + const { processOrganizeFiles } = await import('@/lib/processors/organize-files.processor'); + const result = await processOrganizeFiles({ + requestId: 'req-coerce-off', + audiobookId: 'a-coerce-off', + downloadPath: '/downloads/book', + jobId: 'job-coerce-off', + }); + + expect(result.success).toBe(true); + expect(formatCoercionMock.coerceToPlexCompatible).not.toHaveBeenCalled(); + }); + + it('coercion failure does NOT mark request failed', async () => { + prismaMock.audiobook.findUnique.mockResolvedValue({ + id: 'a-coerce-throw', + title: 'Book', + author: 'Author', + narrator: null, + coverArtUrl: null, + audibleAsin: 'ASIN-CO3', + }); + prismaMock.configuration.findUnique.mockResolvedValue(undefined); + organizerMock.organize.mockResolvedValue({ + success: true, + targetPath: '/media/Author/Book', + filesMovedCount: 1, + errors: [], + audioFiles: ['/media/Author/Book/Book.mp4'], + }); + formatCoercionMock.coerceToPlexCompatible.mockRejectedValueOnce(new Error('boom')); + configMock.getBackendMode.mockResolvedValue('plex'); + configMock.get.mockResolvedValue('false'); + + const { processOrganizeFiles } = await import('@/lib/processors/organize-files.processor'); + const result = await processOrganizeFiles({ + requestId: 'req-coerce-throw', + audiobookId: 'a-coerce-throw', + downloadPath: '/downloads/book', + jobId: 'job-coerce-throw', + }); + + expect(result.success).toBe(true); + expect(prismaMock.request.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ status: 'downloaded' }), + }) + ); + }); + + it('filesHash reflects post-coercion filenames', async () => { + prismaMock.audiobook.findUnique.mockResolvedValue({ + id: 'a-coerce-hash', + title: 'Book', + author: 'Author', + narrator: null, + coverArtUrl: null, + audibleAsin: 'ASIN-CO4', + }); + prismaMock.configuration.findUnique.mockResolvedValue(undefined); + organizerMock.organize.mockResolvedValue({ + success: true, + targetPath: '/media/Author/Book', + filesMovedCount: 1, + errors: [], + audioFiles: ['/media/Book.mp4'], + }); + // Coercion renames .mp4 -> .m4b + formatCoercionMock.coerceToPlexCompatible.mockResolvedValueOnce({ + renamed: [{ from: '/media/Book.mp4', to: '/media/Book.m4b' }], + warnings: [], + errors: [], + finalAudioFiles: ['/media/Book.m4b'], + }); + configMock.getBackendMode.mockResolvedValue('plex'); + configMock.get.mockResolvedValue('false'); + + const { processOrganizeFiles } = await import('@/lib/processors/organize-files.processor'); + const result = await processOrganizeFiles({ + requestId: 'req-coerce-hash', + audiobookId: 'a-coerce-hash', + downloadPath: '/downloads/book', + jobId: 'job-coerce-hash', + }); + + expect(result.success).toBe(true); + const expectedHash = generateFilesHash(['/media/Book.m4b']); + expect(expectedHash).toMatch(/^[a-f0-9]{64}$/); + expect(prismaMock.audiobook.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'a-coerce-hash' }, + data: expect.objectContaining({ filesHash: expectedHash }), + }) + ); + }); + it('generates and stores filesHash after successful organization', async () => { prismaMock.request.update.mockResolvedValue({}); prismaMock.audiobook.findUnique.mockResolvedValue({ diff --git a/tests/utils/format-coercion.test.ts b/tests/utils/format-coercion.test.ts new file mode 100644 index 0000000..fd989fd --- /dev/null +++ b/tests/utils/format-coercion.test.ts @@ -0,0 +1,219 @@ +/** + * Component: Plex Format Coercion Tests + * Documentation: documentation/phase3/file-organization.md + */ + +import path from 'path'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { coerceToPlexCompatible } from '@/lib/utils/format-coercion'; + +const fsMock = vi.hoisted(() => ({ + access: vi.fn(), + rename: vi.fn(), +})); + +vi.mock('fs', () => ({ + promises: fsMock, + default: { promises: fsMock }, +})); + +/** Make `fs.access` reject with ENOENT (target does not exist) for every path. */ +function targetMissing(): void { + fsMock.access.mockImplementation(() => { + const err = new Error('ENOENT: no such file or directory') as NodeJS.ErrnoException; + err.code = 'ENOENT'; + return Promise.reject(err); + }); +} + +/** Make `fs.rename` resolve successfully. */ +function renameOk(): void { + fsMock.rename.mockResolvedValue(undefined); +} + +function makeLogger() { + return { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }; +} + +describe('coerceToPlexCompatible', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('happy paths', () => { + it('renames .mp4 to .m4b', async () => { + targetMissing(); + renameOk(); + const input = ['/media/Book/Book.mp4']; + + const result = await coerceToPlexCompatible(input); + + expect(result.renamed).toEqual([{ from: '/media/Book/Book.mp4', to: path.join('/media/Book', 'Book.m4b') }]); + expect(result.finalAudioFiles).toEqual([path.join('/media/Book', 'Book.m4b')]); + expect(result.warnings).toEqual([]); + expect(fsMock.rename).toHaveBeenCalledTimes(1); + }); + + it('renames single-file .m4a to .m4b', async () => { + targetMissing(); + renameOk(); + const input = ['/media/Book/Book.m4a']; + + const result = await coerceToPlexCompatible(input); + + expect(result.renamed).toEqual([{ from: '/media/Book/Book.m4a', to: path.join('/media/Book', 'Book.m4b') }]); + expect(result.finalAudioFiles).toEqual([path.join('/media/Book', 'Book.m4b')]); + expect(result.warnings).toEqual([]); + }); + + it('leaves multi-file .m4a audiobooks alone (more than one .m4a in same dir)', async () => { + targetMissing(); + renameOk(); + const input = ['/media/Book/Chapter01.m4a', '/media/Book/Chapter02.m4a']; + + const result = await coerceToPlexCompatible(input); + + expect(result.renamed).toEqual([]); + expect(result.finalAudioFiles).toEqual(input); + expect(fsMock.rename).not.toHaveBeenCalled(); + }); + + it('handles mixed .mp4 + .mp3: renames mp4, leaves mp3 untouched', async () => { + targetMissing(); + renameOk(); + const input = ['/media/Book/Book.mp4', '/media/Other/Intro.mp3']; + + const result = await coerceToPlexCompatible(input); + + expect(result.renamed).toEqual([ + { from: '/media/Book/Book.mp4', to: path.join('/media/Book', 'Book.m4b') }, + ]); + expect(result.finalAudioFiles).toEqual([ + path.join('/media/Book', 'Book.m4b'), + '/media/Other/Intro.mp3', + ]); + expect(fsMock.rename).toHaveBeenCalledTimes(1); + }); + + it('returns empty result for empty input', async () => { + const result = await coerceToPlexCompatible([]); + expect(result).toEqual({ renamed: [], warnings: [], errors: [], finalAudioFiles: [] }); + expect(fsMock.rename).not.toHaveBeenCalled(); + }); + }); + + describe('already-compatible inputs (sanity)', () => { + it('is a silent no-op for already-Plex-compatible files (.m4b/.mp3/.flac)', async () => { + targetMissing(); + renameOk(); + const input = [ + '/media/Book/Book.m4b', + '/media/Other/Track.mp3', + '/media/Third/Track.flac', + ]; + + const result = await coerceToPlexCompatible(input); + + expect(result.renamed).toEqual([]); + expect(result.warnings).toEqual([]); + expect(result.finalAudioFiles).toEqual(input); + expect(fsMock.rename).not.toHaveBeenCalled(); + }); + }); + + describe('DRM and transcode-required formats', () => { + it('warns on .aa (DRM) and skips rename', async () => { + const logger = makeLogger(); + const input = ['/media/Book/Book.aa']; + + const result = await coerceToPlexCompatible(input, logger as never); + + expect(result.renamed).toEqual([]); + expect(result.finalAudioFiles).toEqual(input); + expect(result.warnings.length).toBe(1); + expect(result.warnings[0]).toMatch(/DRM/i); + expect(logger.warn).toHaveBeenCalledTimes(1); + expect(fsMock.rename).not.toHaveBeenCalled(); + }); + + it('warns on .aax (DRM) and skips rename', async () => { + const input = ['/media/Book/Book.aax']; + + const result = await coerceToPlexCompatible(input); + + expect(result.renamed).toEqual([]); + expect(result.warnings.length).toBe(1); + expect(result.warnings[0]).toMatch(/DRM/i); + expect(result.finalAudioFiles).toEqual(input); + }); + + it('warns on .ogg (transcode-required) and skips rename', async () => { + const logger = makeLogger(); + const input = ['/media/Book/Book.ogg']; + + const result = await coerceToPlexCompatible(input, logger as never); + + expect(result.renamed).toEqual([]); + expect(result.warnings.length).toBe(1); + expect(result.warnings[0]).toMatch(/transcode/i); + expect(logger.warn).toHaveBeenCalledTimes(1); + expect(result.finalAudioFiles).toEqual(input); + }); + }); + + describe('idempotency (collision)', () => { + it('does not overwrite an existing target file', async () => { + // First access resolves (target exists), rename should not be called. + fsMock.access.mockResolvedValue(undefined); + const logger = makeLogger(); + const input = ['/media/Book/Book.mp4']; + + const result = await coerceToPlexCompatible(input, logger as never); + + expect(result.renamed).toEqual([]); + expect(result.finalAudioFiles).toEqual(input); + expect(result.warnings.length).toBe(1); + expect(result.warnings[0]).toMatch(/already exists/i); + expect(fsMock.rename).not.toHaveBeenCalled(); + }); + }); + + describe('rename failures', () => { + it('captures EPERM as a warning and preserves the original path', async () => { + targetMissing(); + const epermErr = new Error('EPERM: operation not permitted') as NodeJS.ErrnoException; + epermErr.code = 'EPERM'; + fsMock.rename.mockRejectedValueOnce(epermErr); + const logger = makeLogger(); + const input = ['/media/Book/Book.mp4']; + + const result = await coerceToPlexCompatible(input, logger as never); + + expect(result.renamed).toEqual([]); + expect(result.finalAudioFiles).toEqual(input); + expect(result.warnings.length).toBe(1); + expect(result.warnings[0]).toMatch(/EPERM/); + expect(logger.warn).toHaveBeenCalledTimes(1); + }); + }); + + describe('logger contract', () => { + it('works without a logger (optional parameter)', async () => { + targetMissing(); + renameOk(); + const input = ['/media/Book/Book.mp4']; + + // Must not throw when logger is omitted. + const result = await coerceToPlexCompatible(input); + + expect(result.renamed.length).toBe(1); + expect(result.finalAudioFiles).toEqual([path.join('/media/Book', 'Book.m4b')]); + }); + }); +});