mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +00:00
Implement user soft-delete and improve search ranking
Adds soft-delete support for local users, including backend, API, and UI changes to allow admins to delete local users while preserving their requests. Updates user queries to exclude deleted users and allows username reuse for deleted accounts. Refines search and ranking logic for torrents: uses title-only queries for broader results, increases max results to 100, applies a minimum score threshold (30/100), and logs detailed ranking breakdowns. Updates the ranking algorithm to prioritize title/author match, adjusts scoring weights, and improves BookDate compatibility with Audiobookshelf by disabling rating-based features when unsupported. Enhances file copy operations for large files, improves metadata tagging, and updates documentation to reflect new search and ranking strategies.
This commit is contained in:
+51
-25
@@ -237,41 +237,57 @@ export async function getUserLibraryBooks(
|
||||
scope: 'full' | 'listened' | 'rated'
|
||||
): Promise<LibraryBook[]> {
|
||||
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 [];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
];
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user