- {result.title}
+
{result.format && (
diff --git a/src/lib/bookdate/helpers.ts b/src/lib/bookdate/helpers.ts
index f492cba..dada418 100644
--- a/src/lib/bookdate/helpers.ts
+++ b/src/lib/bookdate/helpers.ts
@@ -237,41 +237,57 @@ export async function getUserLibraryBooks(
scope: 'full' | 'listened' | 'rated'
): Promise {
try {
- // Get user's Plex library configuration
const configService = getConfigService();
- const plexConfig = await configService.getPlexConfig();
+ const backendMode = await configService.getBackendMode();
- if (!plexConfig.libraryId) {
- console.warn('[BookDate] No Plex library ID configured');
- return [];
+ // Early validation: audiobookshelf doesn't support ratings
+ if (backendMode === 'audiobookshelf' && scope === 'rated') {
+ console.warn('[BookDate] Audiobookshelf does not support ratings, falling back to full library');
+ scope = 'full';
}
- const plexLibraryId = plexConfig.libraryId;
+ // Get library ID based on backend mode
+ let libraryId: string;
+ if (backendMode === 'audiobookshelf') {
+ const absLibraryId = await configService.get('audiobookshelf.library_id');
+ if (!absLibraryId) {
+ console.warn('[BookDate] No Audiobookshelf library ID configured');
+ return [];
+ }
+ libraryId = absLibraryId;
+ } else {
+ // Plex mode
+ const plexConfig = await configService.getPlexConfig();
+ if (!plexConfig.libraryId) {
+ console.warn('[BookDate] No Plex library ID configured');
+ return [];
+ }
+ libraryId = plexConfig.libraryId;
+ }
- // Check user type to determine query strategy for 'rated' scope
+ // Check user type for local admin detection (Plex-specific logic)
const user = await prisma.user.findUnique({
where: { id: userId },
select: { plexId: true },
});
-
const isLocalAdmin = user?.plexId.startsWith('local-') ?? false;
- // Build query filters based on scope and user type
- let whereClause: any = { plexLibraryId };
+ // Build query filters
+ let whereClause: any = { plexLibraryId: libraryId };
let takeLimit = 40;
- if (scope === 'rated') {
+ // Apply rating filter only for Plex backend with rated scope
+ if (backendMode === 'plex' && scope === 'rated') {
if (isLocalAdmin) {
- // Local admin: Filter by cached ratings (these are their ratings)
+ // Local admin: Use cached ratings from system token
whereClause.userRating = { not: null };
} else {
- // Plex-authenticated: Fetch more books to ensure we get 40 rated ones
- // Don't filter by cached ratings - user's ratings may differ from system token
+ // OAuth user: Fetch more, filter after user rating enrichment
takeLimit = 100;
}
}
- // Query Plex library from database (cached structure, includes system token's cached ratings)
+ // Query library from database (same table for both backends)
let cachedBooks = await prisma.plexLibrary.findMany({
where: whereClause,
orderBy: {
@@ -284,22 +300,32 @@ export async function getUserLibraryBooks(
narrator: true,
plexGuid: true,
plexRatingKey: true,
- userRating: true, // System token's cached ratings from scan
+ userRating: true,
},
});
- // Enrich with user's personal ratings from Plex
- const enrichedBooks = await enrichWithUserRatings(userId, cachedBooks);
+ // For Plex: Enrich with user's personal ratings
+ // For Audiobookshelf: Skip enrichment (no rating support)
+ if (backendMode === 'plex') {
+ const enrichedBooks = await enrichWithUserRatings(userId, cachedBooks);
- // If scope is 'rated', filter to only books the user has actually rated
- if (scope === 'rated') {
- const ratedBooks = enrichedBooks.filter(book => book.rating != null);
- // Limit to 40 for Plex users (local admin already limited in query)
- return isLocalAdmin ? ratedBooks : ratedBooks.slice(0, 40);
+ // Filter to rated books if scope is 'rated'
+ if (scope === 'rated') {
+ const ratedBooks = enrichedBooks.filter(book => book.rating != null);
+ return isLocalAdmin ? ratedBooks : ratedBooks.slice(0, 40);
+ }
+
+ return enrichedBooks;
+ } else {
+ // Audiobookshelf: Map to LibraryBook without ratings
+ return cachedBooks.map(book => ({
+ title: book.title,
+ author: book.author,
+ narrator: book.narrator || undefined,
+ rating: undefined, // ABS doesn't support ratings
+ }));
}
- return enrichedBooks;
-
} catch (error) {
console.error('[BookDate] Error fetching library books:', error);
return [];
diff --git a/src/lib/processors/search-indexers.processor.ts b/src/lib/processors/search-indexers.processor.ts
index 8a587c5..9b00423 100644
--- a/src/lib/processors/search-indexers.processor.ts
+++ b/src/lib/processors/search-indexers.processor.ts
@@ -52,8 +52,8 @@ export async function processSearchIndexers(payload: SearchIndexersPayload): Pro
// Get Prowlarr service
const prowlarr = await getProwlarrService();
- // Build search query (title + author for better results)
- const searchQuery = `${audiobook.title} ${audiobook.author}`;
+ // Build search query (title only - cast wide net, let ranking filter)
+ const searchQuery = audiobook.title;
await logger?.info(`Searching for: "${searchQuery}"`);
@@ -61,11 +61,11 @@ export async function processSearchIndexers(payload: SearchIndexersPayload): Pro
const searchResults = await prowlarr.search(searchQuery, {
category: 3030, // Audiobooks
minSeeders: 1, // Only torrents with at least 1 seeder
- maxResults: 50, // Limit results
+ maxResults: 100, // Increased limit for broader search
indexerIds: enabledIndexerIds, // Filter by enabled indexers
});
- await logger?.info(`Found ${searchResults.length} results`);
+ await logger?.info(`Found ${searchResults.length} raw results`);
if (searchResults.length === 0) {
// No results found - queue for re-search instead of failing
@@ -98,22 +98,56 @@ export async function processSearchIndexers(payload: SearchIndexersPayload): Pro
durationMinutes: undefined, // We don't have duration from Audible
});
- await logger?.info(`Ranked ${rankedResults.length} results`);
+ // Filter out results below minimum score threshold (30/100)
+ const filteredResults = rankedResults.filter(result => result.score >= 30);
+
+ await logger?.info(`Ranked ${rankedResults.length} results, ${filteredResults.length} above threshold (30/100)`);
+
+ if (filteredResults.length === 0) {
+ // No quality results found - queue for re-search instead of failing
+ await logger?.warn(`No quality matches found for request ${requestId} (all below 30/100), marking as awaiting_search`);
+
+ await prisma.request.update({
+ where: { id: requestId },
+ data: {
+ status: 'awaiting_search',
+ errorMessage: 'No quality matches found. Will retry automatically.',
+ lastSearchAt: new Date(),
+ updatedAt: new Date(),
+ },
+ });
+
+ return {
+ success: false,
+ message: 'No quality matches found, queued for re-search',
+ requestId,
+ };
+ }
// Select best result
- const bestResult = rankedResults[0];
+ const bestResult = filteredResults[0];
- // Log top 3 results
- const top3 = rankedResults.slice(0, 3).map((r, i) => ({
- rank: i + 1,
- title: r.title,
- score: r.score,
- breakdown: r.breakdown,
- }));
-
- await logger?.info(`Best result: ${bestResult.title} (score: ${bestResult.score})`, {
- top3Results: top3,
- });
+ // Log top 3 results with detailed breakdown
+ const top3 = filteredResults.slice(0, 3);
+ await logger?.info(`==================== RANKING DEBUG ====================`);
+ await logger?.info(`Requested Title: "${audiobook.title}"`);
+ await logger?.info(`Requested Author: "${audiobook.author}"`);
+ await logger?.info(`Top ${top3.length} results (out of ${filteredResults.length} above threshold):`);
+ await logger?.info(`--------------------------------------------------------`);
+ for (let i = 0; i < top3.length; i++) {
+ const result = top3[i];
+ await logger?.info(`${i + 1}. "${result.title}"`);
+ await logger?.info(` Indexer: ${result.indexer}`);
+ await logger?.info(` Total: ${result.score.toFixed(1)}/100 | Match: ${result.breakdown.matchScore.toFixed(1)}/50 | Format: ${result.breakdown.formatScore.toFixed(1)}/25 | Seeders: ${result.breakdown.seederScore.toFixed(1)}/15 | Size: ${result.breakdown.sizeScore.toFixed(1)}/10`);
+ if (result.breakdown.notes.length > 0) {
+ await logger?.info(` Notes: ${result.breakdown.notes.join(', ')}`);
+ }
+ if (i < top3.length - 1) {
+ await logger?.info(`--------------------------------------------------------`);
+ }
+ }
+ await logger?.info(`========================================================`);
+ await logger?.info(`Selected best result: ${bestResult.title} (score: ${bestResult.score.toFixed(1)}/100)`);
// Trigger download job with best result
const jobQueue = getJobQueueService();
@@ -125,9 +159,9 @@ export async function processSearchIndexers(payload: SearchIndexersPayload): Pro
return {
success: true,
- message: `Found ${searchResults.length} results, selected best torrent`,
+ message: `Found ${filteredResults.length} quality matches, selected best torrent`,
requestId,
- resultsCount: searchResults.length,
+ resultsCount: filteredResults.length,
selectedTorrent: {
title: bestResult.title,
score: bestResult.score,
diff --git a/src/lib/services/auth/LocalAuthProvider.ts b/src/lib/services/auth/LocalAuthProvider.ts
index cb333b9..6e92f28 100644
--- a/src/lib/services/auth/LocalAuthProvider.ts
+++ b/src/lib/services/auth/LocalAuthProvider.ts
@@ -51,11 +51,12 @@ export class LocalAuthProvider implements IAuthProvider {
return { success: false, error: 'Username and password required' };
}
- // Find user
+ // Find user (exclude soft-deleted users)
const user = await prisma.user.findFirst({
where: {
plexUsername: username,
authProvider: 'local',
+ deletedAt: null, // Exclude soft-deleted users
},
});
@@ -155,11 +156,12 @@ export class LocalAuthProvider implements IAuthProvider {
return { success: false, error: 'Registration is disabled' };
}
- // Check username uniqueness
+ // Check username uniqueness (only among non-deleted users)
const existing = await prisma.user.findFirst({
where: {
plexUsername: username,
authProvider: 'local',
+ deletedAt: null, // Allow reuse of usernames from deleted accounts
},
});
@@ -275,6 +277,11 @@ export class LocalAuthProvider implements IAuthProvider {
return false;
}
+ // Reject soft-deleted users
+ if (user.deletedAt) {
+ return false;
+ }
+
if (user.registrationStatus === 'pending_approval' || user.registrationStatus === 'rejected') {
return false;
}
diff --git a/src/lib/utils/file-organizer.ts b/src/lib/utils/file-organizer.ts
index b456874..c397df6 100644
--- a/src/lib/utils/file-organizer.ts
+++ b/src/lib/utils/file-organizer.ts
@@ -202,10 +202,10 @@ export class FileOrganizer {
// Copy file (do NOT delete original - needed for seeding)
try {
- // Read source file (either tagged version or original)
- const fileData = await fs.readFile(sourcePath);
- // Write to target with explicit permissions
- await fs.writeFile(targetFilePath, fileData, { mode: 0o644 });
+ // Copy file using streaming (handles large files >2GB)
+ await fs.copyFile(sourcePath, targetFilePath);
+ // Set explicit permissions after copy
+ await fs.chmod(targetFilePath, 0o644);
result.audioFiles.push(targetFilePath);
result.filesMovedCount++;
@@ -237,8 +237,8 @@ export class FileOrganizer {
try {
// Copy cover art (do NOT delete original)
- const coverData = await fs.readFile(sourcePath);
- await fs.writeFile(targetCoverPath, coverData, { mode: 0o644 });
+ await fs.copyFile(sourcePath, targetCoverPath);
+ await fs.chmod(targetCoverPath, 0o644);
result.coverArtFile = targetCoverPath;
result.filesMovedCount++;
await logger?.info(`Copied cover art`);
@@ -406,8 +406,8 @@ export class FileOrganizer {
const cachedPath = path.join('/app/cache/thumbnails', filename);
// Copy from local cache instead of downloading
- const coverData = await fs.readFile(cachedPath);
- await fs.writeFile(targetPath, coverData, { mode: 0o644 });
+ await fs.copyFile(cachedPath, targetPath);
+ await fs.chmod(targetPath, 0o644);
console.log(`[FileOrganizer] Copied cover art from cache: ${filename}`);
} else {
// Download from external URL (e.g., Audible CDN)
diff --git a/src/lib/utils/metadata-tagger.ts b/src/lib/utils/metadata-tagger.ts
index d56af57..b5cb2aa 100644
--- a/src/lib/utils/metadata-tagger.ts
+++ b/src/lib/utils/metadata-tagger.ts
@@ -54,6 +54,7 @@ export async function tagAudioFileMetadata(
// Build ffmpeg command
const args: string[] = [
'ffmpeg',
+ '-y', // Automatically overwrite files without prompting
'-i', `"${filePath}"`,
'-codec', 'copy', // No re-encoding, metadata only
];
diff --git a/src/lib/utils/ranking-algorithm.ts b/src/lib/utils/ranking-algorithm.ts
index f781c8c..a8b98ab 100644
--- a/src/lib/utils/ranking-algorithm.ts
+++ b/src/lib/utils/ranking-algorithm.ts
@@ -122,50 +122,50 @@ export class RankingAlgorithm {
}
/**
- * Score format quality (40 points max)
- * M4B with chapters: 40 pts
- * M4B without chapters: 35 pts
- * M4A: 25 pts
- * MP3: 15 pts
- * Other: 5 pts
+ * Score format quality (25 points max)
+ * M4B with chapters: 25 pts
+ * M4B without chapters: 22 pts
+ * M4A: 16 pts
+ * MP3: 10 pts
+ * Other: 3 pts
*/
private scoreFormat(torrent: TorrentResult): number {
const format = this.detectFormat(torrent);
switch (format) {
case 'M4B':
- return torrent.hasChapters !== false ? 40 : 35;
+ return torrent.hasChapters !== false ? 25 : 22;
case 'M4A':
- return 25;
+ return 16;
case 'MP3':
- return 15;
+ return 10;
default:
- return 5;
+ return 3;
}
}
/**
- * Score seeder count (25 points max)
+ * Score seeder count (15 points max)
* Logarithmic scaling:
* 1 seeder: 0 points
- * 10 seeders: 10 points
- * 100 seeders: 20 points
- * 1000+ seeders: 25 points
+ * 10 seeders: 6 points
+ * 100 seeders: 12 points
+ * 1000+ seeders: 15 points
*/
private scoreSeeders(seeders: number): number {
if (seeders === 0) return 0;
- return Math.min(25, Math.log10(seeders + 1) * 10);
+ return Math.min(15, Math.log10(seeders + 1) * 6);
}
/**
- * Score size reasonableness (20 points max)
+ * Score size reasonableness (10 points max)
* Expected: 1-2 MB per minute (64-128 kbps)
- * Perfect match: 20 points
+ * Perfect match: 10 points
* Too small/large: Reduced points
*/
private scoreSize(size: number, durationMinutes?: number): number {
if (!durationMinutes) {
- return 10; // Neutral score if duration unknown
+ return 5; // Neutral score if duration unknown
}
// Expected size: 1-2 MB per minute
@@ -173,7 +173,7 @@ export class RankingAlgorithm {
const maxExpected = durationMinutes * 2 * 1024 * 1024; // 2 MB/min
if (size >= minExpected && size <= maxExpected) {
- return 20; // Perfect size
+ return 10; // Perfect size
}
// Calculate deviation penalty
@@ -182,29 +182,54 @@ export class RankingAlgorithm {
? (minExpected - size) / minExpected
: (size - maxExpected) / maxExpected;
- return Math.max(0, 20 - deviation * 20);
+ return Math.max(0, 10 - deviation * 10);
}
/**
- * Score title/author match quality (15 points max)
- * Title similarity: 0-10 points
- * Author presence: 0-5 points
+ * Score title/author match quality (50 points max)
+ * Title similarity: 0-35 points (heavily weighted!)
+ * Author presence: 0-15 points
*/
private scoreMatch(
torrent: TorrentResult,
audiobook: AudiobookRequest
): number {
- const title = torrent.title.toLowerCase();
+ const torrentTitle = torrent.title.toLowerCase();
const requestTitle = audiobook.title.toLowerCase();
const requestAuthor = audiobook.author.toLowerCase();
- // Title similarity (0-10 points)
- const titleSimilarity = compareTwoStrings(requestTitle, title) * 10;
+ // Title matching (0-35 points)
+ let titleScore = 0;
+ if (torrentTitle.includes(requestTitle)) {
+ // Exact substring match → full points
+ titleScore = 35;
+ } else {
+ // No exact match → use fuzzy similarity for partial credit
+ titleScore = compareTwoStrings(requestTitle, torrentTitle) * 35;
+ }
- // Author presence (0-5 points)
- const hasAuthor = title.includes(requestAuthor) ? 5 : 0;
+ // Author matching (0-15 points)
+ // Parse requested authors (split on separators, filter out roles)
+ const requestAuthors = requestAuthor
+ .split(/,|&| and | - /)
+ .map(a => a.trim())
+ .filter(a => a.length > 2 && !['translator', 'narrator'].includes(a));
- return Math.min(15, titleSimilarity + hasAuthor);
+ // Check how many authors appear in torrent title (exact substring match)
+ const authorMatches = requestAuthors.filter(author =>
+ torrentTitle.includes(author)
+ );
+
+ let authorScore = 0;
+ if (authorMatches.length > 0) {
+ // Exact substring match → proportional credit
+ authorScore = (authorMatches.length / requestAuthors.length) * 15;
+ } else {
+ // No exact match → use fuzzy similarity for partial credit
+ authorScore = compareTwoStrings(requestAuthor, torrentTitle) * 15;
+ }
+
+ return Math.min(50, titleScore + authorScore);
}
/**
@@ -261,21 +286,25 @@ export class RankingAlgorithm {
}
// Size notes
- if (breakdown.sizeScore < 10) {
+ if (breakdown.sizeScore < 5) {
notes.push('⚠️ Unusual file size');
}
- // Match notes
- if (breakdown.matchScore < 8) {
- notes.push('⚠️ Title/author may not match well');
+ // Match notes (now worth 50 points!)
+ if (breakdown.matchScore < 20) {
+ notes.push('⚠️ Poor title/author match');
+ } else if (breakdown.matchScore < 35) {
+ notes.push('⚠️ Weak title/author match');
+ } else if (breakdown.matchScore >= 45) {
+ notes.push('✓ Excellent title/author match');
}
// Overall quality assessment
- if (breakdown.totalScore >= 80) {
+ if (breakdown.totalScore >= 75) {
notes.push('✓ Excellent choice');
- } else if (breakdown.totalScore >= 60) {
+ } else if (breakdown.totalScore >= 55) {
notes.push('✓ Good choice');
- } else if (breakdown.totalScore < 40) {
+ } else if (breakdown.totalScore < 35) {
notes.push('⚠️ Consider reviewing this choice');
}
@@ -299,11 +328,11 @@ export function getRankingAlgorithm(): RankingAlgorithm {
export function rankTorrents(
torrents: TorrentResult[],
audiobook: AudiobookRequest
-): (TorrentResult & { qualityScore: number })[] {
+): (RankedTorrent & { qualityScore: number })[] {
const algorithm = getRankingAlgorithm();
const ranked = algorithm.rankTorrents(torrents, audiobook);
- // Return torrents with qualityScore field for compatibility
+ // Add qualityScore field for UI compatibility (rounded score)
return ranked.map((r) => ({
...r,
qualityScore: Math.round(r.score),