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
+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({
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 }
);
}
});
});
}
+2 -1
View File
@@ -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,
+3
View File
@@ -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,
@@ -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);
+28 -1
View File
@@ -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(
@@ -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);