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`);
}
}
/**