From f043688a713c10b54d4e52170bcda1794b7125cb Mon Sep 17 00:00:00 2001 From: kikootwo Date: Tue, 23 Dec 2025 17:34:29 -0500 Subject: [PATCH] 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. --- documentation/phase3/prowlarr.md | 22 ++- documentation/phase3/ranking-algorithm.md | 41 +++-- prisma/schema.prisma | 5 + src/app/admin/users/page.tsx | 168 ++++++++++++++---- src/app/api/admin/users/[id]/route.ts | 106 ++++++++++- src/app/api/admin/users/pending/route.ts | 3 +- src/app/api/admin/users/route.ts | 3 + .../api/audiobooks/search-torrents/route.ts | 44 ++++- src/app/api/bookdate/preferences/route.ts | 29 ++- .../requests/[id]/interactive-search/route.ts | 44 ++++- src/components/bookdate/SettingsWidget.tsx | 48 +++-- .../InteractiveTorrentSearchModal.tsx | 12 +- src/lib/bookdate/helpers.ts | 76 +++++--- .../processors/search-indexers.processor.ts | 72 ++++++-- src/lib/services/auth/LocalAuthProvider.ts | 11 +- src/lib/utils/file-organizer.ts | 16 +- src/lib/utils/metadata-tagger.ts | 1 + src/lib/utils/ranking-algorithm.ts | 105 +++++++---- 18 files changed, 630 insertions(+), 176 deletions(-) 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 -