mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +00:00
Add ASIN/ISBN fields to library and improve matching
Introduces `asin` and `isbn` fields to the PlexLibrary schema and database, with migration and indexing for fast lookups. Updates scan and recently-added processors to persist ASIN/ISBN from both Plex and Audiobookshelf backends. Enhances matching logic to prioritize exact ASIN matches using the new fields, improving match accuracy for Audiobookshelf users. Also includes minor improvements: fixes cover art handling for cached thumbnails, adds download URL validation in Prowlarr and qBittorrent integrations, and updates documentation to reflect these changes.
This commit is contained in:
@@ -217,6 +217,15 @@ export class ProwlarrService {
|
||||
// Extract metadata from title
|
||||
const metadata = this.extractMetadata(item.title || '');
|
||||
|
||||
// Extract download URL
|
||||
const downloadUrl = item.link || item.enclosure?.['@_url'] || '';
|
||||
|
||||
// Skip torrents without a valid download URL
|
||||
if (!downloadUrl || typeof downloadUrl !== 'string' || downloadUrl.trim() === '') {
|
||||
console.warn(`[Prowlarr] Skipping torrent "${item.title || 'Unknown'}" - missing download URL`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const result: TorrentResult = {
|
||||
indexer: item.prowlarrindexer?.['#text'] || item.prowlarrindexer || 'Unknown',
|
||||
title: item.title || '',
|
||||
@@ -224,7 +233,7 @@ export class ProwlarrService {
|
||||
seeders,
|
||||
leechers,
|
||||
publishDate: item.pubDate ? new Date(item.pubDate) : new Date(),
|
||||
downloadUrl: item.link || item.enclosure?.['@_url'] || '',
|
||||
downloadUrl: downloadUrl.trim(),
|
||||
infoHash: getAttr('infohash'),
|
||||
guid: item.guid || '',
|
||||
format: metadata.format,
|
||||
@@ -274,6 +283,12 @@ export class ProwlarrService {
|
||||
*/
|
||||
private transformResult(result: ProwlarrSearchResult): TorrentResult | null {
|
||||
try {
|
||||
// Validate download URL
|
||||
if (!result.downloadUrl || typeof result.downloadUrl !== 'string' || result.downloadUrl.trim() === '') {
|
||||
console.warn(`[Prowlarr] Skipping result "${result.title}" - missing download URL`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Extract metadata from title
|
||||
const metadata = this.extractMetadata(result.title);
|
||||
|
||||
@@ -284,7 +299,7 @@ export class ProwlarrService {
|
||||
seeders: result.seeders,
|
||||
leechers: result.leechers,
|
||||
publishDate: new Date(result.publishDate),
|
||||
downloadUrl: result.downloadUrl,
|
||||
downloadUrl: result.downloadUrl.trim(),
|
||||
infoHash: result.infoHash,
|
||||
guid: result.guid,
|
||||
format: metadata.format,
|
||||
|
||||
@@ -136,6 +136,12 @@ export class QBittorrentService {
|
||||
* Add torrent (magnet link or file URL) - Enterprise Implementation
|
||||
*/
|
||||
async addTorrent(url: string, options?: AddTorrentOptions): Promise<string> {
|
||||
// Validate URL parameter
|
||||
if (!url || typeof url !== 'string' || url.trim() === '') {
|
||||
console.error('[qBittorrent] Invalid download URL:', url);
|
||||
throw new Error('Invalid download URL: URL is required and must be a non-empty string');
|
||||
}
|
||||
|
||||
// Ensure we're authenticated
|
||||
if (!this.cookie) {
|
||||
await this.login();
|
||||
@@ -242,7 +248,7 @@ export class QBittorrentService {
|
||||
responseType: 'arraybuffer',
|
||||
maxRedirects: 0,
|
||||
validateStatus: (status) => status >= 200 && status < 300, // Only 2xx is success
|
||||
timeout: 10000,
|
||||
timeout: 30000, // 30 seconds - public indexers can be slow
|
||||
});
|
||||
|
||||
console.log(`[qBittorrent] Got 2xx response, size=${torrentResponse.data.length} bytes`);
|
||||
|
||||
@@ -103,6 +103,8 @@ export async function processPlexRecentlyAddedCheck(payload: PlexRecentlyAddedPa
|
||||
summary: item.description,
|
||||
duration: item.duration ? item.duration * 1000 : null, // Convert seconds to milliseconds
|
||||
year: item.year,
|
||||
asin: item.asin, // Store ASIN from library backend
|
||||
isbn: item.isbn, // Store ISBN from library backend
|
||||
thumbUrl: item.coverUrl,
|
||||
plexLibraryId: libraryId!,
|
||||
addedAt: item.addedAt,
|
||||
@@ -121,6 +123,8 @@ export async function processPlexRecentlyAddedCheck(payload: PlexRecentlyAddedPa
|
||||
summary: item.description || existing.summary,
|
||||
duration: item.duration ? item.duration * 1000 : existing.duration,
|
||||
year: item.year || existing.year,
|
||||
asin: item.asin || existing.asin, // Update ASIN if available
|
||||
isbn: item.isbn || existing.isbn, // Update ISBN if available
|
||||
thumbUrl: item.coverUrl || existing.thumbUrl,
|
||||
lastScannedAt: new Date(),
|
||||
},
|
||||
|
||||
@@ -87,6 +87,8 @@ export async function processScanPlex(payload: ScanPlexPayload): Promise<any> {
|
||||
summary: item.description || existing.summary,
|
||||
duration: item.duration ? item.duration * 1000 : existing.duration, // Convert seconds to milliseconds
|
||||
year: item.year || existing.year,
|
||||
asin: item.asin || existing.asin, // Store ASIN from library backend
|
||||
isbn: item.isbn || existing.isbn, // Store ISBN from library backend
|
||||
thumbUrl: item.coverUrl || existing.thumbUrl,
|
||||
plexLibraryId: targetLibraryId,
|
||||
plexRatingKey: item.id || existing.plexRatingKey,
|
||||
@@ -108,6 +110,8 @@ export async function processScanPlex(payload: ScanPlexPayload): Promise<any> {
|
||||
summary: item.description,
|
||||
duration: item.duration ? item.duration * 1000 : null, // Convert seconds to milliseconds
|
||||
year: item.year,
|
||||
asin: item.asin, // Store ASIN from library backend (Plex or Audiobookshelf)
|
||||
isbn: item.isbn, // Store ISBN from library backend
|
||||
thumbUrl: item.coverUrl,
|
||||
plexLibraryId: targetLibraryId,
|
||||
addedAt: item.addedAt,
|
||||
|
||||
@@ -84,6 +84,8 @@ export async function findPlexMatch(
|
||||
plexGuid: true,
|
||||
title: true,
|
||||
author: true,
|
||||
asin: true, // Include ASIN field for direct matching
|
||||
isbn: true, // Include ISBN field for additional matching
|
||||
},
|
||||
take: 20,
|
||||
});
|
||||
@@ -109,10 +111,27 @@ export async function findPlexMatch(
|
||||
return null;
|
||||
}
|
||||
|
||||
// PRIORITY 1: Check for EXACT ASIN match in plexGuid
|
||||
// PRIORITY 1a: Check for EXACT ASIN match in dedicated field (works for all backends)
|
||||
for (const plexBook of plexBooks) {
|
||||
if (plexBook.asin && plexBook.asin.toLowerCase() === audiobook.asin.toLowerCase()) {
|
||||
matchResult.matchType = 'asin_exact_field';
|
||||
matchResult.matched = true;
|
||||
matchResult.result = {
|
||||
plexGuid: plexBook.plexGuid,
|
||||
plexTitle: plexBook.title,
|
||||
plexAuthor: plexBook.author,
|
||||
asin: plexBook.asin,
|
||||
confidence: 100,
|
||||
};
|
||||
if (DEBUG_ENABLED) console.log(JSON.stringify({ MATCHER: matchResult }));
|
||||
return plexBook;
|
||||
}
|
||||
}
|
||||
|
||||
// PRIORITY 1b: Check for ASIN in plexGuid (backward compatibility for Plex)
|
||||
for (const plexBook of plexBooks) {
|
||||
if (plexBook.plexGuid && plexBook.plexGuid.includes(audiobook.asin)) {
|
||||
matchResult.matchType = 'asin_exact';
|
||||
matchResult.matchType = 'asin_exact_guid';
|
||||
matchResult.matched = true;
|
||||
matchResult.result = {
|
||||
plexGuid: plexBook.plexGuid,
|
||||
@@ -125,10 +144,20 @@ export async function findPlexMatch(
|
||||
}
|
||||
}
|
||||
|
||||
// FILTER OUT candidates with wrong ASINs in plexGuid
|
||||
// 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;
|
||||
|
||||
@@ -393,18 +393,32 @@ export class FileOrganizer {
|
||||
}
|
||||
|
||||
/**
|
||||
* Download cover art from URL
|
||||
* Download cover art from URL or copy from local cache
|
||||
*/
|
||||
private async downloadCoverArt(url: string, targetDir: string): Promise<void> {
|
||||
const targetPath = path.join(targetDir, 'cover.jpg');
|
||||
|
||||
try {
|
||||
const response = await axios.get(url, {
|
||||
responseType: 'arraybuffer',
|
||||
timeout: 30000,
|
||||
});
|
||||
// Check if this is a cached thumbnail (local file)
|
||||
if (url.startsWith('/api/cache/thumbnails/')) {
|
||||
// Extract filename from the API path
|
||||
const filename = url.replace('/api/cache/thumbnails/', '');
|
||||
const cachedPath = path.join('/app/cache/thumbnails', filename);
|
||||
|
||||
await fs.writeFile(targetPath, response.data);
|
||||
// Copy from local cache instead of downloading
|
||||
const coverData = await fs.readFile(cachedPath);
|
||||
await fs.writeFile(targetPath, coverData, { mode: 0o644 });
|
||||
console.log(`[FileOrganizer] Copied cover art from cache: ${filename}`);
|
||||
} else {
|
||||
// Download from external URL (e.g., Audible CDN)
|
||||
const response = await axios.get(url, {
|
||||
responseType: 'arraybuffer',
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
await fs.writeFile(targetPath, response.data);
|
||||
console.log(`[FileOrganizer] Downloaded cover art from URL`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[FileOrganizer] Failed to download cover art:', error);
|
||||
throw error;
|
||||
|
||||
Reference in New Issue
Block a user