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`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user