diff --git a/documentation/phase3/prowlarr.md b/documentation/phase3/prowlarr.md index 725b33b..5d8d339 100644 --- a/documentation/phase3/prowlarr.md +++ b/documentation/phase3/prowlarr.md @@ -14,10 +14,22 @@ Indexer aggregator for searching multiple torrent/usenet indexers simultaneously **GET /indexerstats** - Indexer statistics **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 +**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 interface TorrentResult { indexer: string; @@ -53,13 +65,15 @@ interface TorrentResult { **Manual Search** (`POST /api/requests/{id}/manual-search`) - Triggers automatic search job for requests with status: pending, failed, awaiting_search -- Searches only enabled indexers -- Uses ranking algorithm to select best torrent +- Searches only enabled indexers (title only, maxResults: 100) +- Ranks all results, filters scores < 30 +- Selects best torrent from filtered results - Updates request status to 'pending' **Interactive Search** (`POST /api/requests/{id}/interactive-search`) - 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 - Available for same statuses as manual search - User clicks "Download" button to select specific torrent diff --git a/documentation/phase3/ranking-algorithm.md b/documentation/phase3/ranking-algorithm.md index 547ba51..5c86d34 100644 --- a/documentation/phase3/ranking-algorithm.md +++ b/documentation/phase3/ranking-algorithm.md @@ -1,29 +1,38 @@ # Intelligent Ranking Algorithm -**Status:** ❌ Not Implemented +**Status:** ✅ Implemented Evaluates and scores torrents to automatically select best audiobook download. ## Scoring Criteria (100 points max) -**1. Format Quality (40 pts max)** -- M4B with chapters: 40 -- M4B without chapters: 35 -- M4A: 25 -- MP3: 15 -- Other: 5 +**1. Title/Author Match (50 pts max) - MOST IMPORTANT** +- Title matching: 0-35 pts + - Exact substring match → 35 pts + - No exact match → fuzzy similarity (partial credit) +- Author presence: 0-15 pts + - 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)** -- Formula: `Math.min(25, Math.log10(seeders + 1) * 10)` -- 1 seeder: 0pts, 10 seeders: 10pts, 100 seeders: 20pts, 1000+: 25pts +**2. Format Quality (25 pts max)** +- M4B with chapters: 25 +- 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) -- Deviation from expected → penalty - -**4. Title Match Quality (15 pts max)** -- Fuzzy match: title + author (Levenshtein distance) -- Narrator bonus +- Perfect match: 10 pts +- Deviation → penalty +- Unknown duration: 5 pts (neutral) ## Interface diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 963c9f4..0a6718b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -49,6 +49,10 @@ model User { bookDateCustomPrompt String? @map("bookdate_custom_prompt") @db.Text 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 requests Request[] bookDateRecommendations BookDateRecommendation[] @@ -56,6 +60,7 @@ model User { @@index([plexId]) @@index([role]) + @@index([deletedAt]) @@map("users") } diff --git a/src/app/admin/users/page.tsx b/src/app/admin/users/page.tsx index 086672a..957af39 100644 --- a/src/app/admin/users/page.tsx +++ b/src/app/admin/users/page.tsx @@ -55,6 +55,11 @@ function AdminUsersPageContent() { type: 'approve' | 'reject' | null; user: PendingUser | 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 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) { return (
@@ -289,10 +333,16 @@ function AdminUsersPageContent() { {user.plexUsername}
copyToClipboard(user.plexId, 'User ID')} > - ID: {user.plexId.length > 12 ? `${user.plexId.substring(0, 12)}...` : user.plexId} + + + + + ID: {user.plexId.length > 12 ? `${user.plexId.substring(0, 12)}...` : user.plexId} +
@@ -329,31 +379,65 @@ function AdminUsersPageContent() { : 'Never'} - {user.isSetupAdmin ? ( - - - - - Protected - - ) : user.authProvider === 'oidc' ? ( - - - - - OIDC Managed - - ) : ( - - )} +
+ {user.isSetupAdmin ? ( + + + + + Protected + + ) : user.authProvider === 'oidc' ? ( + + + + + OIDC Managed + + ) : user.authProvider === 'plex' ? ( + + ) : user.authProvider === 'local' ? ( + <> + + + + ) : ( + + )} +
))} @@ -370,15 +454,16 @@ function AdminUsersPageContent() { {/* Info Box */}

- About User Roles + About User Management

@@ -492,6 +577,23 @@ function AdminUsersPageContent() { isLoading={processingUserId !== null} variant={confirmDialog.type === 'reject' ? 'danger' : 'primary'} /> + + {/* Delete User Dialog */} + ); diff --git a/src/app/api/admin/users/[id]/route.ts b/src/app/api/admin/users/[id]/route.ts index 3286ad2..4d4baad 100644 --- a/src/app/api/admin/users/[id]/route.ts +++ b/src/app/api/admin/users/[id]/route.ts @@ -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({ where: { id }, select: { isSetupAdmin: true, authProvider: 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 if (targetUser.isSetupAdmin && role !== 'admin') { 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 } + ); + } + }); + }); +} diff --git a/src/app/api/admin/users/pending/route.ts b/src/app/api/admin/users/pending/route.ts index 859d1de..9523710 100644 --- a/src/app/api/admin/users/pending/route.ts +++ b/src/app/api/admin/users/pending/route.ts @@ -13,7 +13,8 @@ export async function GET(request: NextRequest) { try { const pendingUsers = await prisma.user.findMany({ where: { - registrationStatus: 'pending_approval' + registrationStatus: 'pending_approval', + deletedAt: null, // Exclude soft-deleted users }, select: { id: true, diff --git a/src/app/api/admin/users/route.ts b/src/app/api/admin/users/route.ts index 3c7d97c..5d79e3d 100644 --- a/src/app/api/admin/users/route.ts +++ b/src/app/api/admin/users/route.ts @@ -12,6 +12,9 @@ export async function GET(request: NextRequest) { return requireAdmin(req, async () => { try { const users = await prisma.user.findMany({ + where: { + deletedAt: null, // Exclude soft-deleted users + }, select: { id: true, plexId: true, diff --git a/src/app/api/audiobooks/search-torrents/route.ts b/src/app/api/audiobooks/search-torrents/route.ts index 6d55e6c..087d706 100644 --- a/src/app/api/audiobooks/search-torrents/route.ts +++ b/src/app/api/audiobooks/search-torrents/route.ts @@ -57,14 +57,17 @@ export async function POST(request: NextRequest) { // Search Prowlarr for torrents - ONLY enabled indexers 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}`); const results = await prowlarr.search(searchQuery, { 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) { return NextResponse.json({ success: true, @@ -76,18 +79,49 @@ export async function POST(request: NextRequest) { // Rank torrents using the ranking algorithm 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 - const resultsWithRank = rankedResults.map((result, index) => ({ + const resultsWithRank = filteredResults.map((result, index) => ({ ...result, rank: index + 1, })); - console.log(`[AudiobookSearch] Found ${resultsWithRank.length} results for "${title}" by ${author}`); - return NextResponse.json({ success: true, results: resultsWithRank, - message: `Found ${resultsWithRank.length} torrents`, + message: filteredResults.length > 0 + ? `Found ${filteredResults.length} quality matches` + : 'No quality matches found', }); } catch (error) { console.error('Failed to search for torrents:', error); diff --git a/src/app/api/bookdate/preferences/route.ts b/src/app/api/bookdate/preferences/route.ts index 3a2d3b3..b599a3c 100644 --- a/src/app/api/bookdate/preferences/route.ts +++ b/src/app/api/bookdate/preferences/route.ts @@ -6,6 +6,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth'; import { prisma } from '@/lib/db'; +import { getConfigService } from '@/lib/services/config.service'; /** * 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({ - libraryScope: user.bookDateLibraryScope || 'full', + libraryScope: effectiveScope, customPrompt: user.bookDateCustomPrompt || '', // Always return empty string for UI onboardingComplete: user.bookDateOnboardingComplete || false, + backendCapabilities: { + supportsRatings, + }, }); } 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) if (customPrompt && typeof customPrompt === 'string' && customPrompt.trim() && customPrompt.length > 1000) { return NextResponse.json( diff --git a/src/app/api/requests/[id]/interactive-search/route.ts b/src/app/api/requests/[id]/interactive-search/route.ts index 96cf96c..fd2fc09 100644 --- a/src/app/api/requests/[id]/interactive-search/route.ts +++ b/src/app/api/requests/[id]/interactive-search/route.ts @@ -74,14 +74,17 @@ export async function POST( // Search Prowlarr for torrents - ONLY enabled indexers 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}`); const results = await prowlarr.search(searchQuery, { indexerIds: enabledIndexerIds, + maxResults: 100, // Increased limit for broader search }); + console.log(`[InteractiveSearch] Found ${results.length} raw results for request ${id}`); + if (results.length === 0) { return NextResponse.json({ success: true, @@ -96,18 +99,49 @@ export async function POST( 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 - const resultsWithRank = rankedResults.map((result, index) => ({ + const resultsWithRank = filteredResults.map((result, index) => ({ ...result, rank: index + 1, })); - console.log(`[InteractiveSearch] Found ${resultsWithRank.length} results for request ${id}`); - return NextResponse.json({ success: true, results: resultsWithRank, - message: `Found ${resultsWithRank.length} torrents`, + message: filteredResults.length > 0 + ? `Found ${filteredResults.length} quality matches` + : 'No quality matches found', }); } catch (error) { console.error('Failed to perform interactive search:', error); diff --git a/src/components/bookdate/SettingsWidget.tsx b/src/components/bookdate/SettingsWidget.tsx index 1f8a5da..e3fc06d 100644 --- a/src/components/bookdate/SettingsWidget.tsx +++ b/src/components/bookdate/SettingsWidget.tsx @@ -21,6 +21,11 @@ export function SettingsWidget({ isOpen, onClose, isOnboarding = false, onOnboar const [saving, setSaving] = useState(false); const [error, setError] = useState(null); const [successMessage, setSuccessMessage] = useState(null); + const [backendCapabilities, setBackendCapabilities] = useState<{ + supportsRatings: boolean; + }>({ + supportsRatings: true, // Default assume Plex + }); // Load current preferences useEffect(() => { @@ -48,6 +53,7 @@ export function SettingsWidget({ isOpen, onClose, isOnboarding = false, onOnboar const data = await response.json(); setLibraryScope(data.libraryScope || 'full'); setCustomPrompt(data.customPrompt || ''); + setBackendCapabilities(data.backendCapabilities || { supportsRatings: true }); } catch (error: any) { console.error('Load preferences error:', error); setError(error.message || 'Failed to load preferences'); @@ -186,24 +192,34 @@ export function SettingsWidget({ isOpen, onClose, isOnboarding = false, onOnboar -