diff --git a/documentation/admin-features/request-deletion.md b/documentation/admin-features/request-deletion.md index df95d2c..859d3f5 100644 --- a/documentation/admin-features/request-deletion.md +++ b/documentation/admin-features/request-deletion.md @@ -100,9 +100,20 @@ model Request { - Queries plex_library table to get plexRatingKey from audiobook's plexGuid - Calls Plex DELETE `/library/metadata/{ratingKey}` endpoint with the ratingKey - Requires deletion enabled in Plex: Settings > Server > Library - - Also clears plex_library cache records -5. **Soft Delete Request** +5. **Delete plex_library Cache Records** + - **Primary:** Delete by ASIN (same query as availability check) + - `WHERE asin = audiobookAsin OR plexGuid CONTAINS audiobookAsin` + - Ensures exact same record found during availability check gets deleted + - **Fallback:** Delete by exact title/author (for legacy records without ASIN) + - Only used if ASIN-based deletion finds no records + - **Result:** Book immediately shows as NOT available, can be re-requested + +6. **Clear Audiobook Linkage** + - Reset audiobook.status to 'requested' + - Clear plexGuid (Plex mode) or absItemId (ABS mode) + +7. **Soft Delete Request** - UPDATE: `deletedAt = NOW(), deletedBy = adminUserId` - Preserves for audit trail and orphaned download tracking @@ -201,6 +212,17 @@ where: { 11. ✅ **Plex library item deletion fails** - Log error, continue with soft delete 12. ✅ **No plexGuid present** - Skip Plex deletion (not yet in library) 13. ✅ **Plex deletion not enabled in settings** - Log error, continue with soft delete +14. ✅ **Title mismatch in plex_library** - ASIN-based deletion handles title variations (e.g., "(Unabridged)" suffix) +15. ✅ **No ASIN available** - Falls back to exact title/author matching + +## Fixed Issues ✅ + +**1. Book Shows "Available" After Deletion Until Library Scan** +- **Issue:** Deleted books remained "available" until the next library scan +- **Cause:** plex_library deletion used title/author matching, but availability check used ASIN matching +- **Impact:** Title variations (e.g., "Book Title" vs "Book Title (Unabridged)") caused plex_library records to persist +- **Fix:** Changed plex_library deletion to use ASIN-based matching (same as availability check) +- **Result:** Books immediately show as NOT available after deletion, can be re-requested right away ## File Structure diff --git a/documentation/phase3/sabnzbd.md b/documentation/phase3/sabnzbd.md index ffb166c..51357de 100644 --- a/documentation/phase3/sabnzbd.md +++ b/documentation/phase3/sabnzbd.md @@ -65,9 +65,18 @@ Service uses singleton pattern. When settings change, singleton invalidated to f **Category:** `readmeabook` (auto-created for all downloads) **Save Path Synchronization:** -- Category created on first download if not exists -- Category path set to `download_dir` config value -- Unlike qBittorrent, SABnzbd categories are less frequently updated (set once at creation) +- Category created/updated on every download (matches qBittorrent behavior) +- Fetches SABnzbd's `complete_dir` setting via API to understand download location +- Applies remote path mapping to translate RMAB's `download_dir` to SABnzbd's perspective +- Calculates optimal category path (relative, absolute, or root) + +**Smart Path Calculation:** +1. Get SABnzbd's `complete_dir` from `misc.complete_dir` config +2. Apply `PathMapper.reverseTransform()` to RMAB's `download_dir` +3. Compare transformed path to `complete_dir`: + - **Match:** Use empty string (downloads go to complete_dir root) + - **Subdirectory:** Use relative path (e.g., `audiobooks`) + - **Different:** Use absolute path (e.g., `/mnt/media/audiobooks`) ## Post-Processing @@ -121,6 +130,12 @@ interface HistoryItem { completedTimestamp: string; // Unix timestamp downloadTime: string; // Seconds } + +interface SABnzbdConfig { + version: string; + completeDir: string; // SABnzbd's configured complete download folder + categories: Array<{ name: string; dir: string }>; +} ``` ## NZB ID vs Torrent Hash @@ -168,11 +183,40 @@ interface HistoryItem { **Use Case:** SABnzbd runs on different machine/container with different filesystem perspective. **Example Scenario:** -- SABnzbd reports: `/remote/usenet/complete/Audiobook.Name` -- ReadMeABook needs: `/downloads/Audiobook.Name` -- Mapping: Remote `/remote/usenet/complete` → Local `/downloads` +- SABnzbd sees: `/mnt/usenet/complete` +- ReadMeABook sees: `/downloads` +- Mapping: Remote `/mnt/usenet/complete` ↔ Local `/downloads` -**Implementation:** Same as qBittorrent (uses `PathMapper` utility) +**Bidirectional Path Mapping:** + +**1. Outgoing (RMAB → SABnzbd):** When setting category path +- RMAB's download path: `/downloads` +- Translated to SABnzbd's path: `/mnt/usenet/complete` +- Applied in `sabnzbd.service.ts` via `PathMapper.reverseTransform()` +- Combined with `complete_dir` detection for optimal category configuration + +**2. Incoming (SABnzbd → RMAB):** When processing completed downloads +- SABnzbd reports: `/mnt/usenet/complete/Audiobook.Name` +- Translated to RMAB's path: `/downloads/Audiobook.Name` +- Applied in `monitor-download.processor.ts` via `PathMapper.transform()` +- Ensures RMAB can find and organize files + +**Path Transformation Examples:** +```typescript +// Outgoing: RMAB → SABnzbd (when setting category) +localPath = "/downloads" +config = { remotePath: "/mnt/usenet/complete", localPath: "/downloads" } +remotePath = PathMapper.reverseTransform(localPath, config) +// Result: "/mnt/usenet/complete" + +// Incoming: SABnzbd → RMAB (when processing completion) +sabPath = "/mnt/usenet/complete/Audiobook.Name" +config = { remotePath: "/mnt/usenet/complete", localPath: "/downloads" } +organizePath = PathMapper.transform(sabPath, config) +// Result: "/downloads/Audiobook.Name" +``` + +**Implementation:** Uses `PathMapper` utility (same as qBittorrent) ## Fixed Issues ✅ @@ -181,6 +225,16 @@ interface HistoryItem { **3. Post-Processing Tracking** - Monitors extracting/repairing states **4. Queue vs History Logic** - Checks queue first, falls back to history **5. SSL Certificate Errors** - Optional SSL verification disable for self-signed certs +**6. Category path not synced with complete_dir** - SABnzbd downloads to its own `complete_dir`, not RMAB's path. Fixed by: + - Fetching SABnzbd's `complete_dir` from config API (`misc.complete_dir`) + - Calculating relative or absolute category path based on path comparison + - Applying remote path mapping before comparison + - Syncing category path on every download (same as qBittorrent) +**7. Remote path mapping not applied** - Paths weren't translated between RMAB and SABnzbd perspectives. Fixed by: + - Adding `PathMappingConfig` to SABnzbd service constructor + - Applying `reverseTransform()` when setting category path (outgoing) + - Applying `transform()` when processing completed downloads (incoming) + - Using same `PathMapper` utility as qBittorrent for consistency ## Automatic Cleanup @@ -223,9 +277,11 @@ interface HistoryItem { | ID Format | NZB ID (immediate) | Torrent hash (extracted) | | Post-Processing | Automatic (par2, extraction) | None (manual) | | Seeding | N/A (Usenet is not P2P) | Required (tracker) | -| Categories | Path-based | Path + tag-based | +| Categories | Path-based (relative to complete_dir) | Path + tag-based | | File Handling | Auto-extracts archives | Downloads as-is | | Cleanup | Automatic (optional, per-indexer) | Seeding time based | +| Path Mapping | ✅ Bidirectional (same as qBit) | ✅ Bidirectional | +| Category Sync | ✅ Every download | ✅ Every download | ## Tech Stack diff --git a/src/lib/integrations/sabnzbd.service.ts b/src/lib/integrations/sabnzbd.service.ts index 98b4c9e..3c700d5 100644 --- a/src/lib/integrations/sabnzbd.service.ts +++ b/src/lib/integrations/sabnzbd.service.ts @@ -6,6 +6,7 @@ import axios, { AxiosInstance } from 'axios'; import https from 'https'; import { RMABLogger } from '@/lib/utils/logger'; +import { PathMapper, PathMappingConfig } from '@/lib/utils/path-mapper'; const logger = RMABLogger.create('SABnzbd'); @@ -68,6 +69,7 @@ export interface SABnzbdConfig { name: string; dir: string; }>; + completeDir: string; // SABnzbd's configured complete download folder } export interface DownloadProgress { @@ -84,19 +86,25 @@ export class SABnzbdService { private baseUrl: string; private apiKey: string; private defaultCategory: string; + private defaultDownloadDir: string; private disableSSLVerify: boolean; private httpsAgent?: https.Agent; + private pathMappingConfig: PathMappingConfig; constructor( baseUrl: string, apiKey: string, defaultCategory: string = 'readmeabook', - disableSSLVerify: boolean = false + defaultDownloadDir: string = '/downloads', + disableSSLVerify: boolean = false, + pathMappingConfig?: PathMappingConfig ) { this.baseUrl = baseUrl.replace(/\/$/, ''); this.apiKey = apiKey?.trim() || ''; this.defaultCategory = defaultCategory; + this.defaultDownloadDir = defaultDownloadDir; this.disableSSLVerify = disableSSLVerify; + this.pathMappingConfig = pathMappingConfig || { enabled: false, remotePath: '', localPath: '' }; // Configure HTTPS agent if SSL verification is disabled if (this.disableSSLVerify && this.baseUrl.startsWith('https')) { @@ -206,7 +214,11 @@ export class SABnzbdService { } /** - * Get SABnzbd configuration + * Get SABnzbd configuration including complete download folder + * + * SABnzbd config structure: + * - misc.complete_dir: The base folder where completed downloads are stored + * - categories: Object mapping category names to their settings (dir is relative to complete_dir) */ async getConfig(): Promise { const response = await this.client.get('/api', { @@ -222,8 +234,23 @@ export class SABnzbdService { throw new Error('Failed to get SABnzbd configuration'); } + // Extract complete_dir from misc section + // This is where SABnzbd stores completed downloads before category subdirectories are applied + const completeDir = config.misc?.complete_dir || ''; + + logger.debug('SABnzbd config retrieved from API', { + completeDir: completeDir || '(not configured)', + downloadDir: config.misc?.download_dir || '(not set)', + categoryCount: Object.keys(config.categories || {}).length, + categories: Object.entries(config.categories || {}).map(([name, details]: [string, any]) => ({ + name, + dir: details.dir || '(root)', + })), + }); + return { version: config.version || '', + completeDir, categories: Object.entries(config.categories || {}).map(([name, details]: [string, any]) => ({ name, dir: details.dir || '', @@ -232,36 +259,190 @@ export class SABnzbdService { } /** - * Ensure the default category exists - * Creates category if it doesn't exist + * Get SABnzbd's complete download folder + * This is the base directory where SABnzbd stores completed downloads */ - async ensureCategory(downloadPath?: string): Promise { + async getCompleteDir(): Promise { + const config = await this.getConfig(); + return config.completeDir; + } + + /** + * Calculate the correct category path for SABnzbd + * + * SABnzbd categories use paths relative to complete_dir by default, but can also + * accept absolute paths. This method calculates the correct path based on: + * 1. SABnzbd's complete_dir setting + * 2. RMAB's desired download path + * 3. Remote path mapping (if enabled) + * + * @returns The path to set for the category (relative, absolute, or empty string) + */ + private calculateCategoryPath(completeDir: string, desiredPath: string): string { + // Normalize paths for comparison (convert backslashes, remove trailing slashes) + const normalizeForCompare = (p: string): string => { + return p.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase(); + }; + + const normalizedComplete = normalizeForCompare(completeDir); + const normalizedDesired = normalizeForCompare(desiredPath); + + logger.debug('Path comparison (normalized)', { + completeDir: { original: completeDir, normalized: normalizedComplete }, + desiredPath: { original: desiredPath, normalized: normalizedDesired }, + }); + + // Case 1: Desired path exactly matches complete_dir + // Use empty string so downloads go to complete_dir root + if (normalizedComplete === normalizedDesired) { + logger.debug('Path match result: EXACT_MATCH - paths are identical after normalization'); + logger.info('Desired path matches SABnzbd complete_dir, using category root'); + return ''; + } + + // Case 2: Desired path is under complete_dir + // Calculate relative path (SABnzbd will append it to complete_dir) + if (normalizedDesired.startsWith(normalizedComplete + '/')) { + const relativePath = desiredPath.substring(completeDir.length).replace(/^[/\\]+/, ''); + logger.debug('Path match result: SUBDIRECTORY - desired path is under complete_dir', { + relativePath, + calculation: `"${desiredPath}".substring(${completeDir.length}) = "${relativePath}"`, + }); + logger.info(`Desired path is under complete_dir, using relative path: ${relativePath}`); + return relativePath; + } + + // Case 3: Desired path is completely different + // Use absolute path (SABnzbd will use it directly) + logger.debug('Path match result: DIFFERENT - paths do not overlap, using absolute path'); + logger.info(`Desired path differs from complete_dir, using absolute path: ${desiredPath}`); + return desiredPath; + } + + /** + * Ensure the category exists with the correct download path + * + * This method handles the complexity of SABnzbd's path handling: + * - Fetches SABnzbd's complete_dir to understand where downloads go + * - Applies remote path mapping to translate between RMAB and SABnzbd perspectives + * - Calculates the appropriate category path (relative or absolute) + * - Creates or updates the category as needed + * + * Called before every download to ensure path settings stay synchronized. + */ + async ensureCategory(): Promise { try { + logger.debug('ensureCategory() called - syncing category path with SABnzbd'); + + // Get SABnzbd's configuration including complete_dir const config = await this.getConfig(); - const categoryExists = config.categories.some(cat => cat.name === this.defaultCategory); + const completeDir = config.completeDir; - if (!categoryExists) { - logger.info(`Creating category: ${this.defaultCategory}`); + logger.debug('Retrieved SABnzbd configuration', { + completeDir: completeDir || '(not set)', + existingCategories: config.categories.map(c => ({ name: c.name, dir: c.dir || '(root)' })), + }); + + if (!completeDir) { + logger.warn('SABnzbd complete_dir not found in config, category path may be incorrect'); + } + + // Apply reverse path mapping to get the path from SABnzbd's perspective + // Example: RMAB sees /downloads, SABnzbd sees /mnt/usenet/complete + logger.debug('Applying reverse path mapping', { + inputPath: this.defaultDownloadDir, + pathMappingEnabled: this.pathMappingConfig.enabled, + remotePath: this.pathMappingConfig.remotePath || '(not set)', + localPath: this.pathMappingConfig.localPath || '(not set)', + }); + + const desiredPath = PathMapper.reverseTransform(this.defaultDownloadDir, this.pathMappingConfig); + + const pathWasTransformed = desiredPath !== this.defaultDownloadDir; + logger.debug('Reverse path mapping result', { + originalPath: this.defaultDownloadDir, + transformedPath: desiredPath, + wasTransformed: pathWasTransformed, + }); + + logger.info('Category path calculation', { + rmabDownloadDir: this.defaultDownloadDir, + pathMappingEnabled: this.pathMappingConfig.enabled, + desiredPathForSab: desiredPath, + sabCompleteDir: completeDir, + }); + + // Calculate the correct category path + const categoryPath = completeDir + ? this.calculateCategoryPath(completeDir, desiredPath) + : desiredPath; // Fallback to desired path if complete_dir unknown + + logger.debug('Final category path determined', { + categoryPath: categoryPath || '(empty - downloads to complete_dir root)', + category: this.defaultCategory, + }); + + // Check if category exists and has the correct path + const existingCategory = config.categories.find(cat => cat.name === this.defaultCategory); + + logger.debug('Checking existing category', { + categoryName: this.defaultCategory, + exists: !!existingCategory, + currentDir: existingCategory?.dir || '(not set)', + targetDir: categoryPath || '(root)', + needsUpdate: existingCategory ? existingCategory.dir !== categoryPath : true, + }); + + if (!existingCategory) { + // Create new category + logger.info(`Creating category "${this.defaultCategory}" with path: "${categoryPath || '(root)'}"`); + logger.debug('SABnzbd API call: set_config (create category)', { + section: 'categories', + keyword: this.defaultCategory, + dir: categoryPath, + }); - // Create category await this.client.get('/api', { params: { mode: 'set_config', section: 'categories', keyword: this.defaultCategory, - value: downloadPath || '', + dir: categoryPath, output: 'json', apikey: this.apiKey, }, }); - logger.info(`Category created successfully: ${this.defaultCategory}`); + logger.info(`Category "${this.defaultCategory}" created successfully`); + } else if (existingCategory.dir !== categoryPath) { + // Update existing category with new path + logger.info(`Updating category "${this.defaultCategory}" path from "${existingCategory.dir || '(root)'}" to "${categoryPath || '(root)'}"`); + logger.debug('SABnzbd API call: set_config (update category)', { + section: 'categories', + keyword: this.defaultCategory, + oldDir: existingCategory.dir, + newDir: categoryPath, + }); + + await this.client.get('/api', { + params: { + mode: 'set_config', + section: 'categories', + keyword: this.defaultCategory, + dir: categoryPath, + output: 'json', + apikey: this.apiKey, + }, + }); + + logger.info(`Category "${this.defaultCategory}" path updated successfully`); } else { - logger.info(`Category already exists: ${this.defaultCategory}`); + logger.debug(`Category "${this.defaultCategory}" already has correct path: "${categoryPath || '(root)'}" - no update needed`); } } catch (error) { logger.error('Failed to ensure category', { error: error instanceof Error ? error.message : String(error) }); - // Don't throw - category creation failure shouldn't block downloads + // Don't throw - category issues shouldn't block downloads + // Downloads will still work, just may end up in wrong location } } @@ -272,11 +453,17 @@ export class SABnzbdService { async addNZB(url: string, options?: AddNZBOptions): Promise { logger.info(`Adding NZB from URL: ${url.substring(0, 150)}...`); + const category = options?.category || this.defaultCategory; + + // Ensure category exists with correct path before every download + // This syncs the category path with SABnzbd's complete_dir and handles path mapping + await this.ensureCategory(); + const response = await this.client.get('/api', { params: { mode: 'addurl', name: url, - cat: options?.category || this.defaultCategory, + cat: category, priority: this.mapPriority(options?.priority), pp: '3', // Post-processing: +Repair, +Unpack, +Delete output: 'json', @@ -583,55 +770,90 @@ export class SABnzbdService { * Singleton instance and factory */ let sabnzbdServiceInstance: SABnzbdService | null = null; +let configLoaded = false; export async function getSABnzbdService(): Promise { - if (sabnzbdServiceInstance) { + // Always recreate if config hasn't been loaded successfully + if (sabnzbdServiceInstance && configLoaded) { return sabnzbdServiceInstance; } - // Load configuration from download client manager (uses new multi-client config format) - const { getConfigService } = await import('../services/config.service'); - const { getDownloadClientManager } = await import('../services/download-client-manager.service'); - const configService = await getConfigService(); - const manager = getDownloadClientManager(configService); + try { + // Load configuration from download client manager (uses new multi-client config format) + const { getConfigService } = await import('../services/config.service'); + const { getDownloadClientManager } = await import('../services/download-client-manager.service'); + const configService = await getConfigService(); + const manager = getDownloadClientManager(configService); - logger.info('Loading configuration from download client manager...'); - const clientConfig = await manager.getClientForProtocol('usenet'); + logger.info('Loading configuration from download client manager...'); + const clientConfig = await manager.getClientForProtocol('usenet'); - if (!clientConfig) { - throw new Error('SABnzbd is not configured. Please configure a SABnzbd client in the admin settings.'); + if (!clientConfig) { + throw new Error('SABnzbd is not configured. Please configure a SABnzbd client in the admin settings.'); + } + + if (clientConfig.type !== 'sabnzbd') { + throw new Error(`Expected SABnzbd client but found ${clientConfig.type}`); + } + + // Get download_dir from main config + const downloadDir = await configService.get('download_dir') || '/downloads'; + + logger.debug('RMAB download_dir from config', { downloadDir }); + + // Build path mapping configuration from client settings + const pathMappingConfig: PathMappingConfig = { + enabled: clientConfig.remotePathMappingEnabled || false, + remotePath: clientConfig.remotePath || '', + localPath: clientConfig.localPath || '', + }; + + logger.debug('Path mapping configuration built', { + enabled: pathMappingConfig.enabled, + remotePath: pathMappingConfig.remotePath || '(not set)', + localPath: pathMappingConfig.localPath || '(not set)', + explanation: pathMappingConfig.enabled + ? `Will translate "${pathMappingConfig.localPath}" ↔ "${pathMappingConfig.remotePath}"` + : 'Path mapping disabled - paths used as-is', + }); + + logger.info('Config loaded:', { + name: clientConfig.name, + hasUrl: !!clientConfig.url, + hasApiKey: !!clientConfig.password, + disableSSLVerify: clientConfig.disableSSLVerify, + downloadDir, + pathMappingEnabled: pathMappingConfig.enabled, + }); + + if (!clientConfig.url || !clientConfig.password) { + throw new Error('SABnzbd is not fully configured. Please check your configuration in admin settings.'); + } + + sabnzbdServiceInstance = new SABnzbdService( + clientConfig.url, + clientConfig.password, // API key stored in password field + clientConfig.category || 'readmeabook', + downloadDir, + clientConfig.disableSSLVerify, + pathMappingConfig + ); + + // Ensure category exists with correct path (handles path mapping and complete_dir sync) + await sabnzbdServiceInstance.ensureCategory(); + + configLoaded = true; + return sabnzbdServiceInstance; + } catch (error) { + logger.error('Failed to initialize service', { error: error instanceof Error ? error.message : String(error) }); + sabnzbdServiceInstance = null; + configLoaded = false; + throw error; } - - if (clientConfig.type !== 'sabnzbd') { - throw new Error(`Expected SABnzbd client but found ${clientConfig.type}`); - } - - logger.info('Config loaded:', { - name: clientConfig.name, - hasUrl: !!clientConfig.url, - hasApiKey: !!clientConfig.password, - disableSSLVerify: clientConfig.disableSSLVerify, - }); - - if (!clientConfig.url || !clientConfig.password) { - throw new Error('SABnzbd is not fully configured. Please check your configuration in admin settings.'); - } - - sabnzbdServiceInstance = new SABnzbdService( - clientConfig.url, - clientConfig.password, // API key stored in password field - clientConfig.category || 'readmeabook', - clientConfig.disableSSLVerify - ); - - // Ensure category exists - const downloadDir = await configService.get('download_dir'); - await sabnzbdServiceInstance.ensureCategory(downloadDir || undefined); - - return sabnzbdServiceInstance; } export function invalidateSABnzbdService(): void { sabnzbdServiceInstance = null; + configLoaded = false; logger.info('Service singleton invalidated'); } diff --git a/src/lib/services/download-client-manager.service.ts b/src/lib/services/download-client-manager.service.ts index ab15e55..c82190c 100644 --- a/src/lib/services/download-client-manager.service.ts +++ b/src/lib/services/download-client-manager.service.ts @@ -196,11 +196,21 @@ export class DownloadClientManager { * Create SABnzbd service instance */ private createSABnzbdService(config: DownloadClientConfig): SABnzbdService { + const pathMapping: PathMappingConfig | undefined = config.remotePathMappingEnabled && config.remotePath && config.localPath + ? { + enabled: true, + remotePath: config.remotePath, + localPath: config.localPath, + } + : undefined; + return new SABnzbdService( config.url, config.password, // API key stored in password field config.category || 'readmeabook', // defaultCategory - config.disableSSLVerify + '/downloads', // defaultDownloadDir (will be overridden by singleton with actual config) + config.disableSSLVerify, + pathMapping ); } diff --git a/src/lib/services/request-delete.service.ts b/src/lib/services/request-delete.service.ts index d99928c..3119e83 100644 --- a/src/lib/services/request-delete.service.ts +++ b/src/lib/services/request-delete.service.ts @@ -341,42 +341,62 @@ export async function deleteRequest( } } - // Delete ALL plex_library records matching this audiobook's title and author - // This handles cases where there might be duplicate library records - // and ensures the book doesn't show as "In Your Library" during searches + // Delete plex_library records to ensure book shows as NOT available + // Uses ASIN-based matching (same as availability check) for consistency try { - // Find all matching library records (by title/author fuzzy match) - const matchingLibraryRecords = await prisma.plexLibrary.findMany({ - where: { - title: { - contains: request.audiobook.title.substring(0, 20), - mode: 'insensitive', + let deletedCount = 0; + + // Primary method: Delete by ASIN (matches availability check logic exactly) + // This ensures the same record found during availability check gets deleted + if (request.audiobook.audibleAsin) { + const asinDeleteResult = await prisma.plexLibrary.deleteMany({ + where: { + OR: [ + { asin: request.audiobook.audibleAsin }, + { plexGuid: { contains: request.audiobook.audibleAsin } }, + ], }, - }, - }); + }); + deletedCount = asinDeleteResult.count; - // Filter to exact matches (case-insensitive title and author) - const exactMatches = matchingLibraryRecords.filter((record) => { - const titleMatch = record.title.toLowerCase() === request.audiobook.title.toLowerCase(); - const authorMatch = record.author.toLowerCase() === request.audiobook.author.toLowerCase(); - return titleMatch && authorMatch; - }); + if (deletedCount > 0) { + logger.info( + `Deleted ${deletedCount} plex_library record(s) by ASIN "${request.audiobook.audibleAsin}" for "${request.audiobook.title}"` + ); + } + } - if (exactMatches.length > 0) { - // Delete all exact matches - const deletePromises = exactMatches.map((record) => - prisma.plexLibrary.delete({ where: { id: record.id } }) - ); + // Fallback: Delete by exact title/author match (for legacy records without ASIN) + // Only used if ASIN deletion didn't find any records + if (deletedCount === 0) { + const matchingLibraryRecords = await prisma.plexLibrary.findMany({ + where: { + title: { + equals: request.audiobook.title, + mode: 'insensitive', + }, + author: { + equals: request.audiobook.author, + mode: 'insensitive', + }, + }, + }); - await Promise.all(deletePromises); + if (matchingLibraryRecords.length > 0) { + const deletePromises = matchingLibraryRecords.map((record) => + prisma.plexLibrary.delete({ where: { id: record.id } }) + ); + await Promise.all(deletePromises); + deletedCount = matchingLibraryRecords.length; - logger.info( - `Deleted ${exactMatches.length} plex_library record(s) for "${request.audiobook.title}"` - ); - } else { - logger.info( - `No plex_library records found for "${request.audiobook.title}"` - ); + logger.info( + `Deleted ${deletedCount} plex_library record(s) by title/author for "${request.audiobook.title}"` + ); + } else { + logger.info( + `No plex_library records found for "${request.audiobook.title}" (ASIN: ${request.audiobook.audibleAsin || 'none'})` + ); + } } } catch (libError) { logger.error( diff --git a/tests/app/admin/components/RequestActionsDropdown.test.tsx b/tests/app/admin/components/RequestActionsDropdown.test.tsx index 2f28280..be8c7af 100644 --- a/tests/app/admin/components/RequestActionsDropdown.test.tsx +++ b/tests/app/admin/components/RequestActionsDropdown.test.tsx @@ -98,9 +98,9 @@ describe('RequestActionsDropdown', () => { fireEvent.click(screen.getByTitle('Actions')); expect(screen.getByText('View Source')).toBeInTheDocument(); - expect(screen.getByText('Try to fetch Ebook')).toBeInTheDocument(); + expect(screen.getByText('Grab Ebook')).toBeInTheDocument(); - fireEvent.click(screen.getByText('Try to fetch Ebook')); + fireEvent.click(screen.getByText('Grab Ebook')); await waitFor(() => expect(onFetchEbook).toHaveBeenCalledWith('req-2')); }); }); diff --git a/tests/components/admin/indexers/IndexerConfigModal.test.tsx b/tests/components/admin/indexers/IndexerConfigModal.test.tsx index 8487fd5..9d079cb 100644 --- a/tests/components/admin/indexers/IndexerConfigModal.test.tsx +++ b/tests/components/admin/indexers/IndexerConfigModal.test.tsx @@ -44,7 +44,8 @@ describe('IndexerConfigModal', () => { priority: 25, seedingTimeMinutes: 0, rssEnabled: false, - categories: expect.arrayContaining([3030]), + audiobookCategories: expect.arrayContaining([3030]), + ebookCategories: expect.arrayContaining([7020]), }) ); expect(onClose).toHaveBeenCalledTimes(1); @@ -63,6 +64,7 @@ describe('IndexerConfigModal', () => { /> ); + // Find the Audiobook toggle in the category tree and click it to deselect const audiobookLabel = screen.getByText('Audiobook'); const audiobookRow = audiobookLabel.closest('div')?.parentElement; if (!audiobookRow) { @@ -72,7 +74,8 @@ describe('IndexerConfigModal', () => { fireEvent.click(within(audiobookRow).getByRole('switch')); fireEvent.click(screen.getByRole('button', { name: 'Add Indexer' })); - expect(screen.getByText('At least one category must be selected')).toBeInTheDocument(); + // Component now shows specific error for audiobook categories + expect(screen.getByText('At least one audiobook category must be selected')).toBeInTheDocument(); expect(onSave).not.toHaveBeenCalled(); }); diff --git a/tests/components/audiobooks/AudiobookDetailsModal.test.tsx b/tests/components/audiobooks/AudiobookDetailsModal.test.tsx index 2b05f5f..5ea9adf 100644 --- a/tests/components/audiobooks/AudiobookDetailsModal.test.tsx +++ b/tests/components/audiobooks/AudiobookDetailsModal.test.tsx @@ -12,6 +12,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const useAuthMock = vi.hoisted(() => vi.fn()); const useAudiobookDetailsMock = vi.hoisted(() => vi.fn()); const createRequestMock = vi.hoisted(() => vi.fn()); +const fetchEbookMock = vi.hoisted(() => vi.fn()); +const revalidateEbookStatusMock = vi.hoisted(() => vi.fn()); vi.mock('@/contexts/AuthContext', () => ({ useAuth: () => useAuthMock(), @@ -23,6 +25,11 @@ vi.mock('@/lib/hooks/useAudiobooks', () => ({ vi.mock('@/lib/hooks/useRequests', () => ({ useCreateRequest: () => ({ createRequest: createRequestMock, isLoading: false }), + useEbookStatus: () => ({ + ebookStatus: { ebookSourcesEnabled: false, hasActiveEbookRequest: false }, + revalidate: revalidateEbookStatusMock, + }), + useFetchEbookByAsin: () => ({ fetchEbook: fetchEbookMock, isLoading: false }), })); vi.mock('@/components/requests/InteractiveTorrentSearchModal', () => ({ diff --git a/tests/components/requests/InteractiveTorrentSearchModal.test.tsx b/tests/components/requests/InteractiveTorrentSearchModal.test.tsx index 1ca1b59..3b22cf4 100644 --- a/tests/components/requests/InteractiveTorrentSearchModal.test.tsx +++ b/tests/components/requests/InteractiveTorrentSearchModal.test.tsx @@ -13,6 +13,10 @@ const searchByRequestMock = vi.hoisted(() => vi.fn()); const selectTorrentMock = vi.hoisted(() => vi.fn()); const searchByAudiobookMock = vi.hoisted(() => vi.fn()); const requestWithTorrentMock = vi.hoisted(() => vi.fn()); +const searchEbooksMock = vi.hoisted(() => vi.fn()); +const selectEbookMock = vi.hoisted(() => vi.fn()); +const searchEbooksByAsinMock = vi.hoisted(() => vi.fn()); +const selectEbookByAsinMock = vi.hoisted(() => vi.fn()); vi.mock('@/lib/hooks/useRequests', () => ({ useInteractiveSearch: () => ({ @@ -35,6 +39,26 @@ vi.mock('@/lib/hooks/useRequests', () => ({ isLoading: false, error: null, }), + useInteractiveSearchEbook: () => ({ + searchEbooks: searchEbooksMock, + isLoading: false, + error: null, + }), + useSelectEbook: () => ({ + selectEbook: selectEbookMock, + isLoading: false, + error: null, + }), + useInteractiveSearchEbookByAsin: () => ({ + searchEbooks: searchEbooksByAsinMock, + isLoading: false, + error: null, + }), + useSelectEbookByAsin: () => ({ + selectEbook: selectEbookByAsinMock, + isLoading: false, + error: null, + }), })); const baseResult = { diff --git a/tests/integrations/sabnzbd.service.test.ts b/tests/integrations/sabnzbd.service.test.ts index 5204b75..da65477 100644 --- a/tests/integrations/sabnzbd.service.test.ts +++ b/tests/integrations/sabnzbd.service.test.ts @@ -119,31 +119,43 @@ describe('SABnzbdService', () => { }); it('adds NZB with mapped priority', async () => { - clientMock.get.mockResolvedValueOnce({ - data: { status: true, nzo_ids: ['nzb-1'] }, - }); + // Mock getConfig for ensureCategory (called before adding NZB) + clientMock.get + .mockResolvedValueOnce({ + data: { config: { version: '1', misc: { complete_dir: '/downloads' }, categories: { books: { dir: '' } } } }, + }) + .mockResolvedValueOnce({ + data: { status: true, nzo_ids: ['nzb-1'] }, + }); - const service = new SABnzbdService('http://sab', 'key'); + const service = new SABnzbdService('http://sab', 'key', 'books', '/downloads'); const nzbId = await service.addNZB('https://example.com/book.nzb', { category: 'books', priority: 'high', }); - const params = clientMock.get.mock.calls[0][1].params; + // Second call is the addurl call + const params = clientMock.get.mock.calls[1][1].params; expect(nzbId).toBe('nzb-1'); expect(params.cat).toBe('books'); expect(params.priority).toBe('1'); }); it('adds NZB with force priority', async () => { - clientMock.get.mockResolvedValueOnce({ - data: { status: true, nzo_ids: ['nzb-9'] }, - }); + // Mock getConfig for ensureCategory (called before adding NZB) + clientMock.get + .mockResolvedValueOnce({ + data: { config: { version: '1', misc: { complete_dir: '/downloads' }, categories: { readmeabook: { dir: '' } } } }, + }) + .mockResolvedValueOnce({ + data: { status: true, nzo_ids: ['nzb-9'] }, + }); - const service = new SABnzbdService('http://sab', 'key'); + const service = new SABnzbdService('http://sab', 'key', 'readmeabook', '/downloads'); await service.addNZB('https://example.com/book.nzb', { priority: 'force' }); - const params = clientMock.get.mock.calls[0][1].params; + // Second call is the addurl call + const params = clientMock.get.mock.calls[1][1].params; expect(params.priority).toBe('2'); }); @@ -376,12 +388,12 @@ describe('SABnzbdService', () => { it('creates the default category when missing', async () => { clientMock.get .mockResolvedValueOnce({ - data: { config: { version: '1', categories: {} } }, + data: { config: { version: '1', misc: { complete_dir: '/mnt/usenet/complete' }, categories: {} } }, }) .mockResolvedValueOnce({ data: { status: true } }); - const service = new SABnzbdService('http://sab', 'key', 'readmeabook'); - await service.ensureCategory('/downloads'); + const service = new SABnzbdService('http://sab', 'key', 'readmeabook', '/downloads'); + await service.ensureCategory(); expect(clientMock.get).toHaveBeenCalledWith('/api', expect.objectContaining({ params: expect.objectContaining({ mode: 'set_config', keyword: 'readmeabook' }), @@ -389,46 +401,58 @@ describe('SABnzbdService', () => { }); it('swallows errors when ensuring categories fails', async () => { - const service = new SABnzbdService('http://sab', 'key', 'readmeabook'); + const service = new SABnzbdService('http://sab', 'key', 'readmeabook', '/downloads'); const configSpy = vi.spyOn(service, 'getConfig').mockRejectedValue(new Error('bad config')); - await expect(service.ensureCategory('/downloads')).resolves.toBeUndefined(); + await expect(service.ensureCategory()).resolves.toBeUndefined(); configSpy.mockRestore(); }); - it('does not create category when it already exists', async () => { + it('does not create category when it already exists with correct path', async () => { clientMock.get.mockResolvedValueOnce({ data: { config: { version: '1', + misc: { complete_dir: '/mnt/usenet/complete' }, categories: { readmeabook: { dir: '/downloads' } }, }, }, }); - const service = new SABnzbdService('http://sab', 'key', 'readmeabook'); - await service.ensureCategory('/downloads'); + const service = new SABnzbdService('http://sab', 'key', 'readmeabook', '/downloads'); + await service.ensureCategory(); + // Only get_config called, no set_config because path already matches expect(clientMock.get).toHaveBeenCalledTimes(1); expect(clientMock.get.mock.calls[0][1].params.mode).toBe('get_config'); }); it('throws when addNZB reports a failure', async () => { - clientMock.get.mockResolvedValueOnce({ - data: { status: false, error: 'Bad NZB' }, - }); + // Mock getConfig for ensureCategory, then the addurl failure + clientMock.get + .mockResolvedValueOnce({ + data: { config: { version: '1', misc: { complete_dir: '/downloads' }, categories: { readmeabook: { dir: '' } } } }, + }) + .mockResolvedValueOnce({ + data: { status: false, error: 'Bad NZB' }, + }); - const service = new SABnzbdService('http://sab', 'key'); + const service = new SABnzbdService('http://sab', 'key', 'readmeabook', '/downloads'); await expect(service.addNZB('https://example.com/book.nzb')).rejects.toThrow('Bad NZB'); }); it('throws when SABnzbd returns no NZB IDs', async () => { - clientMock.get.mockResolvedValueOnce({ - data: { status: true, nzo_ids: [] }, - }); + // Mock getConfig for ensureCategory, then the addurl with empty IDs + clientMock.get + .mockResolvedValueOnce({ + data: { config: { version: '1', misc: { complete_dir: '/downloads' }, categories: { readmeabook: { dir: '' } } } }, + }) + .mockResolvedValueOnce({ + data: { status: true, nzo_ids: [] }, + }); - const service = new SABnzbdService('http://sab', 'key'); + const service = new SABnzbdService('http://sab', 'key', 'readmeabook', '/downloads'); await expect(service.addNZB('https://example.com/book.nzb')).rejects.toThrow('did not return an NZB ID'); }); @@ -491,8 +515,281 @@ describe('SABnzbdService', () => { const again = await getSABnzbdService(); expect(service).toBe(again); - expect(ensureSpy).toHaveBeenCalledWith('/downloads'); + expect(ensureSpy).toHaveBeenCalled(); ensureSpy.mockRestore(); }); + + it('creates singleton with path mapping config when enabled', async () => { + downloadClientManagerMock.getClientForProtocol.mockResolvedValue({ + id: 'client-1', + type: 'sabnzbd', + name: 'SABnzbd', + enabled: true, + url: 'http://sab', + password: 'api-key', + disableSSLVerify: false, + remotePathMappingEnabled: true, + remotePath: '/mnt/usenet/complete', + localPath: '/downloads', + category: 'readmeabook', + }); + configServiceMock.get.mockResolvedValue('/downloads'); + + const ensureSpy = vi.spyOn(SABnzbdService.prototype, 'ensureCategory').mockResolvedValue(); + + const service = await getSABnzbdService(); + + expect(service).toBeDefined(); + expect(ensureSpy).toHaveBeenCalled(); + + ensureSpy.mockRestore(); + }); + + describe('Path Mapping', () => { + it('uses empty category path when download_dir matches complete_dir', async () => { + clientMock.get + .mockResolvedValueOnce({ + data: { + config: { + version: '1', + misc: { complete_dir: '/downloads' }, + categories: {}, + }, + }, + }) + .mockResolvedValueOnce({ data: { status: true } }); + + const service = new SABnzbdService('http://sab', 'key', 'readmeabook', '/downloads'); + await service.ensureCategory(); + + // Should set empty dir when paths match + const setCategoryCall = clientMock.get.mock.calls.find( + (call) => call[1]?.params?.mode === 'set_config' + ); + expect(setCategoryCall).toBeDefined(); + expect(setCategoryCall![1].params.dir).toBe(''); + }); + + it('uses relative path when download_dir is under complete_dir', async () => { + clientMock.get + .mockResolvedValueOnce({ + data: { + config: { + version: '1', + misc: { complete_dir: '/mnt/usenet/complete' }, + categories: {}, + }, + }, + }) + .mockResolvedValueOnce({ data: { status: true } }); + + const service = new SABnzbdService( + 'http://sab', + 'key', + 'readmeabook', + '/mnt/usenet/complete/audiobooks' + ); + await service.ensureCategory(); + + const setCategoryCall = clientMock.get.mock.calls.find( + (call) => call[1]?.params?.mode === 'set_config' + ); + expect(setCategoryCall).toBeDefined(); + expect(setCategoryCall![1].params.dir).toBe('audiobooks'); + }); + + it('uses absolute path when download_dir differs from complete_dir', async () => { + clientMock.get + .mockResolvedValueOnce({ + data: { + config: { + version: '1', + misc: { complete_dir: '/mnt/usenet/complete' }, + categories: {}, + }, + }, + }) + .mockResolvedValueOnce({ data: { status: true } }); + + const service = new SABnzbdService( + 'http://sab', + 'key', + 'readmeabook', + '/different/path/audiobooks' + ); + await service.ensureCategory(); + + const setCategoryCall = clientMock.get.mock.calls.find( + (call) => call[1]?.params?.mode === 'set_config' + ); + expect(setCategoryCall).toBeDefined(); + expect(setCategoryCall![1].params.dir).toBe('/different/path/audiobooks'); + }); + + it('applies reverse path mapping before comparing with complete_dir', async () => { + clientMock.get + .mockResolvedValueOnce({ + data: { + config: { + version: '1', + misc: { complete_dir: '/mnt/usenet/complete' }, + categories: {}, + }, + }, + }) + .mockResolvedValueOnce({ data: { status: true } }); + + // RMAB sees /downloads but SABnzbd sees /mnt/usenet/complete + const pathMappingConfig = { + enabled: true, + remotePath: '/mnt/usenet/complete', + localPath: '/downloads', + }; + + const service = new SABnzbdService( + 'http://sab', + 'key', + 'readmeabook', + '/downloads', // RMAB's local path + false, + pathMappingConfig + ); + await service.ensureCategory(); + + // After reverse transform, /downloads becomes /mnt/usenet/complete + // which matches complete_dir, so category dir should be empty + const setCategoryCall = clientMock.get.mock.calls.find( + (call) => call[1]?.params?.mode === 'set_config' + ); + expect(setCategoryCall).toBeDefined(); + expect(setCategoryCall![1].params.dir).toBe(''); + }); + + it('updates category path when it differs from calculated path', async () => { + clientMock.get + .mockResolvedValueOnce({ + data: { + config: { + version: '1', + misc: { complete_dir: '/mnt/usenet/complete' }, + categories: { readmeabook: { dir: '/old/path' } }, + }, + }, + }) + .mockResolvedValueOnce({ data: { status: true } }); + + const service = new SABnzbdService( + 'http://sab', + 'key', + 'readmeabook', + '/mnt/usenet/complete/audiobooks' + ); + await service.ensureCategory(); + + // Should update the category with new relative path + const setCategoryCall = clientMock.get.mock.calls.find( + (call) => call[1]?.params?.mode === 'set_config' + ); + expect(setCategoryCall).toBeDefined(); + expect(setCategoryCall![1].params.dir).toBe('audiobooks'); + }); + + it('fetches complete_dir from SABnzbd config', async () => { + clientMock.get.mockResolvedValueOnce({ + data: { + config: { + version: '4.0.0', + misc: { complete_dir: '/mnt/usenet/complete' }, + categories: { test: { dir: 'test-dir' } }, + }, + }, + }); + + const service = new SABnzbdService('http://sab', 'key', 'readmeabook', '/downloads'); + const config = await service.getConfig(); + + expect(config.completeDir).toBe('/mnt/usenet/complete'); + expect(config.categories).toEqual([{ name: 'test', dir: 'test-dir' }]); + }); + + it('returns complete_dir via getCompleteDir helper', async () => { + clientMock.get.mockResolvedValueOnce({ + data: { + config: { + version: '4.0.0', + misc: { complete_dir: '/var/usenet/done' }, + categories: {}, + }, + }, + }); + + const service = new SABnzbdService('http://sab', 'key', 'readmeabook', '/downloads'); + const completeDir = await service.getCompleteDir(); + + expect(completeDir).toBe('/var/usenet/done'); + }); + + it('handles missing complete_dir gracefully', async () => { + clientMock.get + .mockResolvedValueOnce({ + data: { + config: { + version: '4.0.0', + misc: {}, // No complete_dir + categories: {}, + }, + }, + }) + .mockResolvedValueOnce({ data: { status: true } }); + + const service = new SABnzbdService('http://sab', 'key', 'readmeabook', '/downloads'); + await service.ensureCategory(); + + // Should fallback to using download_dir directly + const setCategoryCall = clientMock.get.mock.calls.find( + (call) => call[1]?.params?.mode === 'set_config' + ); + expect(setCategoryCall).toBeDefined(); + expect(setCategoryCall![1].params.dir).toBe('/downloads'); + }); + + it('handles Windows-style paths in path mapping', async () => { + clientMock.get + .mockResolvedValueOnce({ + data: { + config: { + version: '1', + misc: { complete_dir: 'D:\\Usenet\\Complete' }, + categories: {}, + }, + }, + }) + .mockResolvedValueOnce({ data: { status: true } }); + + const pathMappingConfig = { + enabled: true, + remotePath: 'D:\\Usenet\\Complete', + localPath: '/downloads', + }; + + const service = new SABnzbdService( + 'http://sab', + 'key', + 'readmeabook', + '/downloads', + false, + pathMappingConfig + ); + await service.ensureCategory(); + + // After reverse transform and comparison (normalized), should match + const setCategoryCall = clientMock.get.mock.calls.find( + (call) => call[1]?.params?.mode === 'set_config' + ); + expect(setCategoryCall).toBeDefined(); + // Path should be empty since /downloads maps to D:\Usenet\Complete which matches complete_dir + expect(setCategoryCall![1].params.dir).toBe(''); + }); + }); }); diff --git a/tests/processors/search-ebook.processor.test.ts b/tests/processors/search-ebook.processor.test.ts index 9aa5b20..d11e149 100644 --- a/tests/processors/search-ebook.processor.test.ts +++ b/tests/processors/search-ebook.processor.test.ts @@ -42,6 +42,8 @@ describe('processSearchEbook', () => { configServiceMock.get.mockImplementation(async (key: string) => { if (key === 'ebook_sidecar_preferred_format') return 'epub'; if (key === 'ebook_sidecar_base_url') return 'https://annas-archive.li'; + if (key === 'ebook_annas_archive_enabled') return 'true'; + if (key === 'ebook_indexer_search_enabled') return 'false'; return null; }); }); @@ -71,7 +73,7 @@ describe('processSearchEbook', () => { }); expect(result.success).toBe(true); - expect(result.message).toContain('ASIN'); + expect(result.message).toContain("Anna's Archive"); expect(ebookScraperMock.searchByAsin).toHaveBeenCalledWith( 'B001ASIN', 'epub', @@ -113,7 +115,7 @@ describe('processSearchEbook', () => { }); expect(result.success).toBe(true); - expect(result.message).toContain('title search'); + expect(result.message).toContain("Anna's Archive"); expect(ebookScraperMock.searchByAsin).toHaveBeenCalled(); expect(ebookScraperMock.searchByTitle).toHaveBeenCalledWith( 'Another Book', @@ -179,6 +181,7 @@ describe('processSearchEbook', () => { data: expect.objectContaining({ status: 'awaiting_search', errorMessage: expect.stringContaining('No ebook found'), + lastSearchAt: expect.any(Date), }), }); expect(jobQueueMock.addStartDirectDownloadJob).not.toHaveBeenCalled(); @@ -209,7 +212,8 @@ describe('processSearchEbook', () => { where: { id: 'req-5' }, data: expect.objectContaining({ status: 'awaiting_search', - errorMessage: expect.stringContaining('no download links'), + errorMessage: expect.stringContaining('No ebook found'), + lastSearchAt: expect.any(Date), }), }); }); @@ -223,6 +227,8 @@ describe('processSearchEbook', () => { if (key === 'ebook_sidecar_preferred_format') return 'epub'; if (key === 'ebook_sidecar_base_url') return 'https://annas-archive.li'; if (key === 'ebook_sidecar_flaresolverr_url') return 'http://flaresolverr:8191'; + if (key === 'ebook_annas_archive_enabled') return 'true'; + if (key === 'ebook_indexer_search_enabled') return 'false'; return null; }); diff --git a/tests/services/request-delete.service.test.ts b/tests/services/request-delete.service.test.ts index b08326a..ab55a54 100644 --- a/tests/services/request-delete.service.test.ts +++ b/tests/services/request-delete.service.test.ts @@ -110,9 +110,8 @@ describe('deleteRequest', () => { prismaMock.audibleCache.findUnique.mockResolvedValueOnce({ releaseDate: '2021-01-01T00:00:00.000Z', }); - prismaMock.plexLibrary.findMany.mockResolvedValue([ - { id: 'lib-1', title: 'Book', author: 'Author' }, - ]); + // Mock deleteMany for ASIN-based deletion + prismaMock.plexLibrary.deleteMany.mockResolvedValue({ count: 1 }); fsMock.access.mockResolvedValue(undefined); fsMock.rm.mockResolvedValue(undefined); prismaMock.request.update.mockResolvedValue({}); @@ -124,7 +123,15 @@ describe('deleteRequest', () => { expect(result.success).toBe(true); expect(result.torrentsRemoved).toBe(1); expect(qbtMock.deleteTorrent).toHaveBeenCalledWith('hash-1', true); - expect(prismaMock.plexLibrary.delete).toHaveBeenCalledWith({ where: { id: 'lib-1' } }); + // Code now uses deleteMany with ASIN-based matching + expect(prismaMock.plexLibrary.deleteMany).toHaveBeenCalledWith({ + where: { + OR: [ + { asin: 'ASIN1' }, + { plexGuid: { contains: 'ASIN1' } }, + ], + }, + }); const expectedPath = path.join('/media', 'Author', 'Book ASIN1'); expect(fsMock.rm).toHaveBeenCalledWith(expectedPath, { recursive: true, force: true });