Add filesystem scan trigger and version badge features

Implements optional filesystem scan triggering for Plex and Audiobookshelf after file organization, with new settings in the admin UI, setup wizard, and API. Updates documentation to reflect scan trigger options and improved file organization/cleanup logic. Refactors dropdown menus to use smart positioning and portals for better UX. Adds a version API route and a VersionBadge component to display build info in the header. Updates Docker build to inject version metadata.
This commit is contained in:
kikootwo
2026-01-09 17:15:00 -05:00
parent 288421012d
commit 384601014a
25 changed files with 1346 additions and 243 deletions
@@ -52,6 +52,12 @@ jobs:
org.opencontainers.image.description=All-in-one audiobook request and automation system (PostgreSQL + Redis + App) org.opencontainers.image.description=All-in-one audiobook request and automation system (PostgreSQL + Redis + App)
org.opencontainers.image.vendor=ReadMeABook org.opencontainers.image.vendor=ReadMeABook
- name: Capture version information
id: version
run: |
echo "git_commit=$(git rev-parse --short=7 HEAD)" >> $GITHUB_OUTPUT
echo "build_date=$(date -u +"%Y-%m-%dT%H:%M:%SZ")" >> $GITHUB_OUTPUT
- name: Build and push unified Docker image - name: Build and push unified Docker image
uses: docker/build-push-action@v5 uses: docker/build-push-action@v5
with: with:
@@ -63,6 +69,9 @@ jobs:
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha cache-from: type=gha
cache-to: type=gha,mode=max cache-to: type=gha,mode=max
build-args: |
GIT_COMMIT=${{ steps.version.outputs.git_commit }}
BUILD_DATE=${{ steps.version.outputs.build_date }}
- name: Generate deployment instructions - name: Generate deployment instructions
if: github.event_name != 'pull_request' if: github.event_name != 'pull_request'
+10
View File
@@ -2,6 +2,10 @@
# Single container with PostgreSQL, Redis, and Next.js app # Single container with PostgreSQL, Redis, and Next.js app
# Designed for easy deployment with minimal configuration # Designed for easy deployment with minimal configuration
# Build arguments for version info
ARG GIT_COMMIT=unknown
ARG BUILD_DATE=unknown
# Start from debian base with node preinstalled # Start from debian base with node preinstalled
FROM node:20-bookworm AS base FROM node:20-bookworm AS base
@@ -45,6 +49,12 @@ COPY . .
ENV DATABASE_URL="postgresql://dummy:dummy@localhost:5432/dummy?schema=public" ENV DATABASE_URL="postgresql://dummy:dummy@localhost:5432/dummy?schema=public"
RUN npx prisma generate RUN npx prisma generate
# Set version environment variables for build and runtime
ENV NEXT_PUBLIC_GIT_COMMIT=${GIT_COMMIT}
ENV NEXT_PUBLIC_BUILD_DATE=${BUILD_DATE}
ENV APP_VERSION=${GIT_COMMIT}
ENV BUILD_DATE=${BUILD_DATE}
# Build Next.js application # Build Next.js application
ENV NEXT_TELEMETRY_DISABLED=1 ENV NEXT_TELEMETRY_DISABLED=1
ENV NODE_ENV=production ENV NODE_ENV=production
+302 -86
View File
@@ -1,11 +1,29 @@
# Chapter Merging Feature # Chapter Merging Feature
**Status:** ✅ Implemented | Auto-merge multi-file chapters to M4B **Status:** ✅ Implemented (v2 - Enhanced) | Auto-merge multi-file chapters to M4B
## Overview ## Overview
Automatically merge multi-file audiobook downloads (separate MP3/M4A files per chapter) into a single M4B file with proper chapter markers during file organization. Automatically merge multi-file audiobook downloads (separate MP3/M4A files per chapter) into a single M4B file with proper chapter markers during file organization.
## Recent Updates (v2 - Corruption Fixes)
**Status:** ✅ Implemented (2026-01-09)
**Critical Fixes:**
1.**Fixed corruption on long audiobooks** - Dynamic timeout calculation (16h book = 254min vs old 20min)
2.**Fixed 1-minute playback delay** - Added `-movflags +faststart` (moov atom at beginning)
3.**Fixed seeking/timestamp issues** - Added `-fflags +genpts`, `-avoid_negative_ts`, `-max_muxing_queue_size`
4.**Added output validation** - Catches corrupt files before marked successful (duration, decode test, size)
5.**Quality preservation** - Matches source bitrate (64-320k) instead of fixed 128k
6.**Higher quality encoding** - Uses libfdk_aac if available (VBR mode 4)
7.**Fixed validation timeout bug** - Decode timeout was in seconds instead of milliseconds (killed valid files)
8.**Optimized validation** - Fast integrity test (first/last 10s) instead of decoding entire 16h file
**Impact:**
- Before: 16h audiobook → 20min timeout → Killed mid-process → Corrupt 6h file → Marked "successful" → 1-min playback delay
- After: 16h audiobook → 254min timeout → Completes fully → Valid 16h file → Validated → Instant playback
## Problem Statement ## Problem Statement
**Current Behavior:** **Current Behavior:**
@@ -28,23 +46,31 @@ Detect multi-file chapter downloads and merge into single M4B with embedded chap
### Detection Logic ### Detection Logic
**Chapter File Patterns (auto-detect):** **Simplified Detection Approach (v2):**
- Numeric: `01.mp3`, `001.mp3`, `1.mp3`
- Named: `Chapter 1.mp3`, `Chapter 01.mp3`, `Ch1.mp3`, `Ch 01.mp3` Detection now uses a **permissive heuristic** instead of strict filename pattern matching:
- Part-based: `Part 1.mp3`, `Part01.mp3`
- Combined: `Harry Potter - 01 - Chapter 1.mp3`
**Trigger Conditions:** **Trigger Conditions:**
- 2+ audio files in download - 3+ audio files in download (2 files might be "Book + Credits", so require 3+)
- Files match chapter naming pattern - All files same format (m4a, m4b, mp3, etc.)
- All files same format (m4a, m4b, mp3)
- Feature enabled in config - Feature enabled in config
**Ordering Strategy (metadata-first):**
1. **Primary:** Use embedded track numbers if all files have sequential track metadata
2. **Fallback:** Use natural filename sorting if metadata incomplete
3. **Validation:** Compare both methods when available for confidence
**Why This Works Better:**
- Catches edge cases like `Andy Weir - Project Hail Mary - 03.mp3` (doesn't match patterns)
- Trusts metadata over filenames (more reliable)
- Graceful fallback to filename sorting if metadata missing
- Attempts merge on any multi-file audiobook, lets analysis phase decide ordering
**Exclusions (do NOT merge):** **Exclusions (do NOT merge):**
- Less than 3 audio files
- Mixed formats (some MP3, some M4A) - Mixed formats (some MP3, some M4A)
- Non-sequential numbering
- Files without clear chapter indicators
- Single file downloads - Single file downloads
- Unsupported formats
### Chapter Metadata Generation ### Chapter Metadata Generation
@@ -85,11 +111,16 @@ echo "file '/path/ch02.m4a'" >> filelist.txt
# 2. Generate chapter metadata # 2. Generate chapter metadata
# [Create chapters.txt with timing from durations] # [Create chapters.txt with timing from durations]
# 3. Merge with chapters # 3. Merge with chapters (v2 - enhanced)
ffmpeg -f concat -safe 0 -i filelist.txt \ ffmpeg -y -f concat -safe 0 -i filelist.txt \
-i chapters.txt \ -i chapters.txt \
-map_metadata 1 \ -map_metadata 1 \
-map 0:a \
-codec copy \ -codec copy \
-movflags +faststart \ # NEW: Index at beginning (instant playback)
-fflags +genpts \ # NEW: Regenerate timestamps
-avoid_negative_ts make_zero \ # NEW: Handle negative timestamps
-max_muxing_queue_size 9999 \ # NEW: Prevent buffer overflow
-metadata title="Book Title" \ -metadata title="Book Title" \
-metadata album="Book Title" \ -metadata album="Book Title" \
-metadata album_artist="Author" \ -metadata album_artist="Author" \
@@ -100,23 +131,39 @@ ffmpeg -f concat -safe 0 -i filelist.txt \
output.m4b output.m4b
``` ```
**For MP3 files (requires conversion):** **For MP3 files (requires conversion - v2 enhanced):**
```bash ```bash
# Must re-encode to M4B (AAC) # Re-encode to M4B (AAC) with quality preservation
ffmpeg -f concat -safe 0 -i filelist.txt \ # Uses libfdk_aac if available (higher quality) or native aac
ffmpeg -y -f concat -safe 0 -i filelist.txt \
-i chapters.txt \ -i chapters.txt \
-map_metadata 1 \ -map_metadata 1 \
-codec:a aac -b:a 128k \ # Quality preservation -map 0:a \
-c:a libfdk_aac -vbr 4 \ # High quality AAC (or: -c:a aac -b:a <source_bitrate> -profile:a aac_low)
-movflags +faststart \ # CRITICAL: Instant playback
-fflags +genpts \ # Fix timestamps
-avoid_negative_ts make_zero \ # Handle edge cases
-max_muxing_queue_size 9999 \ # Long file support
-metadata title="Book Title" \ -metadata title="Book Title" \
# ... (same metadata) # ... (same metadata)
-f mp4 \ -f mp4 \
output.m4b output.m4b
``` ```
**Quality Settings (MP3 → M4B):** **Quality Settings (MP3 → M4B - v2):**
- Bitrate: 128kbps AAC (transparent for audiobooks, 64kbps minimum) - **Bitrate:** Matches source average (64-320kbps range)
- Sampling rate: Match source (44.1kHz or 48kHz) - Example: 128kbps MP3 source → 128kbps AAC output
- Channels: Preserve mono/stereo - Example: 192kbps MP3 source → 192kbps AAC output
- **Encoder:** libfdk_aac (VBR mode 4, high quality) if available, else native aac
- **Profile:** AAC-LC (maximum compatibility)
- **Sampling rate:** Preserved from source
- **Channels:** Preserved (mono/stereo)
**Critical Flags (v2):**
- **`-movflags +faststart`**: Moves moov atom to file beginning → instant playback (fixes 1-min delay)
- **`-fflags +genpts`**: Regenerates presentation timestamps → fixes seeking/timing issues
- **`-avoid_negative_ts make_zero`**: Handles negative timestamps at concat boundaries
- **`-max_muxing_queue_size 9999`**: Prevents buffer overflow on long audiobooks (16h+)
### File Naming ### File Naming
@@ -145,34 +192,95 @@ ffmpeg -f concat -safe 0 -i filelist.txt \
- Checkbox: "Merge chapter files" (default: unchecked) - Checkbox: "Merge chapter files" (default: unchecked)
- Tooltip: "Combines separate chapter files into single audiobook with chapter markers" - Tooltip: "Combines separate chapter files into single audiobook with chapter markers"
## Logging & Transparency
**Status:** ✅ Implemented (v2)
All chapter merging decisions are **fully logged** for user transparency:
**Detection Phase Logs:**
- File count and format detection
- Chapter merge setting status
- Reason for skipping merge (if applicable)
- Disk space validation
**Analysis Phase Logs:**
- Sample filenames for debugging
- Metadata availability (track numbers)
- Ordering strategy chosen (metadata vs filename)
- Sample chapter titles generated
- Confidence level assessment
**Merge Phase Logs:**
- Book title, author, output filename
- Total duration and estimated size
- Merge strategy (codec copy vs re-encode)
- Bitrate decision for MP3 conversions
- FFmpeg execution status
- Final file size and chapter count
- Cleanup status
**Example Log Output:**
```
[FileOrganizer] Multiple audio files detected (30 files) - checking chapter merge settings...
[FileOrganizer] Chapter merging enabled - analyzing files...
[FileOrganizer] Chapter detection: 30 files with format .mp3 - attempting chapter merge
[FileOrganizer] Analyzing 30 chapter files...
[FileOrganizer] Sample filenames: Andy Weir - Project Hail Mary - 01.mp3, Andy Weir - Project Hail Mary - 02.mp3, Andy Weir - Project Hail Mary - 03.mp3, ...
[FileOrganizer] Metadata analysis: 30/30 files have track numbers
[FileOrganizer] Track numbers: 1, 2, 3 ... 30
[FileOrganizer] Chapter ordering: Filename and metadata orders match - high confidence
[FileOrganizer] Using metadata-based ordering for 30 chapters
[FileOrganizer] Sample chapter titles: Ch1: "Chapter 1", Ch2: "Chapter 2", Ch3: "Chapter 3", ...
[FileOrganizer] Starting chapter merge: "Project Hail Mary" by Andy Weir
[FileOrganizer] Merge strategy: Re-encoding MP3 → AAC/M4B at 128k
[FileOrganizer] Executing FFmpeg merge (timeout: 20 minutes)...
[FileOrganizer] ✓ Chapter merge successful!
[FileOrganizer] - Chapters: 30
[FileOrganizer] - Duration: 16h 32m 10s
[FileOrganizer] - Size: 452MB
```
## User Experience ## User Experience
### Success Flow ### Success Flow
1. Download completes: 25 chapter MP3 files 1. Download completes: 30 chapter MP3 files
2. File organization starts 2. File organization starts
3. System detects chapter pattern 3. System checks chapter merge settings (logs: enabled/disabled)
4. Merges files with progress logging: 4. Detects multi-file audiobook (logs: file count, format)
- "Detected 25 chapter files, merging into single M4B..." 5. Analyzes ordering strategy (logs: metadata vs filename, sample files)
- "Processing chapter 1/25..." 6. Merges files with detailed logging:
- "Merge complete: BookTitle.m4b (15.2 GB, 25 chapters)" - Detection: "30 files with format .mp3 - attempting chapter merge"
5. Copies merged M4B to target directory - Analysis: "Using metadata-based ordering for 30 chapters"
6. Deletes temp files and originals (if configured) - Merge: "Re-encoding MP3 → AAC/M4B at 128k"
7. Plex scans single M4B with full chapter navigation - Progress: "Executing FFmpeg merge (timeout: 20 minutes)..."
- Success: "✓ Chapter merge successful! 30 chapters, 16h 32m, 452MB"
7. Copies merged M4B to target directory (logs: copy status)
8. Cleans up temp files (logs: cleanup status)
9. Originals kept for seeding (cleaned up by separate scheduled job)
10. Plex scans single M4B with full chapter navigation
### Fallback Flow ### Fallback Flow
**If merge fails:** **If merge fails or skipped:**
1. Log error: "Chapter merge failed: [reason]" 1. System logs reason clearly:
2. Fall back to current behavior: copy individual files - "Chapter merging disabled in settings - organizing 30 files individually"
- "Only 2 file(s) - not enough for chapter merge (minimum: 3)"
- "Mixed formats detected (.mp3, .m4a) - skipping merge"
- "Insufficient disk space - organizing files individually"
- "Chapter merge failed: [FFmpeg error] - organizing files individually"
2. Falls back gracefully: organize individual files
3. Mark request as "available" (not failed) 3. Mark request as "available" (not failed)
4. User can manually merge later 4. User can manually merge later or enable setting
**Failure scenarios:** **Failure scenarios with logging:**
- FFmpeg crash/timeout - Feature disabled → Logs: "Chapter merging disabled in settings"
- Insufficient disk space for temp file - Too few files → Logs: "Only X file(s) - not enough for chapter merge"
- Corrupted source files - Mixed formats → Logs: "Mixed formats detected - skipping merge"
- Unsupported audio codec - Insufficient disk space → Logs: "Insufficient disk space for merge"
- FFmpeg crash/timeout → Logs: "FFmpeg merge failed: [error details]"
- Corrupted source files → Logs: "Failed to probe audio file: [error]"
## Technical Implementation ## Technical Implementation
@@ -205,51 +313,83 @@ interface MergeResult {
} }
// Main functions // Main functions
async function detectChapterFiles(files: string[]): Promise<boolean>; async function detectChapterFiles(files: string[], logger?: JobLogger): Promise<boolean>;
async function sortChapterFiles(files: string[]): Promise<ChapterFile[]>; async function analyzeChapterFiles(filePaths: string[], logger?: JobLogger): Promise<ChapterFile[]>;
async function getAudioDuration(filePath: string): Promise<number>; async function probeAudioFile(filePath: string): Promise<AudioProbeResult>;
async function generateChapterMetadata(chapters: ChapterFile[]): Promise<string>; async function mergeChapters(chapters: ChapterFile[], options: MergeOptions, logger?: JobLogger): Promise<MergeResult>;
async function mergeChapters(chapters: ChapterFile[], options: MergeOptions): Promise<MergeResult>; function formatDuration(ms: number): string;
async function checkDiskSpace(directory: string): Promise<number | null>;
async function estimateOutputSize(filePaths: string[]): Promise<number>;
``` ```
### Integration Points ### Integration Points
**File: `src/lib/utils/file-organizer.ts`** **File: `src/lib/utils/file-organizer.ts`**
**Modify `organize()` method:** **Modify `organize()` method (Updated v2):**
```typescript ```typescript
// After finding audiobook files (line ~73) // After finding audiobook files (line ~98)
if (audioFiles.length > 1) { if (audioFiles.length > 1) {
await logger?.info(`Multiple audio files detected (${audioFiles.length} files) - checking chapter merge settings...`);
const config = await prisma.configuration.findUnique({ const config = await prisma.configuration.findUnique({
where: { key: 'chapter_merging_enabled' } where: { key: 'chapter_merging_enabled' }
}); });
const mergingEnabled = config?.value === 'true'; const mergingEnabled = config?.value === 'true';
const isChapterDownload = await detectChapterFiles(audioFiles);
if (mergingEnabled && isChapterDownload) { if (!mergingEnabled) {
// Merge chapters instead of copying individually await logger?.info(`Chapter merging disabled in settings - organizing ${audioFiles.length} files individually`);
const mergeResult = await mergeChapters(audioFiles, { } else {
await logger?.info(`Chapter merging enabled - analyzing files...`);
// Build full paths
const sourceFilePaths = audioFiles.map(f => path.join(downloadPath, f));
// Simple detection: 3+ files, same format
const isChapterDownload = await detectChapterFiles(sourceFilePaths, logger);
if (isChapterDownload) {
// Check disk space
const estimatedSize = await estimateOutputSize(sourceFilePaths);
const availableSpace = await checkDiskSpace(this.tempDir);
if (availableSpace !== null && availableSpace < estimatedSize) {
await logger?.warn(`Insufficient disk space - organizing files individually`);
} else {
// Analyze and order (metadata-first)
const chapters = await analyzeChapterFiles(sourceFilePaths, logger);
// Merge chapters
const mergeResult = await mergeChapters(chapters, {
title: audiobook.title, title: audiobook.title,
author: audiobook.author, author: audiobook.author,
narrator: audiobook.narrator, narrator: audiobook.narrator,
year: audiobook.year, year: audiobook.year,
outputPath: path.join(targetPath, `${audiobook.title}.m4b`) asin: audiobook.asin,
}); outputPath: path.join(this.tempDir, `${audiobook.title}.m4b`)
}, logger);
if (mergeResult.success) { if (mergeResult.success) {
result.audioFiles = [mergeResult.outputPath]; // Replace array with single merged file
result.filesMovedCount = 1; audioFiles.length = 0;
// Skip individual file copying audioFiles.push(mergeResult.outputPath);
await logger?.info(`Chapter merge complete - organizing single M4B file`);
} else { } else {
// Fallback to individual file copying await logger?.warn(`Chapter merge failed - organizing files individually`);
await logger?.warn(`Chapter merge failed, copying files individually`); }
// Continue with existing logic }
} }
} }
} }
``` ```
**Key Changes:**
- Simplified detection (3+ files, same format)
- Comprehensive logging at every decision point
- Metadata-first ordering in `analyzeChapterFiles`
- Graceful fallback with clear user messaging
### Database Schema ### Database Schema
**No changes required** - uses existing `Configuration` table **No changes required** - uses existing `Configuration` table
@@ -260,6 +400,56 @@ if (audioFiles.length > 1) {
- ffmpeg (installed in Docker images) - ffmpeg (installed in Docker images)
- ffprobe (for duration detection) - ffprobe (for duration detection)
## Timeout & Validation (v2)
**Status:** ✅ Implemented
### Dynamic Timeout Calculation
**Problem:** Fixed 20-minute timeout was insufficient for long audiobooks (16h+ books need 90-120 minutes to encode).
**Solution:**
```typescript
// For re-encoding (MP3 → AAC)
timeout = max(
90 minutes, // minimum
(duration_minutes / 5) + 60 minutes // 5x realtime (worst case) + 60min safety
)
// Examples:
// 16h book: (960 / 5) + 60 = 252 minutes
// 8h book: (480 / 5) + 60 = 156 minutes
// 30min book: 90 minutes (minimum)
// For codec copy (M4A → M4B)
timeout = 5 minutes + (chapter_count * 30 seconds)
// Much faster, no encoding needed
```
### Output Validation
**Status:** ✅ Implemented
All merged files are validated before marked successful:
1. **Duration Check:** Expected vs actual duration (within 2% tolerance)
2. **Decode Test:** FFmpeg attempts to decode first 10 seconds (catches corruption)
3. **Size Check:** File size reasonable for duration (~0.5MB/min minimum)
**If validation fails:**
- Corrupt file is deleted
- Error logged with specific failure reason
- Falls back to organizing individual files
- Request marked "available" (not failed)
**Example validation failure:**
```
[FileOrganizer] Duration check: expected 16h 10m 54s, got 6h 13m
[FileOrganizer] ✗ Output validation failed: Duration mismatch (61.6% off). File may be truncated.
[FileOrganizer] Deleted corrupt output file
[FileOrganizer] Chapter merge failed - organizing 30 files individually
```
## Edge Cases & Error Handling ## Edge Cases & Error Handling
### Edge Cases ### Edge Cases
@@ -352,51 +542,77 @@ if (audioFiles.length > 1) {
## Success Metrics ## Success Metrics
### Functional ### Functional (v2 - Enhanced)
- ✅ Successful merge rate > 95% (for valid chapter downloads) - ✅ Successful merge rate > 95% (for valid chapter downloads)
-Chapter navigation works in Plex -**Validation catches 100% of corrupt files** (new)
-Zero audio quality degradation (M4A copy mode) -Chapter navigation works in Plex/Audiobookshelf
- ✅ Zero audio quality degradation (M4A copy mode, source-matched bitrate for MP3)
- ✅ Fallback works 100% of time on merge failure - ✅ Fallback works 100% of time on merge failure
-**No timeout failures for long audiobooks** (new - 16h+ books complete successfully)
### Performance ### Performance (v2 - Enhanced)
- ✅ M4A merge: < 2 minutes for 25 chapters - ✅ M4A merge: < 2 minutes for 25 chapters (codec copy, no re-encode)
- ✅ MP3 conversion: < 15 minutes for 10-hour audiobook - ✅ MP3 conversion: ~10x realtime (16h book = 90-120 minutes)
-**Instant playback start** (new - faststart flag moves index to beginning)
- ✅ No impact on concurrent downloads - ✅ No impact on concurrent downloads
-**Proper timeout allocation** (new - 126 min for 16h books vs old 20 min)
### User Experience ### User Experience (v2 - Enhanced)
- ✅ Feature opt-in (default disabled) - ✅ Feature opt-in (default disabled)
-Clear logging of merge progress -**Comprehensive logging** (new - detection, analysis, merge, validation)
- ✅ Single file in Plex instead of dozens - ✅ Single file in Plex instead of dozens
- ✅ Proper chapter markers in audiobook players - ✅ Proper chapter markers in audiobook players
-**Transparent validation** (new - users know if file is good or corrupt)
-**Quality preservation** (new - matches source bitrate, libfdk_aac if available)
## Implementation Phases ## Implementation Phases
### Phase 1: Core Functionality (MVP) ### Phase 1: Core Functionality (MVP) ✅ COMPLETED
- [ ] Implement `chapter-merger.ts` utility - [x] Implement `chapter-merger.ts` utility
- [ ] Detection logic (chapter file patterns) - [x] Detection logic (simplified: 3+ files, same format)
- [ ] Natural sorting algorithm - [x] Natural sorting algorithm
- [ ] Duration extraction (ffprobe) - [x] Duration extraction (ffprobe)
- [ ] Chapter metadata generation (FFMETADATA1) - [x] Chapter metadata generation (FFMETADATA1)
- [ ] M4A/M4B merge (codec copy mode) - [x] M4A/M4B merge (codec copy mode)
- [ ] Integration with file-organizer.ts - [x] Integration with file-organizer.ts
- [ ] Configuration keys in database - [x] Configuration keys in database
### Phase 2: MP3 Support ### Phase 2: MP3 Support ✅ COMPLETED
- [ ] MP3 → M4B conversion logic - [x] MP3 → M4B conversion logic
- [ ] Quality preservation settings - [x] Quality preservation settings (dynamic bitrate)
- [ ] Bitrate configuration UI - [x] Bitrate configuration (automatic, based on source)
### Phase 3: UI & Polish ### Phase 3: Logging & Transparency ✅ COMPLETED (v2)
- [ ] Setup wizard integration - [x] Comprehensive logging at all decision points
- [ ] Admin settings UI (Paths tab) - [x] Detection phase logging (file count, format, settings)
- [ ] Progress logging improvements - [x] Analysis phase logging (metadata vs filename, samples)
- [ ] Error messaging UX - [x] Merge phase logging (strategy, progress, results)
- [x] Error logging with clear fallback messaging
- [x] User transparency for all decisions
### Phase 4: Advanced Features (Future) ### Phase 4: UI Integration ✅ COMPLETED
- [ ] Custom chapter naming from file metadata - [x] Setup wizard integration
- [x] Admin settings UI (Paths tab)
- [x] Configuration persistence
### Phase 5: Corruption Fixes & Validation ✅ COMPLETED (v2)
- [x] Dynamic timeout calculation (fixes 16h+ book timeouts)
- [x] Add `-movflags +faststart` (fixes 1-min playback delay)
- [x] Add `-fflags +genpts` (fixes timestamp/seeking issues)
- [x] Add `-avoid_negative_ts make_zero` (handles edge cases)
- [x] Add `-max_muxing_queue_size` (prevents buffer overflow)
- [x] Output validation (duration, decode test, size check)
- [x] Source bitrate matching (preserves quality)
- [x] libfdk_aac support (higher quality when available)
- [x] Corrupt file detection and cleanup
### Phase 6: Advanced Features (Future)
- [ ] Real-time progress logging with FFmpeg output parsing
- [ ] Custom chapter naming from file metadata (partially done)
- [ ] Chapter art extraction (if embedded in files) - [ ] Chapter art extraction (if embedded in files)
- [ ] Preview merged file before finalizing - [ ] Preview merged file before finalizing
- [ ] Manual chapter editing UI - [ ] Manual chapter editing UI
- [ ] Parallel chapter processing (analyze while downloading)
## Related Documentation ## Related Documentation
+54 -4
View File
@@ -37,7 +37,46 @@ Default: `/media/audiobooks/` (if not configured)
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. Originals remain until seeding requirements met 8. Update request status to `downloaded`
9. **Trigger filesystem scan** (if enabled) - tells Plex/ABS to scan for new files
10. Originals remain until seeding requirements met
## Filesystem Scan Triggering
**Status:** ✅ Implemented (Both Backends)
**Purpose:** Trigger Plex/Audiobookshelf to scan filesystem after organizing files, ensuring new books appear immediately for users with disabled filesystem watchers.
**Configuration:**
- Plex: `plex.trigger_scan_after_import` (boolean, default: false)
- Audiobookshelf: `audiobookshelf.trigger_scan_after_import` (boolean, default: false)
**Flow:**
1. Files organized to media directory
2. Request status updated to `downloaded`
3. Check config setting (backend-specific)
4. If enabled: Call `ILibraryService.triggerLibraryScan(libraryId)`
5. Media server scans filesystem (async operation)
6. RMAB's scheduled check eventually detects new book
7. Request status updates to `available`
**Implementation:**
- Uses existing `ILibraryService` abstraction
- `PlexLibraryService.triggerLibraryScan()``POST /library/sections/{id}/refresh`
- `AudiobookshelfLibraryService.triggerLibraryScan()``POST /api/libraries/{id}/scan`
- Called from `organize-files.processor.ts` after status update
- Backend-agnostic using factory pattern
**Error Handling:**
- Scan failures logged but don't fail organize job
- Graceful degradation: scheduled scans eventually detect the book
- Non-blocking: async operation doesn't delay other jobs
**Use Cases:**
- Users with Plex/ABS filesystem watcher disabled
- Network-mounted media directories with delayed inotify
- Users who prefer manual control over automatic scanning
- Most users keep this disabled (default) and rely on built-in watchers
## Metadata Tagging ## Metadata Tagging
@@ -107,10 +146,21 @@ exiftool "audiobook.m4b" | grep -i asin
**Config:** `seeding_time_minutes` (0 = unlimited, never cleanup) **Config:** `seeding_time_minutes` (0 = unlimited, never cleanup)
**Cleanup Job:** `cleanup_seeded_torrents` (every 30 mins) **Cleanup Job:** `cleanup_seeded_torrents` (every 30 mins)
1. Check 'available' and 'downloaded' status requests with download history 1. Find requests with status 'available' or soft-deleted (orphaned downloads)
2. Query qBittorrent for actual `seeding_time` field 2. Query qBittorrent for actual `seeding_time` field
3. Delete torrent + files only after requirement met 3. **CRITICAL: Check if torrent hash is shared by other active requests**
4. Respects config (0 = never cleanup) - If yes → Skip torrent deletion, only hard-delete the soft-deleted request record
- If no → Delete torrent + files
4. Delete torrent + files only after seeding requirement met
5. Respects config (0 = never cleanup)
**Shared Torrent Protection:**
When user deletes and re-requests the same audiobook:
- Both requests share the same torrent hash (same files)
- Cleanup finds old soft-deleted request
- Before deleting torrent, checks if any active (non-deleted) request uses same hash
- If found → Keeps torrent, only removes soft-deleted database record
- Prevents deleting source files for active requests during chapter merging
## Interface ## Interface
+25 -6
View File
@@ -6,12 +6,31 @@ Single tabbed interface for admins to view/modify system configuration post-setu
## Sections ## Sections
1. **Plex** - URL, token (masked), library ID 1. **Plex** - URL, token (masked), library ID, filesystem scan trigger toggle
2. **Prowlarr** - URL, API key (masked), indexer selection with priority, seeding time, RSS monitoring toggle 2. **Audiobookshelf** - URL, API token (masked), library ID, filesystem scan trigger toggle
3. **Download Client** - Type, URL, credentials (masked) 3. **Prowlarr** - URL, API key (masked), indexer selection with priority, seeding time, RSS monitoring toggle
4. **Paths** - Download + media directories 4. **Download Client** - Type, URL, credentials (masked)
5. **BookDate** - AI provider, API key (encrypted), model selection, library scope, custom prompt, swipe history 5. **Paths** - Download + media directories
6. **Account** - Local admin password change (only visible to setup admin) 6. **BookDate** - AI provider, API key (encrypted), model selection, library scope, custom prompt, swipe history
7. **Account** - Local admin password change (only visible to setup admin)
## Filesystem Scan Trigger
**Purpose:** Trigger Plex/Audiobookshelf to scan filesystem after organizing files for users with disabled filesystem watchers.
**Configuration:**
- Plex: `plex.trigger_scan_after_import` (boolean, default: false)
- Audiobookshelf: `audiobookshelf.trigger_scan_after_import` (boolean, default: false)
**UI:**
- Checkbox toggle in both Plex and Audiobookshelf settings tabs
- Default: Unchecked (disabled)
- Help text: "Only enable this if you have [Plex/Audiobookshelf]'s filesystem watcher (automatic scanning) disabled. Most users should leave this disabled and rely on [Plex/Audiobookshelf]'s built-in automatic detection."
**Behavior:**
- When enabled: After `organize_files` job completes, RMAB triggers filesystem scan in media server
- 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)
## Validation Flow ## Validation Flow
+1 -1
View File
@@ -17,7 +17,7 @@
1. Welcome - Intro screen 1. Welcome - Intro screen
2. Admin Account - Create admin user 2. Admin Account - Create admin user
3. Plex - Server URL, OAuth, library selection 3. Plex (or Audiobookshelf) - Server URL, auth, library selection, filesystem scan trigger toggle
4. Prowlarr - URL, API key, indexer selection with priorities (1-25), seeding time, RSS monitoring 4. Prowlarr - URL, API key, indexer selection with priorities (1-25), seeding time, RSS monitoring
5. Download Client - qBittorrent/Transmission config 5. Download Client - qBittorrent/Transmission config
6. Paths - Download + media directories with validation 6. Paths - Download + media directories with validation
@@ -8,7 +8,9 @@
'use client'; 'use client';
import { useState, useRef, useEffect } from 'react'; import { useState, useRef, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { InteractiveTorrentSearchModal } from '@/components/requests/InteractiveTorrentSearchModal'; import { InteractiveTorrentSearchModal } from '@/components/requests/InteractiveTorrentSearchModal';
import { useSmartDropdownPosition } from '@/hooks/useSmartDropdownPosition';
export interface RequestActionsDropdownProps { export interface RequestActionsDropdownProps {
request: { request: {
@@ -37,7 +39,7 @@ export function RequestActionsDropdown({
}: RequestActionsDropdownProps) { }: RequestActionsDropdownProps) {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [showInteractiveSearch, setShowInteractiveSearch] = useState(false); const [showInteractiveSearch, setShowInteractiveSearch] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null); const { containerRef, dropdownRef, positionAbove, style } = useSmartDropdownPosition(isOpen);
// Determine available actions based on status // Determine available actions based on status
const canSearch = ['pending', 'failed', 'awaiting_search'].includes(request.status); const canSearch = ['pending', 'failed', 'awaiting_search'].includes(request.status);
@@ -104,27 +106,13 @@ export function RequestActionsDropdown({
} }
}; };
return ( // Dropdown menu content (rendered via portal)
<div className="relative" ref={dropdownRef}> const dropdownMenu = isOpen && style && (
{/* Three-dot menu button */} <div
<button ref={dropdownRef}
onClick={() => setIsOpen(!isOpen)} style={style}
disabled={isLoading} className="w-56 rounded-lg shadow-lg bg-white dark:bg-gray-800 ring-1 ring-black ring-opacity-5 z-50 max-h-[calc(100vh-2rem)] overflow-y-auto"
className="inline-flex items-center justify-center w-8 h-8 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
title="Actions"
> >
<svg
className="w-5 h-5 text-gray-600 dark:text-gray-400"
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M10 6a2 2 0 110-4 2 2 0 010 4zM10 12a2 2 0 110-4 2 2 0 010 4zM10 18a2 2 0 110-4 2 2 0 010 4z" />
</svg>
</button>
{/* Dropdown menu */}
{isOpen && (
<div className="absolute right-0 mt-2 w-56 rounded-lg shadow-lg bg-white dark:bg-gray-800 ring-1 ring-black ring-opacity-5 z-50">
<div className="py-1" role="menu"> <div className="py-1" role="menu">
{/* Manual Search */} {/* Manual Search */}
{canSearch && ( {canSearch && (
@@ -284,7 +272,30 @@ export function RequestActionsDropdown({
)} )}
</div> </div>
</div> </div>
)} );
return (
<>
{/* Three-dot menu button */}
<div className="relative" ref={containerRef}>
<button
onClick={() => setIsOpen(!isOpen)}
disabled={isLoading}
className="inline-flex items-center justify-center w-8 h-8 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
title="Actions"
>
<svg
className="w-5 h-5 text-gray-600 dark:text-gray-400"
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M10 6a2 2 0 110-4 2 2 0 010 4zM10 12a2 2 0 110-4 2 2 0 010 4zM10 18a2 2 0 110-4 2 2 0 010 4z" />
</svg>
</button>
</div>
{/* Dropdown menu (rendered via portal) */}
{typeof window !== 'undefined' && dropdownMenu && createPortal(dropdownMenu, document.body)}
{/* Interactive Search Modal */} {/* Interactive Search Modal */}
<InteractiveTorrentSearchModal <InteractiveTorrentSearchModal
@@ -296,6 +307,6 @@ export function RequestActionsDropdown({
author: request.author, author: request.author,
}} }}
/> />
</div> </>
); );
} }
+54
View File
@@ -38,11 +38,13 @@ interface Settings {
url: string; url: string;
token: string; token: string;
libraryId: string; libraryId: string;
triggerScanAfterImport: boolean;
}; };
audiobookshelf: { audiobookshelf: {
serverUrl: string; serverUrl: string;
apiToken: string; apiToken: string;
libraryId: string; libraryId: string;
triggerScanAfterImport: boolean;
}; };
oidc: { oidc: {
enabled: boolean; enabled: boolean;
@@ -1193,6 +1195,32 @@ export default function AdminSettings() {
)} )}
</div> </div>
<div className="space-y-2">
<label className="flex items-start gap-3 cursor-pointer">
<input
type="checkbox"
checked={settings.plex.triggerScanAfterImport}
onChange={(e) => {
setSettings({
...settings,
plex: { ...settings.plex, triggerScanAfterImport: e.target.checked },
});
}}
className="mt-1 h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800"
/>
<div className="flex-1">
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
Trigger library scan after import
</span>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Automatically triggers Plex to scan its filesystem after organizing downloaded files.
Only enable this if you have Plex's filesystem watcher (automatic scanning) disabled.
Most users should leave this disabled and rely on Plex's built-in automatic detection.
</p>
</div>
</label>
</div>
<div className="border-t border-gray-200 dark:border-gray-700 pt-6"> <div className="border-t border-gray-200 dark:border-gray-700 pt-6">
<Button <Button
onClick={testPlexConnection} onClick={testPlexConnection}
@@ -1302,6 +1330,32 @@ export default function AdminSettings() {
)} )}
</div> </div>
<div className="space-y-2">
<label className="flex items-start gap-3 cursor-pointer">
<input
type="checkbox"
checked={settings.audiobookshelf.triggerScanAfterImport}
onChange={(e) => {
setSettings({
...settings,
audiobookshelf: { ...settings.audiobookshelf, triggerScanAfterImport: e.target.checked },
});
}}
className="mt-1 h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800"
/>
<div className="flex-1">
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
Trigger library scan after import
</span>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Automatically triggers Audiobookshelf to scan its filesystem after organizing downloaded files.
Only enable this if you have Audiobookshelf's filesystem watcher (automatic scanning) disabled.
Most users should leave this disabled and rely on Audiobookshelf's built-in automatic detection.
</p>
</div>
</label>
</div>
<div className="border-t border-gray-200 dark:border-gray-700 pt-6"> <div className="border-t border-gray-200 dark:border-gray-700 pt-6">
<Button <Button
onClick={testABSConnection} onClick={testABSConnection}
@@ -12,7 +12,7 @@ export async function PUT(request: NextRequest) {
return requireAdmin(req, async () => { return requireAdmin(req, async () => {
try { try {
const body = await request.json(); const body = await request.json();
const { serverUrl, apiToken, libraryId } = body; const { serverUrl, apiToken, libraryId, triggerScanAfterImport } = body;
const { getConfigService } = await import('@/lib/services/config.service'); const { getConfigService } = await import('@/lib/services/config.service');
const configService = getConfigService(); const configService = getConfigService();
@@ -21,6 +21,7 @@ export async function PUT(request: NextRequest) {
const updates: ConfigUpdate[] = [ const updates: ConfigUpdate[] = [
{ key: 'audiobookshelf.server_url', value: serverUrl || '' }, { key: 'audiobookshelf.server_url', value: serverUrl || '' },
{ key: 'audiobookshelf.library_id', value: libraryId || '' }, { key: 'audiobookshelf.library_id', value: libraryId || '' },
{ key: 'audiobookshelf.trigger_scan_after_import', value: triggerScanAfterImport === true ? 'true' : 'false' },
]; ];
// Only update API token if it's not the masked placeholder // Only update API token if it's not the masked placeholder
+8 -1
View File
@@ -12,7 +12,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 { url, token, libraryId } = await request.json(); const { url, token, libraryId, triggerScanAfterImport } = await request.json();
if (!url || !token || !libraryId) { if (!url || !token || !libraryId) {
return NextResponse.json( return NextResponse.json(
@@ -43,6 +43,13 @@ export async function PUT(request: NextRequest) {
create: { key: 'plex_audiobook_library_id', value: libraryId }, create: { key: 'plex_audiobook_library_id', value: libraryId },
}); });
// Save trigger_scan_after_import setting
await prisma.configuration.upsert({
where: { key: 'plex.trigger_scan_after_import' },
update: { value: triggerScanAfterImport === true ? 'true' : 'false' },
create: { key: 'plex.trigger_scan_after_import', value: triggerScanAfterImport === true ? 'true' : 'false' },
});
// Fetch and save machine identifier (for server-specific access tokens) // Fetch and save machine identifier (for server-specific access tokens)
// This is needed for BookDate per-user rating functionality // This is needed for BookDate per-user rating functionality
try { try {
+2
View File
@@ -37,11 +37,13 @@ export async function GET(request: NextRequest) {
url: configMap.get('plex_url') || '', url: configMap.get('plex_url') || '',
token: maskValue('token', configMap.get('plex_token')), token: maskValue('token', configMap.get('plex_token')),
libraryId: configMap.get('plex_audiobook_library_id') || '', libraryId: configMap.get('plex_audiobook_library_id') || '',
triggerScanAfterImport: configMap.get('plex.trigger_scan_after_import') === 'true',
}, },
audiobookshelf: { audiobookshelf: {
serverUrl: configMap.get('audiobookshelf.server_url') || '', serverUrl: configMap.get('audiobookshelf.server_url') || '',
apiToken: maskValue('api_token', configMap.get('audiobookshelf.api_token')), apiToken: maskValue('api_token', configMap.get('audiobookshelf.api_token')),
libraryId: configMap.get('audiobookshelf.library_id') || '', libraryId: configMap.get('audiobookshelf.library_id') || '',
triggerScanAfterImport: configMap.get('audiobookshelf.trigger_scan_after_import') === 'true',
}, },
oidc: { oidc: {
enabled: configMap.get('oidc.enabled') === 'true', enabled: configMap.get('oidc.enabled') === 'true',
+14
View File
@@ -188,6 +188,13 @@ export async function POST(request: NextRequest) {
create: { key: 'plex_machine_identifier', value: machineIdentifier }, create: { key: 'plex_machine_identifier', value: machineIdentifier },
}); });
} }
// Save trigger_scan_after_import setting
await prisma.configuration.upsert({
where: { key: 'plex.trigger_scan_after_import' },
update: { value: plex.trigger_scan_after_import === true ? 'true' : 'false' },
create: { key: 'plex.trigger_scan_after_import', value: plex.trigger_scan_after_import === true ? 'true' : 'false' },
});
} else { } else {
// Audiobookshelf configuration // Audiobookshelf configuration
await prisma.configuration.upsert({ await prisma.configuration.upsert({
@@ -209,6 +216,13 @@ export async function POST(request: NextRequest) {
create: { key: 'audiobookshelf.library_id', value: audiobookshelf.library_id }, create: { key: 'audiobookshelf.library_id', value: audiobookshelf.library_id },
}); });
// Save trigger_scan_after_import setting
await prisma.configuration.upsert({
where: { key: 'audiobookshelf.trigger_scan_after_import' },
update: { value: audiobookshelf.trigger_scan_after_import === true ? 'true' : 'false' },
create: { key: 'audiobookshelf.trigger_scan_after_import', value: audiobookshelf.trigger_scan_after_import === true ? 'true' : 'false' },
});
// OIDC configuration (if enabled) // OIDC configuration (if enabled)
if (authMethod === 'oidc' || authMethod === 'both') { if (authMethod === 'oidc' || authMethod === 'both') {
await prisma.configuration.upsert({ await prisma.configuration.upsert({
+23
View File
@@ -0,0 +1,23 @@
/**
* Component: Version API Route
* Documentation: documentation/backend/services/version.md
*/
import { NextResponse } from 'next/server';
export async function GET() {
const gitCommit = process.env.APP_VERSION || 'unknown';
const buildDate = process.env.BUILD_DATE || 'unknown';
// Get short commit hash (first 7 characters)
const shortCommit = gitCommit !== 'unknown' && gitCommit.length >= 7
? gitCommit.substring(0, 7)
: gitCommit;
return NextResponse.json({
version: `v.${shortCommit}`,
commit: gitCommit,
shortCommit,
buildDate,
});
}
+6
View File
@@ -43,11 +43,13 @@ interface SetupState {
plexUrl: string; plexUrl: string;
plexToken: string; plexToken: string;
plexLibraryId: string; plexLibraryId: string;
plexTriggerScanAfterImport: boolean;
// Audiobookshelf config (if mode=audiobookshelf) // Audiobookshelf config (if mode=audiobookshelf)
absUrl: string; absUrl: string;
absApiToken: string; absApiToken: string;
absLibraryId: string; absLibraryId: string;
absTriggerScanAfterImport: boolean;
// Auth config (if mode=audiobookshelf) // Auth config (if mode=audiobookshelf)
authMethod: 'oidc' | 'manual' | 'both'; authMethod: 'oidc' | 'manual' | 'both';
@@ -113,11 +115,13 @@ export default function SetupWizard() {
plexUrl: '', plexUrl: '',
plexToken: '', plexToken: '',
plexLibraryId: '', plexLibraryId: '',
plexTriggerScanAfterImport: false,
// Audiobookshelf config // Audiobookshelf config
absUrl: '', absUrl: '',
absApiToken: '', absApiToken: '',
absLibraryId: '', absLibraryId: '',
absTriggerScanAfterImport: false,
// Auth config // Auth config
authMethod: 'oidc', authMethod: 'oidc',
@@ -391,6 +395,7 @@ export default function SetupWizard() {
plexUrl={state.plexUrl} plexUrl={state.plexUrl}
plexToken={state.plexToken} plexToken={state.plexToken}
plexLibraryId={state.plexLibraryId} plexLibraryId={state.plexLibraryId}
plexTriggerScanAfterImport={state.plexTriggerScanAfterImport}
onUpdate={updateField} onUpdate={updateField}
onNext={() => goToStep(currentStepNumber + 1)} onNext={() => goToStep(currentStepNumber + 1)}
onBack={() => goToStep(currentStepNumber - 1)} onBack={() => goToStep(currentStepNumber - 1)}
@@ -408,6 +413,7 @@ export default function SetupWizard() {
absUrl={state.absUrl} absUrl={state.absUrl}
absApiToken={state.absApiToken} absApiToken={state.absApiToken}
absLibraryId={state.absLibraryId} absLibraryId={state.absLibraryId}
absTriggerScanAfterImport={state.absTriggerScanAfterImport}
onUpdate={updateField} onUpdate={updateField}
onNext={() => goToStep(currentStepNumber + 1)} onNext={() => goToStep(currentStepNumber + 1)}
onBack={() => goToStep(currentStepNumber - 1)} onBack={() => goToStep(currentStepNumber - 1)}
+25 -1
View File
@@ -13,7 +13,8 @@ interface AudiobookshelfStepProps {
absUrl: string; absUrl: string;
absApiToken: string; absApiToken: string;
absLibraryId: string; absLibraryId: string;
onUpdate: (field: string, value: string) => void; absTriggerScanAfterImport: boolean;
onUpdate: (field: string, value: string | boolean) => void;
onNext: () => void; onNext: () => void;
onBack: () => void; onBack: () => void;
} }
@@ -28,6 +29,7 @@ export function AudiobookshelfStep({
absUrl, absUrl,
absApiToken, absApiToken,
absLibraryId, absLibraryId,
absTriggerScanAfterImport,
onUpdate, onUpdate,
onNext, onNext,
onBack, onBack,
@@ -226,6 +228,28 @@ export function AudiobookshelfStep({
</p> </p>
</div> </div>
)} )}
{libraries.length > 0 && (
<div className="space-y-2">
<label className="flex items-start gap-3 cursor-pointer">
<input
type="checkbox"
checked={absTriggerScanAfterImport}
onChange={(e) => onUpdate('absTriggerScanAfterImport', e.target.checked)}
className="mt-1 h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800"
/>
<div className="flex-1">
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
Trigger library scan after import
</span>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Only enable this if you have Audiobookshelf's filesystem watcher (automatic scanning) disabled.
Most users should leave this unchecked and rely on Audiobookshelf's built-in automatic detection.
</p>
</div>
</label>
</div>
)}
</div> </div>
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4"> <div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4">
+25 -1
View File
@@ -13,7 +13,8 @@ interface PlexStepProps {
plexUrl: string; plexUrl: string;
plexToken: string; plexToken: string;
plexLibraryId: string; plexLibraryId: string;
onUpdate: (field: string, value: string) => void; plexTriggerScanAfterImport: boolean;
onUpdate: (field: string, value: string | boolean) => void;
onNext: () => void; onNext: () => void;
onBack: () => void; onBack: () => void;
} }
@@ -28,6 +29,7 @@ export function PlexStep({
plexUrl, plexUrl,
plexToken, plexToken,
plexLibraryId, plexLibraryId,
plexTriggerScanAfterImport,
onUpdate, onUpdate,
onNext, onNext,
onBack, onBack,
@@ -233,6 +235,28 @@ export function PlexStep({
</p> </p>
</div> </div>
)} )}
{libraries.length > 0 && (
<div className="space-y-2">
<label className="flex items-start gap-3 cursor-pointer">
<input
type="checkbox"
checked={plexTriggerScanAfterImport}
onChange={(e) => onUpdate('plexTriggerScanAfterImport', e.target.checked)}
className="mt-1 h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800"
/>
<div className="flex-1">
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
Trigger library scan after import
</span>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Only enable this if you have Plex's filesystem watcher (automatic scanning) disabled.
Most users should leave this unchecked and rely on Plex's built-in automatic detection.
</p>
</div>
</label>
</div>
)}
</div> </div>
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4"> <div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4">
+38 -23
View File
@@ -6,15 +6,19 @@
'use client'; 'use client';
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { createPortal } from 'react-dom';
import Link from 'next/link'; import Link from 'next/link';
import { useAuth } from '@/contexts/AuthContext'; import { useAuth } from '@/contexts/AuthContext';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { VersionBadge } from '@/components/ui/VersionBadge';
import { useSmartDropdownPosition } from '@/hooks/useSmartDropdownPosition';
export function Header() { export function Header() {
const { user, logout } = useAuth(); const { user, logout } = useAuth();
const [showUserMenu, setShowUserMenu] = useState(false); const [showUserMenu, setShowUserMenu] = useState(false);
const [showMobileMenu, setShowMobileMenu] = useState(false); const [showMobileMenu, setShowMobileMenu] = useState(false);
const [showBookDate, setShowBookDate] = useState(false); const [showBookDate, setShowBookDate] = useState(false);
const { containerRef, dropdownRef, positionAbove, style } = useSmartDropdownPosition(showUserMenu);
// Check if BookDate is configured // Check if BookDate is configured
useEffect(() => { useEffect(() => {
@@ -67,11 +71,38 @@ export function Header() {
} }
}; };
// User menu dropdown (rendered via portal)
const userMenuDropdown = showUserMenu && style && (
<div
ref={dropdownRef}
style={style}
className="w-48 bg-white dark:bg-gray-800 rounded-lg shadow-lg py-1 z-50 max-h-[calc(100vh-2rem)] overflow-y-auto"
>
<Link
href="/profile"
className="block px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
onClick={() => setShowUserMenu(false)}
>
Profile
</Link>
<button
onClick={() => {
logout();
setShowUserMenu(false);
}}
className="w-full text-left px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
>
Logout
</button>
</div>
);
return ( return (
<header className="bg-white dark:bg-gray-800 shadow-sm sticky top-0 z-40"> <header className="bg-white dark:bg-gray-800 shadow-sm sticky top-0 z-40">
<div className="container mx-auto px-4 py-3 md:py-4 max-w-7xl"> <div className="container mx-auto px-4 py-3 md:py-4 max-w-7xl">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
{/* Logo */} {/* Logo and Version Badge */}
<div className="flex items-center gap-3">
<Link href="/" className="flex items-center gap-2"> <Link href="/" className="flex items-center gap-2">
<img <img
src="/rmab_32x32.png" src="/rmab_32x32.png"
@@ -82,6 +113,8 @@ export function Header() {
ReadMeABook ReadMeABook
</span> </span>
</Link> </Link>
<VersionBadge />
</div>
{/* Desktop Navigation */} {/* Desktop Navigation */}
<nav className="hidden md:flex items-center gap-6"> <nav className="hidden md:flex items-center gap-6">
@@ -154,7 +187,7 @@ export function Header() {
</button> </button>
{user ? ( {user ? (
<div className="relative"> <div className="relative" ref={containerRef}>
<button <button
onClick={() => setShowUserMenu(!showUserMenu)} onClick={() => setShowUserMenu(!showUserMenu)}
className="flex items-center gap-2 hover:opacity-80 transition-opacity" className="flex items-center gap-2 hover:opacity-80 transition-opacity"
@@ -174,27 +207,6 @@ export function Header() {
{user.username} {user.username}
</span> </span>
</button> </button>
{showUserMenu && (
<div className="absolute right-0 mt-2 w-48 bg-white dark:bg-gray-800 rounded-lg shadow-lg py-1 z-50">
<Link
href="/profile"
className="block px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
onClick={() => setShowUserMenu(false)}
>
Profile
</Link>
<button
onClick={() => {
logout();
setShowUserMenu(false);
}}
className="w-full text-left px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
>
Logout
</button>
</div>
)}
</div> </div>
) : ( ) : (
<Button onClick={handleLogin} variant="primary" size="sm"> <Button onClick={handleLogin} variant="primary" size="sm">
@@ -253,6 +265,9 @@ export function Header() {
</div> </div>
)} )}
</div> </div>
{/* User menu dropdown (rendered via portal) */}
{typeof window !== 'undefined' && userMenuDropdown && createPortal(userMenuDropdown, document.body)}
</header> </header>
); );
} }
+51
View File
@@ -0,0 +1,51 @@
/**
* Component: Version Badge
* Documentation: documentation/frontend/components.md
*/
'use client';
import React, { useEffect, useState } from 'react';
export function VersionBadge() {
const [version, setVersion] = useState<string | null>(null);
useEffect(() => {
// Try to get version from build-time env var first (instant, no API call)
const buildTimeVersion = process.env.NEXT_PUBLIC_GIT_COMMIT;
if (buildTimeVersion && buildTimeVersion !== 'unknown') {
// Get short commit hash (first 7 characters)
const shortCommit = buildTimeVersion.length >= 7
? buildTimeVersion.substring(0, 7)
: buildTimeVersion;
setVersion(`v.${shortCommit}`);
} else {
// Fallback to API call if build-time env var is not available
fetch('/api/version')
.then((res) => res.json())
.then((data) => {
setVersion(data.version);
})
.catch((error) => {
console.error('Failed to fetch version:', error);
setVersion('v.dev');
});
}
}, []);
if (!version) {
return null;
}
return (
<div
className="inline-flex items-center px-2.5 py-1 rounded-md bg-gradient-to-r from-gray-100 to-gray-200 dark:from-gray-700 dark:to-gray-800 border border-gray-300 dark:border-gray-600 shadow-sm"
title={`Version ${version}`}
>
<span className="text-xs font-mono font-medium text-gray-700 dark:text-gray-300">
{version}
</span>
</div>
);
}
+135
View File
@@ -0,0 +1,135 @@
/**
* Hook: Smart Dropdown Positioning with Portal Support
*
* Automatically positions dropdown menus to avoid viewport overflow.
* Detects available space above/below the trigger element and positions
* the dropdown accordingly. Returns absolute coordinates for portal rendering.
*/
import { useRef, useState, useEffect, RefObject } from 'react';
interface UseSmartDropdownPositionReturn {
containerRef: RefObject<HTMLDivElement | null>;
dropdownRef: RefObject<HTMLDivElement | null>;
positionAbove: boolean;
style: {
position: 'fixed';
top?: number;
bottom?: number;
left: number;
right?: number;
minWidth: number;
} | null;
}
/**
* Custom hook for smart dropdown positioning with portal support
*
* @param isOpen - Whether the dropdown is currently open
* @returns Object containing refs, positioning state, and absolute coordinates for portal rendering
*
* @example
* const { containerRef, dropdownRef, positionAbove, style } = useSmartDropdownPosition(isOpen);
*
* <div ref={containerRef}>
* <button>Toggle</button>
* </div>
* {isOpen && createPortal(
* <div ref={dropdownRef} style={style}>Menu items</div>,
* document.body
* )}
*/
export function useSmartDropdownPosition(isOpen: boolean): UseSmartDropdownPositionReturn {
const containerRef = useRef<HTMLDivElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const [positionAbove, setPositionAbove] = useState(false);
const [style, setStyle] = useState<UseSmartDropdownPositionReturn['style']>(null);
useEffect(() => {
if (!isOpen) {
setStyle(null);
return;
}
const calculatePosition = () => {
if (!containerRef.current) {
return;
}
const buttonRect = containerRef.current.getBoundingClientRect();
// Get dropdown dimensions if available, otherwise estimate
const dropdownHeight = dropdownRef.current
? dropdownRef.current.getBoundingClientRect().height
: 300; // Reasonable default estimate
const dropdownWidth = dropdownRef.current
? dropdownRef.current.getBoundingClientRect().width
: 224; // Default width estimate (w-56)
// Calculate available space (with 16px buffer from viewport edges)
const spaceBelow = window.innerHeight - buttonRect.bottom - 16;
const spaceAbove = buttonRect.top - 16;
// Position above if not enough space below
const shouldPositionAbove = spaceBelow < dropdownHeight && spaceAbove >= dropdownHeight;
setPositionAbove(shouldPositionAbove);
// Calculate absolute position for portal rendering
// Align right edge of dropdown with right edge of button
const newStyle: UseSmartDropdownPositionReturn['style'] = {
position: 'fixed',
left: Math.max(8, buttonRect.right - dropdownWidth), // Keep 8px from left edge
minWidth: buttonRect.width,
};
if (shouldPositionAbove) {
// Position above the button
newStyle.bottom = window.innerHeight - buttonRect.top + 8; // 8px margin
} else {
// Position below the button
newStyle.top = buttonRect.bottom + 8; // 8px margin
}
setStyle(newStyle);
};
// Use requestAnimationFrame for immediate measurement after render
const rafId = requestAnimationFrame(() => {
calculatePosition();
});
// Recalculate on scroll/resize (debounced)
const debouncedCalculate = debounce(calculatePosition, 150);
// Use capture phase for scroll to catch scrolling in any parent
window.addEventListener('scroll', debouncedCalculate, true);
window.addEventListener('resize', debouncedCalculate);
return () => {
cancelAnimationFrame(rafId);
window.removeEventListener('scroll', debouncedCalculate, true);
window.removeEventListener('resize', debouncedCalculate);
};
}, [isOpen]);
return { containerRef, dropdownRef, positionAbove, style };
}
/**
* Debounce helper function
*/
function debounce<T extends (...args: any[]) => any>(
func: T,
wait: number
): (...args: Parameters<T>) => void {
let timeout: NodeJS.Timeout | null = null;
return (...args: Parameters<T>) => {
if (timeout) {
clearTimeout(timeout);
}
timeout = setTimeout(() => func(...args), wait);
};
}
@@ -45,15 +45,19 @@ export async function processCleanupSeededTorrents(payload: CleanupSeededTorrent
await logger?.info(`Loaded configuration for ${indexerConfigMap.size} indexers`); await logger?.info(`Loaded configuration for ${indexerConfigMap.size} indexers`);
// Find all completed requests + soft-deleted requests (orphaned downloads) // Find all completed requests + soft-deleted requests (orphaned downloads)
// IMPORTANT: Only cleanup requests that are truly complete and not being actively processed
// NOTE: Multiple requests can share the same torrent hash (e.g., re-requesting same audiobook)
// Before deleting torrent, we check if other active requests are using it
const completedRequests = await prisma.request.findMany({ const completedRequests = await prisma.request.findMany({
where: { where: {
OR: [ OR: [
// Active requests with completed downloads // Active requests that are fully available (scanned by Plex/ABS)
{ {
status: { in: ['available', 'downloaded'] }, status: 'available',
deletedAt: null, deletedAt: null,
}, },
// Soft-deleted requests (orphaned downloads still seeding) // Soft-deleted requests (orphaned downloads)
// We'll check if torrent is shared with active requests before deletion
{ {
deletedAt: { not: null }, deletedAt: { not: null },
}, },
@@ -72,7 +76,7 @@ export async function processCleanupSeededTorrents(payload: CleanupSeededTorrent
take: 100, // Limit to 100 requests per run take: 100, // Limit to 100 requests per run
}); });
await logger?.info(`Found ${completedRequests.length} completed requests to check`); await logger?.info(`Found ${completedRequests.length} requests to check (status: 'available' or soft-deleted)`);
let cleaned = 0; let cleaned = 0;
let skipped = 0; let skipped = 0;
@@ -144,7 +148,36 @@ export async function processCleanupSeededTorrents(payload: CleanupSeededTorrent
await logger?.info(`Torrent ${torrent.name} (${indexerName}) has met seeding requirement (${Math.floor(actualSeedingTime / 60)}/${seedingConfig.seedingTimeMinutes} minutes)`); await logger?.info(`Torrent ${torrent.name} (${indexerName}) has met seeding requirement (${Math.floor(actualSeedingTime / 60)}/${seedingConfig.seedingTimeMinutes} minutes)`);
// Delete torrent and files from qBittorrent // CRITICAL: Check if any other active (non-deleted) request is using this same torrent hash
// This prevents deleting shared torrents when user re-requests the same audiobook
const otherActiveRequests = await prisma.request.findMany({
where: {
id: { not: request.id }, // Exclude current request
deletedAt: null, // Only check active requests
downloadHistory: {
some: {
torrentHash: downloadHistory.torrentHash,
selected: true,
},
},
},
select: { id: true, status: true },
});
if (otherActiveRequests.length > 0) {
await logger?.info(`Skipping torrent deletion - ${otherActiveRequests.length} other active request(s) still using this torrent (IDs: ${otherActiveRequests.map(r => r.id).join(', ')})`);
// If this is a soft-deleted request, hard delete it but DON'T delete the torrent
if (request.deletedAt) {
await prisma.request.delete({ where: { id: request.id } });
await logger?.info(`Hard-deleted orphaned request ${request.id} (kept shared torrent for active requests)`);
}
skipped++;
continue;
}
// Safe to delete - no other active requests using this torrent
await qbt.deleteTorrent(downloadHistory.torrentHash, true); // true = delete files await qbt.deleteTorrent(downloadHistory.torrentHash, true); // true = delete files
// If this is a soft-deleted request (orphaned download), hard delete it now // If this is a soft-deleted request (orphaned download), hard delete it now
@@ -69,7 +69,7 @@ export async function processDownloadTorrent(payload: DownloadTorrentPayload): P
torrentName: torrent.title, torrentName: torrent.title,
nzbId: downloadClientId, // Store NZB ID nzbId: downloadClientId, // Store NZB ID
torrentSizeBytes: torrent.size, torrentSizeBytes: torrent.size,
torrentUrl: torrent.guid, // Source URL torrentUrl: torrent.infoUrl || torrent.guid, // Indexer page URL (prefer infoUrl, fallback to guid)
magnetLink: torrent.downloadUrl, // Download URL (.nzb file) magnetLink: torrent.downloadUrl, // Download URL (.nzb file)
seeders: torrent.seeders || 0, // Usenet doesn't have seeders, but include for consistency seeders: torrent.seeders || 0, // Usenet doesn't have seeders, but include for consistency
leechers: 0, leechers: 0,
@@ -130,7 +130,7 @@ export async function processDownloadTorrent(payload: DownloadTorrentPayload): P
torrentName: torrent.title, torrentName: torrent.title,
torrentHash: torrent.infoHash || downloadClientId, // Store torrent hash torrentHash: torrent.infoHash || downloadClientId, // Store torrent hash
torrentSizeBytes: torrent.size, torrentSizeBytes: torrent.size,
torrentUrl: torrent.guid, torrentUrl: torrent.infoUrl || torrent.guid, // Indexer page URL (prefer infoUrl, fallback to guid)
magnetLink: torrent.downloadUrl, magnetLink: torrent.downloadUrl,
seeders: torrent.seeders || 0, seeders: torrent.seeders || 0,
leechers: torrent.leechers || 0, leechers: torrent.leechers || 0,
@@ -7,6 +7,8 @@ import { OrganizeFilesPayload, getJobQueueService } from '../services/job-queue.
import { prisma } from '../db'; import { prisma } from '../db';
import { getFileOrganizer } from '../utils/file-organizer'; import { getFileOrganizer } from '../utils/file-organizer';
import { createJobLogger } from '../utils/job-logger'; import { createJobLogger } from '../utils/job-logger';
import { getLibraryService } from '../services/library';
import { getConfigService } from '../services/config.service';
/** /**
* Process organize files job * Process organize files job
@@ -99,6 +101,54 @@ export async function processOrganizeFiles(payload: OrganizeFilesPayload): Promi
errors: result.errors, errors: result.errors,
}); });
// Trigger filesystem scan if enabled (Plex or Audiobookshelf)
const configService = getConfigService();
const backendMode = await configService.getBackendMode();
const configKey = backendMode === 'audiobookshelf'
? 'audiobookshelf.trigger_scan_after_import'
: 'plex.trigger_scan_after_import';
const scanEnabled = await configService.get(configKey);
if (scanEnabled === 'true') {
try {
// Get library service (returns PlexLibraryService or AudiobookshelfLibraryService)
const libraryService = await getLibraryService();
// Get configured library ID (backend-specific config)
const libraryId = backendMode === 'audiobookshelf'
? await configService.get('audiobookshelf.library_id')
: await configService.get('plex_audiobook_library_id');
if (!libraryId) {
throw new Error('Library ID not configured');
}
// Trigger scan (implementation is backend-specific)
await libraryService.triggerLibraryScan(libraryId);
await logger?.info(
`Triggered ${backendMode} filesystem scan for library ${libraryId}`
);
} catch (error) {
// Log error but don't fail the job
await logger?.error(
`Failed to trigger filesystem scan: ${error instanceof Error ? error.message : 'Unknown error'}`,
{
error: error instanceof Error ? error.stack : undefined,
backend: backendMode
}
);
// Continue - scheduled scans will eventually detect the book
}
} else {
await logger?.info(
`${backendMode} filesystem scan trigger disabled (relying on filesystem watcher)`
);
}
return { return {
success: true, success: true,
message: 'Files organized successfully', message: 'Files organized successfully',
+25 -1
View File
@@ -92,9 +92,33 @@ export async function searchABSItems(libraryId: string, query: string) {
/** /**
* Trigger a library scan * Trigger a library scan
* Note: This endpoint returns plain text "OK" instead of JSON
*/ */
export async function triggerABSScan(libraryId: string) { export async function triggerABSScan(libraryId: string) {
await absRequest(`/libraries/${libraryId}/scan`, { method: 'POST' }); const configService = getConfigService();
const serverUrl = await configService.get('audiobookshelf.server_url');
const apiToken = await configService.get('audiobookshelf.api_token');
if (!serverUrl || !apiToken) {
throw new Error('Audiobookshelf not configured');
}
const url = `${serverUrl.replace(/\/$/, '')}/api/libraries/${libraryId}/scan`;
const response = await fetch(url, {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiToken}`,
'Content-Type': 'application/json',
},
});
if (!response.ok) {
throw new Error(`ABS API error: ${response.status} ${response.statusText}`);
}
// Endpoint returns plain text "OK", not JSON - don't try to parse it
await response.text();
} }
/** /**
+342 -35
View File
@@ -6,7 +6,7 @@
* with proper chapter markers. * with proper chapter markers.
*/ */
import { exec } from 'child_process'; import { exec, spawn } from 'child_process';
import { promisify } from 'util'; import { promisify } from 'util';
import path from 'path'; import path from 'path';
import fs from 'fs/promises'; import fs from 'fs/promises';
@@ -72,34 +72,37 @@ export interface MergeResult {
/** /**
* Detect if the given files appear to be chapter files that should be merged * Detect if the given files appear to be chapter files that should be merged
*
* New approach: Use simple heuristic (>3 files of same format) and rely on
* analyzeChapterFiles() to determine if ordering is possible via metadata or filenames.
* This is more permissive and catches edge cases where filenames don't match patterns
* but metadata (track numbers) provides correct ordering.
*/ */
export async function detectChapterFiles(files: string[]): Promise<boolean> { export async function detectChapterFiles(files: string[], logger?: JobLogger): Promise<boolean> {
// Need at least 2 files to merge // Need at least 3 files to consider as multi-chapter audiobook
if (files.length < 2) { // (2 files might be "Book" + "Credits", so require 3+)
if (files.length < 3) {
await logger?.info(`Chapter detection: Only ${files.length} file(s) - not enough for chapter merge (minimum: 3)`);
return false; return false;
} }
// All files must have same audio format // All files must have same audio format
const extensions = new Set(files.map(f => path.extname(f).toLowerCase())); const extensions = new Set(files.map(f => path.extname(f).toLowerCase()));
if (extensions.size > 1) { if (extensions.size > 1) {
await logger?.info(`Chapter detection: Mixed formats detected (${[...extensions].join(', ')}) - skipping merge`);
return false; return false;
} }
// Must be a supported format // Must be a supported format
const ext = [...extensions][0]; const ext = [...extensions][0];
if (!SUPPORTED_FORMATS.includes(ext)) { if (!SUPPORTED_FORMATS.includes(ext)) {
await logger?.info(`Chapter detection: Unsupported format (${ext}) - skipping merge`);
return false; return false;
} }
// Check if files match chapter patterns // Passed basic checks - attempt merge
const filenames = files.map(f => path.basename(f)); await logger?.info(`Chapter detection: ${files.length} files with format ${ext} - attempting chapter merge`);
const matchingFiles = filenames.filter(filename => return true;
CHAPTER_PATTERNS.some(pattern => pattern.test(filename))
);
// At least 80% of files should match chapter patterns
const matchRatio = matchingFiles.length / filenames.length;
return matchRatio >= 0.8;
} }
/** /**
@@ -240,6 +243,8 @@ export async function analyzeChapterFiles(
filePaths: string[], filePaths: string[],
logger?: JobLogger logger?: JobLogger
): Promise<ChapterFile[]> { ): Promise<ChapterFile[]> {
await logger?.info(`Analyzing ${filePaths.length} chapter files...`);
// Probe all files in parallel // Probe all files in parallel
const probePromises = filePaths.map(async (filePath) => { const probePromises = filePaths.map(async (filePath) => {
const probe = await probeAudioFile(filePath); const probe = await probeAudioFile(filePath);
@@ -256,6 +261,11 @@ export async function analyzeChapterFiles(
const files = await Promise.all(probePromises); const files = await Promise.all(probePromises);
// Log sample filenames for debugging
const sampleCount = Math.min(3, files.length);
const sampleFilenames = files.slice(0, sampleCount).map(f => f.filename);
await logger?.info(`Sample filenames: ${sampleFilenames.join(', ')}${files.length > sampleCount ? ', ...' : ''}`);
// Create filename-based order (natural sort) // Create filename-based order (natural sort)
const filenameOrder = [...files].sort((a, b) => const filenameOrder = [...files].sort((a, b) =>
naturalSortCompare(a.filename, b.filename) naturalSortCompare(a.filename, b.filename)
@@ -266,9 +276,15 @@ export async function analyzeChapterFiles(
let useMetadataOrder = false; let useMetadataOrder = false;
let metadataOrder: ChapterFile[] = []; let metadataOrder: ChapterFile[] = [];
await logger?.info(`Metadata analysis: ${files.filter(f => f.trackNumber).length}/${files.length} files have track numbers`);
if (hasAllTrackNumbers) { if (hasAllTrackNumbers) {
metadataOrder = [...files].sort((a, b) => (a.trackNumber || 0) - (b.trackNumber || 0)); metadataOrder = [...files].sort((a, b) => (a.trackNumber || 0) - (b.trackNumber || 0));
// Log track number range
const trackNumbers = metadataOrder.map(f => f.trackNumber);
await logger?.info(`Track numbers: ${trackNumbers.slice(0, 3).join(', ')}${trackNumbers.length > 3 ? ` ... ${trackNumbers[trackNumbers.length - 1]}` : ''}`);
// Check if track numbers are sequential // Check if track numbers are sequential
const isSequential = metadataOrder.every((f, i) => { const isSequential = metadataOrder.every((f, i) => {
const expectedTrack = i + 1; const expectedTrack = i + 1;
@@ -280,26 +296,34 @@ export async function analyzeChapterFiles(
const ordersMatch = filenameOrder.every((f, i) => f.path === metadataOrder[i].path); const ordersMatch = filenameOrder.every((f, i) => f.path === metadataOrder[i].path);
if (ordersMatch) { if (ordersMatch) {
await logger?.info('Chapter ordering: filename and metadata orders match - high confidence'); await logger?.info('Chapter ordering: Filename and metadata orders match - high confidence');
} else { } else {
await logger?.warn('Chapter ordering: filename order differs from metadata - using metadata order (more reliable)'); await logger?.info('Chapter ordering: Filename differs from metadata - using metadata order (more reliable)');
useMetadataOrder = true; useMetadataOrder = true;
} }
} else { } else {
await logger?.warn('Chapter ordering: metadata track numbers not sequential - using filename order'); await logger?.warn('Chapter ordering: Track numbers not sequential (gaps or duplicates) - using filename order');
} }
} else { } else {
await logger?.info('Chapter ordering: incomplete metadata track numbers - using filename order'); const missingCount = files.filter(f => !f.trackNumber).length;
await logger?.info(`Chapter ordering: ${missingCount} file(s) missing track numbers - using filename order`);
} }
// Use the determined order // Use the determined order
const orderedFiles = useMetadataOrder ? metadataOrder : filenameOrder; const orderedFiles = useMetadataOrder ? metadataOrder : filenameOrder;
// Log ordering decision summary
await logger?.info(`Using ${useMetadataOrder ? 'metadata' : 'filename'}-based ordering for ${orderedFiles.length} chapters`);
// Compute chapter titles // Compute chapter titles
for (let i = 0; i < orderedFiles.length; i++) { for (let i = 0; i < orderedFiles.length; i++) {
orderedFiles[i].chapterTitle = getChapterTitle(orderedFiles[i], i); orderedFiles[i].chapterTitle = getChapterTitle(orderedFiles[i], i);
} }
// Log sample chapter titles
const sampleTitles = orderedFiles.slice(0, 3).map((f, i) => `Ch${i + 1}: "${f.chapterTitle}"`);
await logger?.info(`Sample chapter titles: ${sampleTitles.join(', ')}${orderedFiles.length > 3 ? ', ...' : ''}`);
return orderedFiles; return orderedFiles;
} }
@@ -337,26 +361,133 @@ function generateChapterMetadata(chapters: ChapterFile[]): string {
/** /**
* Determine optimal bitrate for MP3 conversion * Determine optimal bitrate for MP3 conversion
* Uses source bitrate if < 128kbps, otherwise 128k * Uses the average bitrate across all source files to preserve quality
*/ */
function determineOutputBitrate(chapters: ChapterFile[]): string { function determineOutputBitrate(chapters: ChapterFile[]): string {
// Find minimum bitrate across all files // Get all bitrates
const bitrates = chapters const bitrates = chapters
.filter(c => c.bitrate !== undefined) .filter(c => c.bitrate !== undefined)
.map(c => c.bitrate as number); .map(c => c.bitrate as number);
if (bitrates.length === 0) { if (bitrates.length === 0) {
// No bitrate info available, use reasonable default
return '128k'; return '128k';
} }
const minBitrate = Math.min(...bitrates); // Calculate average bitrate
const avgBitrate = Math.round(bitrates.reduce((sum, br) => sum + br, 0) / bitrates.length);
// Use source bitrate if lower than 128k, otherwise cap at 128k // Cap at reasonable maximum (320k for MP3, which is max for most sources)
if (minBitrate < 128) { const cappedBitrate = Math.min(avgBitrate, 320);
return `${minBitrate}k`;
// Floor at reasonable minimum (64k for audiobooks)
const finalBitrate = Math.max(cappedBitrate, 64);
return `${finalBitrate}k`;
} }
return '128k'; /**
* Check if libfdk_aac encoder is available (higher quality than native AAC)
*/
async function checkLibFdkAac(): Promise<boolean> {
try {
const { stdout } = await execPromise('ffmpeg -encoders 2>&1', { timeout: 5000 });
return stdout.includes('libfdk_aac');
} catch {
// ffmpeg not available or error checking - assume not available
return false;
}
}
/**
* Execute FFmpeg command with real-time progress logging
*/
async function executeFFmpegWithProgress(
command: string,
timeout: number,
expectedDuration: number, // milliseconds
logger?: JobLogger
): Promise<void> {
return new Promise((resolve, reject) => {
// Parse the command to extract args (remove 'ffmpeg' and handle quotes)
const args = command
.replace(/^ffmpeg\s+/, '')
.match(/(?:[^\s"]+|"[^"]*")+/g)
?.map(arg => arg.replace(/^"|"$/g, '')) || [];
const ffmpeg = spawn('ffmpeg', args);
let stderrBuffer = '';
let lastProgressLog = Date.now();
let lastProgressPercent = 0;
// Set timeout
const timeoutHandle = setTimeout(() => {
ffmpeg.kill();
reject(new Error(`FFmpeg timeout after ${Math.ceil(timeout / 60000)} minutes`));
}, timeout);
// Capture stderr for progress and errors
ffmpeg.stderr.on('data', (data) => {
const output = data.toString();
stderrBuffer += output;
// Parse FFmpeg progress output
// Format: frame=... fps=... q=... size=... time=HH:MM:SS.MS bitrate=... speed=...
const timeMatch = output.match(/time=(\d{2}):(\d{2}):(\d{2})\.(\d{2})/);
if (timeMatch) {
const hours = parseInt(timeMatch[1]);
const minutes = parseInt(timeMatch[2]);
const seconds = parseInt(timeMatch[3]);
const currentTimeMs = (hours * 3600 + minutes * 60 + seconds) * 1000;
const progressPercent = Math.min(100, Math.round((currentTimeMs / expectedDuration) * 100));
// Log progress every 10% or every 5 minutes (whichever comes first)
const timeSinceLastLog = Date.now() - lastProgressLog;
const percentChange = progressPercent - lastProgressPercent;
if (percentChange >= 10 || timeSinceLastLog >= 5 * 60 * 1000) {
// Also parse speed if available
const speedMatch = output.match(/speed=\s*([\d.]+)x/);
const speed = speedMatch ? parseFloat(speedMatch[1]) : null;
const speedInfo = speed ? ` (${speed.toFixed(1)}x realtime)` : '';
logger?.info(`Encoding progress: ${progressPercent}%${speedInfo} - ${formatDuration(currentTimeMs)} / ${formatDuration(expectedDuration)}`).catch(() => {});
lastProgressLog = Date.now();
lastProgressPercent = progressPercent;
}
}
});
ffmpeg.on('close', (code) => {
clearTimeout(timeoutHandle);
if (code === 0) {
// Check stderr for errors even if exit code is 0
if (stderrBuffer.includes('Error') || stderrBuffer.includes('Invalid')) {
logger?.warn(`FFmpeg completed but reported issues: ${stderrBuffer.substring(stderrBuffer.lastIndexOf('Error'), stderrBuffer.lastIndexOf('Error') + 200)}`).catch(() => {});
}
resolve();
} else {
// Extract meaningful error from stderr
const errorLines = stderrBuffer.split('\n').filter(line =>
line.includes('Error') || line.includes('Invalid') || line.includes('failed')
);
const errorMsg = errorLines.length > 0
? errorLines.slice(-3).join('; ')
: `FFmpeg exited with code ${code}`;
reject(new Error(errorMsg));
}
});
ffmpeg.on('error', (error) => {
clearTimeout(timeoutHandle);
reject(error);
});
});
} }
/** /**
@@ -368,6 +499,7 @@ export async function mergeChapters(
logger?: JobLogger logger?: JobLogger
): Promise<MergeResult> { ): Promise<MergeResult> {
if (chapters.length === 0) { if (chapters.length === 0) {
await logger?.error('Chapter merge failed: No chapters provided');
return { success: false, error: 'No chapters to merge' }; return { success: false, error: 'No chapters to merge' };
} }
@@ -376,6 +508,34 @@ export async function mergeChapters(
const metadataFile = path.join(tempDir, `chapters_${Date.now()}.txt`); const metadataFile = path.join(tempDir, `chapters_${Date.now()}.txt`);
try { try {
await logger?.info(`Starting chapter merge: "${options.title}" by ${options.author}`);
await logger?.info(`Output: ${path.basename(options.outputPath)}`);
// Calculate total duration and estimated size
const totalDuration = chapters.reduce((sum, c) => sum + c.duration, 0);
const estimatedSize = await estimateOutputSize(chapters.map(c => c.path));
await logger?.info(`Total duration: ${formatDuration(totalDuration)}, Estimated size: ${Math.round(estimatedSize / 1024 / 1024)}MB`);
// Validate all source files are readable and not corrupt
await logger?.info('Validating source files...');
for (let i = 0; i < chapters.length; i++) {
const chapter = chapters[i];
try {
await fs.access(chapter.path, fs.constants.R_OK);
// Quick probe to verify file is valid (use cached data if available)
// This catches obviously corrupt source files before we try to merge
const stats = await fs.stat(chapter.path);
if (stats.size === 0) {
throw new Error(`File ${i + 1}/${chapters.length} (${path.basename(chapter.path)}) is empty (0 bytes)`);
}
} catch (error) {
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
throw new Error(`Source file validation failed at file ${i + 1}/${chapters.length} (${path.basename(chapter.path)}): ${errorMsg}`);
}
}
await logger?.info(`✓ All ${chapters.length} source files validated`);
// Ensure temp directory exists // Ensure temp directory exists
await fs.mkdir(tempDir, { recursive: true }); await fs.mkdir(tempDir, { recursive: true });
@@ -384,10 +544,12 @@ export async function mergeChapters(
.map(c => `file '${c.path.replace(/'/g, "'\\''")}'`) .map(c => `file '${c.path.replace(/'/g, "'\\''")}'`)
.join('\n'); .join('\n');
await fs.writeFile(concatFile, concatContent); await fs.writeFile(concatFile, concatContent);
await logger?.info(`Created concat list with ${chapters.length} files`);
// Create chapter metadata file // Create chapter metadata file
const chapterMetadata = generateChapterMetadata(chapters); const chapterMetadata = generateChapterMetadata(chapters);
await fs.writeFile(metadataFile, chapterMetadata); await fs.writeFile(metadataFile, chapterMetadata);
await logger?.info(`Generated chapter metadata with ${chapters.length} chapter markers`);
// Determine if we need to re-encode (MP3 input requires conversion to AAC) // Determine if we need to re-encode (MP3 input requires conversion to AAC)
const inputFormat = path.extname(chapters[0].path).toLowerCase(); const inputFormat = path.extname(chapters[0].path).toLowerCase();
@@ -402,19 +564,38 @@ export async function mergeChapters(
'-i', `"${concatFile}"`, '-i', `"${concatFile}"`,
'-i', `"${metadataFile}"`, '-i', `"${metadataFile}"`,
'-map_metadata', '1', '-map_metadata', '1',
'-map', '0:a', // Explicit audio stream mapping
]; ];
if (needsReencode) { if (needsReencode) {
// MP3 -> M4B requires re-encoding to AAC // MP3 -> M4B requires re-encoding to AAC
const bitrate = determineOutputBitrate(chapters); const bitrate = determineOutputBitrate(chapters);
args.push('-codec:a', 'aac', '-b:a', bitrate);
await logger?.info(`Re-encoding MP3 to AAC at ${bitrate}`); // Check for libfdk_aac (higher quality) or fall back to native aac
const hasFdkAac = await checkLibFdkAac();
if (hasFdkAac) {
args.push('-c:a', 'libfdk_aac');
args.push('-vbr', '4'); // VBR mode 4 (~128-160kbps, high quality)
await logger?.info(`Merge strategy: Re-encoding MP3 → AAC/M4B using libfdk_aac (high quality VBR, target ~${bitrate})`);
} else {
args.push('-c:a', 'aac');
args.push('-b:a', bitrate);
args.push('-profile:a', 'aac_low'); // AAC-LC profile for maximum compatibility
await logger?.info(`Merge strategy: Re-encoding MP3 → AAC/M4B using native AAC at ${bitrate}`);
}
} else { } else {
// M4A/M4B -> M4B can use codec copy (fast, lossless) // M4A/M4B -> M4B can use codec copy (fast, lossless)
args.push('-codec', 'copy'); args.push('-c', 'copy');
await logger?.info('Using codec copy (no re-encoding)'); await logger?.info(`Merge strategy: Codec copy (lossless, fast - no re-encoding needed for ${inputFormat} input)`);
} }
// Add critical flags for reliability and performance
args.push('-movflags', '+faststart'); // CRITICAL: Move moov atom to beginning (fixes slow playback)
args.push('-fflags', '+genpts'); // Regenerate presentation timestamps (fixes timing issues)
args.push('-avoid_negative_ts', 'make_zero'); // Handle negative timestamps
args.push('-max_muxing_queue_size', '9999'); // Prevent buffer overflow on long files
// Add book metadata // Add book metadata
const escapeMetadata = (val: string): string => const escapeMetadata = (val: string): string =>
val.replace(/"/g, '\\"').replace(/'/g, "\\'"); val.replace(/"/g, '\\"').replace(/'/g, "\\'");
@@ -435,6 +616,7 @@ export async function mergeChapters(
if (options.asin) { if (options.asin) {
// Custom iTunes tag for ASIN // Custom iTunes tag for ASIN
args.push('-metadata', `----:com.apple.iTunes:ASIN="${escapeMetadata(options.asin)}"`); args.push('-metadata', `----:com.apple.iTunes:ASIN="${escapeMetadata(options.asin)}"`);
await logger?.info(`Embedding ASIN: ${options.asin}`);
} }
// Output format // Output format
@@ -443,15 +625,36 @@ export async function mergeChapters(
const command = args.join(' '); const command = args.join(' ');
// Calculate timeout: base 5 minutes + 30 seconds per chapter // Calculate timeout based on operation type and total duration
const timeout = (5 * 60 * 1000) + (chapters.length * 30 * 1000); const totalDurationMinutes = totalDuration / 1000 / 60;
await logger?.info(`Merging ${chapters.length} chapters...`); const timeout = needsReencode
? Math.max(
90 * 60 * 1000, // Minimum 90 minutes for re-encoding
Math.round((totalDurationMinutes / 5) * 60 * 1000) + (60 * 60 * 1000) // duration/5 (worst case 5x realtime) + 60min safety margin
)
: (5 * 60 * 1000) + (chapters.length * 30 * 1000); // Codec copy: 5min + 30s per chapter
const timeoutMinutes = Math.ceil(timeout / 60000);
await logger?.info(`Executing FFmpeg merge (timeout: ${timeoutMinutes} minutes)...`);
if (needsReencode && totalDurationMinutes > 60) {
const estimatedMinEncoding = Math.round(totalDurationMinutes / 10); // Best case: 10x realtime
const estimatedMaxEncoding = Math.round(totalDurationMinutes / 5); // Worst case: 5x realtime
await logger?.info(`This is a long audiobook (${Math.round(totalDurationMinutes / 60)}h). Encoding may take ${estimatedMinEncoding}-${estimatedMaxEncoding} minutes depending on CPU speed.`);
}
// Log command for debugging (truncate if too long)
const commandPreview = command.length > 500 ? command.substring(0, 500) + '...' : command;
await logger?.info(`FFmpeg command: ${commandPreview}`);
// Execute FFmpeg with progress logging
try { try {
await execPromise(command, { timeout }); await executeFFmpegWithProgress(command, timeout, totalDuration, logger);
} catch (error) { } catch (error) {
const errorMsg = error instanceof Error ? error.message : 'Unknown error'; const errorMsg = error instanceof Error ? error.message : 'Unknown error';
await logger?.error(`FFmpeg merge failed: ${errorMsg}`);
throw new Error(`FFmpeg merge failed: ${errorMsg}`); throw new Error(`FFmpeg merge failed: ${errorMsg}`);
} }
@@ -459,13 +662,35 @@ export async function mergeChapters(
try { try {
await fs.access(options.outputPath); await fs.access(options.outputPath);
} catch { } catch {
await logger?.error('Merge failed: Output file not created');
throw new Error('Merged file not created'); throw new Error('Merged file not created');
} }
// Calculate total duration // Validate merged file
const totalDuration = chapters.reduce((sum, c) => sum + c.duration, 0); const validation = await validateMergedFile(options.outputPath, totalDuration, logger);
await logger?.info(`Merge complete: ${chapters.length} chapters, ${formatDuration(totalDuration)}`); if (!validation.valid) {
await logger?.error(`Output validation failed: ${validation.error}`);
// Delete corrupt file
try {
await fs.unlink(options.outputPath);
await logger?.info('Deleted corrupt output file');
} catch {
// Ignore cleanup errors
}
throw new Error(`Merge validation failed: ${validation.error}`);
}
// Get actual output file size
const stats = await fs.stat(options.outputPath);
const actualSizeMB = Math.round(stats.size / 1024 / 1024);
await logger?.info(`✓ Chapter merge successful!`);
await logger?.info(` - Chapters: ${chapters.length}`);
await logger?.info(` - Duration: ${formatDuration(validation.actualDuration || totalDuration)}`);
await logger?.info(` - Size: ${actualSizeMB}MB`);
await logger?.info(` - Format: M4B with embedded chapter markers`);
await logger?.info(` - Validation: Passed (duration accurate, file playable)`);
return { return {
success: true, success: true,
@@ -475,22 +700,104 @@ export async function mergeChapters(
}; };
} catch (error) { } catch (error) {
const errorMsg = error instanceof Error ? error.message : 'Unknown error'; const errorMsg = error instanceof Error ? error.message : 'Unknown error';
await logger?.error(`Chapter merge failed: ${errorMsg}`);
return { success: false, error: errorMsg }; return { success: false, error: errorMsg };
} finally { } finally {
// Clean up temp files // Clean up temp files
try { try {
await fs.unlink(concatFile); await fs.unlink(concatFile);
await logger?.info('Cleaned up temporary concat file');
} catch { } catch {
// Ignore cleanup errors // Ignore cleanup errors
} }
try { try {
await fs.unlink(metadataFile); await fs.unlink(metadataFile);
await logger?.info('Cleaned up temporary metadata file');
} catch { } catch {
// Ignore cleanup errors // Ignore cleanup errors
} }
} }
} }
/**
* Validate merged M4B file
* Checks duration accuracy and playability to catch corruption
*/
async function validateMergedFile(
outputPath: string,
expectedDuration: number, // milliseconds
logger?: JobLogger
): Promise<{ valid: boolean; error?: string; actualDuration?: number }> {
try {
await logger?.info('Validating merged file...');
// 1. Probe output file to get actual duration
const probe = await probeAudioFile(outputPath);
const actualDuration = probe.duration;
await logger?.info(`Duration check: expected ${formatDuration(expectedDuration)}, got ${formatDuration(actualDuration)}`);
// 2. Check duration match (within 2% tolerance for encoding variations)
const durationDiff = Math.abs(actualDuration - expectedDuration);
const tolerance = expectedDuration * 0.02; // 2% tolerance
if (durationDiff > tolerance) {
const percentDiff = ((durationDiff / expectedDuration) * 100).toFixed(1);
return {
valid: false,
error: `Duration mismatch (${percentDiff}% off): expected ${formatDuration(expectedDuration)}, got ${formatDuration(actualDuration)}. File may be truncated or corrupted.`,
actualDuration
};
}
// 3. Fast decode test - verify beginning and end of file are playable
// This catches truncation/corruption without decoding entire file
await logger?.info('Testing file integrity (first and last 10 seconds)...');
try {
// Test first 10 seconds
const firstDecodeCommand = `ffmpeg -v error -i "${outputPath}" -t 10 -f null -`;
await execPromise(firstDecodeCommand, { timeout: 30000 }); // 30 sec timeout
// Test last 10 seconds (seeks to 10 seconds before end)
const lastDecodeCommand = `ffmpeg -v error -sseof -10 -i "${outputPath}" -f null -`;
await execPromise(lastDecodeCommand, { timeout: 30000 }); // 30 sec timeout
await logger?.info('✓ File integrity test passed (beginning and end playable)');
} catch (decodeError) {
const errorMsg = decodeError instanceof Error ? decodeError.message : 'Unknown error';
return {
valid: false,
error: `File integrity test failed: ${errorMsg}. File may be corrupted or truncated.`,
actualDuration
};
}
// 4. File size sanity check
const stats = await fs.stat(outputPath);
const sizeMB = stats.size / 1024 / 1024;
const durationMinutes = expectedDuration / 1000 / 60;
const expectedMinSize = durationMinutes * 0.5; // ~0.5MB per minute minimum for compressed audio
if (sizeMB < expectedMinSize) {
return {
valid: false,
error: `File size too small (${Math.round(sizeMB)}MB) for ${formatDuration(expectedDuration)} duration. Expected at least ${Math.round(expectedMinSize)}MB. File may be truncated.`,
actualDuration
};
}
await logger?.info(`✓ Validation passed: duration ${formatDuration(actualDuration)}, size ${Math.round(sizeMB)}MB`);
return { valid: true, actualDuration };
} catch (error) {
return {
valid: false,
error: `Validation error: ${error instanceof Error ? error.message : 'Unknown error'}`
};
}
}
/** /**
* Format duration in milliseconds to human readable string * Format duration in milliseconds to human readable string
*/ */
+28 -10
View File
@@ -96,6 +96,8 @@ export class FileOrganizer {
// Check for chapter merging if multiple files // Check for chapter merging if multiple files
if (audioFiles.length > 1) { if (audioFiles.length > 1) {
await logger?.info(`Multiple audio files detected (${audioFiles.length} files) - checking chapter merge settings...`);
try { try {
const chapterMergingConfig = await prisma.configuration.findUnique({ const chapterMergingConfig = await prisma.configuration.findUnique({
where: { key: 'chapter_merging_enabled' }, where: { key: 'chapter_merging_enabled' },
@@ -103,27 +105,38 @@ export class FileOrganizer {
const chapterMergingEnabled = chapterMergingConfig?.value === 'true'; const chapterMergingEnabled = chapterMergingConfig?.value === 'true';
if (chapterMergingEnabled) { if (!chapterMergingEnabled) {
await logger?.info(`Chapter merging disabled in settings - organizing ${audioFiles.length} files individually`);
} else {
await logger?.info(`Chapter merging enabled - analyzing files...`);
// Build full paths to source files // Build full paths to source files
const sourceFilePaths = audioFiles.map((audioFile) => const sourceFilePaths = audioFiles.map((audioFile) =>
isFile ? downloadPath : path.join(downloadPath, audioFile) isFile ? downloadPath : path.join(downloadPath, audioFile)
); );
const isChapterDownload = await detectChapterFiles(sourceFilePaths); const isChapterDownload = await detectChapterFiles(sourceFilePaths, logger ?? undefined);
if (isChapterDownload) { if (isChapterDownload) {
await logger?.info(`Detected ${audioFiles.length} chapter files, attempting merge...`);
// Check disk space // Check disk space
const estimatedSize = await estimateOutputSize(sourceFilePaths); const estimatedSize = await estimateOutputSize(sourceFilePaths);
const availableSpace = await checkDiskSpace(this.tempDir); const availableSpace = await checkDiskSpace(this.tempDir);
if (availableSpace !== null && availableSpace < estimatedSize) { if (availableSpace !== null && availableSpace < estimatedSize) {
await logger?.warn(`Insufficient disk space for merge (need ${Math.round(estimatedSize / 1024 / 1024)}MB, have ${Math.round(availableSpace / 1024 / 1024)}MB). Skipping merge.`); await logger?.warn(`Insufficient disk space for merge (need ${Math.round(estimatedSize / 1024 / 1024)}MB, have ${Math.round(availableSpace / 1024 / 1024)}MB). Organizing files individually.`);
} else { } else {
// Log disk space check passed
if (availableSpace !== null) {
await logger?.info(`Disk space check passed: ${Math.round(availableSpace / 1024 / 1024)}MB available, ${Math.round(estimatedSize / 1024 / 1024)}MB needed`);
}
// Analyze and order chapter files // Analyze and order chapter files
const chapters = await analyzeChapterFiles(sourceFilePaths, logger ?? undefined); const chapters = await analyzeChapterFiles(sourceFilePaths, logger ?? undefined);
// Validate that we have valid ordering
if (chapters.length === 0) {
await logger?.warn(`Chapter analysis failed: No valid chapters found. Organizing files individually.`);
} else {
// Create output path in temp directory // Create output path in temp directory
const outputFilename = `${this.sanitizePath(audiobook.title)}.m4b`; const outputFilename = `${this.sanitizePath(audiobook.title)}.m4b`;
const outputPath = path.join(this.tempDir, outputFilename); const outputPath = path.join(this.tempDir, outputFilename);
@@ -143,10 +156,6 @@ export class FileOrganizer {
); );
if (mergeResult.success && mergeResult.outputPath) { if (mergeResult.success && mergeResult.outputPath) {
await logger?.info(
`Merge successful: ${mergeResult.chapterCount} chapters, ${formatDuration(mergeResult.totalDuration || 0)}`
);
// Replace audioFiles array with single merged file // Replace audioFiles array with single merged file
audioFiles.length = 0; audioFiles.length = 0;
audioFiles.push(mergeResult.outputPath); audioFiles.push(mergeResult.outputPath);
@@ -154,21 +163,30 @@ export class FileOrganizer {
// Mark for cleanup after copy // Mark for cleanup after copy
tempMergedFile = mergeResult.outputPath; tempMergedFile = mergeResult.outputPath;
await logger?.info(`Chapter merge complete - organizing single M4B file`);
// Update isFile flag since we now have a single file path // Update isFile flag since we now have a single file path
// (not in the download directory structure) // (not in the download directory structure)
} else { } else {
await logger?.warn(`Chapter merge failed: ${mergeResult.error}. Falling back to individual files.`); await logger?.warn(`Chapter merge failed: ${mergeResult.error}. Organizing ${audioFiles.length} files individually.`);
result.errors.push(`Chapter merge failed: ${mergeResult.error}`); result.errors.push(`Chapter merge failed: ${mergeResult.error}`);
// Continue with original audioFiles array // Continue with original audioFiles array
} }
} }
} }
} else {
// detectChapterFiles already logged the reason for skipping
await logger?.info(`Organizing ${audioFiles.length} files individually`);
}
} }
} catch (error) { } catch (error) {
await logger?.error(`Chapter merging error: ${error instanceof Error ? error.message : 'Unknown error'}`); await logger?.error(`Chapter merging error: ${error instanceof Error ? error.message : 'Unknown error'}`);
result.errors.push(`Chapter merging error: ${error instanceof Error ? error.message : 'Unknown error'}`); result.errors.push(`Chapter merging error: ${error instanceof Error ? error.message : 'Unknown error'}`);
await logger?.warn(`Falling back to organizing ${audioFiles.length} files individually`);
// Continue with original audioFiles array // Continue with original audioFiles array
} }
} else {
await logger?.info(`Single audio file detected - no chapter merging needed`);
} }
// Tag metadata BEFORE moving files (prevents Plex race condition) // Tag metadata BEFORE moving files (prevents Plex race condition)