mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +00:00
682836237b
Replaces scattered console statements with a unified RMABLogger across backend API routes and services. Adds LOG_LEVEL-based filtering, job-aware database persistence, and context-based logging. Updates documentation to describe the new logging system and usage patterns. Also documents qBittorrent CSRF header fix
427 lines
15 KiB
TypeScript
427 lines
15 KiB
TypeScript
/**
|
|
* 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<any> {
|
|
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;
|
|
}
|
|
}
|