mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +00:00
Initial commit
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user