/** * Component: Admin Paths Settings API * Documentation: documentation/settings-pages.md */ import { NextRequest, NextResponse } from 'next/server'; import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth'; import { prisma } from '@/lib/db'; import { getConfigService } from '@/lib/services/config.service'; import { RMABLogger } from '@/lib/utils/logger'; const logger = RMABLogger.create('API.Admin.Settings.Paths'); export async function PUT(request: NextRequest) { return requireAuth(request, async (req: AuthenticatedRequest) => { return requireAdmin(req, async () => { try { const { downloadDir, mediaDir, audiobookPathTemplate, ebookPathTemplate, metadataTaggingEnabled, chapterMergingEnabled, plexFormatCoercionEnabled, fileRenameEnabled, fileRenameTemplate, fileChmod, dirChmod } = await request.json(); if (!downloadDir || !mediaDir) { return NextResponse.json( { error: 'Download directory and media directory are required' }, { status: 400 } ); } // Validate paths are not the same if (downloadDir === mediaDir) { return NextResponse.json( { error: 'Download and media directories must be different' }, { status: 400 } ); } // Validate octal permission strings (3-4 digits, each 0-7) const octalRegex = /^[0-7]{3,4}$/; if (fileChmod !== undefined && !octalRegex.test(fileChmod)) { return NextResponse.json( { error: 'File permissions must be 3-4 octal digits (0-7), e.g. 664' }, { status: 400 } ); } if (dirChmod !== undefined && !octalRegex.test(dirChmod)) { return NextResponse.json( { error: 'Directory permissions must be 3-4 octal digits (0-7), e.g. 775' }, { status: 400 } ); } // Update configuration await prisma.configuration.upsert({ where: { key: 'download_dir' }, update: { value: downloadDir }, create: { key: 'download_dir', value: downloadDir }, }); await prisma.configuration.upsert({ where: { key: 'media_dir' }, update: { value: mediaDir }, create: { key: 'media_dir', value: mediaDir }, }); // Update audiobook path template if (audiobookPathTemplate !== undefined) { await prisma.configuration.upsert({ where: { key: 'audiobook_path_template' }, update: { value: audiobookPathTemplate }, create: { key: 'audiobook_path_template', value: audiobookPathTemplate, category: 'automation', description: 'Template for organizing audiobook files in media directory', }, }); } // Update ebook path template if (ebookPathTemplate !== undefined) { await prisma.configuration.upsert({ where: { key: 'ebook_path_template' }, update: { value: ebookPathTemplate }, create: { key: 'ebook_path_template', value: ebookPathTemplate, category: 'automation', description: 'Template for organizing ebook files in media directory', }, }); } // Update metadata tagging setting await prisma.configuration.upsert({ where: { key: 'metadata_tagging_enabled' }, update: { value: String(metadataTaggingEnabled ?? true) }, create: { key: 'metadata_tagging_enabled', value: String(metadataTaggingEnabled ?? true), category: 'automation', description: 'Automatically tag audio files with correct metadata during file organization', }, }); // Update chapter merging setting await prisma.configuration.upsert({ where: { key: 'chapter_merging_enabled' }, update: { value: String(chapterMergingEnabled ?? false) }, create: { key: 'chapter_merging_enabled', value: String(chapterMergingEnabled ?? false), category: 'automation', description: 'Automatically merge multi-file chapter downloads into single M4B with chapter markers', }, }); // Update Plex format coercion setting (issue #166: silently rename .mp4/.m4a to .m4b // post-organize so Plex's audiobook library recognizes them without transcoding) await prisma.configuration.upsert({ where: { key: 'plex_format_coercion_enabled' }, update: { value: String(plexFormatCoercionEnabled ?? true) }, create: { key: 'plex_format_coercion_enabled', value: String(plexFormatCoercionEnabled ?? true), category: 'automation', description: 'Rename audio files to Plex-compatible extensions (.mp4/.m4a -> .m4b) after organization to avoid silent library-scan failures', }, }); // Update file rename setting await prisma.configuration.upsert({ where: { key: 'file_rename_enabled' }, update: { value: String(fileRenameEnabled ?? false) }, create: { key: 'file_rename_enabled', value: String(fileRenameEnabled ?? false), category: 'automation', description: 'Rename audio and ebook files using a custom naming template during organization', }, }); // Update file rename template if (fileRenameTemplate !== undefined) { await prisma.configuration.upsert({ where: { key: 'file_rename_template' }, update: { value: fileRenameTemplate }, create: { key: 'file_rename_template', value: fileRenameTemplate, category: 'automation', description: 'Template for renaming audio and ebook files during organization', }, }); } // Update file permissions (octal chmod) if (fileChmod !== undefined) { await prisma.configuration.upsert({ where: { key: 'file_chmod' }, update: { value: fileChmod }, create: { key: 'file_chmod', value: fileChmod, category: 'automation', description: 'Octal permissions applied to organized files', }, }); } // Update directory permissions (octal chmod) if (dirChmod !== undefined) { await prisma.configuration.upsert({ where: { key: 'dir_chmod' }, update: { value: dirChmod }, create: { key: 'dir_chmod', value: dirChmod, category: 'automation', description: 'Octal permissions applied to created directories', }, }); } logger.info('Paths settings updated'); // Clear config cache for all updated keys so services get fresh values const configService = getConfigService(); configService.clearCache('download_dir'); configService.clearCache('media_dir'); configService.clearCache('audiobook_path_template'); configService.clearCache('ebook_path_template'); configService.clearCache('metadata_tagging_enabled'); configService.clearCache('chapter_merging_enabled'); configService.clearCache('plex_format_coercion_enabled'); configService.clearCache('file_rename_enabled'); configService.clearCache('file_rename_template'); configService.clearCache('file_chmod'); configService.clearCache('dir_chmod'); // Invalidate all download client singletons to force reload of download_dir const { invalidateDownloadClientManager } = await import('@/lib/services/download-client-manager.service'); invalidateDownloadClientManager(); const { invalidateQBittorrentService } = await import('@/lib/integrations/qbittorrent.service'); invalidateQBittorrentService(); const { invalidateSABnzbdService } = await import('@/lib/integrations/sabnzbd.service'); invalidateSABnzbdService(); const { invalidateNZBGetService } = await import('@/lib/integrations/nzbget.service'); invalidateNZBGetService(); const { invalidateTransmissionService } = await import('@/lib/integrations/transmission.service'); invalidateTransmissionService(); return NextResponse.json({ success: true, message: 'Paths settings updated successfully', }); } catch (error) { logger.error('Failed to update paths settings', { error: error instanceof Error ? error.message : String(error) }); return NextResponse.json( { success: false, error: error instanceof Error ? error.message : 'Failed to update settings', }, { status: 500 } ); } }); }); }