/** * Component: Library Scan Job Processor * Documentation: documentation/backend/services/jobs.md * * Scans library (Plex or Audiobookshelf) and populates plex_library table with all audiobooks. * Works with both Plex and Audiobookshelf backends via abstraction layer. */ import { ScanPlexPayload } from '../services/job-queue.service'; import { prisma } from '../db'; import { getLibraryService } from '../services/library'; import { getConfigService } from '../services/config.service'; import { RMABLogger } from '../utils/logger'; /** * Process library scan job * Scans library and updates plex_library table (works for both Plex and Audiobookshelf) */ export async function processScanPlex(payload: ScanPlexPayload): Promise { const { libraryId, partial, path, jobId } = payload; const logger = RMABLogger.forJob(jobId, 'ScanLibrary'); logger.info(`Scanning library ${libraryId || 'default'}${partial ? ' (partial)' : ''}`); try { // 1. Get library service (automatically selects Plex or Audiobookshelf based on config) const libraryService = await getLibraryService(); const configService = getConfigService(); const backendMode = await configService.getBackendMode(); logger.info(`Backend mode: ${backendMode}`); // 2. Get configured library ID let targetLibraryId = libraryId; if (!targetLibraryId) { if (backendMode === 'audiobookshelf') { const absLibraryId = await configService.get('audiobookshelf.library_id'); if (!absLibraryId) { throw new Error('Audiobookshelf library not configured'); } targetLibraryId = absLibraryId; } else { const plexConfig = await configService.getPlexConfig(); if (!plexConfig.libraryId) { throw new Error('Plex audiobook library not configured'); } targetLibraryId = plexConfig.libraryId; } } logger.info(`Fetching content from library ${targetLibraryId}`); // 3. Get all audiobooks from library using abstraction layer const libraryItems = await libraryService.getLibraryItems(targetLibraryId); logger.info(`Found ${libraryItems.length} items in library`); let newCount = 0; let updatedCount = 0; let skippedCount = 0; const results: any[] = []; // 4. Process each library item - populate plex_library table // Note: Table is still called plex_library for backwards compatibility, but now stores items from any backend for (const item of libraryItems) { if (!item.title || !item.externalId) { skippedCount++; continue; } try { // Check if this audiobook already exists in plex_library by externalId (plexGuid or abs_item_id) const existing = await prisma.plexLibrary.findFirst({ where: { plexGuid: item.externalId }, }); if (existing) { // Update existing record with latest data await prisma.plexLibrary.update({ where: { id: existing.id }, data: { title: item.title, author: item.author || existing.author, narrator: item.narrator || existing.narrator, summary: item.description || existing.summary, duration: item.duration ? item.duration * 1000 : existing.duration, // Convert seconds to milliseconds year: item.year || existing.year, asin: item.asin || existing.asin, // Store ASIN from library backend isbn: item.isbn || existing.isbn, // Store ISBN from library backend thumbUrl: item.coverUrl || existing.thumbUrl, plexLibraryId: targetLibraryId, plexRatingKey: item.id || existing.plexRatingKey, lastScannedAt: new Date(), updatedAt: new Date(), }, }); updatedCount++; } else { // Create new plex_library entry const newLibraryItem = await prisma.plexLibrary.create({ data: { plexGuid: item.externalId, plexRatingKey: item.id, title: item.title, author: item.author || 'Unknown Author', narrator: item.narrator, summary: item.description, duration: item.duration ? item.duration * 1000 : null, // Convert seconds to milliseconds year: item.year, asin: item.asin, // Store ASIN from library backend (Plex or Audiobookshelf) isbn: item.isbn, // Store ISBN from library backend thumbUrl: item.coverUrl, plexLibraryId: targetLibraryId, addedAt: item.addedAt, lastScannedAt: new Date(), }, }); newCount++; logger.info(`Added new: "${item.title}" by ${item.author}`); results.push({ id: newLibraryItem.id, plexGuid: newLibraryItem.plexGuid, title: item.title, author: item.author, }); } } catch (error) { logger.error(`Failed to process "${item.title}": ${error instanceof Error ? error.message : 'Unknown error'}`); skippedCount++; } } logger.info(`Scan complete: ${libraryItems.length} items scanned, ${newCount} new, ${updatedCount} updated, ${skippedCount} skipped`); // 5. Remove stale records from plex_library (items no longer in the actual library) // This ensures the database is a fresh snapshot of the library state logger.info(`Checking for stale library records...`); const scannedPlexGuids = libraryItems .filter(item => item.externalId) .map(item => item.externalId); let staleRemovedCount = 0; let audiobooksReset = 0; let requestsReset = 0; // Safety check: Only remove stale records if we actually scanned items // This prevents accidentally deleting everything if the library scan fails or returns empty if (scannedPlexGuids.length > 0) { // Find all plex_library entries for this library that were NOT seen in this scan const staleLibraryItems = await prisma.plexLibrary.findMany({ where: { plexLibraryId: targetLibraryId, plexGuid: { notIn: scannedPlexGuids, }, }, }); if (staleLibraryItems.length > 0) { logger.info(`Found ${staleLibraryItems.length} stale library records to remove`); // For each stale library item, clean up references for (const staleItem of staleLibraryItems) { try { // Find audiobooks that reference this stale library item const linkedAudiobooks = await prisma.audiobook.findMany({ where: { OR: [ { plexGuid: staleItem.plexGuid }, { absItemId: staleItem.plexGuid }, ], }, include: { requests: { where: { deletedAt: null }, }, }, }); // Reset audiobook records and their requests for (const audiobook of linkedAudiobooks) { // Clear library linkage const updateData: any = { status: 'requested', plexGuid: null, absItemId: null, updatedAt: new Date(), }; await prisma.audiobook.update({ where: { id: audiobook.id }, data: updateData, }); audiobooksReset++; // Reset any 'available' requests back to 'downloaded' or 'failed' for (const request of audiobook.requests) { if (request.status === 'available') { await prisma.request.update({ where: { id: request.id }, data: { status: 'downloaded', // Back to downloaded state (files may still be there) updatedAt: new Date(), }, }); requestsReset++; } } logger.info(`Reset audiobook "${staleItem.title}" (no longer in library)`); } // Delete the stale library record await prisma.plexLibrary.delete({ where: { id: staleItem.id }, }); staleRemovedCount++; } catch (error) { logger.error(`Failed to remove stale library item "${staleItem.title}": ${error instanceof Error ? error.message : 'Unknown error'}`); } } logger.info(`Removed ${staleRemovedCount} stale records, reset ${audiobooksReset} audiobooks and ${requestsReset} requests`); } else { logger.info(`No stale library records found`); } } else { logger.warn(`Scan returned no items - skipping stale record cleanup to prevent data loss`); } // 5b. Clean up orphaned audiobooks (audiobooks with plexGuid/absItemId that don't exist in plex_library) // This handles cases where the library record was already deleted but audiobook record wasn't updated logger.info(`Checking for orphaned audiobooks...`); const allPlexGuidsInLibrary = await prisma.plexLibrary.findMany({ select: { plexGuid: true }, }); const validPlexGuids = allPlexGuidsInLibrary.map(item => item.plexGuid); let orphanedAudiobooksReset = 0; let orphanedRequestsReset = 0; // Find audiobooks with plexGuid/absItemId that don't exist in plex_library const orphanedAudiobooks = await prisma.audiobook.findMany({ where: { OR: [ { plexGuid: { not: null }, }, { absItemId: { not: null }, }, ], }, include: { requests: { where: { deletedAt: null }, }, }, }); for (const audiobook of orphanedAudiobooks) { const linkedId = audiobook.plexGuid || audiobook.absItemId; // Skip if this audiobook's library ID is valid (exists in plex_library) if (linkedId && validPlexGuids.includes(linkedId)) { continue; } // This audiobook is orphaned - its library link points to nothing try { logger.info(`Found orphaned audiobook: "${audiobook.title}" (linked to non-existent library item)`); // Clear library linkage await prisma.audiobook.update({ where: { id: audiobook.id }, data: { status: 'requested', plexGuid: null, absItemId: null, updatedAt: new Date(), }, }); orphanedAudiobooksReset++; // Reset any 'available' requests for (const request of audiobook.requests) { if (request.status === 'available') { await prisma.request.update({ where: { id: request.id }, data: { status: 'downloaded', updatedAt: new Date(), }, }); orphanedRequestsReset++; } } } catch (error) { logger.error(`Failed to reset orphaned audiobook "${audiobook.title}": ${error instanceof Error ? error.message : 'Unknown error'}`); } } if (orphanedAudiobooksReset > 0) { logger.info(`Reset ${orphanedAudiobooksReset} orphaned audiobooks and ${orphanedRequestsReset} requests`); } else { logger.info(`No orphaned audiobooks found`); } // 6. Match downloaded requests against library logger.info(`Checking for downloaded requests to match...`); const downloadedRequests = await prisma.request.findMany({ where: { status: 'downloaded', deletedAt: null, }, include: { audiobook: true }, take: 50, // Limit to prevent overwhelming }); logger.info(`Found ${downloadedRequests.length} downloaded requests to match`); let matchedCount = 0; const { findPlexMatch } = await import('../utils/audiobook-matcher'); for (const request of downloadedRequests) { try { const audiobook = request.audiobook; // Use the centralized matcher (handles ASIN matching, title normalization, narrator matching, etc.) // Works for both Plex and Audiobookshelf backends const match = await findPlexMatch({ asin: audiobook.audibleAsin || '', title: audiobook.title, author: audiobook.author, narrator: audiobook.narrator || undefined, }); if (match) { logger.info(`Match found! "${audiobook.title}" -> "${match.title}"`); // Update audiobook with matched library item ID (plexGuid or abs_item_id) const updateData: any = { updatedAt: new Date() }; if (backendMode === 'audiobookshelf') { updateData.absItemId = match.plexGuid; // plexGuid field stores the externalId from either backend } else { updateData.plexGuid = match.plexGuid; } await prisma.audiobook.update({ where: { id: audiobook.id }, data: updateData, }); // Update request to available await prisma.request.update({ where: { id: request.id }, data: { status: 'available', completedAt: new Date(), updatedAt: new Date(), }, }); matchedCount++; // Trigger metadata match for Audiobookshelf items (only for our downloaded requests) if (backendMode === 'audiobookshelf') { const itemId = match.plexGuid; // plexGuid contains the Audiobookshelf item ID const asin = audiobook.audibleAsin || undefined; const matchInfo = asin ? ` with ASIN ${asin}` : ''; logger.info(`Triggering metadata match for matched item: ${itemId}${matchInfo}`); const { triggerABSItemMatch } = await import('../services/audiobookshelf/api'); await triggerABSItemMatch(itemId, asin); } } } catch (error) { logger.error(`Failed to match request ${request.id}: ${error instanceof Error ? error.message : 'Unknown error'}`); } } logger.info(`Matched ${matchedCount}/${downloadedRequests.length} downloaded requests`, { totalScanned: libraryItems.length, newCount, updatedCount, skippedCount, staleRemovedCount, audiobooksReset, requestsReset, orphanedAudiobooksReset, orphanedRequestsReset, matchedDownloads: matchedCount, }); return { success: true, message: `Library scan completed successfully (${backendMode})`, backendMode, libraryId: targetLibraryId, totalScanned: libraryItems.length, newCount, updatedCount, skippedCount, staleRemovedCount, audiobooksReset, requestsReset, orphanedAudiobooksReset, orphanedRequestsReset, newAudiobooks: results, matchedDownloads: matchedCount, }; } catch (error) { logger.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`); throw error; } }