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
-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