mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-02 20:30:10 +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:
@@ -69,6 +69,7 @@
|
|||||||
- **qBittorrent integration (torrents)** → [phase3/qbittorrent.md](phase3/qbittorrent.md)
|
- **qBittorrent integration (torrents)** → [phase3/qbittorrent.md](phase3/qbittorrent.md)
|
||||||
- **SABnzbd integration (Usenet/NZB)** → [phase3/sabnzbd.md](phase3/sabnzbd.md)
|
- **SABnzbd integration (Usenet/NZB)** → [phase3/sabnzbd.md](phase3/sabnzbd.md)
|
||||||
- **File organization, seeding** → [phase3/file-organization.md](phase3/file-organization.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)
|
- **Chapter merging (auto-merge to M4B)** → [features/chapter-merging.md](features/chapter-merging.md)
|
||||||
|
|
||||||
## Background Jobs
|
## Background Jobs
|
||||||
|
|||||||
@@ -154,6 +154,12 @@ model Audiobook {
|
|||||||
- Hash generated AFTER merging
|
- Hash generated AFTER merging
|
||||||
- **Works correctly:** Hash reflects final organized state
|
- **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)
|
### Multiple Downloads (Same Book)
|
||||||
- User re-downloads same audiobook (different edition/request)
|
- User re-downloads same audiobook (different edition/request)
|
||||||
- Multiple records with same `filesHash`
|
- Multiple records with same `filesHash`
|
||||||
|
|||||||
@@ -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)
|
5. **Copy** files (not move - originals stay for seeding)
|
||||||
6. **Tag metadata** (if enabled) - writes correct title, author, narrator, ASIN to audio files
|
6. **Tag metadata** (if enabled) - writes correct title, author, narrator, ASIN to audio files
|
||||||
7. Copy cover art if found, else download from Audible
|
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))
|
8. **Coerce file formats** (if enabled) - rename .mp4 → .m4b and single-file .m4a → .m4b for Plex compatibility (see: Plex Format Coercion below)
|
||||||
9. Update request status to `downloaded` and store file hash in `audiobooks.files_hash`
|
9. **Generate file hash** - SHA256 of sorted audio filenames for library matching (see: [fixes/file-hash-matching.md](../fixes/file-hash-matching.md))
|
||||||
10. **Trigger filesystem scan** (if enabled) - tells Plex/ABS to scan for new files
|
10. Update request status to `downloaded` and store file hash in `audiobooks.files_hash`
|
||||||
11. Originals remain until seeding requirements met
|
11. **Trigger filesystem scan** (if enabled) - tells Plex/ABS to scan for new files
|
||||||
|
12. Originals remain until seeding requirements met
|
||||||
|
|
||||||
## Filesystem Scan Triggering
|
## Filesystem Scan Triggering
|
||||||
|
|
||||||
@@ -150,6 +151,61 @@ exiftool "audiobook.m4b" | grep -i asin
|
|||||||
- Multi-container: `docker exec readmeabook ffmpeg -version`
|
- Multi-container: `docker exec readmeabook ffmpeg -version`
|
||||||
- Unified: `docker exec readmeabook-unified 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
|
## Seeding Support
|
||||||
|
|
||||||
**Config:** `seeding_time_minutes` (0 = unlimited, never cleanup)
|
**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}`)
|
- **Path template:** Read from database config key `audiobook_path_template` (default: `{author}/{title} {asin}`)
|
||||||
- **Metadata tagging:** `metadata_tagging_enabled` (boolean, default: true)
|
- **Metadata tagging:** `metadata_tagging_enabled` (boolean, default: true)
|
||||||
- **Chapter merging:** `chapter_merging_enabled` (boolean, default: false)
|
- **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
|
- **Fallback:** `/media/audiobooks` if media_dir not configured
|
||||||
- **Temp directory:** `/tmp/readmeabook` (or `TEMP_DIR` env var)
|
- **Temp directory:** `/tmp/readmeabook` (or `TEMP_DIR` env var)
|
||||||
|
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ src/app/admin/settings/
|
|||||||
2. **Audiobookshelf** - URL, API token (masked), library ID, Audible region, filesystem scan trigger toggle
|
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**
|
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)
|
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
|
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
|
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
|
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
|
- 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)
|
- 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
|
## Validation Flow
|
||||||
|
|
||||||
**Plex, Download Client, Paths:**
|
**Plex, Download Client, Paths:**
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -113,6 +113,7 @@ export interface PathsSettings {
|
|||||||
audiobookPathTemplate?: string;
|
audiobookPathTemplate?: string;
|
||||||
ebookPathTemplate?: string;
|
ebookPathTemplate?: string;
|
||||||
metadataTaggingEnabled: boolean;
|
metadataTaggingEnabled: boolean;
|
||||||
|
plexFormatCoercionEnabled: boolean;
|
||||||
chapterMergingEnabled: boolean;
|
chapterMergingEnabled: boolean;
|
||||||
fileRenameEnabled: boolean;
|
fileRenameEnabled: boolean;
|
||||||
fileRenameTemplate?: string;
|
fileRenameTemplate?: string;
|
||||||
|
|||||||
@@ -414,6 +414,30 @@ export function PathsTab({ paths, onChange, onValidationChange }: PathsTabProps)
|
|||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* 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="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">
|
<div className="flex items-start gap-4">
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export async function PUT(request: NextRequest) {
|
|||||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||||
return requireAdmin(req, async () => {
|
return requireAdmin(req, async () => {
|
||||||
try {
|
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) {
|
if (!downloadDir || !mediaDir) {
|
||||||
return NextResponse.json(
|
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
|
// Update file rename setting
|
||||||
await prisma.configuration.upsert({
|
await prisma.configuration.upsert({
|
||||||
where: { key: 'file_rename_enabled' },
|
where: { key: 'file_rename_enabled' },
|
||||||
@@ -176,6 +189,7 @@ export async function PUT(request: NextRequest) {
|
|||||||
configService.clearCache('ebook_path_template');
|
configService.clearCache('ebook_path_template');
|
||||||
configService.clearCache('metadata_tagging_enabled');
|
configService.clearCache('metadata_tagging_enabled');
|
||||||
configService.clearCache('chapter_merging_enabled');
|
configService.clearCache('chapter_merging_enabled');
|
||||||
|
configService.clearCache('plex_format_coercion_enabled');
|
||||||
configService.clearCache('file_rename_enabled');
|
configService.clearCache('file_rename_enabled');
|
||||||
configService.clearCache('file_rename_template');
|
configService.clearCache('file_rename_template');
|
||||||
configService.clearCache('file_chmod');
|
configService.clearCache('file_chmod');
|
||||||
|
|||||||
@@ -134,6 +134,7 @@ export async function GET(request: NextRequest) {
|
|||||||
ebookPathTemplate: configMap.get('ebook_path_template') || configMap.get('audiobook_path_template') || '{author}/{title} {asin}',
|
ebookPathTemplate: configMap.get('ebook_path_template') || configMap.get('audiobook_path_template') || '{author}/{title} {asin}',
|
||||||
metadataTaggingEnabled: configMap.get('metadata_tagging_enabled') === 'true',
|
metadataTaggingEnabled: configMap.get('metadata_tagging_enabled') === 'true',
|
||||||
chapterMergingEnabled: configMap.get('chapter_merging_enabled') === 'true',
|
chapterMergingEnabled: configMap.get('chapter_merging_enabled') === 'true',
|
||||||
|
plexFormatCoercionEnabled: configMap.get('plex_format_coercion_enabled') !== 'false',
|
||||||
fileRenameEnabled: configMap.get('file_rename_enabled') === 'true',
|
fileRenameEnabled: configMap.get('file_rename_enabled') === 'true',
|
||||||
fileRenameTemplate: configMap.get('file_rename_template') || '{title}',
|
fileRenameTemplate: configMap.get('file_rename_template') || '{title}',
|
||||||
fileChmod: configMap.get('file_chmod') || '664',
|
fileChmod: configMap.get('file_chmod') || '664',
|
||||||
|
|||||||
@@ -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)
|
// BookDate configuration (optional, global for all users)
|
||||||
// Note: libraryScope and customPrompt are now per-user settings, not required here
|
// Note: libraryScope and customPrompt are now per-user settings, not required here
|
||||||
if (bookdate && bookdate.provider && bookdate.apiKey && bookdate.model) {
|
if (bookdate && bookdate.provider && bookdate.apiKey && bookdate.model) {
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ interface SetupState {
|
|||||||
downloadDir: string;
|
downloadDir: string;
|
||||||
mediaDir: string;
|
mediaDir: string;
|
||||||
metadataTaggingEnabled: boolean;
|
metadataTaggingEnabled: boolean;
|
||||||
|
plexFormatCoercionEnabled: boolean;
|
||||||
chapterMergingEnabled: boolean;
|
chapterMergingEnabled: boolean;
|
||||||
bookdateProvider: string;
|
bookdateProvider: string;
|
||||||
bookdateApiKey: string;
|
bookdateApiKey: string;
|
||||||
@@ -161,6 +162,7 @@ export default function SetupWizard() {
|
|||||||
downloadDir: '/downloads',
|
downloadDir: '/downloads',
|
||||||
mediaDir: '/media/audiobooks',
|
mediaDir: '/media/audiobooks',
|
||||||
metadataTaggingEnabled: true,
|
metadataTaggingEnabled: true,
|
||||||
|
plexFormatCoercionEnabled: true,
|
||||||
chapterMergingEnabled: false,
|
chapterMergingEnabled: false,
|
||||||
bookdateProvider: 'openai',
|
bookdateProvider: 'openai',
|
||||||
bookdateApiKey: '',
|
bookdateApiKey: '',
|
||||||
@@ -237,6 +239,7 @@ export default function SetupWizard() {
|
|||||||
download_dir: state.downloadDir,
|
download_dir: state.downloadDir,
|
||||||
media_dir: state.mediaDir,
|
media_dir: state.mediaDir,
|
||||||
metadata_tagging_enabled: state.metadataTaggingEnabled,
|
metadata_tagging_enabled: state.metadataTaggingEnabled,
|
||||||
|
plex_format_coercion_enabled: state.plexFormatCoercionEnabled,
|
||||||
chapter_merging_enabled: state.chapterMergingEnabled,
|
chapter_merging_enabled: state.chapterMergingEnabled,
|
||||||
},
|
},
|
||||||
bookdate: state.bookdateConfigured ? {
|
bookdate: state.bookdateConfigured ? {
|
||||||
@@ -537,6 +540,7 @@ export default function SetupWizard() {
|
|||||||
downloadDir={state.downloadDir}
|
downloadDir={state.downloadDir}
|
||||||
mediaDir={state.mediaDir}
|
mediaDir={state.mediaDir}
|
||||||
metadataTaggingEnabled={state.metadataTaggingEnabled}
|
metadataTaggingEnabled={state.metadataTaggingEnabled}
|
||||||
|
plexFormatCoercionEnabled={state.plexFormatCoercionEnabled}
|
||||||
chapterMergingEnabled={state.chapterMergingEnabled}
|
chapterMergingEnabled={state.chapterMergingEnabled}
|
||||||
pathsTested={state.pathsTested}
|
pathsTested={state.pathsTested}
|
||||||
onUpdate={updateField}
|
onUpdate={updateField}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ interface PathsStepProps {
|
|||||||
downloadDir: string;
|
downloadDir: string;
|
||||||
mediaDir: string;
|
mediaDir: string;
|
||||||
metadataTaggingEnabled: boolean;
|
metadataTaggingEnabled: boolean;
|
||||||
|
plexFormatCoercionEnabled: boolean;
|
||||||
chapterMergingEnabled: boolean;
|
chapterMergingEnabled: boolean;
|
||||||
pathsTested: boolean;
|
pathsTested: boolean;
|
||||||
onUpdate: (field: string, value: any) => void;
|
onUpdate: (field: string, value: any) => void;
|
||||||
@@ -24,6 +25,7 @@ export function PathsStep({
|
|||||||
downloadDir,
|
downloadDir,
|
||||||
mediaDir,
|
mediaDir,
|
||||||
metadataTaggingEnabled,
|
metadataTaggingEnabled,
|
||||||
|
plexFormatCoercionEnabled,
|
||||||
chapterMergingEnabled,
|
chapterMergingEnabled,
|
||||||
pathsTested,
|
pathsTested,
|
||||||
onUpdate,
|
onUpdate,
|
||||||
@@ -246,6 +248,30 @@ export function PathsStep({
|
|||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* 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="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">
|
<div className="flex items-start gap-4">
|
||||||
|
|||||||
@@ -68,6 +68,42 @@ export type TorrentTitleFormat = (typeof TORRENT_TITLE_FORMATS)[number];
|
|||||||
*/
|
*/
|
||||||
export type AudioFormat = TorrentTitleFormat | 'OTHER';
|
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.
|
* 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}`);
|
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
|
// Generate hash from organized audio files for library matching
|
||||||
const filesHash = generateFilesHash(result.audioFiles);
|
const filesHash = generateFilesHash(result.audioFiles);
|
||||||
if (filesHash) {
|
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 };
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ const PathsHarness = ({
|
|||||||
downloadDir: '/downloads',
|
downloadDir: '/downloads',
|
||||||
mediaDir: '/media/audiobooks',
|
mediaDir: '/media/audiobooks',
|
||||||
metadataTaggingEnabled: true,
|
metadataTaggingEnabled: true,
|
||||||
|
plexFormatCoercionEnabled: true,
|
||||||
chapterMergingEnabled: false,
|
chapterMergingEnabled: false,
|
||||||
...initialState,
|
...initialState,
|
||||||
});
|
});
|
||||||
@@ -84,7 +85,11 @@ describe('PathsStep', () => {
|
|||||||
<PathsHarness
|
<PathsHarness
|
||||||
onNext={vi.fn()}
|
onNext={vi.fn()}
|
||||||
onBack={vi.fn()}
|
onBack={vi.fn()}
|
||||||
initialState={{ metadataTaggingEnabled: false, chapterMergingEnabled: false }}
|
initialState={{
|
||||||
|
metadataTaggingEnabled: false,
|
||||||
|
plexFormatCoercionEnabled: false,
|
||||||
|
chapterMergingEnabled: false,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -97,4 +102,18 @@ describe('PathsStep', () => {
|
|||||||
expect(metadataToggle).toBeChecked();
|
expect(metadataToggle).toBeChecked();
|
||||||
expect(chapterToggle).toBeChecked();
|
expect(chapterToggle).toBeChecked();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('renders plex format coercion toggle with default checked and toggles state', async () => {
|
||||||
|
render(<PathsHarness onNext={vi.fn()} onBack={vi.fn()} />);
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { createPrismaMock } from '../helpers/prisma';
|
import { createPrismaMock } from '../helpers/prisma';
|
||||||
|
import { generateFilesHash } from '@/lib/utils/files-hash';
|
||||||
|
|
||||||
const prismaMock = createPrismaMock();
|
const prismaMock = createPrismaMock();
|
||||||
const organizerMock = vi.hoisted(() => ({ organize: vi.fn() }));
|
const organizerMock = vi.hoisted(() => ({ organize: vi.fn() }));
|
||||||
@@ -16,6 +17,9 @@ const configMock = vi.hoisted(() => ({
|
|||||||
getBackendMode: vi.fn(),
|
getBackendMode: vi.fn(),
|
||||||
get: vi.fn(),
|
get: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
const formatCoercionMock = vi.hoisted(() => ({
|
||||||
|
coerceToPlexCompatible: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('@/lib/db', () => ({
|
vi.mock('@/lib/db', () => ({
|
||||||
prisma: prismaMock,
|
prisma: prismaMock,
|
||||||
@@ -37,6 +41,8 @@ vi.mock('@/lib/services/job-queue.service', () => ({
|
|||||||
getJobQueueService: () => jobQueueMock,
|
getJobQueueService: () => jobQueueMock,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/utils/format-coercion', () => formatCoercionMock);
|
||||||
|
|
||||||
describe('processOrganizeFiles', () => {
|
describe('processOrganizeFiles', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
@@ -46,6 +52,13 @@ describe('processOrganizeFiles', () => {
|
|||||||
type: 'audiobook', // Default to audiobook type
|
type: 'audiobook', // Default to audiobook type
|
||||||
user: { plexUsername: 'testuser' },
|
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 () => {
|
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 () => {
|
it('generates and stores filesHash after successful organization', async () => {
|
||||||
prismaMock.request.update.mockResolvedValue({});
|
prismaMock.request.update.mockResolvedValue({});
|
||||||
prismaMock.audiobook.findUnique.mockResolvedValue({
|
prismaMock.audiobook.findUnique.mockResolvedValue({
|
||||||
|
|||||||
@@ -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')]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user