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:
kikootwo
2025-12-23 17:34:29 -05:00
parent bb42281dac
commit f043688a71
18 changed files with 630 additions and 176 deletions
+18 -4
View File
@@ -14,10 +14,22 @@ Indexer aggregator for searching multiple torrent/usenet indexers simultaneously
**GET /indexerstats** - Indexer statistics **GET /indexerstats** - Indexer statistics
**GET /feed/{indexerId}/api?t=search&cat=3030&limit=100** - RSS feed for specific indexer **GET /feed/{indexerId}/api?t=search&cat=3030&limit=100** - RSS feed for specific indexer
## Search ## Search Strategy
**Search Query:** Title only (not title + author)
- Broader search yields more results (e.g., 20 vs 1)
- Ranking algorithm filters out mismatches using author/narrator
- Works around indexer limitations with complex queries
**Extended Search:** Enabled (`extended=1`) - searches title, tags, labels, and metadata fields **Extended Search:** Enabled (`extended=1`) - searches title, tags, labels, and metadata fields
**Result Filtering:**
- Minimum score threshold: 30/100
- Filters applied after ranking to remove poor matches
- maxResults: 100 (increased from 50 for broader search)
**Example:** "Season of Storms" → finds all "Season of Storms" torrents → ranks by author match → filters score < 30
```typescript ```typescript
interface TorrentResult { interface TorrentResult {
indexer: string; indexer: string;
@@ -53,13 +65,15 @@ interface TorrentResult {
**Manual Search** (`POST /api/requests/{id}/manual-search`) **Manual Search** (`POST /api/requests/{id}/manual-search`)
- Triggers automatic search job for requests with status: pending, failed, awaiting_search - Triggers automatic search job for requests with status: pending, failed, awaiting_search
- Searches only enabled indexers - Searches only enabled indexers (title only, maxResults: 100)
- Uses ranking algorithm to select best torrent - Ranks all results, filters scores < 30
- Selects best torrent from filtered results
- Updates request status to 'pending' - Updates request status to 'pending'
**Interactive Search** (`POST /api/requests/{id}/interactive-search`) **Interactive Search** (`POST /api/requests/{id}/interactive-search`)
- Returns ranked torrent results for user selection - Returns ranked torrent results for user selection
- Searches only enabled indexers - Searches only enabled indexers (title only, maxResults: 100)
- Ranks all results, filters scores < 30
- Shows table with: rank, title, size, quality score, seeders, indexer, publish date - Shows table with: rank, title, size, quality score, seeders, indexer, publish date
- Available for same statuses as manual search - Available for same statuses as manual search
- User clicks "Download" button to select specific torrent - User clicks "Download" button to select specific torrent
+25 -16
View File
@@ -1,29 +1,38 @@
# Intelligent Ranking Algorithm # Intelligent Ranking Algorithm
**Status:** ❌ Not Implemented **Status:** Implemented
Evaluates and scores torrents to automatically select best audiobook download. Evaluates and scores torrents to automatically select best audiobook download.
## Scoring Criteria (100 points max) ## Scoring Criteria (100 points max)
**1. Format Quality (40 pts max)** **1. Title/Author Match (50 pts max) - MOST IMPORTANT**
- M4B with chapters: 40 - Title matching: 0-35 pts
- M4B without chapters: 35 - Exact substring match → 35 pts
- M4A: 25 - No exact match → fuzzy similarity (partial credit)
- MP3: 15 - Author presence: 0-15 pts
- Other: 5 - Splits authors on delimiters (comma, &, "and", " - ")
- Filters out roles ("translator", "narrator")
- Proportional credit for partial matches
- Order-independent, no structure assumptions
- Ensures correct book is selected over wrong book with better format
**2. Seeder Count (25 pts max)** **2. Format Quality (25 pts max)**
- Formula: `Math.min(25, Math.log10(seeders + 1) * 10)` - M4B with chapters: 25
- 1 seeder: 0pts, 10 seeders: 10pts, 100 seeders: 20pts, 1000+: 25pts - M4B without chapters: 22
- M4A: 16
- MP3: 10
- Other: 3
**3. Size Reasonableness (20 pts max)** **3. Seeder Count (15 pts max)**
- Formula: `Math.min(15, Math.log10(seeders + 1) * 6)`
- 1 seeder: 0pts, 10 seeders: 6pts, 100 seeders: 12pts, 1000+: 15pts
**4. Size Reasonableness (10 pts max)**
- Expected: 1-2 MB/min (64-128 kbps) - Expected: 1-2 MB/min (64-128 kbps)
- Deviation from expected → penalty - Perfect match: 10 pts
- Deviation → penalty
**4. Title Match Quality (15 pts max)** - Unknown duration: 5 pts (neutral)
- Fuzzy match: title + author (Levenshtein distance)
- Narrator bonus
## Interface ## Interface
+5
View File
@@ -49,6 +49,10 @@ model User {
bookDateCustomPrompt String? @map("bookdate_custom_prompt") @db.Text bookDateCustomPrompt String? @map("bookdate_custom_prompt") @db.Text
bookDateOnboardingComplete Boolean @default(false) @map("bookdate_onboarding_complete") bookDateOnboardingComplete Boolean @default(false) @map("bookdate_onboarding_complete")
// Soft delete support
deletedAt DateTime? @map("deleted_at")
deletedBy String? @map("deleted_by") // Admin user ID who deleted this user
// Relations // Relations
requests Request[] requests Request[]
bookDateRecommendations BookDateRecommendation[] bookDateRecommendations BookDateRecommendation[]
@@ -56,6 +60,7 @@ model User {
@@index([plexId]) @@index([plexId])
@@index([role]) @@index([role])
@@index([deletedAt])
@@map("users") @@map("users")
} }
+109 -7
View File
@@ -55,6 +55,11 @@ function AdminUsersPageContent() {
type: 'approve' | 'reject' | null; type: 'approve' | 'reject' | null;
user: PendingUser | null; user: PendingUser | null;
}>({ isOpen: false, type: null, user: null }); }>({ isOpen: false, type: null, user: null });
const [deleteDialog, setDeleteDialog] = useState<{
isOpen: boolean;
user: User | null;
}>({ isOpen: false, user: null });
const [deleting, setDeleting] = useState(false);
const toast = useToast(); const toast = useToast();
const isLoading = !data && !error; const isLoading = !data && !error;
@@ -130,6 +135,45 @@ function AdminUsersPageContent() {
} }
}; };
const showDeleteDialog = (user: User) => {
setDeleteDialog({ isOpen: true, user });
};
const closeDeleteDialog = () => {
if (deleting) return; // Don't close while processing
setDeleteDialog({ isOpen: false, user: null });
};
const handleDeleteUser = async () => {
if (!deleteDialog.user) return;
try {
setDeleting(true);
const response = await fetchJSON(`/api/admin/users/${deleteDialog.user.id}`, {
method: 'DELETE',
});
toast.success(response.message || `User "${deleteDialog.user.plexUsername}" has been deleted`);
mutate(); // Refresh users list
closeDeleteDialog();
} catch (err) {
const errorMsg = err instanceof Error ? err.message : 'Failed to delete user';
toast.error(errorMsg);
console.error(err);
} finally {
setDeleting(false);
}
};
const copyToClipboard = async (text: string, label: string) => {
try {
await navigator.clipboard.writeText(text);
toast.success(`${label} copied to clipboard`);
} catch (err) {
console.error('Failed to copy to clipboard:', err);
toast.error('Failed to copy to clipboard');
}
};
if (isLoading) { if (isLoading) {
return ( return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 p-8"> <div className="min-h-screen bg-gray-50 dark:bg-gray-900 p-8">
@@ -289,10 +333,16 @@ function AdminUsersPageContent() {
{user.plexUsername} {user.plexUsername}
</div> </div>
<div <div
className="text-sm text-gray-500 dark:text-gray-400 cursor-help" className="text-sm text-gray-500 dark:text-gray-400 cursor-pointer hover:text-gray-700 dark:hover:text-gray-300 transition-colors"
title={`Full ID: ${user.plexId}`} title={`Click to copy: ${user.plexId}`}
onClick={() => copyToClipboard(user.plexId, 'User ID')}
> >
<span className="inline-flex items-center gap-1">
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
ID: {user.plexId.length > 12 ? `${user.plexId.substring(0, 12)}...` : user.plexId} ID: {user.plexId.length > 12 ? `${user.plexId.substring(0, 12)}...` : user.plexId}
</span>
</div> </div>
</div> </div>
</div> </div>
@@ -329,6 +379,7 @@ function AdminUsersPageContent() {
: 'Never'} : 'Never'}
</td> </td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium"> <td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div className="flex items-center justify-end gap-3">
{user.isSetupAdmin ? ( {user.isSetupAdmin ? (
<span className="inline-flex items-center gap-1 text-gray-400 dark:text-gray-600 cursor-not-allowed" title="Setup admin role cannot be changed"> <span className="inline-flex items-center gap-1 text-gray-400 dark:text-gray-600 cursor-not-allowed" title="Setup admin role cannot be changed">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -343,6 +394,38 @@ function AdminUsersPageContent() {
</svg> </svg>
<span>OIDC Managed</span> <span>OIDC Managed</span>
</span> </span>
) : user.authProvider === 'plex' ? (
<button
onClick={() => showEditDialog(user)}
className="inline-flex items-center gap-1 text-blue-600 hover:text-blue-900 dark:text-blue-400 dark:hover:text-blue-300"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
<span>Edit Role</span>
</button>
) : user.authProvider === 'local' ? (
<>
<button
onClick={() => showEditDialog(user)}
className="inline-flex items-center gap-1 text-blue-600 hover:text-blue-900 dark:text-blue-400 dark:hover:text-blue-300"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
<span>Edit Role</span>
</button>
<button
onClick={() => showDeleteDialog(user)}
className="inline-flex items-center gap-1 text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300"
title="Delete user and all their requests"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
<span>Delete</span>
</button>
</>
) : ( ) : (
<button <button
onClick={() => showEditDialog(user)} onClick={() => showEditDialog(user)}
@@ -354,6 +437,7 @@ function AdminUsersPageContent() {
<span>Edit Role</span> <span>Edit Role</span>
</button> </button>
)} )}
</div>
</td> </td>
</tr> </tr>
))} ))}
@@ -370,15 +454,16 @@ function AdminUsersPageContent() {
{/* Info Box */} {/* Info Box */}
<div className="mt-6 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4"> <div className="mt-6 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<h3 className="text-sm font-medium text-blue-800 dark:text-blue-200 mb-2"> <h3 className="text-sm font-medium text-blue-800 dark:text-blue-200 mb-2">
About User Roles About User Management
</h3> </h3>
<ul className="text-sm text-blue-700 dark:text-blue-300 space-y-1"> <ul className="text-sm text-blue-700 dark:text-blue-300 space-y-1">
<li> <strong>User:</strong> Can request audiobooks, view own requests, and search the catalog</li> <li> <strong>User:</strong> Can request audiobooks, view own requests, and search the catalog</li>
<li> <strong>Admin:</strong> Full system access including settings, user management, and all requests</li> <li> <strong>Admin:</strong> Full system access including settings, user management, and all requests</li>
<li> <strong>Setup Admin:</strong> The initial admin account created during setup - this account's role is protected and cannot be changed</li> <li> <strong>Setup Admin:</strong> The initial admin account created during setup - this account is protected and cannot be changed or deleted</li>
<li>• <strong>OIDC Users:</strong> Role management is handled by the identity provider - use admin role mapping in OIDC settings</li> <li> <strong>OIDC Users:</strong> Role management is handled by the identity provider - use admin role mapping in OIDC settings. Cannot be deleted as access is managed externally.</li>
<li>• <strong>Local Users:</strong> Can be freely assigned user or admin roles (except setup admin)</li> <li> <strong>Plex Users:</strong> Can have their roles changed, but cannot be deleted as access is managed by Plex.</li>
<li>• You cannot change your own role for security reasons</li> <li> <strong>Local Users:</strong> Can be freely assigned user or admin roles (except setup admin). Can be deleted (their requests are preserved for historical records).</li>
<li> You cannot change your own role or delete yourself for security reasons</li>
</ul> </ul>
</div> </div>
@@ -492,6 +577,23 @@ function AdminUsersPageContent() {
isLoading={processingUserId !== null} isLoading={processingUserId !== null}
variant={confirmDialog.type === 'reject' ? 'danger' : 'primary'} variant={confirmDialog.type === 'reject' ? 'danger' : 'primary'}
/> />
{/* Delete User Dialog */}
<ConfirmModal
isOpen={deleteDialog.isOpen}
onClose={closeDeleteDialog}
onConfirm={handleDeleteUser}
title="Delete User"
message={
deleteDialog.user
? `Are you sure you want to delete "${deleteDialog.user.plexUsername}"? The user will be permanently deleted, but their ${deleteDialog.user._count.requests} request(s) will be preserved for historical records. This action cannot be undone.`
: ''
}
confirmText="Delete User"
cancelText="Cancel"
isLoading={deleting}
variant="danger"
/>
</div> </div>
</div> </div>
); );
+105 -1
View File
@@ -34,13 +34,14 @@ export async function PUT(
); );
} }
// Check if user is the setup admin or OIDC user // Check if user is the setup admin, OIDC user, or deleted
const targetUser = await prisma.user.findUnique({ const targetUser = await prisma.user.findUnique({
where: { id }, where: { id },
select: { select: {
isSetupAdmin: true, isSetupAdmin: true,
authProvider: true, authProvider: true,
plexUsername: true, plexUsername: true,
deletedAt: true,
}, },
}); });
@@ -51,6 +52,14 @@ export async function PUT(
); );
} }
// Prevent changing deleted users
if (targetUser.deletedAt) {
return NextResponse.json(
{ error: 'Cannot modify a deleted user' },
{ status: 403 }
);
}
// Prevent changing setup admin role // Prevent changing setup admin role
if (targetUser.isSetupAdmin && role !== 'admin') { if (targetUser.isSetupAdmin && role !== 'admin') {
return NextResponse.json( return NextResponse.json(
@@ -89,3 +98,98 @@ export async function PUT(
}); });
}); });
} }
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
const { id } = await params;
// Prevent user from deleting themselves
if (req.user && id === req.user.sub) {
return NextResponse.json(
{ error: 'You cannot delete your own account' },
{ status: 403 }
);
}
// Check if user exists and get their details
const targetUser = await prisma.user.findUnique({
where: { id },
select: {
id: true,
plexUsername: true,
isSetupAdmin: true,
authProvider: true,
deletedAt: true,
_count: {
select: { requests: true },
},
},
});
if (!targetUser) {
return NextResponse.json(
{ error: 'User not found' },
{ status: 404 }
);
}
// Check if user is already deleted
if (targetUser.deletedAt) {
return NextResponse.json(
{ error: 'User has already been deleted' },
{ status: 400 }
);
}
// Prevent deleting setup admin
if (targetUser.isSetupAdmin) {
return NextResponse.json(
{ error: 'Cannot delete the setup admin account. This account is protected.' },
{ status: 403 }
);
}
// Only allow deleting local users (manual registration)
if (targetUser.authProvider !== 'local') {
const providerName = targetUser.authProvider === 'plex' ? 'Plex' :
targetUser.authProvider === 'oidc' ? 'OIDC' :
targetUser.authProvider || 'external';
return NextResponse.json(
{
error: `Cannot delete ${providerName} users. User access is managed by ${providerName}.`
},
{ status: 403 }
);
}
// Soft-delete user (preserves their requests and history)
// Append timestamp to plexId to free it up for reuse (allows username reuse)
const timestamp = Date.now();
await prisma.user.update({
where: { id },
data: {
deletedAt: new Date(),
deletedBy: req.user?.sub || null,
plexId: `local-${targetUser.plexUsername}-deleted-${timestamp}`,
},
});
return NextResponse.json({
success: true,
message: `User "${targetUser.plexUsername}" has been deleted. Their ${targetUser._count.requests} request(s) have been preserved.`
});
} catch (error) {
console.error('[Admin] Failed to delete user:', error);
return NextResponse.json(
{ error: 'Failed to delete user' },
{ status: 500 }
);
}
});
});
}
+2 -1
View File
@@ -13,7 +13,8 @@ export async function GET(request: NextRequest) {
try { try {
const pendingUsers = await prisma.user.findMany({ const pendingUsers = await prisma.user.findMany({
where: { where: {
registrationStatus: 'pending_approval' registrationStatus: 'pending_approval',
deletedAt: null, // Exclude soft-deleted users
}, },
select: { select: {
id: true, id: true,
+3
View File
@@ -12,6 +12,9 @@ export async function GET(request: NextRequest) {
return requireAdmin(req, async () => { return requireAdmin(req, async () => {
try { try {
const users = await prisma.user.findMany({ const users = await prisma.user.findMany({
where: {
deletedAt: null, // Exclude soft-deleted users
},
select: { select: {
id: true, id: true,
plexId: true, plexId: true,
@@ -57,14 +57,17 @@ export async function POST(request: NextRequest) {
// Search Prowlarr for torrents - ONLY enabled indexers // Search Prowlarr for torrents - ONLY enabled indexers
const prowlarr = await getProwlarrService(); const prowlarr = await getProwlarrService();
const searchQuery = `${title} ${author}`; const searchQuery = title; // Title only - cast wide net
console.log(`[AudiobookSearch] Searching ${enabledIndexerIds.length} enabled indexers for: ${searchQuery}`); console.log(`[AudiobookSearch] Searching ${enabledIndexerIds.length} enabled indexers for: ${searchQuery}`);
const results = await prowlarr.search(searchQuery, { const results = await prowlarr.search(searchQuery, {
indexerIds: enabledIndexerIds, indexerIds: enabledIndexerIds,
maxResults: 100, // Increased limit for broader search
}); });
console.log(`[AudiobookSearch] Found ${results.length} raw results for "${title}" by ${author}`);
if (results.length === 0) { if (results.length === 0) {
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
@@ -76,18 +79,49 @@ export async function POST(request: NextRequest) {
// Rank torrents using the ranking algorithm // Rank torrents using the ranking algorithm
const rankedResults = rankTorrents(results, { title, author }); const rankedResults = rankTorrents(results, { title, author });
// Filter out results below minimum score threshold (30/100)
const filteredResults = rankedResults.filter(result => result.score >= 30);
console.log(`[AudiobookSearch] Ranked ${rankedResults.length} results, ${filteredResults.length} above threshold (30/100)`);
// Log top 3 results with detailed score breakdown for debugging
const top3 = filteredResults.slice(0, 3);
if (top3.length > 0) {
console.log(`[AudiobookSearch] ==================== RANKING DEBUG ====================`);
console.log(`[AudiobookSearch] Requested Title: "${title}"`);
console.log(`[AudiobookSearch] Requested Author: "${author}"`);
console.log(`[AudiobookSearch] Top ${top3.length} results (out of ${filteredResults.length} above threshold):`);
console.log(`[AudiobookSearch] --------------------------------------------------------`);
top3.forEach((result, index) => {
console.log(`[AudiobookSearch] ${index + 1}. "${result.title}"`);
console.log(`[AudiobookSearch] Indexer: ${result.indexer}`);
console.log(`[AudiobookSearch] Total Score: ${result.score.toFixed(1)}/100`);
console.log(`[AudiobookSearch] - Title/Author Match: ${result.breakdown.matchScore.toFixed(1)}/50`);
console.log(`[AudiobookSearch] - Format Quality: ${result.breakdown.formatScore.toFixed(1)}/25 (${result.format || 'unknown'})`);
console.log(`[AudiobookSearch] - Seeder Count: ${result.breakdown.seederScore.toFixed(1)}/15 (${result.seeders} seeders)`);
console.log(`[AudiobookSearch] - Size Score: ${result.breakdown.sizeScore.toFixed(1)}/10 (${(result.size / (1024 ** 3)).toFixed(2)} GB)`);
if (result.breakdown.notes.length > 0) {
console.log(`[AudiobookSearch] Notes: ${result.breakdown.notes.join(', ')}`);
}
if (index < top3.length - 1) {
console.log(`[AudiobookSearch] --------------------------------------------------------`);
}
});
console.log(`[AudiobookSearch] ========================================================`);
}
// Add rank position to each result // Add rank position to each result
const resultsWithRank = rankedResults.map((result, index) => ({ const resultsWithRank = filteredResults.map((result, index) => ({
...result, ...result,
rank: index + 1, rank: index + 1,
})); }));
console.log(`[AudiobookSearch] Found ${resultsWithRank.length} results for "${title}" by ${author}`);
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
results: resultsWithRank, results: resultsWithRank,
message: `Found ${resultsWithRank.length} torrents`, message: filteredResults.length > 0
? `Found ${filteredResults.length} quality matches`
: 'No quality matches found',
}); });
} catch (error) { } catch (error) {
console.error('Failed to search for torrents:', error); console.error('Failed to search for torrents:', error);
+28 -1
View File
@@ -6,6 +6,7 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth'; import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db'; import { prisma } from '@/lib/db';
import { getConfigService } from '@/lib/services/config.service';
/** /**
* GET /api/bookdate/preferences * GET /api/bookdate/preferences
@@ -32,10 +33,24 @@ async function getPreferences(req: AuthenticatedRequest) {
); );
} }
// Add backend capability detection
const configService = getConfigService();
const backendMode = await configService.getBackendMode();
const supportsRatings = backendMode === 'plex';
// Override 'rated' scope if backend doesn't support it
let effectiveScope = user.bookDateLibraryScope || 'full';
if (!supportsRatings && effectiveScope === 'rated') {
effectiveScope = 'full';
}
return NextResponse.json({ return NextResponse.json({
libraryScope: user.bookDateLibraryScope || 'full', libraryScope: effectiveScope,
customPrompt: user.bookDateCustomPrompt || '', // Always return empty string for UI customPrompt: user.bookDateCustomPrompt || '', // Always return empty string for UI
onboardingComplete: user.bookDateOnboardingComplete || false, onboardingComplete: user.bookDateOnboardingComplete || false,
backendCapabilities: {
supportsRatings,
},
}); });
} catch (error: any) { } catch (error: any) {
@@ -67,6 +82,18 @@ async function updatePreferences(req: AuthenticatedRequest) {
); );
} }
// Add validation for rating support
const configService = getConfigService();
const backendMode = await configService.getBackendMode();
const supportsRatings = backendMode === 'plex';
if (libraryScope === 'rated' && !supportsRatings) {
return NextResponse.json(
{ error: 'Your backend does not support ratings. Please select "Full Library".' },
{ status: 400 }
);
}
// Validate custom prompt length (only if provided and not empty) // Validate custom prompt length (only if provided and not empty)
if (customPrompt && typeof customPrompt === 'string' && customPrompt.trim() && customPrompt.length > 1000) { if (customPrompt && typeof customPrompt === 'string' && customPrompt.trim() && customPrompt.length > 1000) {
return NextResponse.json( return NextResponse.json(
@@ -74,14 +74,17 @@ export async function POST(
// Search Prowlarr for torrents - ONLY enabled indexers // Search Prowlarr for torrents - ONLY enabled indexers
const prowlarr = await getProwlarrService(); const prowlarr = await getProwlarrService();
const searchQuery = `${requestRecord.audiobook.title} ${requestRecord.audiobook.author}`; const searchQuery = requestRecord.audiobook.title; // Title only - cast wide net
console.log(`[InteractiveSearch] Searching ${enabledIndexerIds.length} enabled indexers for: ${searchQuery}`); console.log(`[InteractiveSearch] Searching ${enabledIndexerIds.length} enabled indexers for: ${searchQuery}`);
const results = await prowlarr.search(searchQuery, { const results = await prowlarr.search(searchQuery, {
indexerIds: enabledIndexerIds, indexerIds: enabledIndexerIds,
maxResults: 100, // Increased limit for broader search
}); });
console.log(`[InteractiveSearch] Found ${results.length} raw results for request ${id}`);
if (results.length === 0) { if (results.length === 0) {
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
@@ -96,18 +99,49 @@ export async function POST(
author: requestRecord.audiobook.author, author: requestRecord.audiobook.author,
}); });
// Filter out results below minimum score threshold (30/100)
const filteredResults = rankedResults.filter(result => result.score >= 30);
console.log(`[InteractiveSearch] Ranked ${rankedResults.length} results, ${filteredResults.length} above threshold (30/100)`);
// Log top 3 results with detailed score breakdown for debugging
const top3 = filteredResults.slice(0, 3);
if (top3.length > 0) {
console.log(`[InteractiveSearch] ==================== RANKING DEBUG ====================`);
console.log(`[InteractiveSearch] Requested Title: "${requestRecord.audiobook.title}"`);
console.log(`[InteractiveSearch] Requested Author: "${requestRecord.audiobook.author}"`);
console.log(`[InteractiveSearch] Top ${top3.length} results (out of ${filteredResults.length} above threshold):`);
console.log(`[InteractiveSearch] --------------------------------------------------------`);
top3.forEach((result, index) => {
console.log(`[InteractiveSearch] ${index + 1}. "${result.title}"`);
console.log(`[InteractiveSearch] Indexer: ${result.indexer}`);
console.log(`[InteractiveSearch] Total Score: ${result.score.toFixed(1)}/100`);
console.log(`[InteractiveSearch] - Title/Author Match: ${result.breakdown.matchScore.toFixed(1)}/50`);
console.log(`[InteractiveSearch] - Format Quality: ${result.breakdown.formatScore.toFixed(1)}/25 (${result.format || 'unknown'})`);
console.log(`[InteractiveSearch] - Seeder Count: ${result.breakdown.seederScore.toFixed(1)}/15 (${result.seeders} seeders)`);
console.log(`[InteractiveSearch] - Size Score: ${result.breakdown.sizeScore.toFixed(1)}/10 (${(result.size / (1024 ** 3)).toFixed(2)} GB)`);
if (result.breakdown.notes.length > 0) {
console.log(`[InteractiveSearch] Notes: ${result.breakdown.notes.join(', ')}`);
}
if (index < top3.length - 1) {
console.log(`[InteractiveSearch] --------------------------------------------------------`);
}
});
console.log(`[InteractiveSearch] ========================================================`);
}
// Add rank position to each result // Add rank position to each result
const resultsWithRank = rankedResults.map((result, index) => ({ const resultsWithRank = filteredResults.map((result, index) => ({
...result, ...result,
rank: index + 1, rank: index + 1,
})); }));
console.log(`[InteractiveSearch] Found ${resultsWithRank.length} results for request ${id}`);
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
results: resultsWithRank, results: resultsWithRank,
message: `Found ${resultsWithRank.length} torrents`, message: filteredResults.length > 0
? `Found ${filteredResults.length} quality matches`
: 'No quality matches found',
}); });
} catch (error) { } catch (error) {
console.error('Failed to perform interactive search:', error); console.error('Failed to perform interactive search:', error);
@@ -21,6 +21,11 @@ export function SettingsWidget({ isOpen, onClose, isOnboarding = false, onOnboar
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null); const [successMessage, setSuccessMessage] = useState<string | null>(null);
const [backendCapabilities, setBackendCapabilities] = useState<{
supportsRatings: boolean;
}>({
supportsRatings: true, // Default assume Plex
});
// Load current preferences // Load current preferences
useEffect(() => { useEffect(() => {
@@ -48,6 +53,7 @@ export function SettingsWidget({ isOpen, onClose, isOnboarding = false, onOnboar
const data = await response.json(); const data = await response.json();
setLibraryScope(data.libraryScope || 'full'); setLibraryScope(data.libraryScope || 'full');
setCustomPrompt(data.customPrompt || ''); setCustomPrompt(data.customPrompt || '');
setBackendCapabilities(data.backendCapabilities || { supportsRatings: true });
} catch (error: any) { } catch (error: any) {
console.error('Load preferences error:', error); console.error('Load preferences error:', error);
setError(error.message || 'Failed to load preferences'); setError(error.message || 'Failed to load preferences');
@@ -186,6 +192,8 @@ export function SettingsWidget({ isOpen, onClose, isOnboarding = false, onOnboar
</div> </div>
</label> </label>
{/* Show "Rated Books Only" only if backend supports it */}
{backendCapabilities.supportsRatings && (
<label className={`flex items-start p-4 border-2 rounded-lg cursor-pointer transition-all hover:bg-gray-50 dark:hover:bg-gray-700/50 ${libraryScope === 'rated' ? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20' : 'border-gray-200 dark:border-gray-600'}`}> <label className={`flex items-start p-4 border-2 rounded-lg cursor-pointer transition-all hover:bg-gray-50 dark:hover:bg-gray-700/50 ${libraryScope === 'rated' ? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20' : 'border-gray-200 dark:border-gray-600'}`}>
<input <input
type="radio" type="radio"
@@ -204,6 +212,14 @@ export function SettingsWidget({ isOpen, onClose, isOnboarding = false, onOnboar
</div> </div>
</div> </div>
</label> </label>
)}
{/* Show info message if ratings not supported */}
{!backendCapabilities.supportsRatings && (
<div className="text-sm text-gray-600 dark:text-gray-400 mt-2 p-3 bg-gray-50 dark:bg-gray-800 rounded-lg">
Note: Your backend does not support user ratings. Only "Full Library" scope is available.
</div>
)}
</div> </div>
</div> </div>
@@ -192,8 +192,16 @@ export function InteractiveTorrentSearchModal({
{result.rank} {result.rank}
</td> </td>
<td className="px-3 py-3 text-sm text-gray-900 dark:text-gray-100"> <td className="px-3 py-3 text-sm text-gray-900 dark:text-gray-100">
<div className="max-w-xs lg:max-w-md truncate" title={result.title}> <div className="max-w-xs lg:max-w-md truncate">
<a
href={result.guid}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 hover:underline"
title={result.title}
>
{result.title} {result.title}
</a>
</div> </div>
<div className="flex gap-2 mt-1 flex-wrap"> <div className="flex gap-2 mt-1 flex-wrap">
{result.format && ( {result.format && (
+43 -17
View File
@@ -237,41 +237,57 @@ export async function getUserLibraryBooks(
scope: 'full' | 'listened' | 'rated' scope: 'full' | 'listened' | 'rated'
): Promise<LibraryBook[]> { ): Promise<LibraryBook[]> {
try { try {
// Get user's Plex library configuration
const configService = getConfigService(); const configService = getConfigService();
const plexConfig = await configService.getPlexConfig(); const backendMode = await configService.getBackendMode();
// 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';
}
// 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) { if (!plexConfig.libraryId) {
console.warn('[BookDate] No Plex library ID configured'); console.warn('[BookDate] No Plex library ID configured');
return []; return [];
} }
libraryId = plexConfig.libraryId;
}
const plexLibraryId = plexConfig.libraryId; // Check user type for local admin detection (Plex-specific logic)
// Check user type to determine query strategy for 'rated' scope
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
where: { id: userId }, where: { id: userId },
select: { plexId: true }, select: { plexId: true },
}); });
const isLocalAdmin = user?.plexId.startsWith('local-') ?? false; const isLocalAdmin = user?.plexId.startsWith('local-') ?? false;
// Build query filters based on scope and user type // Build query filters
let whereClause: any = { plexLibraryId }; let whereClause: any = { plexLibraryId: libraryId };
let takeLimit = 40; let takeLimit = 40;
if (scope === 'rated') { // Apply rating filter only for Plex backend with rated scope
if (backendMode === 'plex' && scope === 'rated') {
if (isLocalAdmin) { if (isLocalAdmin) {
// Local admin: Filter by cached ratings (these are their ratings) // Local admin: Use cached ratings from system token
whereClause.userRating = { not: null }; whereClause.userRating = { not: null };
} else { } else {
// Plex-authenticated: Fetch more books to ensure we get 40 rated ones // OAuth user: Fetch more, filter after user rating enrichment
// Don't filter by cached ratings - user's ratings may differ from system token
takeLimit = 100; 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({ let cachedBooks = await prisma.plexLibrary.findMany({
where: whereClause, where: whereClause,
orderBy: { orderBy: {
@@ -284,21 +300,31 @@ export async function getUserLibraryBooks(
narrator: true, narrator: true,
plexGuid: true, plexGuid: true,
plexRatingKey: true, plexRatingKey: true,
userRating: true, // System token's cached ratings from scan userRating: true,
}, },
}); });
// Enrich with user's personal ratings from Plex // For Plex: Enrich with user's personal ratings
// For Audiobookshelf: Skip enrichment (no rating support)
if (backendMode === 'plex') {
const enrichedBooks = await enrichWithUserRatings(userId, cachedBooks); const enrichedBooks = await enrichWithUserRatings(userId, cachedBooks);
// If scope is 'rated', filter to only books the user has actually rated // Filter to rated books if scope is 'rated'
if (scope === 'rated') { if (scope === 'rated') {
const ratedBooks = enrichedBooks.filter(book => book.rating != null); 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); return isLocalAdmin ? ratedBooks : ratedBooks.slice(0, 40);
} }
return enrichedBooks; 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
}));
}
} catch (error) { } catch (error) {
console.error('[BookDate] Error fetching library books:', error); console.error('[BookDate] Error fetching library books:', error);
+53 -19
View File
@@ -52,8 +52,8 @@ export async function processSearchIndexers(payload: SearchIndexersPayload): Pro
// Get Prowlarr service // Get Prowlarr service
const prowlarr = await getProwlarrService(); const prowlarr = await getProwlarrService();
// Build search query (title + author for better results) // Build search query (title only - cast wide net, let ranking filter)
const searchQuery = `${audiobook.title} ${audiobook.author}`; const searchQuery = audiobook.title;
await logger?.info(`Searching for: "${searchQuery}"`); await logger?.info(`Searching for: "${searchQuery}"`);
@@ -61,11 +61,11 @@ export async function processSearchIndexers(payload: SearchIndexersPayload): Pro
const searchResults = await prowlarr.search(searchQuery, { const searchResults = await prowlarr.search(searchQuery, {
category: 3030, // Audiobooks category: 3030, // Audiobooks
minSeeders: 1, // Only torrents with at least 1 seeder 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 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) { if (searchResults.length === 0) {
// No results found - queue for re-search instead of failing // 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 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 // Select best result
const bestResult = rankedResults[0]; const bestResult = filteredResults[0];
// Log top 3 results // Log top 3 results with detailed breakdown
const top3 = rankedResults.slice(0, 3).map((r, i) => ({ const top3 = filteredResults.slice(0, 3);
rank: i + 1, await logger?.info(`==================== RANKING DEBUG ====================`);
title: r.title, await logger?.info(`Requested Title: "${audiobook.title}"`);
score: r.score, await logger?.info(`Requested Author: "${audiobook.author}"`);
breakdown: r.breakdown, await logger?.info(`Top ${top3.length} results (out of ${filteredResults.length} above threshold):`);
})); await logger?.info(`--------------------------------------------------------`);
for (let i = 0; i < top3.length; i++) {
await logger?.info(`Best result: ${bestResult.title} (score: ${bestResult.score})`, { const result = top3[i];
top3Results: top3, 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 // Trigger download job with best result
const jobQueue = getJobQueueService(); const jobQueue = getJobQueueService();
@@ -125,9 +159,9 @@ export async function processSearchIndexers(payload: SearchIndexersPayload): Pro
return { return {
success: true, success: true,
message: `Found ${searchResults.length} results, selected best torrent`, message: `Found ${filteredResults.length} quality matches, selected best torrent`,
requestId, requestId,
resultsCount: searchResults.length, resultsCount: filteredResults.length,
selectedTorrent: { selectedTorrent: {
title: bestResult.title, title: bestResult.title,
score: bestResult.score, score: bestResult.score,
+9 -2
View File
@@ -51,11 +51,12 @@ export class LocalAuthProvider implements IAuthProvider {
return { success: false, error: 'Username and password required' }; return { success: false, error: 'Username and password required' };
} }
// Find user // Find user (exclude soft-deleted users)
const user = await prisma.user.findFirst({ const user = await prisma.user.findFirst({
where: { where: {
plexUsername: username, plexUsername: username,
authProvider: 'local', authProvider: 'local',
deletedAt: null, // Exclude soft-deleted users
}, },
}); });
@@ -155,11 +156,12 @@ export class LocalAuthProvider implements IAuthProvider {
return { success: false, error: 'Registration is disabled' }; return { success: false, error: 'Registration is disabled' };
} }
// Check username uniqueness // Check username uniqueness (only among non-deleted users)
const existing = await prisma.user.findFirst({ const existing = await prisma.user.findFirst({
where: { where: {
plexUsername: username, plexUsername: username,
authProvider: 'local', authProvider: 'local',
deletedAt: null, // Allow reuse of usernames from deleted accounts
}, },
}); });
@@ -275,6 +277,11 @@ export class LocalAuthProvider implements IAuthProvider {
return false; return false;
} }
// Reject soft-deleted users
if (user.deletedAt) {
return false;
}
if (user.registrationStatus === 'pending_approval' || user.registrationStatus === 'rejected') { if (user.registrationStatus === 'pending_approval' || user.registrationStatus === 'rejected') {
return false; return false;
} }
+8 -8
View File
@@ -202,10 +202,10 @@ export class FileOrganizer {
// Copy file (do NOT delete original - needed for seeding) // Copy file (do NOT delete original - needed for seeding)
try { try {
// Read source file (either tagged version or original) // Copy file using streaming (handles large files >2GB)
const fileData = await fs.readFile(sourcePath); await fs.copyFile(sourcePath, targetFilePath);
// Write to target with explicit permissions // Set explicit permissions after copy
await fs.writeFile(targetFilePath, fileData, { mode: 0o644 }); await fs.chmod(targetFilePath, 0o644);
result.audioFiles.push(targetFilePath); result.audioFiles.push(targetFilePath);
result.filesMovedCount++; result.filesMovedCount++;
@@ -237,8 +237,8 @@ export class FileOrganizer {
try { try {
// Copy cover art (do NOT delete original) // Copy cover art (do NOT delete original)
const coverData = await fs.readFile(sourcePath); await fs.copyFile(sourcePath, targetCoverPath);
await fs.writeFile(targetCoverPath, coverData, { mode: 0o644 }); await fs.chmod(targetCoverPath, 0o644);
result.coverArtFile = targetCoverPath; result.coverArtFile = targetCoverPath;
result.filesMovedCount++; result.filesMovedCount++;
await logger?.info(`Copied cover art`); await logger?.info(`Copied cover art`);
@@ -406,8 +406,8 @@ export class FileOrganizer {
const cachedPath = path.join('/app/cache/thumbnails', filename); const cachedPath = path.join('/app/cache/thumbnails', filename);
// Copy from local cache instead of downloading // Copy from local cache instead of downloading
const coverData = await fs.readFile(cachedPath); await fs.copyFile(cachedPath, targetPath);
await fs.writeFile(targetPath, coverData, { mode: 0o644 }); await fs.chmod(targetPath, 0o644);
console.log(`[FileOrganizer] Copied cover art from cache: ${filename}`); console.log(`[FileOrganizer] Copied cover art from cache: ${filename}`);
} else { } else {
// Download from external URL (e.g., Audible CDN) // Download from external URL (e.g., Audible CDN)
+1
View File
@@ -54,6 +54,7 @@ export async function tagAudioFileMetadata(
// Build ffmpeg command // Build ffmpeg command
const args: string[] = [ const args: string[] = [
'ffmpeg', 'ffmpeg',
'-y', // Automatically overwrite files without prompting
'-i', `"${filePath}"`, '-i', `"${filePath}"`,
'-codec', 'copy', // No re-encoding, metadata only '-codec', 'copy', // No re-encoding, metadata only
]; ];
+67 -38
View File
@@ -122,50 +122,50 @@ export class RankingAlgorithm {
} }
/** /**
* Score format quality (40 points max) * Score format quality (25 points max)
* M4B with chapters: 40 pts * M4B with chapters: 25 pts
* M4B without chapters: 35 pts * M4B without chapters: 22 pts
* M4A: 25 pts * M4A: 16 pts
* MP3: 15 pts * MP3: 10 pts
* Other: 5 pts * Other: 3 pts
*/ */
private scoreFormat(torrent: TorrentResult): number { private scoreFormat(torrent: TorrentResult): number {
const format = this.detectFormat(torrent); const format = this.detectFormat(torrent);
switch (format) { switch (format) {
case 'M4B': case 'M4B':
return torrent.hasChapters !== false ? 40 : 35; return torrent.hasChapters !== false ? 25 : 22;
case 'M4A': case 'M4A':
return 25; return 16;
case 'MP3': case 'MP3':
return 15; return 10;
default: default:
return 5; return 3;
} }
} }
/** /**
* Score seeder count (25 points max) * Score seeder count (15 points max)
* Logarithmic scaling: * Logarithmic scaling:
* 1 seeder: 0 points * 1 seeder: 0 points
* 10 seeders: 10 points * 10 seeders: 6 points
* 100 seeders: 20 points * 100 seeders: 12 points
* 1000+ seeders: 25 points * 1000+ seeders: 15 points
*/ */
private scoreSeeders(seeders: number): number { private scoreSeeders(seeders: number): number {
if (seeders === 0) return 0; 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) * Expected: 1-2 MB per minute (64-128 kbps)
* Perfect match: 20 points * Perfect match: 10 points
* Too small/large: Reduced points * Too small/large: Reduced points
*/ */
private scoreSize(size: number, durationMinutes?: number): number { private scoreSize(size: number, durationMinutes?: number): number {
if (!durationMinutes) { if (!durationMinutes) {
return 10; // Neutral score if duration unknown return 5; // Neutral score if duration unknown
} }
// Expected size: 1-2 MB per minute // Expected size: 1-2 MB per minute
@@ -173,7 +173,7 @@ export class RankingAlgorithm {
const maxExpected = durationMinutes * 2 * 1024 * 1024; // 2 MB/min const maxExpected = durationMinutes * 2 * 1024 * 1024; // 2 MB/min
if (size >= minExpected && size <= maxExpected) { if (size >= minExpected && size <= maxExpected) {
return 20; // Perfect size return 10; // Perfect size
} }
// Calculate deviation penalty // Calculate deviation penalty
@@ -182,29 +182,54 @@ export class RankingAlgorithm {
? (minExpected - size) / minExpected ? (minExpected - size) / minExpected
: (size - maxExpected) / maxExpected; : (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) * Score title/author match quality (50 points max)
* Title similarity: 0-10 points * Title similarity: 0-35 points (heavily weighted!)
* Author presence: 0-5 points * Author presence: 0-15 points
*/ */
private scoreMatch( private scoreMatch(
torrent: TorrentResult, torrent: TorrentResult,
audiobook: AudiobookRequest audiobook: AudiobookRequest
): number { ): number {
const title = torrent.title.toLowerCase(); const torrentTitle = torrent.title.toLowerCase();
const requestTitle = audiobook.title.toLowerCase(); const requestTitle = audiobook.title.toLowerCase();
const requestAuthor = audiobook.author.toLowerCase(); const requestAuthor = audiobook.author.toLowerCase();
// Title similarity (0-10 points) // Title matching (0-35 points)
const titleSimilarity = compareTwoStrings(requestTitle, title) * 10; 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) // Author matching (0-15 points)
const hasAuthor = title.includes(requestAuthor) ? 5 : 0; // 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 // Size notes
if (breakdown.sizeScore < 10) { if (breakdown.sizeScore < 5) {
notes.push('⚠️ Unusual file size'); notes.push('⚠️ Unusual file size');
} }
// Match notes // Match notes (now worth 50 points!)
if (breakdown.matchScore < 8) { if (breakdown.matchScore < 20) {
notes.push('⚠️ Title/author may not match well'); 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 // Overall quality assessment
if (breakdown.totalScore >= 80) { if (breakdown.totalScore >= 75) {
notes.push('✓ Excellent choice'); notes.push('✓ Excellent choice');
} else if (breakdown.totalScore >= 60) { } else if (breakdown.totalScore >= 55) {
notes.push('✓ Good choice'); notes.push('✓ Good choice');
} else if (breakdown.totalScore < 40) { } else if (breakdown.totalScore < 35) {
notes.push('⚠️ Consider reviewing this choice'); notes.push('⚠️ Consider reviewing this choice');
} }
@@ -299,11 +328,11 @@ export function getRankingAlgorithm(): RankingAlgorithm {
export function rankTorrents( export function rankTorrents(
torrents: TorrentResult[], torrents: TorrentResult[],
audiobook: AudiobookRequest audiobook: AudiobookRequest
): (TorrentResult & { qualityScore: number })[] { ): (RankedTorrent & { qualityScore: number })[] {
const algorithm = getRankingAlgorithm(); const algorithm = getRankingAlgorithm();
const ranked = algorithm.rankTorrents(torrents, audiobook); 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) => ({ return ranked.map((r) => ({
...r, ...r,
qualityScore: Math.round(r.score), qualityScore: Math.round(r.score),