Initial commit

This commit is contained in:
kikootwo
2026-01-28 11:41:24 -05:00
commit a3ba192fbd
257 changed files with 89482 additions and 0 deletions
@@ -0,0 +1,178 @@
/**
* Component: Audible Refresh Processor
* Documentation: documentation/backend/services/scheduler.md
*
* Fetches popular and new release audiobooks from Audible and caches them
*/
import { prisma } from '../db';
import { createJobLogger } from '../utils/job-logger';
export interface AudibleRefreshPayload {
jobId?: string;
scheduledJobId?: string;
}
export async function processAudibleRefresh(payload: AudibleRefreshPayload): Promise<any> {
const { jobId, scheduledJobId } = payload;
const logger = jobId ? createJobLogger(jobId, 'AudibleRefresh') : null;
await logger?.info('Starting Audible data refresh...');
const { getAudibleService } = await import('../integrations/audible.service');
const { getThumbnailCacheService } = await import('../services/thumbnail-cache.service');
const audibleService = getAudibleService();
const thumbnailCache = getThumbnailCacheService();
try {
// Clear previous popular/new-release flags for fresh data
await prisma.audibleCache.updateMany({
where: {
OR: [
{ isPopular: true },
{ isNewRelease: true },
],
},
data: {
isPopular: false,
isNewRelease: false,
popularRank: null,
newReleaseRank: null,
},
});
await logger?.info('Cleared previous popular/new-release flags in audible_cache');
// Fetch popular and new releases - 200 items each
const popular = await audibleService.getPopularAudiobooks(200);
const newReleases = await audibleService.getNewReleases(200);
await logger?.info(`Fetched ${popular.length} popular, ${newReleases.length} new releases from Audible`);
// Persist to audible_cache
let popularSaved = 0;
let newReleasesSaved = 0;
const syncTime = new Date();
for (let i = 0; i < popular.length; i++) {
const audiobook = popular[i];
try {
// Cache thumbnail if coverArtUrl exists
let cachedCoverPath: string | null = null;
if (audiobook.coverArtUrl) {
cachedCoverPath = await thumbnailCache.cacheThumbnail(audiobook.asin, audiobook.coverArtUrl);
}
await prisma.audibleCache.upsert({
where: { asin: audiobook.asin },
create: {
asin: audiobook.asin,
title: audiobook.title,
author: audiobook.author,
narrator: audiobook.narrator,
description: audiobook.description,
coverArtUrl: audiobook.coverArtUrl,
cachedCoverPath: cachedCoverPath,
durationMinutes: audiobook.durationMinutes,
releaseDate: audiobook.releaseDate ? new Date(audiobook.releaseDate) : null,
rating: audiobook.rating ? audiobook.rating : null,
genres: audiobook.genres || [],
isPopular: true,
popularRank: i + 1,
lastSyncedAt: syncTime,
},
update: {
title: audiobook.title,
author: audiobook.author,
narrator: audiobook.narrator,
description: audiobook.description,
coverArtUrl: audiobook.coverArtUrl,
cachedCoverPath: cachedCoverPath,
durationMinutes: audiobook.durationMinutes,
releaseDate: audiobook.releaseDate ? new Date(audiobook.releaseDate) : null,
rating: audiobook.rating ? audiobook.rating : null,
genres: audiobook.genres || [],
isPopular: true,
popularRank: i + 1,
lastSyncedAt: syncTime,
},
});
popularSaved++;
} catch (error) {
await logger?.error(`Failed to save popular audiobook ${audiobook.title}: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
for (let i = 0; i < newReleases.length; i++) {
const audiobook = newReleases[i];
try {
// Cache thumbnail if coverArtUrl exists
let cachedCoverPath: string | null = null;
if (audiobook.coverArtUrl) {
cachedCoverPath = await thumbnailCache.cacheThumbnail(audiobook.asin, audiobook.coverArtUrl);
}
await prisma.audibleCache.upsert({
where: { asin: audiobook.asin },
create: {
asin: audiobook.asin,
title: audiobook.title,
author: audiobook.author,
narrator: audiobook.narrator,
description: audiobook.description,
coverArtUrl: audiobook.coverArtUrl,
cachedCoverPath: cachedCoverPath,
durationMinutes: audiobook.durationMinutes,
releaseDate: audiobook.releaseDate ? new Date(audiobook.releaseDate) : null,
rating: audiobook.rating ? audiobook.rating : null,
genres: audiobook.genres || [],
isNewRelease: true,
newReleaseRank: i + 1,
lastSyncedAt: syncTime,
},
update: {
title: audiobook.title,
author: audiobook.author,
narrator: audiobook.narrator,
description: audiobook.description,
coverArtUrl: audiobook.coverArtUrl,
cachedCoverPath: cachedCoverPath,
durationMinutes: audiobook.durationMinutes,
releaseDate: audiobook.releaseDate ? new Date(audiobook.releaseDate) : null,
rating: audiobook.rating ? audiobook.rating : null,
genres: audiobook.genres || [],
isNewRelease: true,
newReleaseRank: i + 1,
lastSyncedAt: syncTime,
},
});
newReleasesSaved++;
} catch (error) {
await logger?.error(`Failed to save new release ${audiobook.title}: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
await logger?.info(`Saved ${popularSaved} popular and ${newReleasesSaved} new releases to audible_cache`);
// Cleanup unused thumbnails
await logger?.info('Cleaning up unused thumbnails...');
const allActiveAsins = await prisma.audibleCache.findMany({
select: { asin: true },
});
const activeAsinSet = new Set(allActiveAsins.map(item => item.asin));
const deletedCount = await thumbnailCache.cleanupUnusedThumbnails(activeAsinSet);
await logger?.info(`Cleanup complete: ${deletedCount} unused thumbnails removed`);
return {
success: true,
message: 'Audible refresh completed',
popularSaved,
newReleasesSaved,
thumbnailsDeleted: deletedCount,
};
} catch (error) {
await logger?.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
throw error;
}
}
@@ -0,0 +1,146 @@
/**
* Component: Cleanup Seeded Torrents Processor
* Documentation: documentation/backend/services/scheduler.md
*
* Cleans up torrents that have met their seeding requirements
*/
import { prisma } from '../db';
import { createJobLogger } from '../utils/job-logger';
export interface CleanupSeededTorrentsPayload {
jobId?: string;
scheduledJobId?: string;
}
export async function processCleanupSeededTorrents(payload: CleanupSeededTorrentsPayload): Promise<any> {
const { jobId, scheduledJobId } = payload;
const logger = jobId ? createJobLogger(jobId, 'CleanupSeededTorrents') : null;
await logger?.info('Starting cleanup job for seeded torrents...');
try {
// Get indexer configuration with per-indexer seeding times
const { getConfigService } = await import('../services/config.service');
const configService = getConfigService();
const indexersConfigStr = await configService.get('prowlarr_indexers');
if (!indexersConfigStr) {
await logger?.warn('No indexer configuration found, skipping');
return {
success: false,
message: 'No indexer configuration',
skipped: true,
};
}
const indexersConfig = JSON.parse(indexersConfigStr);
// Create a map of indexer name to config for quick lookup
const indexerConfigMap = new Map<string, any>();
for (const indexer of indexersConfig) {
indexerConfigMap.set(indexer.name, indexer);
}
await logger?.info(`Loaded configuration for ${indexerConfigMap.size} indexers`);
// Find all completed requests that have download history
const completedRequests = await prisma.request.findMany({
where: {
status: { in: ['available', 'downloaded'] },
},
include: {
downloadHistory: {
where: {
selected: true,
downloadStatus: 'completed',
},
orderBy: { completedAt: 'desc' },
take: 1,
},
},
take: 100, // Limit to 100 requests per run
});
await logger?.info(`Found ${completedRequests.length} completed requests to check`);
let cleaned = 0;
let skipped = 0;
let noConfig = 0;
for (const request of completedRequests) {
try {
const downloadHistory = request.downloadHistory[0];
if (!downloadHistory || !downloadHistory.downloadClientId || !downloadHistory.indexerName) {
continue;
}
// Get the indexer name from download history
const indexerName = downloadHistory.indexerName;
// Find matching indexer configuration by name
const seedingConfig = indexerConfigMap.get(indexerName);
// If no config found or seeding time is 0 (unlimited), skip
if (!seedingConfig) {
noConfig++;
continue;
}
if (seedingConfig.seedingTimeMinutes === 0) {
noConfig++;
continue;
}
const seedingTimeSeconds = seedingConfig.seedingTimeMinutes * 60;
// Get torrent info from qBittorrent to check seeding time
const { getQBittorrentService } = await import('../integrations/qbittorrent.service');
const qbt = await getQBittorrentService();
let torrent;
try {
torrent = await qbt.getTorrent(downloadHistory.downloadClientId);
} catch (error) {
// Torrent might already be deleted, skip
continue;
}
// Check if seeding time requirement is met
const actualSeedingTime = torrent.seeding_time || 0;
const hasMetRequirement = actualSeedingTime >= seedingTimeSeconds;
if (!hasMetRequirement) {
const remaining = Math.ceil((seedingTimeSeconds - actualSeedingTime) / 60);
skipped++;
continue;
}
await logger?.info(`Torrent ${torrent.name} (${indexerName}) has met seeding requirement (${Math.floor(actualSeedingTime / 60)}/${seedingConfig.seedingTimeMinutes} minutes)`);
// Delete torrent and files from qBittorrent
await qbt.deleteTorrent(downloadHistory.downloadClientId, true); // true = delete files
await logger?.info(`Deleted torrent and files for request ${request.id}`);
cleaned++;
} catch (error) {
await logger?.error(`Failed to cleanup request ${request.id}: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
await logger?.info(`Cleanup complete: ${cleaned} torrents cleaned, ${skipped} still seeding, ${noConfig} unlimited`);
return {
success: true,
message: 'Cleanup seeded torrents completed',
totalChecked: completedRequests.length,
cleaned,
skipped,
unlimited: noConfig,
};
} catch (error) {
await logger?.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
throw error;
}
}
@@ -0,0 +1,119 @@
/**
* Component: Download Torrent Job Processor
* Documentation: documentation/phase3/README.md
*/
import { DownloadTorrentPayload, getJobQueueService } from '../services/job-queue.service';
import { prisma } from '../db';
import { getQBittorrentService } from '../integrations/qbittorrent.service';
import { createJobLogger } from '../utils/job-logger';
/**
* Process download torrent job
* Adds selected torrent to download client and starts monitoring
*/
export async function processDownloadTorrent(payload: DownloadTorrentPayload): Promise<any> {
const { requestId, audiobook, torrent, jobId } = payload;
const logger = jobId ? createJobLogger(jobId, 'DownloadTorrent') : null;
await logger?.info(`Processing request ${requestId} for "${audiobook.title}"`);
await logger?.info(`Selected torrent: ${torrent.title}`, {
size: torrent.size,
seeders: torrent.seeders,
format: torrent.format,
indexer: torrent.indexer,
});
try {
// Update request status to downloading
await prisma.request.update({
where: { id: requestId },
data: {
status: 'downloading',
progress: 0,
updatedAt: new Date(),
},
});
// Get qBittorrent service
const qbt = await getQBittorrentService();
// Add torrent to qBittorrent
await logger?.info(`Adding torrent to qBittorrent`);
const torrentHash = await qbt.addTorrent(torrent.downloadUrl, {
category: 'readmeabook',
tags: [
'audiobook',
`request-${requestId}`,
`audiobook-${audiobook.id}`,
],
sequentialDownload: true, // Download in order for potential streaming
paused: false, // Start immediately
});
await logger?.info(`Torrent added with hash: ${torrentHash}`);
// Create DownloadHistory record
const downloadHistory = await prisma.downloadHistory.create({
data: {
requestId,
indexerName: torrent.indexer,
downloadClient: 'qbittorrent',
downloadClientId: torrentHash,
torrentName: torrent.title,
torrentHash: torrent.infoHash || torrentHash,
torrentSizeBytes: torrent.size,
seeders: torrent.seeders,
leechers: torrent.leechers || 0,
downloadStatus: 'downloading',
selected: true,
startedAt: new Date(),
},
});
await logger?.info(`Created download history record: ${downloadHistory.id}`);
// Trigger monitor download job with initial delay
// qBittorrent needs a few seconds to process the torrent before it's available via API
const jobQueue = getJobQueueService();
await jobQueue.addMonitorJob(
requestId,
downloadHistory.id,
torrentHash,
'qbittorrent',
3 // Wait 3 seconds before first check to avoid race condition
);
await logger?.info(`Started monitoring job for request ${requestId} (3s initial delay)`);
return {
success: true,
message: 'Torrent added to download client and monitoring started',
requestId,
downloadHistoryId: downloadHistory.id,
torrentHash,
torrent: {
title: torrent.title,
size: torrent.size,
seeders: torrent.seeders,
format: torrent.format,
},
};
} catch (error) {
await logger?.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
// Update request status to failed
await prisma.request.update({
where: { id: requestId },
data: {
status: 'failed',
errorMessage: error instanceof Error ? error.message : 'Failed to add torrent to download client',
updatedAt: new Date(),
},
});
throw error;
}
}
+191
View File
@@ -0,0 +1,191 @@
/**
* Component: Match Library Job Processor
* Documentation: documentation/phase3/README.md
*
* DEPRECATED: This processor is deprecated. Matching is now handled by scan_library job.
* Kept for backwards compatibility but should not be used in new code.
*/
import { MatchPlexPayload } from '../services/job-queue.service';
import { prisma } from '../db';
import { getLibraryService } from '../services/library';
import { compareTwoStrings } from 'string-similarity';
import { getConfigService } from '../services/config.service';
import { createJobLogger } from '../utils/job-logger';
/**
* Process match library job (DEPRECATED - use scan_library instead)
* Fuzzy matches requested audiobook to library item and updates status
*/
export async function processMatchPlex(payload: MatchPlexPayload): Promise<any> {
const { requestId, audiobookId, title, author, jobId } = payload;
const logger = jobId ? createJobLogger(jobId, 'MatchLibrary') : null;
await logger?.warn('DEPRECATED: match_plex job is deprecated. Use scan_plex instead.');
await logger?.info(`Matching "${title}" by ${author} in library`);
try {
// Get library service and configuration
const configService = getConfigService();
const libraryService = await getLibraryService();
const backendMode = await configService.getBackendMode();
await logger?.info(`Backend mode: ${backendMode}`);
// Get configured library ID
const libraryId = backendMode === 'audiobookshelf'
? await configService.get('audiobookshelf.library_id')
: (await configService.getPlexConfig()).libraryId;
if (!libraryId) {
throw new Error(`${backendMode} library not configured`);
}
// Search library using abstraction layer
const searchResults = await libraryService.searchItems(libraryId, title);
await logger?.info(`Found ${searchResults.length} results in library`);
if (searchResults.length === 0) {
await logger?.warn(`No matches found in library for "${title}"`);
// Mark as completed anyway - the file is there, library just needs time to scan
await prisma.request.update({
where: { id: requestId },
data: {
status: 'completed',
updatedAt: new Date(),
completedAt: new Date(),
},
});
return {
success: true,
message: 'No library match found yet, but request completed',
requestId,
matched: false,
note: 'Library may need time to scan the new files',
};
}
// Fuzzy match against results
const matches = searchResults.map((item) => {
const titleScore = compareTwoStrings(title.toLowerCase(), (item.title || '').toLowerCase());
const authorScore = author
? compareTwoStrings(author.toLowerCase(), (item.author || '').toLowerCase())
: 0.5;
// Weighted average: title is more important
const overallScore = titleScore * 0.7 + authorScore * 0.3;
return {
item,
score: overallScore,
titleScore,
authorScore,
};
});
// Sort by score
matches.sort((a, b) => b.score - a.score);
const bestMatch = matches[0];
await logger?.info(`Best match: "${bestMatch.item.title}" by ${bestMatch.item.author || 'Unknown'}`, {
score: Math.round(bestMatch.score * 100),
titleScore: Math.round(bestMatch.titleScore * 100),
authorScore: Math.round(bestMatch.authorScore * 100),
});
// Accept match if score >= 70%
if (bestMatch.score >= 0.7) {
await logger?.info(`Match accepted!`);
// Update audiobook with library item ID
const updateData: any = {
completedAt: new Date(),
updatedAt: new Date(),
};
if (backendMode === 'audiobookshelf') {
updateData.absItemId = bestMatch.item.externalId;
} else {
updateData.plexGuid = bestMatch.item.externalId;
}
await prisma.audiobook.update({
where: { id: audiobookId },
data: updateData,
});
// Ensure request is marked as completed
await prisma.request.update({
where: { id: requestId },
data: {
status: 'completed',
updatedAt: new Date(),
completedAt: new Date(),
},
});
return {
success: true,
message: `Successfully matched audiobook in library (${backendMode})`,
backendMode,
requestId,
matched: true,
matchScore: bestMatch.score,
libraryItem: {
title: bestMatch.item.title,
author: bestMatch.item.author,
id: bestMatch.item.id,
externalId: bestMatch.item.externalId,
},
};
} else {
await logger?.warn(`Match score too low (${Math.round(bestMatch.score * 100)}%), but marking as completed anyway`);
// Mark as completed even if match is poor
await prisma.request.update({
where: { id: requestId },
data: {
status: 'completed',
updatedAt: new Date(),
completedAt: new Date(),
},
});
return {
success: true,
message: 'Request completed, but library match uncertain',
requestId,
matched: false,
matchScore: bestMatch.score,
note: `Low match score: ${Math.round(bestMatch.score * 100)}%`,
};
}
} catch (error) {
await logger?.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
// Don't fail the request - the files are organized correctly
// Just log the error and mark as completed
await prisma.request.update({
where: { id: requestId },
data: {
status: 'completed',
errorMessage: `Library matching failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
updatedAt: new Date(),
completedAt: new Date(),
},
});
return {
success: false,
message: 'Request completed despite library matching error',
requestId,
matched: false,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
}
@@ -0,0 +1,225 @@
/**
* Component: Monitor Download Job Processor
* Documentation: documentation/phase3/README.md
*/
import { MonitorDownloadPayload, getJobQueueService } from '../services/job-queue.service';
import { prisma } from '../db';
import { getQBittorrentService } from '../integrations/qbittorrent.service';
import { createJobLogger, JobLogger } from '../utils/job-logger';
/**
* Helper function to retry getTorrent with exponential backoff
* Handles race condition where torrent isn't immediately available after adding
*/
async function getTorrentWithRetry(
qbt: any,
hash: string,
logger: JobLogger | null,
maxRetries: number = 3,
initialDelayMs: number = 500
): Promise<any> {
let lastError: Error | null = null;
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
return await qbt.getTorrent(hash);
} catch (error) {
lastError = error as Error;
// If this is the last attempt, throw the error
if (attempt === maxRetries - 1) {
break;
}
// Exponential backoff: 500ms, 1000ms, 2000ms
const delayMs = initialDelayMs * Math.pow(2, attempt);
await logger?.warn(`Torrent ${hash} not found, retrying in ${delayMs}ms (attempt ${attempt + 1}/${maxRetries})`);
await new Promise(resolve => setTimeout(resolve, delayMs));
}
}
// All retries failed
throw lastError || new Error('Failed to get torrent after retries');
}
/**
* Process monitor download job
* Checks download progress from download client and updates request status
* Re-schedules itself if download is still in progress
*/
export async function processMonitorDownload(payload: MonitorDownloadPayload): Promise<any> {
const { requestId, downloadHistoryId, downloadClientId, downloadClient, jobId } = payload;
const logger = jobId ? createJobLogger(jobId, 'MonitorDownload') : null;
try {
// Get download client service (currently only qBittorrent supported)
if (downloadClient !== 'qbittorrent') {
throw new Error(`Download client ${downloadClient} not yet supported`);
}
const qbt = await getQBittorrentService();
// Get torrent status with retry logic (handles race condition)
const torrent = await getTorrentWithRetry(qbt, downloadClientId, logger);
const progress = qbt.getDownloadProgress(torrent);
// Update request progress
await prisma.request.update({
where: { id: requestId },
data: {
progress: progress.percent,
updatedAt: new Date(),
},
});
// Update download history
await prisma.downloadHistory.update({
where: { id: downloadHistoryId },
data: {
downloadStatus: progress.state,
},
});
// Check download state
if (progress.state === 'completed') {
await logger?.info(`Download completed for request ${requestId}`);
// Get torrent files to find download path
const files = await qbt.getFiles(downloadClientId);
const downloadPath = torrent.save_path;
await logger?.info(`Downloaded to: ${downloadPath}`, {
filesCount: files.length,
torrentName: torrent.name,
});
// Update download history to completed
await prisma.downloadHistory.update({
where: { id: downloadHistoryId },
data: {
downloadStatus: 'completed',
completedAt: new Date(),
},
});
// Get request with audiobook details
const request = await prisma.request.findUnique({
where: { id: requestId },
include: {
audiobook: true,
},
});
if (!request || !request.audiobook) {
throw new Error('Request or audiobook not found');
}
// Trigger organize files job
const jobQueue = getJobQueueService();
await jobQueue.addOrganizeJob(
requestId,
request.audiobook.id,
`${downloadPath}/${torrent.name}`,
`/media/audiobooks/${request.audiobook.author}/${request.audiobook.title}`
);
await logger?.info(`Triggered organize_files job for request ${requestId}`);
return {
success: true,
completed: true,
message: 'Download completed, organizing files',
requestId,
progress: 100,
downloadPath,
};
} else if (progress.state === 'failed') {
await logger?.error(`Download failed for request ${requestId}`);
// Update request to failed
await prisma.request.update({
where: { id: requestId },
data: {
status: 'failed',
errorMessage: 'Download failed in qBittorrent',
updatedAt: new Date(),
},
});
// Update download history
await prisma.downloadHistory.update({
where: { id: downloadHistoryId },
data: {
downloadStatus: 'failed',
downloadError: 'Download failed in qBittorrent',
},
});
return {
success: false,
completed: true,
message: 'Download failed',
requestId,
progress: progress.percent,
};
} else {
// Still downloading - schedule another check in 10 seconds
const jobQueue = getJobQueueService();
await jobQueue.addMonitorJob(
requestId,
downloadHistoryId,
downloadClientId,
downloadClient,
10 // Delay 10 seconds between checks
);
// Only log every 5% progress to reduce log spam
const shouldLog = progress.percent % 5 === 0 || progress.percent < 5;
if (shouldLog) {
await logger?.info(`Request ${requestId}: ${progress.percent}% complete (${progress.state})`, {
speed: progress.speed,
eta: progress.eta,
});
}
return {
success: true,
completed: false,
message: 'Download in progress, monitoring continues',
requestId,
progress: progress.percent,
speed: progress.speed,
eta: progress.eta,
state: progress.state,
};
}
} catch (error) {
await logger?.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
// Check if this is a transient "torrent not found" error
const errorMessage = error instanceof Error ? error.message : '';
const isTorrentNotFound = errorMessage.includes('not found') || errorMessage.includes('Torrent') && errorMessage.includes('not found');
if (isTorrentNotFound) {
// Transient error - don't mark request as failed, let Bull retry
// The request stays in 'downloading' status until Bull exhausts all retries
await logger?.warn(`Transient error for request ${requestId}, allowing Bull to retry`);
} else {
// Permanent error - mark request as failed immediately
await prisma.request.update({
where: { id: requestId },
data: {
status: 'failed',
errorMessage: errorMessage || 'Monitor download failed',
updatedAt: new Date(),
},
});
}
// Rethrow to trigger Bull's retry mechanism
throw error;
}
}
@@ -0,0 +1,122 @@
/**
* Component: Monitor RSS Feeds Processor
* Documentation: documentation/backend/services/scheduler.md
*
* Monitors RSS feeds for new audiobook releases and matches against missing requests
*/
import { prisma } from '../db';
import { createJobLogger } from '../utils/job-logger';
import { getJobQueueService } from '../services/job-queue.service';
export interface MonitorRssFeedsPayload {
jobId?: string;
scheduledJobId?: string;
}
export async function processMonitorRssFeeds(payload: MonitorRssFeedsPayload): Promise<any> {
const { jobId, scheduledJobId } = payload;
const logger = jobId ? createJobLogger(jobId, 'MonitorRssFeeds') : null;
await logger?.info(`Starting RSS feed monitoring...`);
// Get indexer configuration
const { getConfigService } = await import('../services/config.service');
const configService = getConfigService();
const indexersConfigStr = await configService.get('prowlarr_indexers');
if (!indexersConfigStr) {
await logger?.warn(`No indexers configured, skipping`);
return { success: false, message: 'No indexers configured', skipped: true };
}
const indexersConfig = JSON.parse(indexersConfigStr);
// Filter indexers that have RSS enabled
const rssEnabledIndexers = indexersConfig.filter(
(indexer: any) => indexer.rssEnabled === true
);
if (rssEnabledIndexers.length === 0) {
await logger?.warn(`No indexers with RSS enabled, skipping`);
return { success: false, message: 'No RSS-enabled indexers', skipped: true };
}
await logger?.info(`Monitoring ${rssEnabledIndexers.length} RSS-enabled indexers`);
// Get RSS feeds from all enabled indexers
const { getProwlarrService } = await import('../integrations/prowlarr.service');
const prowlarrService = await getProwlarrService();
const indexerIds = rssEnabledIndexers.map((i: any) => i.id);
const rssResults = await prowlarrService.getAllRssFeeds(indexerIds);
await logger?.info(`Retrieved ${rssResults.length} items from RSS feeds`);
if (rssResults.length === 0) {
return { success: true, message: 'No RSS results', matched: 0 };
}
// Get all requests awaiting search (missing audiobooks)
const missingRequests = await prisma.request.findMany({
where: { status: 'awaiting_search' },
include: { audiobook: true },
take: 100,
});
await logger?.info(`Found ${missingRequests.length} requests awaiting search`);
if (missingRequests.length === 0) {
return { success: true, message: 'No missing requests', matched: 0 };
}
// Match RSS results against missing audiobooks
let matched = 0;
const jobQueue = getJobQueueService();
for (const request of missingRequests) {
const audiobook = request.audiobook;
// Simple fuzzy matching: check if torrent title contains author and partial title
const authorWords = audiobook.author.toLowerCase().split(' ');
const titleWords = audiobook.title.toLowerCase().split(' ').slice(0, 3);
for (const torrent of rssResults) {
const torrentTitle = torrent.title.toLowerCase();
// Check if torrent contains author name and at least 2 title words
const hasAuthor = authorWords.some(word => word.length > 2 && torrentTitle.includes(word));
const titleMatchCount = titleWords.filter(word => word.length > 2 && torrentTitle.includes(word)).length;
if (hasAuthor && titleMatchCount >= 2) {
await logger?.info(`Match found! "${audiobook.title}" by ${audiobook.author} matches torrent: ${torrent.title}`);
// Trigger search job to process this request
try {
await jobQueue.addSearchJob(request.id, {
id: audiobook.id,
title: audiobook.title,
author: audiobook.author,
});
matched++;
await logger?.info(`Triggered search job for request ${request.id}`);
} catch (error) {
await logger?.error(`Failed to trigger search for request ${request.id}: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
// Only trigger once per request
break;
}
}
}
await logger?.info(`RSS monitoring complete: ${matched} matches found and queued for processing`);
return {
success: true,
message: 'RSS monitoring completed',
matched,
totalFeeds: rssResults.length,
totalMissing: missingRequests.length,
};
}
@@ -0,0 +1,191 @@
/**
* Component: Organize Files Job Processor
* Documentation: documentation/phase3/README.md
*/
import { OrganizeFilesPayload, getJobQueueService } from '../services/job-queue.service';
import { prisma } from '../db';
import { getFileOrganizer } from '../utils/file-organizer';
import { createJobLogger } from '../utils/job-logger';
/**
* Process organize files job
* Moves completed downloads to media library in proper directory structure
*/
export async function processOrganizeFiles(payload: OrganizeFilesPayload): Promise<any> {
const { requestId, audiobookId, downloadPath, jobId } = payload;
// Create logger (fallback to console-only if jobId not provided)
const logger = jobId ? createJobLogger(jobId, 'OrganizeFiles') : null;
await logger?.info(`Processing request ${requestId}`);
await logger?.info(`Download path: ${downloadPath}`);
try {
// Update request status to processing
await prisma.request.update({
where: { id: requestId },
data: {
status: 'processing',
progress: 100, // Download is complete, now organizing
updatedAt: new Date(),
},
});
// Get audiobook details
const audiobook = await prisma.audiobook.findUnique({
where: { id: audiobookId },
});
if (!audiobook) {
throw new Error(`Audiobook ${audiobookId} not found`);
}
await logger?.info(`Organizing: ${audiobook.title} by ${audiobook.author}`);
// Get file organizer
const organizer = getFileOrganizer();
// Organize files (pass logger to file organizer)
const result = await organizer.organize(
downloadPath,
{
title: audiobook.title,
author: audiobook.author,
narrator: audiobook.narrator || undefined,
coverArtUrl: audiobook.coverArtUrl || undefined,
},
jobId ? { jobId, context: 'FileOrganizer' } : undefined
);
if (!result.success) {
throw new Error(`File organization failed: ${result.errors.join(', ')}`);
}
await logger?.info(`Successfully moved ${result.filesMovedCount} files to ${result.targetPath}`);
// Update audiobook record with file path and status
await prisma.audiobook.update({
where: { id: audiobookId },
data: {
filePath: result.targetPath,
status: 'completed',
completedAt: new Date(),
updatedAt: new Date(),
},
});
// Update request to downloaded (green status, waiting for Plex scan)
await prisma.request.update({
where: { id: requestId },
data: {
status: 'downloaded',
progress: 100,
completedAt: new Date(),
updatedAt: new Date(),
},
});
await logger?.info(`Request ${requestId} completed successfully - status: downloaded`, {
success: true,
message: 'Files organized successfully',
requestId,
audiobookId,
targetPath: result.targetPath,
filesCount: result.filesMovedCount,
audioFiles: result.audioFiles,
coverArt: result.coverArtFile,
errors: result.errors,
});
return {
success: true,
message: 'Files organized successfully',
requestId,
audiobookId,
targetPath: result.targetPath,
filesCount: result.filesMovedCount,
audioFiles: result.audioFiles,
coverArt: result.coverArtFile,
errors: result.errors,
};
} catch (error) {
await logger?.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
const errorMessage = error instanceof Error ? error.message : 'File organization failed';
// Check if this is a "no files found" error that should be retried
const isNoFilesError = errorMessage.includes('No audiobook files found');
if (isNoFilesError) {
// Get current request to check retry count
const currentRequest = await prisma.request.findUnique({
where: { id: requestId },
select: { importAttempts: true, maxImportRetries: true },
});
if (!currentRequest) {
throw new Error('Request not found');
}
const newAttempts = currentRequest.importAttempts + 1;
if (newAttempts < currentRequest.maxImportRetries) {
// Still have retries left - queue for re-import
await logger?.warn(`No files found for request ${requestId}, queueing for retry (attempt ${newAttempts}/${currentRequest.maxImportRetries})`);
await prisma.request.update({
where: { id: requestId },
data: {
status: 'awaiting_import',
importAttempts: newAttempts,
lastImportAt: new Date(),
errorMessage: `${errorMessage}. Retry ${newAttempts}/${currentRequest.maxImportRetries}`,
updatedAt: new Date(),
},
});
return {
success: false,
message: 'No audiobook files found, queued for re-import',
requestId,
attempts: newAttempts,
maxRetries: currentRequest.maxImportRetries,
};
} else {
// Max retries exceeded - move to warn status
await logger?.warn(`Max retries (${currentRequest.maxImportRetries}) exceeded for request ${requestId}, moving to warn status`);
await prisma.request.update({
where: { id: requestId },
data: {
status: 'warn',
importAttempts: newAttempts,
errorMessage: `${errorMessage}. Max retries (${currentRequest.maxImportRetries}) exceeded. Manual retry available.`,
updatedAt: new Date(),
},
});
return {
success: false,
message: 'Max import retries exceeded, manual intervention required',
requestId,
attempts: newAttempts,
maxRetries: currentRequest.maxImportRetries,
};
}
} else {
// Other error - fail immediately
await prisma.request.update({
where: { id: requestId },
data: {
status: 'failed',
errorMessage,
updatedAt: new Date(),
},
});
throw error;
}
}
}
@@ -0,0 +1,198 @@
/**
* 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 { createJobLogger } from '../utils/job-logger';
import { getLibraryService } from '../services/library';
export interface PlexRecentlyAddedPayload {
jobId?: string;
scheduledJobId?: string;
}
export async function processPlexRecentlyAddedCheck(payload: PlexRecentlyAddedPayload): Promise<any> {
const { jobId, scheduledJobId } = payload;
const logger = jobId ? createJobLogger(jobId, 'RecentlyAdded') : null;
const { getConfigService } = await import('../services/config.service');
const configService = getConfigService();
// Get backend mode
const backendMode = await configService.getBackendMode();
await 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(', ')}`;
await 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(', ')}`;
await logger?.warn(errorMsg);
return { success: false, message: errorMsg, skipped: true };
}
}
await logger?.info(`Starting recently added check...`);
// Get library service (automatically selects Plex or Audiobookshelf)
const libraryService = await getLibraryService();
try {
// Get configured library ID
const libraryId = backendMode === 'audiobookshelf'
? await configService.get('audiobookshelf.library_id')
: await configService.get('plex_audiobook_library_id');
// Fetch top 10 recently added items using abstraction layer
const recentItems = await libraryService.getRecentlyAdded(libraryId!, 10);
await 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) {
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,
thumbUrl: item.coverUrl,
plexLibraryId: libraryId!,
addedAt: item.addedAt,
lastScannedAt: new Date(),
},
});
newCount++;
await 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 ? item.duration * 1000 : existing.duration,
year: item.year || existing.year,
thumbUrl: item.coverUrl || existing.thumbUrl,
lastScannedAt: new Date(),
},
});
updatedCount++;
}
}
// Check for downloaded requests to match
const downloadedRequests = await prisma.request.findMany({
where: { status: 'downloaded' },
include: { audiobook: true },
take: 50,
});
if (downloadedRequests.length > 0) {
await logger?.info(`Checking ${downloadedRequests.length} downloaded requests for matches`);
const { findPlexMatch } = await import('../utils/audiobook-matcher');
for (const request of downloadedRequests) {
try {
const audiobook = request.audiobook;
const match = await findPlexMatch({
asin: audiobook.audibleAsin || '',
title: audiobook.title,
author: audiobook.author,
narrator: audiobook.narrator || undefined,
});
if (match) {
await logger?.info(`Match found: "${audiobook.title}" → "${match.title}"`);
// 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(), updatedAt: new Date() },
});
matchedDownloads++;
}
} catch (error) {
await logger?.error(`Failed to match request ${request.id}: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
}
await logger?.info(`Complete: ${newCount} new, ${updatedCount} updated, ${matchedDownloads} matched downloads`);
return {
success: true,
message: `Recently added check completed (${backendMode})`,
backendMode,
newCount,
updatedCount,
matchedDownloads,
};
} catch (error) {
await logger?.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
throw error;
}
}
@@ -0,0 +1,99 @@
/**
* Component: Retry Failed Imports Processor
* Documentation: documentation/backend/services/scheduler.md
*
* Retries file organization for requests that are awaiting import
*/
import { prisma } from '../db';
import { createJobLogger } from '../utils/job-logger';
import { getJobQueueService } from '../services/job-queue.service';
export interface RetryFailedImportsPayload {
jobId?: string;
scheduledJobId?: string;
}
export async function processRetryFailedImports(payload: RetryFailedImportsPayload): Promise<any> {
const { jobId, scheduledJobId } = payload;
const logger = jobId ? createJobLogger(jobId, 'RetryFailedImports') : null;
await logger?.info('Starting retry job for requests awaiting import...');
try {
// Find all requests in awaiting_import status
const requests = await prisma.request.findMany({
where: {
status: 'awaiting_import',
},
include: {
audiobook: true,
downloadHistory: {
where: { selected: true },
orderBy: { createdAt: 'desc' },
take: 1,
},
},
take: 50, // Limit to 50 requests per run
});
await logger?.info(`Found ${requests.length} requests awaiting import`);
if (requests.length === 0) {
return {
success: true,
message: 'No requests awaiting import',
triggered: 0,
};
}
// Trigger organize job for each request
const jobQueue = getJobQueueService();
let triggered = 0;
let skipped = 0;
for (const request of requests) {
try {
// Get the download path from the most recent download history
const downloadHistory = request.downloadHistory[0];
if (!downloadHistory || !downloadHistory.downloadClientId) {
await logger?.warn(`No download history found for request ${request.id}, skipping`);
skipped++;
continue;
}
// Get download path from qBittorrent
const { getQBittorrentService } = await import('../integrations/qbittorrent.service');
const qbt = await getQBittorrentService();
const torrent = await qbt.getTorrent(downloadHistory.downloadClientId);
const downloadPath = `${torrent.save_path}/${torrent.name}`;
await jobQueue.addOrganizeJob(
request.id,
request.audiobook.id,
downloadPath,
`/media/audiobooks/${request.audiobook.author}/${request.audiobook.title}`
);
triggered++;
await logger?.info(`Triggered organize job for request ${request.id}: ${request.audiobook.title}`);
} catch (error) {
await logger?.error(`Failed to trigger organize for request ${request.id}: ${error instanceof Error ? error.message : 'Unknown error'}`);
skipped++;
}
}
await logger?.info(`Triggered ${triggered}/${requests.length} organize jobs (${skipped} skipped)`);
return {
success: true,
message: 'Retry failed imports completed',
totalRequests: requests.length,
triggered,
skipped,
};
} catch (error) {
await logger?.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
throw error;
}
}
@@ -0,0 +1,75 @@
/**
* Component: Retry Missing Torrents Processor
* Documentation: documentation/backend/services/scheduler.md
*
* Retries search for requests that are awaiting torrent search
*/
import { prisma } from '../db';
import { createJobLogger } from '../utils/job-logger';
import { getJobQueueService } from '../services/job-queue.service';
export interface RetryMissingTorrentsPayload {
jobId?: string;
scheduledJobId?: string;
}
export async function processRetryMissingTorrents(payload: RetryMissingTorrentsPayload): Promise<any> {
const { jobId, scheduledJobId } = payload;
const logger = jobId ? createJobLogger(jobId, 'RetryMissingTorrents') : null;
await logger?.info('Starting retry job for requests awaiting search...');
try {
// Find all requests in awaiting_search status
const requests = await prisma.request.findMany({
where: {
status: 'awaiting_search',
},
include: {
audiobook: true,
},
take: 50, // Limit to 50 requests per run
});
await logger?.info(`Found ${requests.length} requests awaiting search`);
if (requests.length === 0) {
return {
success: true,
message: 'No requests awaiting search',
triggered: 0,
};
}
// Trigger search job for each request
const jobQueue = getJobQueueService();
let triggered = 0;
for (const request of requests) {
try {
await jobQueue.addSearchJob(request.id, {
id: request.audiobook.id,
title: request.audiobook.title,
author: request.audiobook.author,
});
triggered++;
await logger?.info(`Triggered search for request ${request.id}: ${request.audiobook.title}`);
} catch (error) {
await logger?.error(`Failed to trigger search for request ${request.id}: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
await logger?.info(`Triggered ${triggered}/${requests.length} search jobs`);
return {
success: true,
message: 'Retry missing torrents completed',
totalRequests: requests.length,
triggered,
};
} catch (error) {
await logger?.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
throw error;
}
}
+220
View File
@@ -0,0 +1,220 @@
/**
* 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 { createJobLogger } from '../utils/job-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 = jobId ? createJobLogger(jobId, 'ScanLibrary') : null;
await 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();
await 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;
}
}
await logger?.info(`Fetching content from library ${targetLibraryId}`);
// 3. Get all audiobooks from library using abstraction layer
const libraryItems = await libraryService.getLibraryItems(targetLibraryId);
await 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,
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,
thumbUrl: item.coverUrl,
plexLibraryId: targetLibraryId,
addedAt: item.addedAt,
lastScannedAt: new Date(),
},
});
newCount++;
await 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) {
await logger?.error(`Failed to process "${item.title}": ${error instanceof Error ? error.message : 'Unknown error'}`);
skippedCount++;
}
}
await logger?.info(`Scan complete: ${libraryItems.length} items scanned, ${newCount} new, ${updatedCount} updated, ${skippedCount} skipped`);
// 5. Match downloaded requests against library
await logger?.info(`Checking for downloaded requests to match...`);
const downloadedRequests = await prisma.request.findMany({
where: { status: 'downloaded' },
include: { audiobook: true },
take: 50, // Limit to prevent overwhelming
});
await 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) {
await 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++;
}
} catch (error) {
await logger?.error(`Failed to match request ${request.id}: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
await logger?.info(`Matched ${matchedCount}/${downloadedRequests.length} downloaded requests`, {
totalScanned: libraryItems.length,
newCount,
updatedCount,
skippedCount,
matchedDownloads: matchedCount,
});
return {
success: true,
message: `Library scan completed successfully (${backendMode})`,
backendMode,
libraryId: targetLibraryId,
totalScanned: libraryItems.length,
newCount,
updatedCount,
skippedCount,
newAudiobooks: results,
matchedDownloads: matchedCount,
};
} catch (error) {
await logger?.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
throw error;
}
}
@@ -0,0 +1,133 @@
/**
* Component: Search Indexers Job Processor
* Documentation: documentation/phase3/README.md
*/
import { SearchIndexersPayload, getJobQueueService } from '../services/job-queue.service';
import { prisma } from '../db';
import { getProwlarrService } from '../integrations/prowlarr.service';
import { getRankingAlgorithm } from '../utils/ranking-algorithm';
import { createJobLogger } from '../utils/job-logger';
/**
* Process search indexers job
* Searches configured indexers for audiobook torrents
*/
export async function processSearchIndexers(payload: SearchIndexersPayload): Promise<any> {
const { requestId, audiobook, jobId } = payload;
const logger = jobId ? createJobLogger(jobId, 'SearchIndexers') : null;
await logger?.info(`Processing request ${requestId} for "${audiobook.title}"`);
try {
// Update request status to searching
await prisma.request.update({
where: { id: requestId },
data: {
status: 'searching',
searchAttempts: { increment: 1 },
updatedAt: new Date(),
},
});
// Get Prowlarr service
const prowlarr = await getProwlarrService();
// Build search query (title + author for better results)
const searchQuery = `${audiobook.title} ${audiobook.author}`;
await logger?.info(`Searching for: "${searchQuery}"`);
// Search indexers
const searchResults = await prowlarr.search(searchQuery, {
category: 3030, // Audiobooks
minSeeders: 1, // Only torrents with at least 1 seeder
maxResults: 50, // Limit results
});
await logger?.info(`Found ${searchResults.length} results`);
if (searchResults.length === 0) {
// No results found - queue for re-search instead of failing
await logger?.warn(`No torrents found for request ${requestId}, marking as awaiting_search`);
await prisma.request.update({
where: { id: requestId },
data: {
status: 'awaiting_search',
errorMessage: 'No torrents found. Will retry automatically.',
lastSearchAt: new Date(),
updatedAt: new Date(),
},
});
return {
success: false,
message: 'No torrents found, queued for re-search',
requestId,
};
}
// Get ranking algorithm
const ranker = getRankingAlgorithm();
// Rank results
const rankedResults = ranker.rankTorrents(searchResults, {
title: audiobook.title,
author: audiobook.author,
durationMinutes: undefined, // We don't have duration from Audible
});
await logger?.info(`Ranked ${rankedResults.length} results`);
// Select best result
const bestResult = rankedResults[0];
// Log top 3 results
const top3 = rankedResults.slice(0, 3).map((r, i) => ({
rank: i + 1,
title: r.title,
score: r.score,
breakdown: r.breakdown,
}));
await logger?.info(`Best result: ${bestResult.title} (score: ${bestResult.score})`, {
top3Results: top3,
});
// Trigger download job with best result
const jobQueue = getJobQueueService();
await jobQueue.addDownloadJob(requestId, {
id: audiobook.id,
title: audiobook.title,
author: audiobook.author,
}, bestResult);
return {
success: true,
message: `Found ${searchResults.length} results, selected best torrent`,
requestId,
resultsCount: searchResults.length,
selectedTorrent: {
title: bestResult.title,
score: bestResult.score,
seeders: bestResult.seeders,
format: bestResult.format,
},
};
} catch (error) {
await logger?.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
await prisma.request.update({
where: { id: requestId },
data: {
status: 'failed',
errorMessage: error instanceof Error ? error.message : 'Unknown error during search',
updatedAt: new Date(),
},
});
throw error;
}
}