mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-02 20:30:10 +00:00
37f063229c
The duration column (Int/int4, max ~2.15B) overflows when storing millisecond values for items with large durations from Audiobookshelf or Plex backends. Change to BigInt (int8) and wrap duration calculations in BigInt() at the Prisma write boundary. Changes: - prisma/schema.prisma: PlexLibrary.duration Int? → BigInt? - plex-recently-added.processor.ts: BigInt(Math.round(...)) wrapping - scan-plex.processor.ts: same BigInt wrapping - documentation/backend/database.md: updated duration type notation Fixes #193 Co-Authored-By: Oz <oz-agent@warp.dev>
361 lines
13 KiB
TypeScript
361 lines
13 KiB
TypeScript
/**
|
|
* Component: Library Recently Added Check Processor
|
|
* Documentation: documentation/backend/services/scheduler.md
|
|
*
|
|
* Lightweight polling for new library items (Plex or Audiobookshelf)
|
|
*/
|
|
|
|
import { prisma } from '../db';
|
|
import { RMABLogger } from '../utils/logger';
|
|
import { getLibraryService } from '../services/library';
|
|
import { getThumbnailCacheService } from '../services/thumbnail-cache.service';
|
|
|
|
export interface PlexRecentlyAddedPayload {
|
|
jobId?: string;
|
|
scheduledJobId?: string;
|
|
}
|
|
|
|
export async function processPlexRecentlyAddedCheck(payload: PlexRecentlyAddedPayload): Promise<any> {
|
|
const { jobId, scheduledJobId } = payload;
|
|
const logger = RMABLogger.forJob(jobId, 'RecentlyAdded');
|
|
|
|
const { getConfigService } = await import('../services/config.service');
|
|
const configService = getConfigService();
|
|
|
|
// Get backend mode
|
|
const backendMode = await configService.getBackendMode();
|
|
logger.info(`Backend mode: ${backendMode}`);
|
|
|
|
// Validate configuration based on backend mode
|
|
if (backendMode === 'audiobookshelf') {
|
|
const absConfig = await configService.getMany([
|
|
'audiobookshelf.server_url',
|
|
'audiobookshelf.api_token',
|
|
'audiobookshelf.library_id',
|
|
]);
|
|
|
|
const missingFields: string[] = [];
|
|
if (!absConfig['audiobookshelf.server_url']) missingFields.push('Audiobookshelf server URL');
|
|
if (!absConfig['audiobookshelf.api_token']) missingFields.push('Audiobookshelf API token');
|
|
if (!absConfig['audiobookshelf.library_id']) missingFields.push('Audiobookshelf library ID');
|
|
|
|
if (missingFields.length > 0) {
|
|
const errorMsg = `Audiobookshelf is not configured. Missing: ${missingFields.join(', ')}`;
|
|
logger.warn(errorMsg);
|
|
return { success: false, message: errorMsg, skipped: true };
|
|
}
|
|
} else {
|
|
const plexConfig = await configService.getMany([
|
|
'plex_url',
|
|
'plex_token',
|
|
'plex_audiobook_library_id',
|
|
]);
|
|
|
|
const missingFields: string[] = [];
|
|
if (!plexConfig.plex_url) missingFields.push('Plex server URL');
|
|
if (!plexConfig.plex_token) missingFields.push('Plex auth token');
|
|
if (!plexConfig.plex_audiobook_library_id) missingFields.push('Plex audiobook library ID');
|
|
|
|
if (missingFields.length > 0) {
|
|
const errorMsg = `Plex is not configured. Missing: ${missingFields.join(', ')}`;
|
|
logger.warn(errorMsg);
|
|
return { success: false, message: errorMsg, skipped: true };
|
|
}
|
|
}
|
|
|
|
logger.info(`Starting recently added check...`);
|
|
|
|
// Get library service (automatically selects Plex or Audiobookshelf)
|
|
const libraryService = await getLibraryService();
|
|
const thumbnailCacheService = getThumbnailCacheService();
|
|
|
|
try {
|
|
// Get configured library ID
|
|
const libraryId = backendMode === 'audiobookshelf'
|
|
? await configService.get('audiobookshelf.library_id')
|
|
: await configService.get('plex_audiobook_library_id');
|
|
|
|
// Get cover caching parameters (needed for thumbnail caching)
|
|
const coverCachingParams = await (libraryService as any).getCoverCachingParams();
|
|
|
|
// Fetch top 10 recently added items using abstraction layer
|
|
const recentItems = await libraryService.getRecentlyAdded(libraryId!, 10);
|
|
|
|
logger.info(`Found ${recentItems.length} recently added items`);
|
|
|
|
if (recentItems.length === 0) {
|
|
return { success: true, message: 'No recent items', newCount: 0, updatedCount: 0, matchedDownloads: 0 };
|
|
}
|
|
|
|
// Check for new items not in database
|
|
let newCount = 0;
|
|
let updatedCount = 0;
|
|
let matchedDownloads = 0;
|
|
|
|
for (const item of recentItems) {
|
|
const existing = await prisma.plexLibrary.findUnique({
|
|
where: { plexGuid: item.externalId },
|
|
});
|
|
|
|
if (!existing) {
|
|
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 ? BigInt(Math.round(item.duration * 1000)) : null, // Convert seconds to milliseconds
|
|
year: item.year,
|
|
asin: item.asin, // Store ASIN from library backend
|
|
isbn: item.isbn, // Store ISBN from library backend
|
|
thumbUrl: item.coverUrl,
|
|
plexLibraryId: libraryId!,
|
|
addedAt: item.addedAt,
|
|
lastScannedAt: new Date(),
|
|
},
|
|
});
|
|
|
|
// Cache library cover (synchronous with smart skip-if-exists logic)
|
|
if (item.coverUrl && item.externalId) {
|
|
const cachedPath = await thumbnailCacheService.cacheLibraryThumbnail(
|
|
item.externalId,
|
|
item.coverUrl,
|
|
coverCachingParams.backendBaseUrl,
|
|
coverCachingParams.authToken,
|
|
coverCachingParams.backendMode
|
|
);
|
|
|
|
// Update database with cached path if successful
|
|
if (cachedPath) {
|
|
await prisma.plexLibrary.update({
|
|
where: { id: newLibraryItem.id },
|
|
data: { cachedLibraryCoverPath: cachedPath },
|
|
});
|
|
}
|
|
}
|
|
|
|
newCount++;
|
|
logger.info(`New item added: ${item.title} by ${item.author}`);
|
|
} else {
|
|
await prisma.plexLibrary.update({
|
|
where: { plexGuid: item.externalId },
|
|
data: {
|
|
title: item.title,
|
|
author: item.author || existing.author,
|
|
narrator: item.narrator || existing.narrator,
|
|
summary: item.description || existing.summary,
|
|
duration: item.duration ? BigInt(Math.round(item.duration * 1000)) : existing.duration,
|
|
year: item.year || existing.year,
|
|
asin: item.asin || existing.asin, // Update ASIN if available
|
|
isbn: item.isbn || existing.isbn, // Update ISBN if available
|
|
thumbUrl: item.coverUrl || existing.thumbUrl,
|
|
lastScannedAt: new Date(),
|
|
},
|
|
});
|
|
|
|
// Cache library cover (synchronous with smart skip-if-exists logic)
|
|
if (item.coverUrl && item.externalId) {
|
|
const cachedPath = await thumbnailCacheService.cacheLibraryThumbnail(
|
|
item.externalId,
|
|
item.coverUrl,
|
|
coverCachingParams.backendBaseUrl,
|
|
coverCachingParams.authToken,
|
|
coverCachingParams.backendMode
|
|
);
|
|
|
|
// Update database with cached path if successful
|
|
if (cachedPath) {
|
|
await prisma.plexLibrary.update({
|
|
where: { id: existing.id },
|
|
data: { cachedLibraryCoverPath: cachedPath },
|
|
});
|
|
}
|
|
}
|
|
|
|
updatedCount++;
|
|
}
|
|
}
|
|
|
|
// For Audiobookshelf: Trigger metadata match for items without ASIN
|
|
// This ensures ASIN gets populated so items can be matched against requests
|
|
if (backendMode === 'audiobookshelf') {
|
|
const { triggerABSItemMatch, getABSItem } = await import('../services/audiobookshelf/api');
|
|
const { generateFilesHash } = await import('../utils/files-hash');
|
|
|
|
const itemsWithoutAsin = recentItems.filter(item => !item.asin && item.externalId);
|
|
|
|
if (itemsWithoutAsin.length > 0) {
|
|
logger.info(`Found ${itemsWithoutAsin.length} recent items without ASIN, attempting file hash matching...`);
|
|
|
|
let fileMatchCount = 0;
|
|
let fuzzyMatchCount = 0;
|
|
|
|
for (const item of itemsWithoutAsin) {
|
|
try {
|
|
// 1. Fetch full item details to get file list
|
|
const absItem = await getABSItem(item.externalId);
|
|
|
|
// 2. Extract audio filenames and generate hash
|
|
const audioFilenames = absItem.media?.audioFiles?.map((f: any) => f.metadata?.filename).filter(Boolean) || [];
|
|
const itemHash = generateFilesHash(audioFilenames);
|
|
|
|
// 3. Query database for matching downloaded request
|
|
let matchedAsin: string | undefined = undefined;
|
|
|
|
if (itemHash) {
|
|
const matchedAudiobook = await prisma.audiobook.findFirst({
|
|
where: {
|
|
filesHash: itemHash,
|
|
status: 'completed',
|
|
},
|
|
select: {
|
|
audibleAsin: true,
|
|
title: true,
|
|
},
|
|
});
|
|
|
|
if (matchedAudiobook?.audibleAsin) {
|
|
matchedAsin = matchedAudiobook.audibleAsin;
|
|
logger.info(
|
|
`File hash match found for "${item.title}" → ASIN: ${matchedAsin} (from "${matchedAudiobook.title}")`
|
|
);
|
|
fileMatchCount++;
|
|
}
|
|
}
|
|
|
|
// 4. Trigger metadata match (with ASIN if matched, undefined if not)
|
|
await triggerABSItemMatch(item.externalId, matchedAsin);
|
|
|
|
if (matchedAsin) {
|
|
logger.info(`Triggered metadata match with ASIN ${matchedAsin} for: "${item.title}"`);
|
|
} else {
|
|
logger.info(`No file match found, triggering fuzzy metadata match for: "${item.title}"`);
|
|
fuzzyMatchCount++;
|
|
}
|
|
|
|
} catch (error) {
|
|
logger.error(
|
|
`Failed to process metadata match for "${item.title}": ${error instanceof Error ? error.message : 'Unknown error'}`
|
|
);
|
|
fuzzyMatchCount++;
|
|
}
|
|
}
|
|
|
|
logger.info(
|
|
`Metadata match complete: ${fileMatchCount} file hash matches, ${fuzzyMatchCount} fuzzy matches (ASIN population is async)`
|
|
);
|
|
}
|
|
}
|
|
|
|
// Check for all non-terminal audiobook requests to match
|
|
// Note: Ebook requests don't match to Plex/ABS library - they stop at 'downloaded' status
|
|
const matchableRequests = await prisma.request.findMany({
|
|
where: {
|
|
type: 'audiobook', // Only match audiobook requests (ebooks don't go to 'available')
|
|
status: { notIn: ['available', 'cancelled', 'denied'] },
|
|
deletedAt: null,
|
|
},
|
|
include: {
|
|
audiobook: true,
|
|
user: {
|
|
select: {
|
|
plexUsername: true,
|
|
},
|
|
},
|
|
},
|
|
|
|
});
|
|
|
|
if (matchableRequests.length > 0) {
|
|
logger.info(`Checking ${matchableRequests.length} matchable requests for matches (all non-terminal statuses)`);
|
|
|
|
const { findPlexMatch } = await import('../utils/audiobook-matcher');
|
|
|
|
for (const request of matchableRequests) {
|
|
try {
|
|
const audiobook = request.audiobook;
|
|
const match = await findPlexMatch({
|
|
asin: audiobook.audibleAsin || '',
|
|
title: audiobook.title,
|
|
author: audiobook.author,
|
|
narrator: audiobook.narrator || undefined,
|
|
});
|
|
|
|
if (match) {
|
|
const originalStatus = request.status;
|
|
logger.info(
|
|
`Match found: "${audiobook.title}" → "${match.title}"` +
|
|
(originalStatus !== 'downloaded' ? ` (was '${originalStatus}')` : '')
|
|
);
|
|
|
|
// Update audiobook with matched library 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,
|
|
});
|
|
|
|
await prisma.request.update({
|
|
where: { id: request.id },
|
|
data: {
|
|
status: 'available',
|
|
completedAt: new Date(),
|
|
errorMessage: null,
|
|
searchAttempts: 0,
|
|
downloadAttempts: 0,
|
|
importAttempts: 0,
|
|
updatedAt: new Date(),
|
|
},
|
|
});
|
|
|
|
// Send notification that audiobook is now available
|
|
const { getJobQueueService } = await import('../services/job-queue.service');
|
|
const jobQueue = getJobQueueService();
|
|
await jobQueue.addNotificationJob(
|
|
'request_available',
|
|
request.id,
|
|
audiobook.title,
|
|
audiobook.author,
|
|
request.user.plexUsername || 'Unknown User',
|
|
undefined,
|
|
'audiobook'
|
|
).catch((error) => {
|
|
logger.error('Failed to queue notification', { error: error instanceof Error ? error.message : String(error) });
|
|
});
|
|
|
|
matchedDownloads++;
|
|
|
|
// Note: Audiobookshelf metadata matching is handled in the file hash phase above
|
|
// Items without ASIN get file-hash-matched ASIN, items with ASIN already have correct metadata
|
|
}
|
|
} catch (error) {
|
|
logger.error(`Failed to match request ${request.id}: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
logger.info(`Complete: ${newCount} new, ${updatedCount} updated, ${matchedDownloads} matched requests`);
|
|
|
|
return {
|
|
success: true,
|
|
message: `Recently added check completed (${backendMode})`,
|
|
backendMode,
|
|
newCount,
|
|
updatedCount,
|
|
matchedDownloads,
|
|
};
|
|
} catch (error) {
|
|
logger.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
throw error;
|
|
}
|
|
}
|