mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 12:50: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:
+135
-33
@@ -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 (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 p-8">
|
||||
@@ -289,10 +333,16 @@ function AdminUsersPageContent() {
|
||||
{user.plexUsername}
|
||||
</div>
|
||||
<div
|
||||
className="text-sm text-gray-500 dark:text-gray-400 cursor-help"
|
||||
title={`Full ID: ${user.plexId}`}
|
||||
className="text-sm text-gray-500 dark:text-gray-400 cursor-pointer hover:text-gray-700 dark:hover:text-gray-300 transition-colors"
|
||||
title={`Click to copy: ${user.plexId}`}
|
||||
onClick={() => copyToClipboard(user.plexId, 'User ID')}
|
||||
>
|
||||
ID: {user.plexId.length > 12 ? `${user.plexId.substring(0, 12)}...` : user.plexId}
|
||||
<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}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -329,31 +379,65 @@ function AdminUsersPageContent() {
|
||||
: 'Never'}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
{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">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
<span>Protected</span>
|
||||
</span>
|
||||
) : user.authProvider === 'oidc' ? (
|
||||
<span className="inline-flex items-center gap-1 text-gray-400 dark:text-gray-600 cursor-not-allowed" title="OIDC user roles are managed by the identity provider (use admin role mapping in settings)">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>OIDC Managed</span>
|
||||
</span>
|
||||
) : (
|
||||
<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>
|
||||
)}
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
{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">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
<span>Protected</span>
|
||||
</span>
|
||||
) : user.authProvider === 'oidc' ? (
|
||||
<span className="inline-flex items-center gap-1 text-gray-400 dark:text-gray-600 cursor-not-allowed" title="OIDC user roles are managed by the identity provider (use admin role mapping in settings)">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>OIDC Managed</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
|
||||
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>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
@@ -370,15 +454,16 @@ function AdminUsersPageContent() {
|
||||
{/* 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">
|
||||
<h3 className="text-sm font-medium text-blue-800 dark:text-blue-200 mb-2">
|
||||
About User Roles
|
||||
About User Management
|
||||
</h3>
|
||||
<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>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>OIDC Users:</strong> Role management is handled by the identity provider - use admin role mapping in OIDC settings</li>
|
||||
<li>• <strong>Local Users:</strong> Can be freely assigned user or admin roles (except setup admin)</li>
|
||||
<li>• You cannot change your own role for security reasons</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. Cannot be deleted as access is managed externally.</li>
|
||||
<li>• <strong>Plex Users:</strong> Can have their roles changed, but cannot be deleted as access is managed by Plex.</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>
|
||||
</div>
|
||||
|
||||
@@ -492,6 +577,23 @@ function AdminUsersPageContent() {
|
||||
isLoading={processingUserId !== null}
|
||||
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>
|
||||
);
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user