mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +00:00
Implement file hash-based library matching and remove fuzzy ASIN matching
Adds file hash-based matching for Audiobookshelf library items to ensure 100% accurate ASIN assignment for RMAB-organized content. Removes fuzzy matching from library availability checks, making all matching ASIN-only to eliminate false positives and race conditions. Updates database schema, processors, and matcher utilities; adds new tests and documentation for the new matching strategy. Removes obsolete scripts, Dockerfile, and related tests; updates docker-compose for test environments.
This commit is contained in:
@@ -157,6 +157,47 @@ export class AudibleService {
|
||||
throw lastError || new Error('Request failed after retries');
|
||||
}
|
||||
|
||||
/**
|
||||
* External API fetch with retry logic and exponential backoff
|
||||
* Used for Audnexus and other external APIs
|
||||
*/
|
||||
private async externalFetchWithRetry(
|
||||
url: string,
|
||||
config: any = {},
|
||||
maxRetries: number = 3
|
||||
): Promise<any> {
|
||||
let lastError: Error | null = null;
|
||||
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
return await axios.get(url, config);
|
||||
} catch (error: any) {
|
||||
lastError = error;
|
||||
const status = error.response?.status;
|
||||
const isRetryable = !status || status === 503 || status === 429 || status >= 500;
|
||||
|
||||
// Don't retry on 404, 403, etc.
|
||||
if (!isRetryable) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Don't retry on last attempt
|
||||
if (attempt === maxRetries) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Exponential backoff: 2^attempt * 1000ms (1s, 2s, 4s...)
|
||||
const backoffMs = Math.pow(2, attempt) * 1000;
|
||||
logger.info(` External API request failed (${status || 'network error'}), retrying in ${backoffMs}ms (attempt ${attempt + 1}/${maxRetries})...`);
|
||||
|
||||
await this.delay(backoffMs);
|
||||
}
|
||||
}
|
||||
|
||||
// All retries exhausted
|
||||
throw lastError || new Error('External API request failed after retries');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get popular audiobooks from best sellers (with pagination support)
|
||||
*/
|
||||
@@ -349,7 +390,7 @@ export class AudibleService {
|
||||
try {
|
||||
logger.info(` Searching for "${query}"...`);
|
||||
|
||||
const response = await this.client.get('/search', {
|
||||
const response = await this.fetchWithRetry('/search', {
|
||||
params: {
|
||||
keywords: query,
|
||||
page,
|
||||
@@ -470,7 +511,7 @@ export class AudibleService {
|
||||
const audnexusRegion = AUDIBLE_REGIONS[this.region].audnexusParam;
|
||||
logger.debug(`Fetching ASIN from Audnexus: ${asin} (region: ${audnexusRegion})`);
|
||||
|
||||
const response = await axios.get(`https://api.audnex.us/books/${asin}`, {
|
||||
const response = await this.externalFetchWithRetry(`https://api.audnex.us/books/${asin}`, {
|
||||
params: {
|
||||
region: audnexusRegion, // Pass region parameter to Audnexus
|
||||
},
|
||||
@@ -531,7 +572,7 @@ export class AudibleService {
|
||||
*/
|
||||
private async scrapeAudibleDetails(asin: string): Promise<AudibleAudiobook | null> {
|
||||
try {
|
||||
const response = await this.client.get(`/pd/${asin}`);
|
||||
const response = await this.fetchWithRetry(`/pd/${asin}`);
|
||||
const $ = cheerio.load(response.data);
|
||||
|
||||
// Initialize result object
|
||||
@@ -870,7 +911,7 @@ export class AudibleService {
|
||||
// Use Audnexus API for fast, reliable runtime data
|
||||
const audnexusRegion = AUDIBLE_REGIONS[this.region].audnexusParam;
|
||||
|
||||
const response = await axios.get(`https://api.audnex.us/books/${asin}`, {
|
||||
const response = await this.externalFetchWithRetry(`https://api.audnex.us/books/${asin}`, {
|
||||
params: { region: audnexusRegion },
|
||||
timeout: 5000, // Quick timeout for search performance
|
||||
headers: { 'User-Agent': 'ReadMeABook/1.0' },
|
||||
|
||||
@@ -775,6 +775,44 @@ export class PlexService {
|
||||
return ratingsMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a library item by ratingKey
|
||||
* Note: Deletion must be enabled in Plex under Settings > Server > Library
|
||||
*
|
||||
* @param serverUrl - The Plex server URL
|
||||
* @param authToken - Authentication token
|
||||
* @param ratingKey - The ratingKey of the item to delete
|
||||
*/
|
||||
async deleteItem(
|
||||
serverUrl: string,
|
||||
authToken: string,
|
||||
ratingKey: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
await this.client.delete(
|
||||
`${serverUrl}/library/metadata/${ratingKey}`,
|
||||
{
|
||||
headers: {
|
||||
'X-Plex-Token': authToken,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
logger.info(`Deleted Plex library item with ratingKey ${ratingKey}`);
|
||||
} catch (error: any) {
|
||||
if (error.response?.status === 404) {
|
||||
logger.warn('Item not found in Plex library', { ratingKey });
|
||||
// Don't throw - item might already be deleted
|
||||
return;
|
||||
}
|
||||
logger.error('Failed to delete Plex library item', {
|
||||
ratingKey,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
throw new Error('Failed to delete item from Plex library');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of Plex Home users/profiles
|
||||
* Returns all managed users and home members for the authenticated account
|
||||
|
||||
@@ -406,10 +406,12 @@ export class SABnzbdService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete NZB download
|
||||
* Delete NZB download from queue
|
||||
*/
|
||||
async deleteNZB(nzbId: string, deleteFiles: boolean = false): Promise<void> {
|
||||
await this.client.get('/api', {
|
||||
logger.info(`Deleting NZB from queue: ${nzbId} (del_files: ${deleteFiles ? '1' : '0'})`);
|
||||
|
||||
const response = await this.client.get('/api', {
|
||||
params: {
|
||||
mode: 'queue',
|
||||
name: 'delete',
|
||||
@@ -419,6 +421,59 @@ export class SABnzbdService {
|
||||
apikey: this.apiKey,
|
||||
},
|
||||
});
|
||||
|
||||
logger.info(`SABnzbd queue delete response: ${JSON.stringify(response.data)}`);
|
||||
|
||||
// Check if SABnzbd returned an error
|
||||
if (response.data?.status === false) {
|
||||
throw new Error(response.data.error || `Failed to delete NZB ${nzbId} from queue`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Archive NZB from history (hides from main view but preserves for troubleshooting)
|
||||
* Note: SABnzbd's default behavior is to archive. Use archive=0 to permanently delete.
|
||||
*/
|
||||
async archiveFromHistory(nzbId: string): Promise<void> {
|
||||
logger.info(`Archiving NZB from history: ${nzbId}`);
|
||||
|
||||
const response = await this.client.get('/api', {
|
||||
params: {
|
||||
mode: 'history',
|
||||
name: 'delete',
|
||||
value: nzbId,
|
||||
// No del_files parameter - we'll handle file cleanup manually
|
||||
// No archive parameter - defaults to archive=1 (move to hidden archive, not permanent delete)
|
||||
output: 'json',
|
||||
apikey: this.apiKey,
|
||||
},
|
||||
});
|
||||
|
||||
logger.info(`SABnzbd history archive response: ${JSON.stringify(response.data)}`);
|
||||
|
||||
// Check if SABnzbd returned an error
|
||||
if (response.data?.status === false) {
|
||||
throw new Error(response.data.error || `Failed to archive NZB ${nzbId} from history`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Archive completed NZB from history after file organization
|
||||
* Note: Only archives from history (not queue). If still in queue, something went wrong.
|
||||
* Archives to SABnzbd's hidden archive (preserves for troubleshooting, doesn't permanently delete)
|
||||
*/
|
||||
async archiveCompletedNZB(nzbId: string): Promise<void> {
|
||||
logger.info(`Attempting to archive completed NZB ${nzbId}`);
|
||||
|
||||
try {
|
||||
await this.archiveFromHistory(nzbId);
|
||||
logger.info(`Successfully archived ${nzbId} from history`);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to archive ${nzbId} from history`, {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
throw new Error(`NZB ${nzbId} not found in history or failed to archive`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -67,6 +67,7 @@ export async function processDownloadTorrent(payload: DownloadTorrentPayload): P
|
||||
data: {
|
||||
requestId,
|
||||
indexerName: torrent.indexer,
|
||||
indexerId: torrent.indexerId, // Store indexer ID for configuration lookup
|
||||
downloadClient: 'sabnzbd',
|
||||
downloadClientId,
|
||||
torrentName: torrent.title,
|
||||
@@ -131,6 +132,7 @@ export async function processDownloadTorrent(payload: DownloadTorrentPayload): P
|
||||
data: {
|
||||
requestId,
|
||||
indexerName: torrent.indexer,
|
||||
indexerId: torrent.indexerId, // Store indexer ID for configuration lookup
|
||||
downloadClient: 'qbittorrent',
|
||||
downloadClientId,
|
||||
torrentName: torrent.title,
|
||||
|
||||
@@ -1,191 +0,0 @@
|
||||
/**
|
||||
* 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 { RMABLogger } from '../utils/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 = RMABLogger.forJob(jobId, 'MatchLibrary');
|
||||
|
||||
logger.warn('DEPRECATED: match_plex job is deprecated. Use scan_plex instead.');
|
||||
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();
|
||||
|
||||
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);
|
||||
|
||||
logger.info(`Found ${searchResults.length} results in library`);
|
||||
|
||||
if (searchResults.length === 0) {
|
||||
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];
|
||||
|
||||
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) {
|
||||
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 {
|
||||
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) {
|
||||
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',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -85,7 +85,7 @@ export async function processMonitorDownload(payload: MonitorDownloadPayload): P
|
||||
|
||||
// Convert NZBInfo to progress format
|
||||
progress = {
|
||||
percent: nzbInfo.progress,
|
||||
percent: nzbInfo.progress * 100, // Convert 0.0-1.0 to 0-100 (matches qBittorrent format)
|
||||
bytesDownloaded: nzbInfo.size * nzbInfo.progress,
|
||||
bytesTotal: nzbInfo.size,
|
||||
speed: nzbInfo.downloadSpeed,
|
||||
|
||||
@@ -9,6 +9,7 @@ import { getFileOrganizer } from '../utils/file-organizer';
|
||||
import { RMABLogger } from '../utils/logger';
|
||||
import { getLibraryService } from '../services/library';
|
||||
import { getConfigService } from '../services/config.service';
|
||||
import { generateFilesHash } from '../utils/files-hash';
|
||||
|
||||
/**
|
||||
* Process organize files job
|
||||
@@ -107,11 +108,18 @@ export async function processOrganizeFiles(payload: OrganizeFilesPayload): Promi
|
||||
|
||||
logger.info(`Successfully moved ${result.filesMovedCount} files to ${result.targetPath}`);
|
||||
|
||||
// Update audiobook record with file path and status
|
||||
// Generate hash from organized audio files for library matching
|
||||
const filesHash = generateFilesHash(result.audioFiles);
|
||||
if (filesHash) {
|
||||
logger.info(`Generated files hash: ${filesHash.substring(0, 16)}... (${result.audioFiles.length} audio files)`);
|
||||
}
|
||||
|
||||
// Update audiobook record with file path, hash, and status
|
||||
await prisma.audiobook.update({
|
||||
where: { id: audiobookId },
|
||||
data: {
|
||||
filePath: result.targetPath,
|
||||
filesHash: filesHash || null,
|
||||
status: 'completed',
|
||||
completedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
@@ -189,6 +197,95 @@ export async function processOrganizeFiles(payload: OrganizeFilesPayload): Promi
|
||||
);
|
||||
}
|
||||
|
||||
// Cleanup Usenet downloads if configured
|
||||
try {
|
||||
logger.info('Checking if cleanup is needed for this download');
|
||||
|
||||
// Get download history to find NZB ID and indexer
|
||||
const downloadHistory = await prisma.downloadHistory.findFirst({
|
||||
where: { requestId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
logger.info(`Download history found: ${downloadHistory ? 'yes' : 'no'}`, {
|
||||
hasNzbId: !!downloadHistory?.nzbId,
|
||||
hasIndexerId: !!downloadHistory?.indexerId,
|
||||
nzbId: downloadHistory?.nzbId || 'none',
|
||||
indexerId: downloadHistory?.indexerId || 'none',
|
||||
});
|
||||
|
||||
if (downloadHistory?.nzbId && downloadHistory?.indexerId) {
|
||||
// Get indexer configuration
|
||||
const indexersConfig = await configService.get('prowlarr_indexers');
|
||||
logger.info(`Indexers config found: ${indexersConfig ? 'yes' : 'no'}`);
|
||||
|
||||
if (indexersConfig) {
|
||||
const indexers: Array<{ id: number; protocol: string; removeAfterProcessing?: boolean }> = JSON.parse(indexersConfig);
|
||||
const indexer = indexers.find(idx => idx.id === downloadHistory.indexerId);
|
||||
|
||||
logger.info(`Indexer found in config: ${indexer ? 'yes' : 'no'}`, {
|
||||
indexerId: downloadHistory.indexerId,
|
||||
protocol: indexer?.protocol || 'none',
|
||||
removeAfterProcessing: indexer?.removeAfterProcessing ?? 'undefined',
|
||||
});
|
||||
|
||||
// Check if this is a Usenet indexer with cleanup enabled
|
||||
if (indexer && indexer.protocol?.toLowerCase() !== 'torrent' && indexer.removeAfterProcessing) {
|
||||
logger.info(`Cleaning up NZB ${downloadHistory.nzbId} (cleanup enabled for indexer ${indexer.id})`);
|
||||
|
||||
// First, manually delete files from filesystem
|
||||
if (downloadPath) {
|
||||
logger.info(`Removing download files from filesystem: ${downloadPath}`);
|
||||
|
||||
const fs = await import('fs/promises');
|
||||
|
||||
try {
|
||||
// Check if it's a file or directory
|
||||
const stats = await fs.stat(downloadPath);
|
||||
|
||||
if (stats.isDirectory()) {
|
||||
// Remove directory and all contents
|
||||
await fs.rm(downloadPath, { recursive: true, force: true });
|
||||
logger.info(`Removed directory: ${downloadPath}`);
|
||||
} else {
|
||||
// Remove single file
|
||||
await fs.unlink(downloadPath);
|
||||
logger.info(`Removed file: ${downloadPath}`);
|
||||
}
|
||||
} catch (fsError) {
|
||||
// File/directory might already be deleted or not exist
|
||||
if ((fsError as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
logger.info(`Download path already deleted: ${downloadPath}`);
|
||||
} else {
|
||||
throw fsError;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
logger.warn(`No download path available, skipping filesystem deletion`);
|
||||
}
|
||||
|
||||
// Then archive from SABnzbd history (hides from UI but preserves for troubleshooting)
|
||||
// Note: We only archive from history, not queue. If the NZB is still in the queue
|
||||
// when we're organizing files, something went wrong with the download monitoring.
|
||||
const { getSABnzbdService } = await import('../integrations/sabnzbd.service');
|
||||
const sabnzbd = await getSABnzbdService();
|
||||
|
||||
await sabnzbd.archiveCompletedNZB(downloadHistory.nzbId);
|
||||
|
||||
logger.info(`Successfully archived NZB ${downloadHistory.nzbId} and removed files`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Log error but don't fail the job - cleanup is optional
|
||||
logger.warn(
|
||||
`Failed to cleanup NZB download: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
{
|
||||
error: error instanceof Error ? error.stack : undefined,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Files organized successfully',
|
||||
|
||||
@@ -178,6 +178,77 @@ export async function processPlexRecentlyAddedCheck(payload: PlexRecentlyAddedPa
|
||||
}
|
||||
}
|
||||
|
||||
// For Audiobookshelf: Trigger metadata match for items without ASIN
|
||||
// This ensures ASIN gets populated so items can be matched against requests
|
||||
if (backendMode === 'audiobookshelf') {
|
||||
const { triggerABSItemMatch, getABSItem } = await import('../services/audiobookshelf/api');
|
||||
const { generateFilesHash } = await import('../utils/files-hash');
|
||||
|
||||
const itemsWithoutAsin = recentItems.filter(item => !item.asin && item.externalId);
|
||||
|
||||
if (itemsWithoutAsin.length > 0) {
|
||||
logger.info(`Found ${itemsWithoutAsin.length} recent items without ASIN, attempting file hash matching...`);
|
||||
|
||||
let fileMatchCount = 0;
|
||||
let fuzzyMatchCount = 0;
|
||||
|
||||
for (const item of itemsWithoutAsin) {
|
||||
try {
|
||||
// 1. Fetch full item details to get file list
|
||||
const absItem = await getABSItem(item.externalId);
|
||||
|
||||
// 2. Extract audio filenames and generate hash
|
||||
const audioFilenames = absItem.media?.audioFiles?.map((f: any) => f.metadata?.filename).filter(Boolean) || [];
|
||||
const itemHash = generateFilesHash(audioFilenames);
|
||||
|
||||
// 3. Query database for matching downloaded request
|
||||
let matchedAsin: string | undefined = undefined;
|
||||
|
||||
if (itemHash) {
|
||||
const matchedAudiobook = await prisma.audiobook.findFirst({
|
||||
where: {
|
||||
filesHash: itemHash,
|
||||
status: 'completed',
|
||||
},
|
||||
select: {
|
||||
audibleAsin: true,
|
||||
title: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (matchedAudiobook?.audibleAsin) {
|
||||
matchedAsin = matchedAudiobook.audibleAsin;
|
||||
logger.info(
|
||||
`File hash match found for "${item.title}" → ASIN: ${matchedAsin} (from "${matchedAudiobook.title}")`
|
||||
);
|
||||
fileMatchCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Trigger metadata match (with ASIN if matched, undefined if not)
|
||||
await triggerABSItemMatch(item.externalId, matchedAsin);
|
||||
|
||||
if (matchedAsin) {
|
||||
logger.info(`Triggered metadata match with ASIN ${matchedAsin} for: "${item.title}"`);
|
||||
} else {
|
||||
logger.info(`No file match found, triggering fuzzy metadata match for: "${item.title}"`);
|
||||
fuzzyMatchCount++;
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Failed to process metadata match for "${item.title}": ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
fuzzyMatchCount++;
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Metadata match complete: ${fileMatchCount} file hash matches, ${fuzzyMatchCount} fuzzy matches (ASIN population is async)`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for all non-terminal requests to match
|
||||
const matchableRequests = await prisma.request.findMany({
|
||||
where: {
|
||||
@@ -259,15 +330,8 @@ export async function processPlexRecentlyAddedCheck(payload: PlexRecentlyAddedPa
|
||||
|
||||
matchedDownloads++;
|
||||
|
||||
// Trigger metadata match for Audiobookshelf items (only for our downloaded requests)
|
||||
if (backendMode === 'audiobookshelf') {
|
||||
const itemId = match.plexGuid; // plexGuid contains the Audiobookshelf item ID
|
||||
const asin = audiobook.audibleAsin || undefined;
|
||||
const matchInfo = asin ? ` with ASIN ${asin}` : '';
|
||||
logger.info(`Triggering metadata match for matched item: ${itemId}${matchInfo}`);
|
||||
const { triggerABSItemMatch } = await import('../services/audiobookshelf/api');
|
||||
await triggerABSItemMatch(itemId, asin);
|
||||
}
|
||||
// Note: Audiobookshelf metadata matching is handled in the file hash phase above
|
||||
// Items without ASIN get file-hash-matched ASIN, items with ASIN already have correct metadata
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Failed to match request ${request.id}: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
|
||||
@@ -180,6 +180,80 @@ export async function processScanPlex(payload: ScanPlexPayload): Promise<any> {
|
||||
|
||||
logger.info(`Scan complete: ${libraryItems.length} items scanned, ${newCount} new, ${updatedCount} updated, ${skippedCount} skipped`);
|
||||
|
||||
// 4b. For Audiobookshelf: Trigger metadata match for items without ASIN
|
||||
// This ensures ASIN gets populated so items can be matched against requests
|
||||
if (backendMode === 'audiobookshelf') {
|
||||
logger.info(`Checking for Audiobookshelf items without ASIN...`);
|
||||
const { triggerABSItemMatch, getABSItem } = await import('../services/audiobookshelf/api');
|
||||
const { generateFilesHash } = await import('../utils/files-hash');
|
||||
|
||||
const itemsWithoutAsin = libraryItems.filter(item => !item.asin && item.externalId);
|
||||
|
||||
if (itemsWithoutAsin.length > 0) {
|
||||
logger.info(`Found ${itemsWithoutAsin.length} items without ASIN, attempting file hash matching...`);
|
||||
|
||||
let fileMatchCount = 0;
|
||||
let fuzzyMatchCount = 0;
|
||||
|
||||
for (const item of itemsWithoutAsin) {
|
||||
try {
|
||||
// 1. Fetch full item details to get file list
|
||||
const absItem = await getABSItem(item.externalId);
|
||||
|
||||
// 2. Extract audio filenames and generate hash
|
||||
const audioFilenames = absItem.media?.audioFiles?.map((f: any) => f.metadata?.filename).filter(Boolean) || [];
|
||||
const itemHash = generateFilesHash(audioFilenames);
|
||||
|
||||
// 3. Query database for matching downloaded request
|
||||
let matchedAsin: string | undefined = undefined;
|
||||
|
||||
if (itemHash) {
|
||||
const matchedAudiobook = await prisma.audiobook.findFirst({
|
||||
where: {
|
||||
filesHash: itemHash,
|
||||
status: 'completed',
|
||||
},
|
||||
select: {
|
||||
audibleAsin: true,
|
||||
title: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (matchedAudiobook?.audibleAsin) {
|
||||
matchedAsin = matchedAudiobook.audibleAsin;
|
||||
logger.info(
|
||||
`File hash match found for "${item.title}" → ASIN: ${matchedAsin} (from "${matchedAudiobook.title}")`
|
||||
);
|
||||
fileMatchCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Trigger metadata match (with ASIN if matched, undefined if not)
|
||||
await triggerABSItemMatch(item.externalId, matchedAsin);
|
||||
|
||||
if (matchedAsin) {
|
||||
logger.info(`Triggered metadata match with ASIN ${matchedAsin} for: "${item.title}"`);
|
||||
} else {
|
||||
logger.info(`No file match found, triggering fuzzy metadata match for: "${item.title}"`);
|
||||
fuzzyMatchCount++;
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Failed to process metadata match for "${item.title}": ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
fuzzyMatchCount++;
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Metadata match complete: ${fileMatchCount} file hash matches, ${fuzzyMatchCount} fuzzy matches (ASIN population is async)`
|
||||
);
|
||||
} else {
|
||||
logger.info(`All items have ASIN, no metadata match needed`);
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Remove stale records from plex_library (items no longer in the actual library)
|
||||
// This ensures the database is a fresh snapshot of the library state
|
||||
logger.info(`Checking for stale library records...`);
|
||||
@@ -445,15 +519,8 @@ export async function processScanPlex(payload: ScanPlexPayload): Promise<any> {
|
||||
|
||||
matchedCount++;
|
||||
|
||||
// Trigger metadata match for Audiobookshelf items (only for our downloaded requests)
|
||||
if (backendMode === 'audiobookshelf') {
|
||||
const itemId = match.plexGuid; // plexGuid contains the Audiobookshelf item ID
|
||||
const asin = audiobook.audibleAsin || undefined;
|
||||
const matchInfo = asin ? ` with ASIN ${asin}` : '';
|
||||
logger.info(`Triggering metadata match for matched item: ${itemId}${matchInfo}`);
|
||||
const { triggerABSItemMatch } = await import('../services/audiobookshelf/api');
|
||||
await triggerABSItemMatch(itemId, asin);
|
||||
}
|
||||
// Note: Audiobookshelf metadata matching is handled in the file hash phase above
|
||||
// Items without ASIN get file-hash-matched ASIN, items with ASIN already have correct metadata
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Failed to match request ${request.id}: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
|
||||
@@ -103,13 +103,13 @@ export async function processSearchIndexers(payload: SearchIndexersPayload): Pro
|
||||
|
||||
if (searchResults.length === 0) {
|
||||
// No results found - queue for re-search instead of failing
|
||||
logger.warn(`No torrents found for request ${requestId}, marking as awaiting_search`);
|
||||
logger.warn(`No torrents/nzbs 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.',
|
||||
errorMessage: 'No torrents/nzbs found. Will retry automatically.',
|
||||
lastSearchAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
@@ -117,7 +117,7 @@ export async function processSearchIndexers(payload: SearchIndexersPayload): Pro
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: 'No torrents found, queued for re-search',
|
||||
message: 'No torrents/nzbs found, queued for re-search',
|
||||
requestId,
|
||||
};
|
||||
}
|
||||
@@ -149,11 +149,16 @@ export async function processSearchIndexers(payload: SearchIndexersPayload): Pro
|
||||
|
||||
// Rank results with indexer priorities and flag configs
|
||||
// Note: rankTorrents now filters out results < 20 MB internally
|
||||
// requireAuthor: true (default) - strict filtering for automatic selection
|
||||
const rankedResults = ranker.rankTorrents(searchResults, {
|
||||
title: audiobook.title,
|
||||
author: audiobook.author,
|
||||
durationMinutes,
|
||||
}, indexerPriorities, flagConfigs);
|
||||
}, {
|
||||
indexerPriorities,
|
||||
flagConfigs,
|
||||
requireAuthor: true // Automatic mode - prevent wrong authors
|
||||
});
|
||||
|
||||
// Log filter results
|
||||
const postFilterCount = rankedResults.length;
|
||||
|
||||
@@ -10,7 +10,6 @@ export interface UserInfo {
|
||||
email?: string;
|
||||
avatarUrl?: string;
|
||||
role?: string; // 'admin' | 'user'
|
||||
isAdmin?: boolean; // Deprecated: use role instead
|
||||
authProvider?: string; // 'plex' | 'oidc' | 'local'
|
||||
}
|
||||
|
||||
|
||||
@@ -113,7 +113,7 @@ export class LocalAuthProvider implements IAuthProvider {
|
||||
id: user.id,
|
||||
plexId: user.plexId,
|
||||
username: user.plexUsername,
|
||||
isAdmin: user.role === 'admin',
|
||||
role: user.role,
|
||||
});
|
||||
|
||||
logger.info('Tokens generated, returning user data');
|
||||
@@ -214,7 +214,7 @@ export class LocalAuthProvider implements IAuthProvider {
|
||||
id: user.id,
|
||||
plexId: user.plexId,
|
||||
username: user.plexUsername,
|
||||
isAdmin: user.role === 'admin',
|
||||
role: user.role,
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -245,7 +245,7 @@ export class LocalAuthProvider implements IAuthProvider {
|
||||
sub: userInfo.id,
|
||||
plexId: userInfo.plexId,
|
||||
username: userInfo.username,
|
||||
role: userInfo.isAdmin ? 'admin' : 'user',
|
||||
role: userInfo.role || 'user',
|
||||
};
|
||||
|
||||
logger.debug('JWT token payload', { tokenPayload });
|
||||
|
||||
@@ -454,7 +454,7 @@ export class OIDCAuthProvider implements IAuthProvider {
|
||||
username: user.plexUsername,
|
||||
email: user.plexEmail || undefined,
|
||||
avatarUrl: user.avatarUrl || undefined,
|
||||
isAdmin: user.role === 'admin',
|
||||
role: user.role,
|
||||
authProvider: 'oidc',
|
||||
},
|
||||
isFirstLogin: isFirstUser && shouldTriggerJobs,
|
||||
@@ -518,7 +518,7 @@ export class OIDCAuthProvider implements IAuthProvider {
|
||||
sub: userInfo.id,
|
||||
plexId: userInfo.id, // For backwards compatibility
|
||||
username: userInfo.username,
|
||||
role: userInfo.isAdmin ? 'admin' : 'user',
|
||||
role: userInfo.role || 'user',
|
||||
});
|
||||
|
||||
const refreshToken = generateRefreshToken(userInfo.id);
|
||||
|
||||
@@ -239,7 +239,7 @@ export class PlexAuthProvider implements IAuthProvider {
|
||||
username: user.plexUsername,
|
||||
email: user.plexEmail || undefined,
|
||||
avatarUrl: user.avatarUrl || undefined,
|
||||
isAdmin: user.role === 'admin',
|
||||
role: user.role,
|
||||
authProvider: 'plex',
|
||||
};
|
||||
}
|
||||
@@ -252,7 +252,7 @@ export class PlexAuthProvider implements IAuthProvider {
|
||||
sub: userInfo.id,
|
||||
plexId: userInfo.id, // For backwards compatibility
|
||||
username: userInfo.username,
|
||||
role: userInfo.isAdmin ? 'admin' : 'user',
|
||||
role: userInfo.role || 'user',
|
||||
});
|
||||
|
||||
const refreshToken = generateRefreshToken(userInfo.id);
|
||||
|
||||
@@ -7,7 +7,6 @@ import axios, { AxiosError } from 'axios';
|
||||
import * as cheerio from 'cheerio';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { JobLogger } from '../utils/job-logger';
|
||||
import { RMABLogger } from '../utils/logger';
|
||||
|
||||
// Module-level logger (renamed to avoid shadowing function parameter 'logger')
|
||||
@@ -90,7 +89,7 @@ async function fetchViaFlareSolverr(
|
||||
async function fetchHtml(
|
||||
url: string,
|
||||
flaresolverrUrl?: string,
|
||||
logger?: JobLogger
|
||||
logger?: RMABLogger
|
||||
): Promise<string> {
|
||||
// Try FlareSolverr first if configured
|
||||
if (flaresolverrUrl) {
|
||||
@@ -169,7 +168,7 @@ export async function downloadEbook(
|
||||
targetDir: string,
|
||||
preferredFormat: string = 'epub',
|
||||
baseUrl: string = 'https://annas-archive.li',
|
||||
logger?: JobLogger,
|
||||
logger?: RMABLogger,
|
||||
flaresolverrUrl?: string
|
||||
): Promise<EbookDownloadResult> {
|
||||
try {
|
||||
@@ -310,7 +309,7 @@ async function searchByAsin(
|
||||
asin: string,
|
||||
format: string,
|
||||
baseUrl: string,
|
||||
logger?: JobLogger,
|
||||
logger?: RMABLogger,
|
||||
flaresolverrUrl?: string
|
||||
): Promise<string | null> {
|
||||
// Check cache first
|
||||
@@ -326,7 +325,7 @@ async function searchByAsin(
|
||||
try {
|
||||
// Build search URL with ASIN and optional format filter
|
||||
const formatParam = format && format !== 'any' ? `ext=${format}&` : '';
|
||||
const searchUrl = `${baseUrl}/search?${formatParam}q=%22asin:${asin}%22`;
|
||||
const searchUrl = `${baseUrl}/search?${formatParam}lang=en&q=%22asin:${asin}%22`;
|
||||
|
||||
moduleLogger.debug(`ASIN search URL: ${searchUrl}`);
|
||||
|
||||
@@ -401,7 +400,7 @@ async function searchByTitle(
|
||||
author: string,
|
||||
format: string,
|
||||
baseUrl: string,
|
||||
logger?: JobLogger,
|
||||
logger?: RMABLogger,
|
||||
flaresolverrUrl?: string
|
||||
): Promise<string | null> {
|
||||
// Check cache first
|
||||
@@ -491,7 +490,7 @@ async function searchByTitle(
|
||||
async function getSlowDownloadLinks(
|
||||
md5: string,
|
||||
baseUrl: string,
|
||||
logger?: JobLogger,
|
||||
logger?: RMABLogger,
|
||||
flaresolverrUrl?: string
|
||||
): Promise<string[]> {
|
||||
try {
|
||||
@@ -576,7 +575,7 @@ async function extractDownloadUrl(
|
||||
slowDownloadUrl: string,
|
||||
baseUrl: string,
|
||||
format: string,
|
||||
logger?: JobLogger,
|
||||
logger?: RMABLogger,
|
||||
flaresolverrUrl?: string
|
||||
): Promise<ExtractedDownload | null> {
|
||||
try {
|
||||
@@ -641,7 +640,7 @@ async function extractDownloadUrl(
|
||||
async function downloadFile(
|
||||
url: string,
|
||||
targetPath: string,
|
||||
logger?: JobLogger
|
||||
logger?: RMABLogger
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const response = await axios.get(url, {
|
||||
|
||||
@@ -17,7 +17,6 @@ export type JobType =
|
||||
| 'monitor_download'
|
||||
| 'organize_files'
|
||||
| 'scan_plex'
|
||||
| 'match_plex'
|
||||
| 'plex_library_scan'
|
||||
| 'plex_recently_added_check'
|
||||
| 'audible_refresh'
|
||||
@@ -72,13 +71,6 @@ export interface ScanPlexPayload extends JobPayload {
|
||||
path?: string;
|
||||
}
|
||||
|
||||
export interface MatchPlexPayload extends JobPayload {
|
||||
requestId: string;
|
||||
audiobookId: string;
|
||||
title: string;
|
||||
author: string;
|
||||
}
|
||||
|
||||
export interface PlexRecentlyAddedPayload extends JobPayload {
|
||||
scheduledJobId?: string;
|
||||
}
|
||||
@@ -260,12 +252,6 @@ export class JobQueueService {
|
||||
return await processScanPlex(job.data);
|
||||
});
|
||||
|
||||
// Match Plex processor
|
||||
this.queue.process('match_plex', 3, async (job: BullJob<MatchPlexPayload>) => {
|
||||
const { processMatchPlex } = await import('../processors/match-plex.processor');
|
||||
return await processMatchPlex(job.data);
|
||||
});
|
||||
|
||||
// Scheduled job processors
|
||||
this.queue.process('plex_library_scan', 1, async (job: BullJob) => {
|
||||
// plex_library_scan is just an alias for scan_plex
|
||||
@@ -559,29 +545,6 @@ export class JobQueueService {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add Plex match job
|
||||
*/
|
||||
async addPlexMatchJob(
|
||||
requestId: string,
|
||||
audiobookId: string,
|
||||
title: string,
|
||||
author: string
|
||||
): Promise<string> {
|
||||
return await this.addJob(
|
||||
'match_plex',
|
||||
{
|
||||
requestId,
|
||||
audiobookId,
|
||||
title,
|
||||
author,
|
||||
} as MatchPlexPayload,
|
||||
{
|
||||
priority: 6,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add Plex recently added check job
|
||||
*/
|
||||
|
||||
@@ -248,8 +248,9 @@ export async function deleteRequest(
|
||||
const configService = getConfigService();
|
||||
const backendMode = await configService.getBackendMode();
|
||||
|
||||
// If backend is Audiobookshelf, delete the library item from ABS
|
||||
// Delete from library backend (ABS or Plex)
|
||||
if (backendMode === 'audiobookshelf' && request.audiobook.absItemId) {
|
||||
// Audiobookshelf: delete the library item from ABS
|
||||
try {
|
||||
const { deleteABSItem } = await import('../services/audiobookshelf/api');
|
||||
await deleteABSItem(request.audiobook.absItemId);
|
||||
@@ -263,6 +264,44 @@ export async function deleteRequest(
|
||||
);
|
||||
// Continue with deletion even if ABS deletion fails
|
||||
}
|
||||
} else if (backendMode === 'plex' && request.audiobook.plexGuid) {
|
||||
// Plex: delete the library item from Plex by ratingKey
|
||||
try {
|
||||
// Query plex_library table to get the ratingKey
|
||||
const plexLibraryRecord = await prisma.plexLibrary.findUnique({
|
||||
where: { plexGuid: request.audiobook.plexGuid },
|
||||
select: { plexRatingKey: true },
|
||||
});
|
||||
|
||||
if (plexLibraryRecord && plexLibraryRecord.plexRatingKey) {
|
||||
const ratingKey = plexLibraryRecord.plexRatingKey;
|
||||
|
||||
// Get Plex config
|
||||
const plexServerUrl = (await configService.get('plex_url')) || '';
|
||||
const plexToken = (await configService.get('plex_token')) || '';
|
||||
|
||||
if (plexServerUrl && plexToken) {
|
||||
const { getPlexService } = await import('../integrations/plex.service');
|
||||
const plexService = getPlexService();
|
||||
await plexService.deleteItem(plexServerUrl, plexToken, ratingKey);
|
||||
logger.info(
|
||||
`Deleted Plex library item ${ratingKey} (plexGuid: ${request.audiobook.plexGuid}) for "${request.audiobook.title}"`
|
||||
);
|
||||
} else {
|
||||
logger.warn('Plex server URL or token not configured, skipping Plex library deletion');
|
||||
}
|
||||
} else {
|
||||
logger.warn(
|
||||
`No plexRatingKey found in plex_library for plexGuid: ${request.audiobook.plexGuid}`
|
||||
);
|
||||
}
|
||||
} catch (plexError) {
|
||||
logger.error(
|
||||
`Error deleting Plex library item (plexGuid: ${request.audiobook.plexGuid})`,
|
||||
{ error: plexError instanceof Error ? plexError.message : String(plexError) }
|
||||
);
|
||||
// Continue with deletion even if Plex deletion fails
|
||||
}
|
||||
}
|
||||
|
||||
// Delete ALL plex_library records matching this audiobook's title and author
|
||||
|
||||
@@ -3,11 +3,10 @@
|
||||
* Documentation: documentation/integrations/audible.md
|
||||
*
|
||||
* Real-time matching between Audible books and library backends (Plex or Audiobookshelf).
|
||||
* Supports ASIN, ISBN, and fuzzy title/author matching.
|
||||
* ASIN-only matching for library availability checks (exact matches only).
|
||||
*/
|
||||
|
||||
import { prisma } from '@/lib/db';
|
||||
import { compareTwoStrings } from 'string-similarity';
|
||||
import { LibraryItem } from '@/lib/services/library';
|
||||
import { RMABLogger } from './logger';
|
||||
|
||||
@@ -28,43 +27,13 @@ export interface AudiobookMatchResult {
|
||||
author: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize audiobook title for matching by removing common suffixes/prefixes
|
||||
* that don't affect the core title identity.
|
||||
*/
|
||||
function normalizeTitle(title: string): string {
|
||||
let normalized = title.toLowerCase().trim();
|
||||
|
||||
// Remove common parenthetical additions (case-insensitive)
|
||||
normalized = normalized.replace(/\s*\(unabridged\)\s*/gi, ' ');
|
||||
normalized = normalized.replace(/\s*\(abridged\)\s*/gi, ' ');
|
||||
normalized = normalized.replace(/\s*\(full cast\)\s*/gi, ' ');
|
||||
normalized = normalized.replace(/\s*\(full-cast edition\)\s*/gi, ' ');
|
||||
normalized = normalized.replace(/\s*\(dramatized\)\s*/gi, ' ');
|
||||
normalized = normalized.replace(/\s*\(narrated by[^)]*\)\s*/gi, ' ');
|
||||
|
||||
// Remove common subtitle patterns
|
||||
normalized = normalized.replace(/:\s*a novel\s*$/gi, '');
|
||||
normalized = normalized.replace(/:\s*a thriller\s*$/gi, '');
|
||||
normalized = normalized.replace(/:\s*a memoir\s*$/gi, '');
|
||||
|
||||
// Remove book number suffixes (but keep them in main title if they're significant)
|
||||
// Only remove if they're clearly series indicators at the end
|
||||
normalized = normalized.replace(/,?\s*book\s+\d+\s*$/gi, '');
|
||||
normalized = normalized.replace(/:\s*book\s+\d+\s*$/gi, '');
|
||||
|
||||
// Clean up extra whitespace
|
||||
normalized = normalized.replace(/\s+/g, ' ').trim();
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a matching audiobook in the Plex library for a given Audible audiobook.
|
||||
*
|
||||
* Matching logic (in order of priority):
|
||||
* 1. **ASIN in plexGuid** - Check if any Plex book's GUID contains the Audible ASIN (100% match)
|
||||
* 2. **Fuzzy matching** - Normalized title/author string similarity with 70% threshold
|
||||
* Matching logic (ASIN-only, exact matches):
|
||||
* 1. **ASIN in dedicated field** - Check if plexLibrary.asin matches (100% confidence)
|
||||
* 2. **ASIN in plexGuid** - Check if Plex GUID contains the Audible ASIN (backward compatibility)
|
||||
* 3. **No match** - Return null (no fuzzy fallback)
|
||||
*
|
||||
* @param audiobook - Audible audiobook to match
|
||||
* @returns Matched Plex library item or null
|
||||
@@ -72,25 +41,22 @@ function normalizeTitle(title: string): string {
|
||||
export async function findPlexMatch(
|
||||
audiobook: AudiobookMatchInput
|
||||
): Promise<AudiobookMatchResult | null> {
|
||||
// Query plex_library for potential matches
|
||||
// IMPORTANT: Search by TITLE ONLY (not author) because Plex often has narrator as author
|
||||
const titleSearchLength = Math.min(20, audiobook.title.length);
|
||||
// Query plex_library directly by ASIN (indexed O(1) lookup)
|
||||
// Check both dedicated asin field and plexGuid for backward compatibility
|
||||
const plexBooks = await prisma.plexLibrary.findMany({
|
||||
where: {
|
||||
title: {
|
||||
contains: audiobook.title.substring(0, titleSearchLength),
|
||||
mode: 'insensitive',
|
||||
},
|
||||
OR: [
|
||||
{ asin: audiobook.asin },
|
||||
{ plexGuid: { contains: audiobook.asin } },
|
||||
],
|
||||
},
|
||||
select: {
|
||||
plexGuid: true,
|
||||
plexRatingKey: true,
|
||||
title: true,
|
||||
author: true,
|
||||
asin: true, // Include ASIN field for direct matching
|
||||
isbn: true, // Include ISBN field for additional matching
|
||||
asin: true,
|
||||
},
|
||||
take: 20,
|
||||
});
|
||||
|
||||
// Build match result for logging
|
||||
@@ -107,9 +73,9 @@ export async function findPlexMatch(
|
||||
result: null,
|
||||
};
|
||||
|
||||
// If no candidates found, log and return null
|
||||
// If no ASIN matches found, log and return null
|
||||
if (plexBooks.length === 0) {
|
||||
matchResult.matchType = 'no_candidates';
|
||||
matchResult.matchType = 'no_asin_match';
|
||||
logger.debug('Matcher result', { MATCHER: matchResult });
|
||||
return null;
|
||||
}
|
||||
@@ -147,116 +113,8 @@ export async function findPlexMatch(
|
||||
}
|
||||
}
|
||||
|
||||
// FILTER OUT candidates with wrong ASINs (check both dedicated field and plexGuid)
|
||||
const ASIN_PATTERN = /[A-Z0-9]{10}/g;
|
||||
const rejectedAsins: string[] = [];
|
||||
const validCandidates = plexBooks.filter((plexBook) => {
|
||||
// Check dedicated ASIN field first (more reliable)
|
||||
if (plexBook.asin) {
|
||||
if (plexBook.asin.toLowerCase() !== audiobook.asin.toLowerCase()) {
|
||||
rejectedAsins.push(plexBook.asin);
|
||||
return false; // Wrong ASIN in dedicated field - reject
|
||||
}
|
||||
return true; // Correct ASIN in dedicated field - keep
|
||||
}
|
||||
|
||||
// Fall back to checking plexGuid for legacy Plex data
|
||||
if (!plexBook.plexGuid) return true;
|
||||
const asinsInGuid = plexBook.plexGuid.match(ASIN_PATTERN);
|
||||
if (!asinsInGuid || asinsInGuid.length === 0) return true;
|
||||
|
||||
const hasOurAsin = asinsInGuid.some(asin => asin === audiobook.asin);
|
||||
const hasOtherAsins = asinsInGuid.some(asin => asin !== audiobook.asin);
|
||||
|
||||
if (hasOtherAsins && !hasOurAsin) {
|
||||
rejectedAsins.push(...asinsInGuid);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
matchResult.asinFiltering = {
|
||||
beforeCount: plexBooks.length,
|
||||
afterCount: validCandidates.length,
|
||||
rejectedAsins: rejectedAsins.length > 0 ? rejectedAsins : undefined,
|
||||
};
|
||||
|
||||
if (validCandidates.length === 0) {
|
||||
matchResult.matchType = 'asin_filtered_all';
|
||||
logger.debug('Matcher result', { MATCHER: matchResult });
|
||||
return null;
|
||||
}
|
||||
|
||||
// Normalize the Audible title
|
||||
const normalizedAudibleTitle = normalizeTitle(audiobook.title);
|
||||
|
||||
// PRIORITY 2: Perform fuzzy matching
|
||||
const candidates = validCandidates.map((plexBook) => {
|
||||
const normalizedPlexTitle = normalizeTitle(plexBook.title);
|
||||
const titleScore = compareTwoStrings(normalizedAudibleTitle, normalizedPlexTitle);
|
||||
const authorScore = compareTwoStrings(
|
||||
audiobook.author.toLowerCase(),
|
||||
plexBook.author.toLowerCase()
|
||||
);
|
||||
|
||||
let narratorScore = 0;
|
||||
let usedNarratorMatch = false;
|
||||
if (audiobook.narrator) {
|
||||
narratorScore = compareTwoStrings(
|
||||
audiobook.narrator.toLowerCase(),
|
||||
plexBook.author.toLowerCase()
|
||||
);
|
||||
usedNarratorMatch = narratorScore > authorScore;
|
||||
}
|
||||
|
||||
const personScore = usedNarratorMatch ? narratorScore : authorScore;
|
||||
const overallScore = titleScore * 0.7 + personScore * 0.3;
|
||||
|
||||
return {
|
||||
plexBook,
|
||||
titleScore,
|
||||
authorScore,
|
||||
narratorScore,
|
||||
usedNarratorMatch,
|
||||
score: overallScore
|
||||
};
|
||||
});
|
||||
|
||||
// Sort by score descending
|
||||
candidates.sort((a, b) => b.score - a.score);
|
||||
const bestMatch = candidates[0];
|
||||
|
||||
// Add best match details to result
|
||||
matchResult.bestCandidate = {
|
||||
plexTitle: bestMatch.plexBook.title,
|
||||
plexAuthor: bestMatch.plexBook.author,
|
||||
plexGuid: bestMatch.plexBook.plexGuid,
|
||||
scores: {
|
||||
title: Math.round(bestMatch.titleScore * 100),
|
||||
author: Math.round(bestMatch.authorScore * 100),
|
||||
narrator: audiobook.narrator ? Math.round(bestMatch.narratorScore * 100) : null,
|
||||
usedMatch: bestMatch.usedNarratorMatch ? 'narrator' : 'author',
|
||||
overall: Math.round(bestMatch.score * 100),
|
||||
},
|
||||
threshold: 70,
|
||||
};
|
||||
|
||||
// Accept match if score >= 70%
|
||||
if (bestMatch && bestMatch.score >= 0.7) {
|
||||
matchResult.matchType = 'fuzzy';
|
||||
matchResult.matched = true;
|
||||
matchResult.result = {
|
||||
plexGuid: bestMatch.plexBook.plexGuid,
|
||||
plexTitle: bestMatch.plexBook.title,
|
||||
plexAuthor: bestMatch.plexBook.author,
|
||||
confidence: Math.round(bestMatch.score * 100),
|
||||
};
|
||||
logger.debug('Matcher result', { MATCHER: matchResult });
|
||||
return bestMatch.plexBook;
|
||||
}
|
||||
|
||||
// No match found
|
||||
matchResult.matchType = 'fuzzy_below_threshold';
|
||||
// No exact match found (shouldn't happen given the query, but defensive)
|
||||
matchResult.matchType = 'no_exact_match';
|
||||
logger.debug('Matcher result', { MATCHER: matchResult });
|
||||
return null;
|
||||
}
|
||||
@@ -384,10 +242,10 @@ function normalizeISBN(isbn: string): string {
|
||||
* Generic audiobook matching function that works with LibraryItem interface.
|
||||
* Works with any library backend (Plex, Audiobookshelf, etc.)
|
||||
*
|
||||
* Matching priority:
|
||||
* Matching priority (ASIN-only, exact matches):
|
||||
* 1. Exact ASIN match (100% confidence)
|
||||
* 2. Exact ISBN match (95% confidence)
|
||||
* 3. Fuzzy title/author match (70%+ threshold)
|
||||
* 3. No match - Return null (no fuzzy fallback)
|
||||
*
|
||||
* @param request - Audiobook request details
|
||||
* @param libraryItems - Items from library backend
|
||||
@@ -430,49 +288,15 @@ export function matchAudiobook(
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Fuzzy title/author match
|
||||
const normalizedRequestTitle = normalizeTitle(request.title);
|
||||
const normalizedRequestAuthor = request.author.toLowerCase();
|
||||
|
||||
const candidates = libraryItems.map(item => {
|
||||
const normalizedItemTitle = normalizeTitle(item.title);
|
||||
const normalizedItemAuthor = item.author.toLowerCase();
|
||||
|
||||
const titleScore = compareTwoStrings(normalizedRequestTitle, normalizedItemTitle);
|
||||
const authorScore = compareTwoStrings(normalizedRequestAuthor, normalizedItemAuthor);
|
||||
|
||||
// Weighted average: title is more important
|
||||
const overallScore = titleScore * 0.7 + authorScore * 0.3;
|
||||
|
||||
return { item, titleScore, authorScore, score: overallScore };
|
||||
});
|
||||
|
||||
// Sort by score and get best match
|
||||
candidates.sort((a, b) => b.score - a.score);
|
||||
const bestMatch = candidates[0];
|
||||
|
||||
// Accept if score >= 70%
|
||||
if (bestMatch && bestMatch.score >= 0.7) {
|
||||
logger.debug('Generic matcher result', {
|
||||
matchType: 'fuzzy',
|
||||
input: { title: request.title, author: request.author },
|
||||
matched: { title: bestMatch.item.title, author: bestMatch.item.author },
|
||||
scores: {
|
||||
title: Math.round(bestMatch.titleScore * 100),
|
||||
author: Math.round(bestMatch.authorScore * 100),
|
||||
overall: Math.round(bestMatch.score * 100)
|
||||
},
|
||||
confidence: Math.round(bestMatch.score * 100)
|
||||
});
|
||||
return bestMatch.item;
|
||||
}
|
||||
|
||||
// No match found
|
||||
// No match found (no ASIN/ISBN match, no fuzzy fallback)
|
||||
logger.debug('Generic matcher result', {
|
||||
matchType: 'no_match',
|
||||
input: { title: request.title, author: request.author },
|
||||
bestScore: bestMatch ? Math.round(bestMatch.score * 100) : 0,
|
||||
threshold: 70
|
||||
matchType: 'no_asin_isbn_match',
|
||||
input: {
|
||||
title: request.title,
|
||||
author: request.author,
|
||||
asin: request.asin || 'none',
|
||||
isbn: request.isbn || 'none'
|
||||
},
|
||||
});
|
||||
|
||||
return null;
|
||||
|
||||
@@ -10,7 +10,7 @@ import { exec, spawn } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import path from 'path';
|
||||
import fs from 'fs/promises';
|
||||
import { JobLogger } from './job-logger';
|
||||
import { RMABLogger } from './logger';
|
||||
|
||||
const execPromise = promisify(exec);
|
||||
|
||||
@@ -79,7 +79,7 @@ export interface MergeResult {
|
||||
* This is more permissive and catches edge cases where filenames don't match patterns
|
||||
* but metadata (track numbers) provides correct ordering.
|
||||
*/
|
||||
export async function detectChapterFiles(files: string[], logger?: JobLogger): Promise<boolean> {
|
||||
export async function detectChapterFiles(files: string[], logger?: RMABLogger): Promise<boolean> {
|
||||
// Need at least 3 files to consider as multi-chapter audiobook
|
||||
// (2 files might be "Book" + "Credits", so require 3+)
|
||||
if (files.length < 3) {
|
||||
@@ -285,7 +285,7 @@ function detectBookTitle(files: { titleMetadata?: string }[]): string | null {
|
||||
*/
|
||||
export async function analyzeChapterFiles(
|
||||
filePaths: string[],
|
||||
logger?: JobLogger
|
||||
logger?: RMABLogger
|
||||
): Promise<ChapterFile[]> {
|
||||
await logger?.info(`Analyzing ${filePaths.length} chapter files...`);
|
||||
|
||||
@@ -484,7 +484,7 @@ async function executeFFmpegWithProgress(
|
||||
command: string,
|
||||
timeout: number,
|
||||
expectedDuration: number, // milliseconds
|
||||
logger?: JobLogger
|
||||
logger?: RMABLogger
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Parse the command to extract args (remove 'ffmpeg' and handle quotes)
|
||||
@@ -532,7 +532,7 @@ async function executeFFmpegWithProgress(
|
||||
const speed = speedMatch ? parseFloat(speedMatch[1]) : null;
|
||||
|
||||
const speedInfo = speed ? ` (${speed.toFixed(1)}x realtime)` : '';
|
||||
logger?.info(`Encoding progress: ${progressPercent}%${speedInfo} - ${formatDuration(currentTimeMs)} / ${formatDuration(expectedDuration)}`).catch(() => {});
|
||||
logger?.info(`Encoding progress: ${progressPercent}%${speedInfo} - ${formatDuration(currentTimeMs)} / ${formatDuration(expectedDuration)}`);
|
||||
|
||||
lastProgressLog = Date.now();
|
||||
lastProgressPercent = progressPercent;
|
||||
@@ -546,7 +546,7 @@ async function executeFFmpegWithProgress(
|
||||
if (code === 0) {
|
||||
// Check stderr for errors even if exit code is 0
|
||||
if (stderrBuffer.includes('Error') || stderrBuffer.includes('Invalid')) {
|
||||
logger?.warn(`FFmpeg completed but reported issues: ${stderrBuffer.substring(stderrBuffer.lastIndexOf('Error'), stderrBuffer.lastIndexOf('Error') + 200)}`).catch(() => {});
|
||||
logger?.warn(`FFmpeg completed but reported issues: ${stderrBuffer.substring(stderrBuffer.lastIndexOf('Error'), stderrBuffer.lastIndexOf('Error') + 200)}`);
|
||||
}
|
||||
resolve();
|
||||
} else {
|
||||
@@ -574,7 +574,7 @@ async function executeFFmpegWithProgress(
|
||||
export async function mergeChapters(
|
||||
chapters: ChapterFile[],
|
||||
options: MergeOptions,
|
||||
logger?: JobLogger
|
||||
logger?: RMABLogger
|
||||
): Promise<MergeResult> {
|
||||
if (chapters.length === 0) {
|
||||
await logger?.error('Chapter merge failed: No chapters provided');
|
||||
@@ -806,7 +806,7 @@ export async function mergeChapters(
|
||||
async function validateMergedFile(
|
||||
outputPath: string,
|
||||
expectedDuration: number, // milliseconds
|
||||
logger?: JobLogger
|
||||
logger?: RMABLogger
|
||||
): Promise<{ valid: boolean; error?: string; actualDuration?: number }> {
|
||||
try {
|
||||
await logger?.info('Validating merged file...');
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import axios from 'axios';
|
||||
import { createJobLogger, JobLogger } from './job-logger';
|
||||
import { tagMultipleFiles, checkFfmpegAvailable } from './metadata-tagger';
|
||||
import { RMABLogger } from './logger';
|
||||
|
||||
@@ -73,7 +72,7 @@ export class FileOrganizer {
|
||||
loggerConfig?: LoggerConfig
|
||||
): Promise<OrganizationResult> {
|
||||
// Create logger if config provided
|
||||
const logger = loggerConfig ? createJobLogger(loggerConfig.jobId, loggerConfig.context) : null;
|
||||
const logger = loggerConfig ? RMABLogger.forJob(loggerConfig.jobId, loggerConfig.context) : null;
|
||||
|
||||
const result: OrganizationResult = {
|
||||
success: false,
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* File Hash Utility
|
||||
* Documentation: documentation/fixes/file-hash-matching.md
|
||||
*
|
||||
* Generates deterministic hashes of audio file collections for accurate library matching.
|
||||
* Used to match RMAB-organized audiobooks with Audiobookshelf library items.
|
||||
*/
|
||||
|
||||
import crypto from 'crypto';
|
||||
import path from 'path';
|
||||
|
||||
/**
|
||||
* Supported audio file extensions for hash generation
|
||||
*/
|
||||
const AUDIO_EXTENSIONS = ['.m4b', '.m4a', '.mp3', '.mp4', '.aa', '.aax'];
|
||||
|
||||
/**
|
||||
* Generates a SHA256 hash of audio filenames for library matching.
|
||||
*
|
||||
* Process:
|
||||
* 1. Extract basenames from file paths
|
||||
* 2. Filter to supported audio extensions
|
||||
* 3. Normalize to lowercase
|
||||
* 4. Sort alphabetically
|
||||
* 5. Generate SHA256 hash
|
||||
*
|
||||
* @param filePaths - Array of absolute or relative file paths
|
||||
* @returns 64-character hex string (SHA256 hash) or empty string if no audio files
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const hash = generateFilesHash([
|
||||
* '/path/to/Chapter 01.mp3',
|
||||
* '/path/to/Chapter 02.mp3',
|
||||
* '/path/to/cover.jpg' // Filtered out (not audio)
|
||||
* ]);
|
||||
* // Returns: "abc123def456..." (64 chars)
|
||||
* ```
|
||||
*/
|
||||
export function generateFilesHash(filePaths: string[]): string {
|
||||
if (!filePaths || filePaths.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Extract basenames and filter to audio files only
|
||||
const audioBasenames = filePaths
|
||||
.map((filePath) => path.basename(filePath))
|
||||
.filter((basename) => {
|
||||
const ext = path.extname(basename).toLowerCase();
|
||||
return AUDIO_EXTENSIONS.includes(ext);
|
||||
})
|
||||
.map((basename) => basename.toLowerCase()) // Normalize case
|
||||
.sort(); // Sort alphabetically for deterministic hash
|
||||
|
||||
// No audio files found
|
||||
if (audioBasenames.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Generate SHA256 hash
|
||||
const hash = crypto
|
||||
.createHash('sha256')
|
||||
.update(JSON.stringify(audioBasenames))
|
||||
.digest('hex');
|
||||
|
||||
return hash;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates if a hash string is a valid SHA256 hash
|
||||
*/
|
||||
export function isValidHash(hash: string): boolean {
|
||||
return /^[a-f0-9]{64}$/i.test(hash);
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
/**
|
||||
* Component: Job Logger Utility (Backward Compatibility)
|
||||
* Documentation: documentation/backend/services/jobs.md
|
||||
*
|
||||
* @deprecated Use RMABLogger.forJob() directly for new code.
|
||||
* This file provides backward compatibility for existing processors.
|
||||
*
|
||||
* Migration example:
|
||||
* ```typescript
|
||||
* // Before (deprecated)
|
||||
* const logger = jobId ? createJobLogger(jobId, 'Context') : null;
|
||||
* await logger?.info('message');
|
||||
*
|
||||
* // After (preferred)
|
||||
* import { RMABLogger } from './logger';
|
||||
* const logger = RMABLogger.forJob(jobId, 'Context');
|
||||
* logger.info('message'); // No await needed!
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { RMABLogger, LogMetadata } from './logger';
|
||||
|
||||
export type LogLevel = 'info' | 'warn' | 'error';
|
||||
|
||||
/**
|
||||
* @deprecated Use RMABLogger.forJob() directly
|
||||
*/
|
||||
export class JobLogger {
|
||||
private logger: RMABLogger;
|
||||
|
||||
constructor(jobId: string, context: string) {
|
||||
this.logger = RMABLogger.forJob(jobId, context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log info message
|
||||
* @deprecated Returns Promise for backward compat but is actually synchronous
|
||||
*/
|
||||
async info(message: string, metadata?: LogMetadata): Promise<void> {
|
||||
this.logger.info(message, metadata);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log warning message
|
||||
* @deprecated Returns Promise for backward compat but is actually synchronous
|
||||
*/
|
||||
async warn(message: string, metadata?: LogMetadata): Promise<void> {
|
||||
this.logger.warn(message, metadata);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log error message
|
||||
* @deprecated Returns Promise for backward compat but is actually synchronous
|
||||
*/
|
||||
async error(message: string, metadata?: LogMetadata): Promise<void> {
|
||||
this.logger.error(message, metadata);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a job logger instance
|
||||
* @deprecated Use RMABLogger.forJob() directly
|
||||
*/
|
||||
export function createJobLogger(jobId: string, context: string): JobLogger {
|
||||
return new JobLogger(jobId, context);
|
||||
}
|
||||
@@ -36,6 +36,12 @@ export interface IndexerFlagConfig {
|
||||
modifier: number; // -100 to 100 (percentage)
|
||||
}
|
||||
|
||||
export interface RankTorrentsOptions {
|
||||
indexerPriorities?: Map<number, number>; // indexerId -> priority (1-25)
|
||||
flagConfigs?: IndexerFlagConfig[]; // Flag bonus configurations
|
||||
requireAuthor?: boolean; // Enforce author presence check (default: true)
|
||||
}
|
||||
|
||||
export interface BonusModifier {
|
||||
type: 'indexer_priority' | 'indexer_flag' | 'custom';
|
||||
value: number; // Multiplier (e.g., 0.4 for 40%)
|
||||
@@ -66,15 +72,18 @@ export class RankingAlgorithm {
|
||||
* Rank all torrents and return sorted by finalScore (best first)
|
||||
* @param torrents - Array of torrent results to rank
|
||||
* @param audiobook - Audiobook request details for matching (includes durationMinutes for size scoring)
|
||||
* @param indexerPriorities - Optional map of indexerId to priority (1-25), defaults to 10
|
||||
* @param flagConfigs - Optional array of flag configurations for bonus/penalty modifiers
|
||||
* @param options - Optional configuration for ranking behavior
|
||||
*/
|
||||
rankTorrents(
|
||||
torrents: TorrentResult[],
|
||||
audiobook: AudiobookRequest,
|
||||
indexerPriorities?: Map<number, number>,
|
||||
flagConfigs?: IndexerFlagConfig[]
|
||||
options: RankTorrentsOptions = {}
|
||||
): RankedTorrent[] {
|
||||
const {
|
||||
indexerPriorities,
|
||||
flagConfigs,
|
||||
requireAuthor = true // Safe default: require author in automatic mode
|
||||
} = options;
|
||||
// Filter out files < 20 MB (likely ebooks/samples)
|
||||
const filteredTorrents = torrents.filter((torrent) => {
|
||||
const sizeMB = torrent.size / (1024 * 1024);
|
||||
@@ -86,7 +95,7 @@ export class RankingAlgorithm {
|
||||
const formatScore = this.scoreFormat(torrent);
|
||||
const sizeScore = this.scoreSize(torrent, audiobook.durationMinutes);
|
||||
const seederScore = this.scoreSeeders(torrent.seeders);
|
||||
const matchScore = this.scoreMatch(torrent, audiobook);
|
||||
const matchScore = this.scoreMatch(torrent, audiobook, requireAuthor);
|
||||
|
||||
const baseScore = formatScore + sizeScore + seederScore + matchScore;
|
||||
|
||||
@@ -183,12 +192,13 @@ export class RankingAlgorithm {
|
||||
*/
|
||||
getScoreBreakdown(
|
||||
torrent: TorrentResult,
|
||||
audiobook: AudiobookRequest
|
||||
audiobook: AudiobookRequest,
|
||||
requireAuthor: boolean = true
|
||||
): ScoreBreakdown {
|
||||
const formatScore = this.scoreFormat(torrent);
|
||||
const sizeScore = this.scoreSize(torrent, audiobook.durationMinutes);
|
||||
const seederScore = this.scoreSeeders(torrent.seeders);
|
||||
const matchScore = this.scoreMatch(torrent, audiobook);
|
||||
const matchScore = this.scoreMatch(torrent, audiobook, requireAuthor);
|
||||
const totalScore = formatScore + sizeScore + seederScore + matchScore;
|
||||
|
||||
return {
|
||||
@@ -297,7 +307,8 @@ export class RankingAlgorithm {
|
||||
*/
|
||||
private scoreMatch(
|
||||
torrent: TorrentResult,
|
||||
audiobook: AudiobookRequest
|
||||
audiobook: AudiobookRequest,
|
||||
requireAuthor: boolean = true
|
||||
): number {
|
||||
// Normalize whitespace (multiple spaces → single space) for consistent matching
|
||||
const torrentTitle = torrent.title.toLowerCase().replace(/\s+/g, ' ').trim();
|
||||
@@ -356,6 +367,14 @@ export class RankingAlgorithm {
|
||||
}
|
||||
}
|
||||
|
||||
// ========== STAGE 1.5: AUTHOR PRESENCE CHECK (OPTIONAL) ==========
|
||||
// Only enforced in automatic mode (requireAuthor: true)
|
||||
// Interactive search (requireAuthor: false) shows all results
|
||||
if (requireAuthor && !this.checkAuthorPresence(torrentTitle, requestAuthor)) {
|
||||
// No high-confidence author match → reject to prevent wrong-author matches
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ========== STAGE 2: TITLE MATCHING (0-35 points) ==========
|
||||
let titleScore = 0;
|
||||
|
||||
@@ -455,6 +474,60 @@ export class RankingAlgorithm {
|
||||
return Math.min(60, titleScore + authorScore);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if author is present in torrent title with high confidence
|
||||
* Handles variations: middle initials, spacing, punctuation, name order
|
||||
*
|
||||
* @param torrentTitle - Normalized torrent title (lowercase)
|
||||
* @param requestAuthor - Normalized author name (lowercase)
|
||||
* @returns true if at least ONE author is present with high confidence
|
||||
*/
|
||||
private checkAuthorPresence(torrentTitle: string, requestAuthor: string): boolean {
|
||||
// Parse multiple authors (same logic as Stage 3 author matching)
|
||||
const authors = requestAuthor
|
||||
.split(/,|&| and | - /)
|
||||
.map(a => a.trim())
|
||||
.filter(a => a.length > 2 && !['translator', 'narrator'].includes(a));
|
||||
|
||||
// At least ONE author must match with high confidence
|
||||
return authors.some(author => {
|
||||
// Check 1: Exact substring match
|
||||
if (torrentTitle.includes(author)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check 2: High fuzzy similarity (≥ 0.85)
|
||||
// Handles: "J.K. Rowling" vs "J. K. Rowling" vs "JK Rowling"
|
||||
// Also handles: "Dennis E. Taylor" vs "Dennis Taylor"
|
||||
const similarity = compareTwoStrings(author, torrentTitle);
|
||||
if (similarity >= 0.85) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check 3: Core name components (first + last name present within 30 chars)
|
||||
// Handles: "Sanderson, Brandon" vs "Brandon Sanderson"
|
||||
// Handles: "Brandon R. Sanderson" vs "Brandon Sanderson"
|
||||
const words = author.split(/\s+/).filter(w => w.length > 1);
|
||||
if (words.length >= 2) {
|
||||
const firstName = words[0];
|
||||
const lastName = words[words.length - 1];
|
||||
|
||||
const firstIdx = torrentTitle.indexOf(firstName);
|
||||
const lastIdx = torrentTitle.indexOf(lastName);
|
||||
|
||||
// Both components present and reasonably close?
|
||||
if (firstIdx !== -1 && lastIdx !== -1) {
|
||||
const distance = Math.abs(lastIdx - firstIdx);
|
||||
if (distance <= 30) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect format from torrent title
|
||||
*/
|
||||
@@ -563,15 +636,52 @@ export function getRankingAlgorithm(): RankingAlgorithm {
|
||||
|
||||
/**
|
||||
* Helper function to rank torrents using the singleton instance
|
||||
*
|
||||
* @param torrents - Array of torrent results to rank
|
||||
* @param audiobook - Audiobook request details
|
||||
* @param options - Optional ranking configuration
|
||||
* @returns Ranked torrents with quality scores
|
||||
*/
|
||||
export function rankTorrents(
|
||||
torrents: TorrentResult[],
|
||||
audiobook: AudiobookRequest,
|
||||
options?: RankTorrentsOptions
|
||||
): (RankedTorrent & { qualityScore: number })[];
|
||||
|
||||
/**
|
||||
* Helper function to rank torrents using the singleton instance (legacy signature)
|
||||
* @deprecated Use options object instead
|
||||
*/
|
||||
export function rankTorrents(
|
||||
torrents: TorrentResult[],
|
||||
audiobook: AudiobookRequest,
|
||||
indexerPriorities?: Map<number, number>,
|
||||
flagConfigs?: IndexerFlagConfig[]
|
||||
): (RankedTorrent & { qualityScore: number })[];
|
||||
|
||||
export function rankTorrents(
|
||||
torrents: TorrentResult[],
|
||||
audiobook: AudiobookRequest,
|
||||
optionsOrPriorities?: RankTorrentsOptions | Map<number, number>,
|
||||
flagConfigs?: IndexerFlagConfig[]
|
||||
): (RankedTorrent & { qualityScore: number })[] {
|
||||
const algorithm = getRankingAlgorithm();
|
||||
const ranked = algorithm.rankTorrents(torrents, audiobook, indexerPriorities, flagConfigs);
|
||||
|
||||
// Handle both new options object and legacy parameters
|
||||
let options: RankTorrentsOptions;
|
||||
if (optionsOrPriorities instanceof Map) {
|
||||
// Legacy call: rankTorrents(torrents, audiobook, priorities, flags)
|
||||
options = {
|
||||
indexerPriorities: optionsOrPriorities,
|
||||
flagConfigs,
|
||||
requireAuthor: true // Safe default
|
||||
};
|
||||
} else {
|
||||
// New call: rankTorrents(torrents, audiobook, options)
|
||||
options = optionsOrPriorities || {};
|
||||
}
|
||||
|
||||
const ranked = algorithm.rankTorrents(torrents, audiobook, options);
|
||||
|
||||
// Add qualityScore field for UI compatibility (rounded score)
|
||||
return ranked.map((r) => ({
|
||||
|
||||
Reference in New Issue
Block a user