From 03f82d4841c6d9fef8844357a8c41826edde86bc Mon Sep 17 00:00:00 2001 From: kikootwo Date: Wed, 25 Feb 2026 09:47:57 -0500 Subject: [PATCH] File rename templates & admin torrent approval Add support for admin-driven interactive torrent selection and a file rename/template feature. Integrates an InteractiveTorrentSearchModal into the pending-approval admin UI, adds an admin approve flow that accepts an admin-selected torrent, and surfaces user/admin-selected torrent details in the UI. Introduces fileRenameEnabled and fileRenameTemplate settings (API + UI), persists them to configuration, and clears related caches. Pass renameConfig through the organize/organizeEbook flows and implement renaming in the FileOrganizer (single/multi-file handling). Enhance path-template utilities with conditional block resolution, filename-template validation, mock filename previews, and a buildRenamedFilename helper. Update tests to cover conditional templates and filename preview behavior. --- src/app/admin/page.tsx | 227 +++++++---- src/app/admin/settings/lib/types.ts | 2 + .../admin/settings/tabs/PathsTab/PathsTab.tsx | 135 ++++++- .../api/admin/requests/[id]/approve/route.ts | 25 +- src/app/api/admin/settings/paths/route.ts | 30 +- src/app/api/admin/settings/route.ts | 2 + .../requests/[id]/interactive-search/route.ts | 4 +- .../InteractiveTorrentSearchModal.tsx | 23 +- .../processors/organize-files.processor.ts | 32 +- src/lib/utils/file-organizer.ts | 64 +++- src/lib/utils/path-template.util.ts | 305 ++++++++++++++- tests/api/setup-tests.routes.test.ts | 2 +- tests/lib/utils/path-template.util.test.ts | 352 +++++++++++++++++- 13 files changed, 1095 insertions(+), 108 deletions(-) diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 9fcd9b7..a154309 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -13,17 +13,34 @@ import { ActiveDownloadsTable } from './components/ActiveDownloadsTable'; import { RecentRequestsTable } from './components/RecentRequestsTable'; import { ToastProvider, useToast } from '@/components/ui/Toast'; import { ReportedIssuesSection } from './components/ReportedIssuesSection'; +import { InteractiveTorrentSearchModal } from '@/components/requests/InteractiveTorrentSearchModal'; +import { TorrentResult } from '@/lib/utils/ranking-algorithm'; import { formatDistanceToNow } from 'date-fns'; import { useState } from 'react'; +interface SelectedTorrentData { + title?: string; + indexer?: string; + size?: number; + format?: string; + ebookFormat?: string; + seeders?: number; + infoUrl?: string; + source?: string; + protocol?: string; + score?: number; +} + interface PendingApprovalRequest { id: string; createdAt: string; type: 'audiobook' | 'ebook'; + selectedTorrent: SelectedTorrentData | null; audiobook: { title: string; author: string; coverArtUrl: string | null; + audibleAsin: string | null; }; user: { id: string; @@ -32,9 +49,20 @@ interface PendingApprovalRequest { }; } +function formatTorrentSize(bytes: number): string { + const gb = bytes / (1024 ** 3); + const mb = bytes / (1024 ** 2); + return gb >= 1 ? `${gb.toFixed(1)} GB` : `${mb.toFixed(0)} MB`; +} + function PendingApprovalSection({ requests }: { requests: PendingApprovalRequest[] }) { const toast = useToast(); const [loadingStates, setLoadingStates] = useState>({}); + const [searchModalRequestId, setSearchModalRequestId] = useState(null); + + const searchModalRequest = searchModalRequestId + ? requests.find((r) => r.id === searchModalRequestId) + : null; const handleApproveRequest = async (requestId: string) => { setLoadingStates((prev) => ({ ...prev, [requestId]: true })); @@ -47,7 +75,6 @@ function PendingApprovalSection({ requests }: { requests: PendingApprovalRequest toast.success('Request approved'); - // Mutate both pending requests and recent requests caches await mutate('/api/admin/requests/pending-approval'); await mutate('/api/admin/requests/recent'); await mutate('/api/admin/metrics'); @@ -72,7 +99,6 @@ function PendingApprovalSection({ requests }: { requests: PendingApprovalRequest toast.success('Request denied'); - // Mutate pending requests cache await mutate('/api/admin/requests/pending-approval'); await mutate('/api/admin/metrics'); } catch (error) { @@ -85,6 +111,26 @@ function PendingApprovalSection({ requests }: { requests: PendingApprovalRequest } }; + const handleApproveWithTorrent = async (requestId: string, torrent: TorrentResult) => { + await fetchJSON(`/api/admin/requests/${requestId}/approve`, { + method: 'POST', + body: JSON.stringify({ action: 'approve', selectedTorrent: torrent }), + }); + + toast.success('Request approved and download started'); + + await mutate('/api/admin/requests/pending-approval'); + await mutate('/api/admin/requests/recent'); + await mutate('/api/admin/metrics'); + }; + + const LoadingSpinner = () => ( + + + + + ); + return (
{/* Section Header */} @@ -116,6 +162,9 @@ function PendingApprovalSection({ requests }: { requests: PendingApprovalRequest
{requests.map((request) => { const isLoading = loadingStates[request.id] || false; + const torrent = request.selectedTorrent; + const displayFormat = torrent?.format || torrent?.ebookFormat; + const isAnnasArchive = torrent?.source === 'annas_archive'; return (
+ {/* Pre-Selected Release */} + {torrent && torrent.title && ( +
+
+ + + + + User-Selected Release + +
+ {torrent.infoUrl ? ( + + {torrent.title} + + ) : ( +

+ {torrent.title} +

+ )} +
+ {isAnnasArchive ? ( + Anna's Archive + ) : torrent.indexer ? ( + {torrent.indexer} + ) : null} + {torrent.size && torrent.size > 0 ? ( + <> + · + {formatTorrentSize(torrent.size)} + + ) : null} + {displayFormat ? ( + <> + · + + {displayFormat} + + + ) : null} + {torrent.protocol === 'usenet' ? ( + <> + · + NZB + + ) : torrent.seeders !== undefined && torrent.seeders !== null ? ( + <> + · + {torrent.seeders} seeds + + ) : null} + {torrent.score !== undefined && torrent.score !== null ? ( + <> + · + Score {Math.round(torrent.score)} + + ) : null} +
+
+ )} + {/* Action Buttons */}
+ +
+ + {/* Interactive Search Modal */} + {searchModalRequest && ( + setSearchModalRequestId(null)} + requestId={searchModalRequest.id} + audiobook={{ + title: searchModalRequest.audiobook.title, + author: searchModalRequest.audiobook.author, + }} + searchMode={searchModalRequest.type === 'ebook' ? 'ebook' : 'audiobook'} + onConfirm={async (torrent) => { + await handleApproveWithTorrent(searchModalRequest.id, torrent); + }} + onSuccess={() => { + setSearchModalRequestId(null); + }} + /> + )}
); } diff --git a/src/app/admin/settings/lib/types.ts b/src/app/admin/settings/lib/types.ts index 00f4b69..6a104cf 100644 --- a/src/app/admin/settings/lib/types.ts +++ b/src/app/admin/settings/lib/types.ts @@ -100,6 +100,8 @@ export interface PathsSettings { ebookPathTemplate?: string; metadataTaggingEnabled: boolean; chapterMergingEnabled: boolean; + fileRenameEnabled: boolean; + fileRenameTemplate?: string; } /** diff --git a/src/app/admin/settings/tabs/PathsTab/PathsTab.tsx b/src/app/admin/settings/tabs/PathsTab/PathsTab.tsx index 63e96dd..d1f3646 100644 --- a/src/app/admin/settings/tabs/PathsTab/PathsTab.tsx +++ b/src/app/admin/settings/tabs/PathsTab/PathsTab.tsx @@ -10,7 +10,7 @@ import { Button } from '@/components/ui/Button'; import { Input } from '@/components/ui/Input'; import { usePathsSettings } from './usePathsSettings'; import type { PathsSettings } from '../../lib/types'; -import { validateTemplate, generateMockPreviews } from '@/lib/utils/path-template.util'; +import { validateTemplate, generateMockPreviews, validateFilenameTemplate, generateMockFilenamePreviews } from '@/lib/utils/path-template.util'; interface PathsTabProps { paths: PathsSettings; @@ -24,6 +24,13 @@ interface TemplatePreview { previewPaths?: string[]; } +interface FilenamePreview { + isValid: boolean; + error?: string; + single?: string[]; + multi?: string[]; +} + export function PathsTab({ paths, onChange, onValidationChange }: PathsTabProps) { const { testing, testResult, updatePath, testPaths } = usePathsSettings({ paths, @@ -73,6 +80,34 @@ export function PathsTab({ paths, onChange, onValidationChange }: PathsTabProps) } }, [paths.ebookPathTemplate]); + // Live preview state for filename template + const [filenamePreview, setFilenamePreview] = useState(null); + + // Update filename live preview whenever template changes + useEffect(() => { + if (!paths.fileRenameEnabled) { + setFilenamePreview(null); + return; + } + + const template = paths.fileRenameTemplate || '{title}'; + const validation = validateFilenameTemplate(template); + + if (validation.valid) { + const previews = generateMockFilenamePreviews(template); + setFilenamePreview({ + isValid: true, + single: previews.single, + multi: previews.multi, + }); + } else { + setFilenamePreview({ + isValid: false, + error: validation.error, + }); + } + }, [paths.fileRenameTemplate, paths.fileRenameEnabled]); + const audiobookTemplate = paths.audiobookPathTemplate || '{author}/{title} {asin}'; const ebookTemplate = paths.ebookPathTemplate || '{author}/{title} {asin}'; const ebookMatchesAudiobook = ebookTemplate === audiobookTemplate; @@ -218,6 +253,83 @@ export function PathsTab({ paths, onChange, onValidationChange }: PathsTabProps) )}
+ {/* File Rename Toggle */} +
+
+ updatePath('fileRenameEnabled', e.target.checked)} + className="mt-1 h-5 w-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500" + /> +
+ +

+ Rename audio and ebook files using a custom naming template when organizing into the media + library. When multiple files exist (e.g. chapterized MP3s), an index number is appended. +

+
+
+ + {/* File Naming Template (shown when enabled) */} + {paths.fileRenameEnabled && ( +
+ + updatePath('fileRenameTemplate', e.target.value)} + placeholder="{title}" + className="font-mono" + /> +

+ Uses the same variables as the organization template. Do not include the file extension. +

+ + {/* Filename Validation Error */} + {filenamePreview && !filenamePreview.isValid && ( +
+ +
+ {filenamePreview.error || 'Invalid filename template'} +
+
+ )} + + {/* Filename Preview */} + {filenamePreview && filenamePreview.isValid && ( +
+

+ Single File +

+
+ {filenamePreview.single?.map((preview, index) => ( +
{preview}
+ ))} +
+ +

+ Multiple Files (chapterized) +

+
+ {filenamePreview.multi?.map((preview, index) => ( +
{preview}
+ ))} +
+
+ )} +
+ )} +
+ {/* Variable Reference Panel (shared for both templates) */}

@@ -255,6 +367,27 @@ export function PathsTab({ paths, onChange, onValidationChange }: PathsTabProps)

+ {/* Conditional Syntax Help */} +
+

+ Conditional Syntax +

+

+ Wrap text around a variable in {'{ }'} to + include that text only when the variable has a value. If the variable is empty, the entire block is removed. +

+
+
+ {'{Book seriesPart - }'} +
+
+ With value: Book 1 - +  •  + Without value: (removed) +
+
+
+ {/* Metadata Tagging Toggle */}
diff --git a/src/app/api/admin/requests/[id]/approve/route.ts b/src/app/api/admin/requests/[id]/approve/route.ts index 7f1d758..5686953 100644 --- a/src/app/api/admin/requests/[id]/approve/route.ts +++ b/src/app/api/admin/requests/[id]/approve/route.ts @@ -14,6 +14,7 @@ const logger = RMABLogger.create('API.Admin.Requests.Approve'); const ApprovalActionSchema = z.object({ action: z.enum(['approve', 'deny']), + selectedTorrent: z.any().optional(), }); /** @@ -37,8 +38,8 @@ export async function POST( const { id } = await params; const body = await request.json(); - // Validate action - const { action } = ApprovalActionSchema.parse(body); + // Validate action and optional admin-selected torrent + const { action, selectedTorrent: adminSelectedTorrent } = ApprovalActionSchema.parse(body); // Fetch the request const existingRequest = await prisma.request.findUnique({ @@ -78,12 +79,15 @@ export async function POST( const jobQueue = getJobQueueService(); const isEbookRequest = existingRequest.type === 'ebook'; - // Check if request has a pre-selected torrent (from interactive search) - if (existingRequest.selectedTorrent) { - const selectedTorrent = existingRequest.selectedTorrent as any; + // Use admin-provided torrent (from admin interactive search) or fall back to user's pre-selected torrent + const effectiveTorrent = adminSelectedTorrent || existingRequest.selectedTorrent; - // User pre-selected a specific torrent - download that torrent directly - logger.info(`Request ${id} has pre-selected torrent, starting download`, { + if (effectiveTorrent) { + const selectedTorrent = effectiveTorrent as any; + const torrentSource = adminSelectedTorrent ? 'admin' : 'user'; + + // Download the selected torrent directly + logger.info(`Request ${id} has ${torrentSource}-selected torrent, starting download`, { requestId: id, userId: existingRequest.userId, adminId: req.user.sub, @@ -167,17 +171,20 @@ export async function POST( logger.error('Failed to queue notification', { error: error instanceof Error ? error.message : String(error) }); }); - logger.info(`Request ${id} approved by admin ${req.user.sub}, downloading pre-selected torrent`, { + logger.info(`Request ${id} approved by admin ${req.user.sub}, downloading ${torrentSource}-selected torrent`, { requestId: id, userId: updatedRequest.userId, audiobookTitle: existingRequest.audiobook.title, adminId: req.user.sub, type: existingRequest.type, + torrentSource, }); return NextResponse.json({ success: true, - message: 'Request approved and download started with pre-selected torrent', + message: adminSelectedTorrent + ? 'Request approved and download started with admin-selected torrent' + : 'Request approved and download started with pre-selected torrent', request: updatedRequest, }); } else { diff --git a/src/app/api/admin/settings/paths/route.ts b/src/app/api/admin/settings/paths/route.ts index 3caaaf4..b7e4337 100644 --- a/src/app/api/admin/settings/paths/route.ts +++ b/src/app/api/admin/settings/paths/route.ts @@ -15,7 +15,7 @@ export async function PUT(request: NextRequest) { return requireAuth(request, async (req: AuthenticatedRequest) => { return requireAdmin(req, async () => { try { - const { downloadDir, mediaDir, audiobookPathTemplate, ebookPathTemplate, metadataTaggingEnabled, chapterMergingEnabled } = await request.json(); + const { downloadDir, mediaDir, audiobookPathTemplate, ebookPathTemplate, metadataTaggingEnabled, chapterMergingEnabled, fileRenameEnabled, fileRenameTemplate } = await request.json(); if (!downloadDir || !mediaDir) { return NextResponse.json( @@ -97,6 +97,32 @@ export async function PUT(request: NextRequest) { }, }); + // 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', + }, + }); + } + logger.info('Paths settings updated'); // Clear config cache for all updated keys so services get fresh values @@ -107,6 +133,8 @@ export async function PUT(request: NextRequest) { configService.clearCache('ebook_path_template'); configService.clearCache('metadata_tagging_enabled'); configService.clearCache('chapter_merging_enabled'); + configService.clearCache('file_rename_enabled'); + configService.clearCache('file_rename_template'); // Invalidate all download client singletons to force reload of download_dir const { invalidateDownloadClientManager } = await import('@/lib/services/download-client-manager.service'); diff --git a/src/app/api/admin/settings/route.ts b/src/app/api/admin/settings/route.ts index 0f31863..f6e4707 100644 --- a/src/app/api/admin/settings/route.ts +++ b/src/app/api/admin/settings/route.ts @@ -128,6 +128,8 @@ export async function GET(request: NextRequest) { ebookPathTemplate: configMap.get('ebook_path_template') || configMap.get('audiobook_path_template') || '{author}/{title} {asin}', metadataTaggingEnabled: configMap.get('metadata_tagging_enabled') === 'true', chapterMergingEnabled: configMap.get('chapter_merging_enabled') === 'true', + fileRenameEnabled: configMap.get('file_rename_enabled') === 'true', + fileRenameTemplate: configMap.get('file_rename_template') || '{title}', }, ebook: { // New granular source toggles (with migration from legacy ebook_sidecar_enabled) diff --git a/src/app/api/requests/[id]/interactive-search/route.ts b/src/app/api/requests/[id]/interactive-search/route.ts index 5269f28..98e9a39 100644 --- a/src/app/api/requests/[id]/interactive-search/route.ts +++ b/src/app/api/requests/[id]/interactive-search/route.ts @@ -67,8 +67,8 @@ export async function POST( ); } - // Check if request is awaiting approval - if (requestRecord.status === 'awaiting_approval') { + // Check if request is awaiting approval (admins can still search to override the user's selection) + if (requestRecord.status === 'awaiting_approval' && req.user.role !== 'admin') { return NextResponse.json( { error: 'AwaitingApproval', message: 'This request is awaiting admin approval. You cannot search for torrents until it is approved.' }, { status: 403 } diff --git a/src/components/requests/InteractiveTorrentSearchModal.tsx b/src/components/requests/InteractiveTorrentSearchModal.tsx index aae716e..5d8747c 100644 --- a/src/components/requests/InteractiveTorrentSearchModal.tsx +++ b/src/components/requests/InteractiveTorrentSearchModal.tsx @@ -38,6 +38,7 @@ interface InteractiveTorrentSearchModalProps { onSuccess?: () => void; searchMode?: 'audiobook' | 'ebook'; // Search mode - defaults to audiobook replaceIssueId?: string; // Optional - when set, confirm handler calls replace endpoint instead + onConfirm?: (torrent: TorrentResult) => Promise; // Optional - overrides default confirm handler } // Format relative time from publish date @@ -90,6 +91,7 @@ export function InteractiveTorrentSearchModal({ onSuccess, searchMode = 'audiobook', replaceIssueId, + onConfirm, }: InteractiveTorrentSearchModalProps) { // Hooks for existing audiobook request flow const { searchTorrents: searchByRequestId, isLoading: isSearchingByRequest, error: searchByRequestError } = useInteractiveSearch(); @@ -113,6 +115,7 @@ export function InteractiveTorrentSearchModal({ const [results, setResults] = useState<(RankedTorrent & { qualityScore?: number; source?: string; ebookFormat?: string })[]>([]); const [confirmTorrent, setConfirmTorrent] = useState(null); const [searchTitle, setSearchTitle] = useState(audiobook.title); + const [isCustomConfirming, setIsCustomConfirming] = useState(false); const [mounted, setMounted] = useState(false); // Stable close handler via ref @@ -130,11 +133,13 @@ export function InteractiveTorrentSearchModal({ const isSearching = isEbookMode ? (useAsinMode ? isSearchingEbooksByAsin : isSearchingEbooks) : (hasRequestId ? isSearchingByRequest : isSearchingByAudiobook); - const isDownloading = replaceIssueId - ? isReplacing - : isEbookMode - ? (useAsinMode ? isSelectingEbookByAsin : isSelectingEbook) - : (hasRequestId ? isSelectingTorrent : isRequestingWithTorrent); + const isDownloading = isCustomConfirming + ? true + : replaceIssueId + ? isReplacing + : isEbookMode + ? (useAsinMode ? isSelectingEbookByAsin : isSelectingEbook) + : (hasRequestId ? isSelectingTorrent : isRequestingWithTorrent); const error = replaceIssueId ? (replaceError || (hasRequestId ? searchByRequestError : searchByAudiobookError)) : isEbookMode @@ -218,7 +223,11 @@ export function InteractiveTorrentSearchModal({ const handleConfirmDownload = async () => { if (!confirmTorrent) return; try { - if (replaceIssueId) { + if (onConfirm) { + // Custom confirm handler (e.g., admin approve-with-torrent flow) + setIsCustomConfirming(true); + await onConfirm(confirmTorrent); + } else if (replaceIssueId) { // Reported issue replacement flow await replaceWithTorrent(replaceIssueId, confirmTorrent); } else if (isEbookMode) { @@ -241,6 +250,8 @@ export function InteractiveTorrentSearchModal({ } catch (err) { console.error('Failed to download:', err); setConfirmTorrent(null); + } finally { + setIsCustomConfirming(false); } }; diff --git a/src/lib/processors/organize-files.processor.ts b/src/lib/processors/organize-files.processor.ts index 2bcc2c8..c99356e 100644 --- a/src/lib/processors/organize-files.processor.ts +++ b/src/lib/processors/organize-files.processor.ts @@ -128,7 +128,19 @@ export async function processOrganizeFiles(payload: OrganizeFilesPayload): Promi }); const template = templateConfig?.value || '{author}/{title} {asin}'; - // Organize files (pass template and logger to file organizer) + // Read file rename configuration + const fileRenameEnabledConfig = await prisma.configuration.findUnique({ + where: { key: 'file_rename_enabled' }, + }); + const fileRenameTemplateConfig = await prisma.configuration.findUnique({ + where: { key: 'file_rename_template' }, + }); + const renameConfig = { + enabled: fileRenameEnabledConfig?.value === 'true', + template: fileRenameTemplateConfig?.value || '{title}', + }; + + // Organize files (pass template, logger, and rename config to file organizer) const result = await organizer.organize( downloadPath, { @@ -142,7 +154,8 @@ export async function processOrganizeFiles(payload: OrganizeFilesPayload): Promi seriesPart: audiobook.seriesPart || undefined, }, template, - jobId ? { jobId, context: 'FileOrganizer' } : undefined + jobId ? { jobId, context: 'FileOrganizer' } : undefined, + renameConfig ); if (!result.success) { @@ -556,6 +569,18 @@ async function processEbookOrganization( } } + // Read file rename configuration + const fileRenameEnabledConfig = await prisma.configuration.findUnique({ + where: { key: 'file_rename_enabled' }, + }); + const fileRenameTemplateConfig = await prisma.configuration.findUnique({ + where: { key: 'file_rename_template' }, + }); + const ebookRenameConfig = { + enabled: fileRenameEnabledConfig?.value === 'true', + template: fileRenameTemplateConfig?.value || '{title}', + }; + // Organize ebook files (organizer will detect ebook type and skip audio-specific processing) // Pass all metadata that could be used in path templates (same as audiobooks) const result = await organizer.organizeEbook( @@ -571,7 +596,8 @@ async function processEbookOrganization( }, template, jobId ? { jobId, context: 'FileOrganizer.Ebook' } : undefined, - isIndexerDownload + isIndexerDownload, + ebookRenameConfig ); // Clean up fixed EPUB temp file after organization (regardless of success) diff --git a/src/lib/utils/file-organizer.ts b/src/lib/utils/file-organizer.ts index 6af1115..2311b53 100644 --- a/src/lib/utils/file-organizer.ts +++ b/src/lib/utils/file-organizer.ts @@ -20,7 +20,7 @@ import { checkDiskSpace, } from './chapter-merger'; import { prisma } from '../db'; -import { substituteTemplate, type TemplateVariables } from './path-template.util'; +import { substituteTemplate, buildRenamedFilename, type TemplateVariables } from './path-template.util'; import { AUDIO_EXTENSIONS } from '../constants/audio-formats'; export interface AudiobookMetadata { @@ -77,7 +77,8 @@ export class FileOrganizer { downloadPath: string, audiobook: AudiobookMetadata, template: string, - loggerConfig?: LoggerConfig + loggerConfig?: LoggerConfig, + renameConfig?: { enabled: boolean; template: string } ): Promise { // Create logger if config provided const logger = loggerConfig ? RMABLogger.forJob(loggerConfig.jobId, loggerConfig.context) : null; @@ -294,8 +295,17 @@ export class FileOrganizer { // Create target directory await fs.mkdir(targetPath, { recursive: true }); + // Determine if file renaming should be applied + const shouldRename = renameConfig?.enabled && renameConfig.template; + const isMultiFile = audioFiles.length > 1; + + if (shouldRename) { + await logger?.info(`File renaming enabled with template: ${renameConfig.template}${isMultiFile ? ` (${audioFiles.length} files, indices will be appended)` : ''}`); + } + // Copy audio files (do NOT delete originals - needed for seeding) - for (const audioFile of audioFiles) { + for (let i = 0; i < audioFiles.length; i++) { + const audioFile = audioFiles[i]; // Handle merged files (absolute paths) vs original files (relative paths) const isAbsolutePath = path.isAbsolute(audioFile); const originalSourcePath = isAbsolutePath @@ -303,7 +313,30 @@ export class FileOrganizer { : isFile ? downloadPath : path.join(downloadPath, audioFile); - const filename = path.basename(audioFile); + + // Determine target filename (apply rename template if enabled) + let filename: string; + if (shouldRename) { + const ext = path.extname(audioFile); + const variables: TemplateVariables = { + author: audiobook.author, + title: audiobook.title, + narrator: audiobook.narrator, + asin: audiobook.asin, + year: audiobook.year, + series: audiobook.series, + seriesPart: audiobook.seriesPart, + }; + filename = buildRenamedFilename( + renameConfig.template, + variables, + ext, + isMultiFile ? i + 1 : undefined, + ); + } else { + filename = path.basename(audioFile); + } + const targetFilePath = path.join(targetPath, filename); // Check if we have a tagged version of this file @@ -690,7 +723,8 @@ export class FileOrganizer { metadata: { title: string; author: string; narrator?: string; asin?: string; year?: number; series?: string; seriesPart?: string }, template: string, loggerConfig?: LoggerConfig, - isIndexerDownload: boolean = false + isIndexerDownload: boolean = false, + renameConfig?: { enabled: boolean; template: string } ): Promise { const logger = loggerConfig ? RMABLogger.forJob(loggerConfig.jobId, loggerConfig.context) : null; @@ -739,9 +773,25 @@ export class FileOrganizer { // Create target directory await fs.mkdir(targetDir, { recursive: true }); - // Build target filename (sanitize source filename) + // Build target filename (apply rename template if enabled, otherwise sanitize source filename) const sourceFilename = path.basename(ebookFile); - const targetFilename = this.sanitizePath(sourceFilename); + let targetFilename: string; + if (renameConfig?.enabled && renameConfig.template) { + const originalExt = path.extname(ebookFile); + const variables: TemplateVariables = { + author: metadata.author, + title: metadata.title, + narrator: metadata.narrator, + asin: metadata.asin, + year: metadata.year, + series: metadata.series, + seriesPart: metadata.seriesPart, + }; + targetFilename = buildRenamedFilename(renameConfig.template, variables, originalExt); + await logger?.info(`Renamed ebook file: ${sourceFilename} -> ${targetFilename}`); + } else { + targetFilename = this.sanitizePath(sourceFilename); + } const targetPath = path.join(targetDir, targetFilename); // Check if target already exists diff --git a/src/lib/utils/path-template.util.ts b/src/lib/utils/path-template.util.ts index ce3dda3..7a037df 100644 --- a/src/lib/utils/path-template.util.ts +++ b/src/lib/utils/path-template.util.ts @@ -68,6 +68,81 @@ function sanitizePath(name: string): string { ); } +/** + * Find valid template variable names within arbitrary content text. + * Sorts by length descending to prevent substring false matches + * (e.g., 'seriesPart' matched before 'series'). + * Uses word-boundary detection to avoid matching variable names + * that are substrings of other words. + */ +function findVariablesInContent(content: string): string[] { + const sortedVars = [...VALID_VARIABLES].sort((a, b) => b.length - a.length); + const found: string[] = []; + + for (const varName of sortedVars) { + const regex = new RegExp(`(? { + // If content is exactly a valid variable name, skip (leave for simple substitution) + if (VALID_VARIABLES.includes(content)) { + return match; + } + + // Find variables in the content + const foundVars = findVariablesInContent(content); + + // If no variables found, leave as-is (validation will catch it) + if (foundVars.length === 0) { + return match; + } + + // Check if all found variables have non-empty values + const allPresent = foundVars.every(varName => { + const value = variables[varName as keyof TemplateVariables]; + return value !== undefined && value !== null && String(value).trim() !== ''; + }); + + if (!allPresent) { + return ''; + } + + // Substitute variables within the content, output rest as literal text + // Sort by length descending to prevent substring false matches + let result = content; + const sortedVars = [...foundVars].sort((a, b) => b.length - a.length); + for (const varName of sortedVars) { + const value = variables[varName as keyof TemplateVariables]; + const sanitizedValue = sanitizePath(String(value).trim()); + const regex = new RegExp(`(? `{${v}}`).join(', ')}` + error: `No valid variable found in conditional block: {${content}}. Valid variables are: ${VALID_VARIABLES.map(v => `{${v}}`).join(', ')}` + }; + } + + // Check literal text inside conditional block for invalid path chars + let literalText = content; + const sortedVars = [...foundVars].sort((a, b) => b.length - a.length); + for (const varName of sortedVars) { + literalText = literalText.replace( + new RegExp(`(? `{${v}}`).join(', ')}`, + }; + } + + // Check literal text inside conditional block for invalid filename chars + let literalText = content; + const sortedVars = [...foundVars].sort((a, b) => b.length - a.length); + for (const varName of sortedVars) { + literalText = literalText.replace( + new RegExp(`(? { + const name = substituteTemplate(template, variables); + return `${name}.m4b`; + }); + + // Show multi-file example with first mock data only + const multiName = substituteTemplate(template, mockData[0]); + const multi = [ + `${multiName} - 1.mp3`, + `${multiName} - 2.mp3`, + `${multiName} - 3.mp3`, + ]; + + return { single, multi }; +} + +/** + * Build a renamed filename from a template, metadata variables, and original extension. + * Optionally appends a 1-based index for multi-file scenarios. + * + * @param template - Filename template string (e.g., "{title}") + * @param variables - Template variables with metadata values + * @param originalExtension - File extension including dot (e.g., ".m4b") + * @param index - Optional 1-based index for multi-file scenarios + * @returns Sanitized filename with extension + */ +export function buildRenamedFilename( + template: string, + variables: TemplateVariables, + originalExtension: string, + index?: number, +): string { + let baseName = substituteTemplate(template, variables); + + // substituteTemplate cleans up slashes for paths — but since this is a filename, + // remove any residual slashes that conditional blocks might have introduced + baseName = baseName.replace(/[/\\]/g, ''); + + // Sanitize again for filename safety + baseName = baseName + .replace(/[<>:"/\\|?*]/g, '') + .trim() + .replace(/^\.+/, '') + .replace(/\.+$/, '') + .replace(/\s+/g, ' ') + .slice(0, 200); + + if (index !== undefined) { + baseName = `${baseName} - ${index}`; + } + + // Ensure extension starts with a dot + const ext = originalExtension.startsWith('.') ? originalExtension : `.${originalExtension}`; + + return `${baseName}${ext}`; +} diff --git a/tests/api/setup-tests.routes.test.ts b/tests/api/setup-tests.routes.test.ts index 77405e7..32274f2 100644 --- a/tests/api/setup-tests.routes.test.ts +++ b/tests/api/setup-tests.routes.test.ts @@ -433,7 +433,7 @@ describe('Setup test routes', () => { expect(payload.success).toBe(true); expect(payload.template).toBeDefined(); expect(payload.template.isValid).toBe(false); - expect(payload.template.error).toContain('Unknown variable'); + expect(payload.template.error).toContain('No valid variable found in conditional block'); expect(payload.template.previewPaths).toBeUndefined(); }); diff --git a/tests/lib/utils/path-template.util.test.ts b/tests/lib/utils/path-template.util.test.ts index d5d3f46..0586adb 100644 --- a/tests/lib/utils/path-template.util.test.ts +++ b/tests/lib/utils/path-template.util.test.ts @@ -8,6 +8,9 @@ import { validateTemplate, generateMockPreviews, getValidVariables, + validateFilenameTemplate, + generateMockFilenamePreviews, + buildRenamedFilename, type TemplateVariables } from '@/lib/utils/path-template.util'; @@ -213,6 +216,142 @@ describe('substituteTemplate', () => { const result = substituteTemplate(template, variables); expect(result).toBe('Author/{narrated}/Title'); }); + + // Conditional block tests + it('should render conditional block when variable has a value', () => { + const template = '{author}/{Book seriesPart - }{title}'; + const variables: TemplateVariables = { + author: 'Brandon Sanderson', + title: 'Mistborn', + seriesPart: '1' + }; + + const result = substituteTemplate(template, variables); + expect(result).toBe('Brandon Sanderson/Book 1 - Mistborn'); + }); + + it('should remove conditional block when variable is missing', () => { + const template = '{author}/{Book seriesPart - }{title}'; + const variables: TemplateVariables = { + author: 'Andy Weir', + title: 'Project Hail Mary' + // seriesPart is missing + }; + + const result = substituteTemplate(template, variables); + expect(result).toBe('Andy Weir/Project Hail Mary'); + }); + + it('should handle conditional block with path separator', () => { + const template = '{author}/{series/Book seriesPart - }{title}'; + const variables: TemplateVariables = { + author: 'Brandon Sanderson', + title: 'Mistborn', + series: 'The Mistborn Saga', + seriesPart: '1' + }; + + const result = substituteTemplate(template, variables); + expect(result).toBe('Brandon Sanderson/The Mistborn Saga/Book 1 - Mistborn'); + }); + + it('should render conditional block when all variables present', () => { + const template = '{author}/{series Book seriesPart}/{title}'; + const variables: TemplateVariables = { + author: 'Brandon Sanderson', + title: 'Mistborn', + series: 'The Mistborn Saga', + seriesPart: '1' + }; + + const result = substituteTemplate(template, variables); + expect(result).toBe('Brandon Sanderson/The Mistborn Saga Book 1/Mistborn'); + }); + + it('should remove conditional block when any variable is missing', () => { + const template = '{author}/{series Book seriesPart}/{title}'; + const variables: TemplateVariables = { + author: 'Andy Weir', + title: 'Project Hail Mary', + series: 'Some Series' + // seriesPart is missing + }; + + const result = substituteTemplate(template, variables); + expect(result).toBe('Andy Weir/Project Hail Mary'); + }); + + it('should handle adjacent conditional blocks', () => { + const template = '{author}/{series - }{Book seriesPart - }{title}'; + const variables: TemplateVariables = { + author: 'Brandon Sanderson', + title: 'Mistborn', + series: 'The Mistborn Saga', + seriesPart: '1' + }; + + const result = substituteTemplate(template, variables); + expect(result).toBe('Brandon Sanderson/The Mistborn Saga - Book 1 - Mistborn'); + }); + + it('should handle conditional block next to simple variable', () => { + const template = '{author}/{series - }{title}'; + const variables: TemplateVariables = { + author: 'Andy Weir', + title: 'Project Hail Mary' + // series is missing + }; + + const result = substituteTemplate(template, variables); + expect(result).toBe('Andy Weir/Project Hail Mary'); + }); + + it('should handle conditional block with year variable', () => { + const template = '{author}/{title} {(year)}'; + const variables: TemplateVariables = { + author: 'Brandon Sanderson', + title: 'Mistborn', + year: 2006 + }; + + const result = substituteTemplate(template, variables); + expect(result).toBe('Brandon Sanderson/Mistborn (2006)'); + }); + + it('should remove year conditional block when year is missing', () => { + const template = '{author}/{title} {(year)}'; + const variables: TemplateVariables = { + author: 'Andy Weir', + title: 'Project Hail Mary' + // year is missing + }; + + const result = substituteTemplate(template, variables); + expect(result).toBe('Andy Weir/Project Hail Mary'); + }); + + it('should still handle simple variables correctly (regression)', () => { + const template = '{author}/{title}'; + const variables: TemplateVariables = { + author: 'Brandon Sanderson', + title: 'Mistborn' + }; + + const result = substituteTemplate(template, variables); + expect(result).toBe('Brandon Sanderson/Mistborn'); + }); + + it('should remove conditional block when variable is empty string', () => { + const template = '{author}/{Book seriesPart - }{title}'; + const variables: TemplateVariables = { + author: 'Author', + title: 'Title', + seriesPart: '' + }; + + const result = substituteTemplate(template, variables); + expect(result).toBe('Author/Title'); + }); }); describe('validateTemplate', () => { @@ -247,7 +386,7 @@ describe('validateTemplate', () => { it('should reject unknown variables', () => { const result = validateTemplate('{author}/{invalid}'); expect(result.valid).toBe(false); - expect(result.error).toContain('Unknown variable'); + expect(result.error).toContain('No valid variable found in conditional block'); expect(result.error).toContain('{invalid}'); }); @@ -287,12 +426,13 @@ describe('validateTemplate', () => { it('should provide helpful error messages for multiple unknown variables', () => { const result = validateTemplate('{author}/{invalid1}/{invalid2}'); expect(result.valid).toBe(false); - expect(result.error).toContain('Unknown variable'); + expect(result.error).toContain('No valid variable found in conditional block'); }); it('should list valid variables in error message', () => { const result = validateTemplate('{invalid}'); expect(result.valid).toBe(false); + expect(result.error).toContain('No valid variable found in conditional block'); expect(result.error).toContain('{author}'); expect(result.error).toContain('{title}'); expect(result.error).toContain('{narrator}'); @@ -334,6 +474,34 @@ describe('validateTemplate', () => { const result = validateTemplate('\\{\\}'); expect(result.valid).toBe(true); }); + + // Conditional block validation tests + it('should accept conditional blocks with valid variables', () => { + const result = validateTemplate('{author}/{Book seriesPart - }{title}'); + expect(result.valid).toBe(true); + }); + + it('should accept conditional blocks with multiple variables', () => { + const result = validateTemplate('{author}/{series Book seriesPart}/{title}'); + expect(result.valid).toBe(true); + }); + + it('should reject conditional blocks with no valid variables', () => { + const result = validateTemplate('{author}/{random text}/{title}'); + expect(result.valid).toBe(false); + expect(result.error).toContain('No valid variable found in conditional block'); + }); + + it('should reject conditional blocks with invalid path chars inside', () => { + const result = validateTemplate('{author}/{series: part}/{title}'); + expect(result.valid).toBe(false); + expect(result.error).toContain('Invalid characters'); + }); + + it('should accept mix of simple variables and conditional blocks', () => { + const result = validateTemplate('{author}/{series - }{Book seriesPart - }{title} {(year)}'); + expect(result.valid).toBe(true); + }); }); describe('generateMockPreviews', () => { @@ -444,3 +612,183 @@ describe('getValidVariables', () => { expect(vars1).not.toBe(vars2); // Different array instances }); }); + +describe('validateFilenameTemplate', () => { + it('should accept valid filename templates', () => { + const templates = [ + '{title}', + '{author} - {title}', + '{title} ({year})', + '{author} - {title} {(year)}', + ]; + + templates.forEach(template => { + const result = validateFilenameTemplate(template); + expect(result.valid).toBe(true); + expect(result.error).toBeUndefined(); + }); + }); + + it('should reject empty templates', () => { + const result = validateFilenameTemplate(''); + expect(result.valid).toBe(false); + expect(result.error).toContain('empty'); + }); + + it('should reject templates containing forward slashes', () => { + const result = validateFilenameTemplate('{author}/{title}'); + expect(result.valid).toBe(false); + expect(result.error).toContain('/'); + expect(result.error).toContain('directory separator'); + }); + + it('should reject templates containing backslashes (not brace escapes)', () => { + const result = validateFilenameTemplate('{author}\\n{title}'); + expect(result.valid).toBe(false); + expect(result.error).toContain('backslash'); + }); + + it('should accept escaped braces in filename templates', () => { + const result = validateFilenameTemplate('\\{{title}\\}'); + expect(result.valid).toBe(true); + }); + + it('should reject unknown variables', () => { + const result = validateFilenameTemplate('{invalid}'); + expect(result.valid).toBe(false); + expect(result.error).toContain('No valid variable found'); + }); + + it('should reject invalid characters', () => { + const invalidChars = ['<', '>', ':', '"', '|', '?', '*']; + + invalidChars.forEach(char => { + const result = validateFilenameTemplate(`{title}${char}extra`); + expect(result.valid).toBe(false); + expect(result.error).toContain('Invalid characters'); + }); + }); + + it('should accept conditional blocks in filename templates', () => { + const result = validateFilenameTemplate('{title} {(year)}'); + expect(result.valid).toBe(true); + }); + + it('should accept templates with only static text', () => { + const result = validateFilenameTemplate('audiobook'); + expect(result.valid).toBe(true); + }); +}); + +describe('generateMockFilenamePreviews', () => { + it('should return single and multi-file previews', () => { + const result = generateMockFilenamePreviews('{title}'); + + expect(result.single).toBeDefined(); + expect(result.multi).toBeDefined(); + expect(result.single.length).toBe(2); + expect(result.multi.length).toBe(3); + }); + + it('should include file extensions in single previews', () => { + const result = generateMockFilenamePreviews('{title}'); + + result.single.forEach(preview => { + expect(preview).toMatch(/\.m4b$/); + }); + }); + + it('should include index and extensions in multi-file previews', () => { + const result = generateMockFilenamePreviews('{title}'); + + expect(result.multi[0]).toMatch(/ - 1\.mp3$/); + expect(result.multi[1]).toMatch(/ - 2\.mp3$/); + expect(result.multi[2]).toMatch(/ - 3\.mp3$/); + }); + + it('should substitute variables correctly', () => { + const result = generateMockFilenamePreviews('{author} - {title}'); + + expect(result.single[0]).toContain('Brandon Sanderson'); + expect(result.single[0]).toContain('Mistborn'); + expect(result.single[1]).toContain('Douglas Adams'); + }); +}); + +describe('buildRenamedFilename', () => { + const baseVariables: TemplateVariables = { + author: 'Brandon Sanderson', + title: 'Mistborn: The Final Empire', + narrator: 'Michael Kramer', + asin: 'B002UZMLXM', + year: 2006, + }; + + it('should build a renamed filename with extension', () => { + const result = buildRenamedFilename('{title}', baseVariables, '.m4b'); + expect(result).toBe('Mistborn The Final Empire.m4b'); + }); + + it('should append index for multi-file scenarios', () => { + const result = buildRenamedFilename('{title}', baseVariables, '.mp3', 1); + expect(result).toBe('Mistborn The Final Empire - 1.mp3'); + }); + + it('should handle multiple variables', () => { + const result = buildRenamedFilename('{author} - {title}', baseVariables, '.m4b'); + expect(result).toBe('Brandon Sanderson - Mistborn The Final Empire.m4b'); + }); + + it('should handle extension without leading dot', () => { + const result = buildRenamedFilename('{title}', baseVariables, 'mp3'); + expect(result).toBe('Mistborn The Final Empire.mp3'); + }); + + it('should sanitize invalid characters from variable values', () => { + const vars: TemplateVariables = { + author: 'Author: ', + title: 'Title|Book*' + }; + const result = buildRenamedFilename('{author} - {title}', vars, '.m4b'); + expect(result).not.toContain(':'); + expect(result).not.toContain('<'); + expect(result).not.toContain('>'); + expect(result).not.toContain('|'); + expect(result).not.toContain('*'); + }); + + it('should strip slashes from conditional block output', () => { + const result = buildRenamedFilename('{author}/{title}', baseVariables, '.m4b'); + expect(result).not.toContain('/'); + expect(result).not.toContain('\\'); + }); + + it('should handle conditional blocks', () => { + const result = buildRenamedFilename('{title} {(year)}', baseVariables, '.m4b'); + expect(result).toBe('Mistborn The Final Empire (2006).m4b'); + }); + + it('should remove conditional blocks when variable is missing', () => { + const vars: TemplateVariables = { + author: 'Andy Weir', + title: 'Project Hail Mary', + }; + const result = buildRenamedFilename('{title} {(year)}', vars, '.m4b'); + expect(result).toBe('Project Hail Mary.m4b'); + }); + + it('should handle index appended after conditional blocks', () => { + const result = buildRenamedFilename('{title} {(year)}', baseVariables, '.mp3', 5); + expect(result).toBe('Mistborn The Final Empire (2006) - 5.mp3'); + }); + + it('should limit very long filenames', () => { + const vars: TemplateVariables = { + author: 'Author', + title: 'A'.repeat(300), + }; + const result = buildRenamedFilename('{title}', vars, '.m4b'); + // 200 char limit on base name + extension + expect(result.length).toBeLessThanOrEqual(204); // 200 + '.m4b' + }); +});