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:
kikootwo
2026-01-28 10:32:14 -05:00
parent 497849f427
commit a97979358f
111 changed files with 6571 additions and 1426 deletions
+45 -4
View File
@@ -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' },
+38
View File
@@ -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
+57 -2
View File
@@ -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,
-191
View File
@@ -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,
+98 -1
View File
@@ -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'}`);
+76 -9
View File
@@ -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;
-1
View File
@@ -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'
}
+3 -3
View File
@@ -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 });
+2 -2
View File
@@ -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);
+2 -2
View File
@@ -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);
+8 -9
View File
@@ -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, {
-37
View File
@@ -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
*/
+40 -1
View File
@@ -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
+26 -202
View File
@@ -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;
+8 -8
View File
@@ -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...');
+1 -2
View 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,
+74
View File
@@ -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);
}
-66
View File
@@ -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);
}
+119 -9
View File
@@ -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) => ({