From bff74446fe57ffcd574ca4c0e0d113f7acd08f9b Mon Sep 17 00:00:00 2001 From: Rob Walsh Date: Mon, 2 Mar 2026 13:48:49 -0700 Subject: [PATCH] Fix gemini key --- .vscode/settings.json | 23 ++ src/app/api/bookdate/test-connection/route.ts | 140 ++++++---- src/lib/bookdate/helpers.ts | 260 +++++++++++------- 3 files changed, 277 insertions(+), 146 deletions(-) create mode 100644 .vscode/settings.json diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..b29d2b3 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,23 @@ +{ + "workbench.colorCustomizations": { + "activityBar.activeBackground": "#9671ea", + "activityBar.background": "#9671ea", + "activityBar.foreground": "#15202b", + "activityBar.inactiveForeground": "#15202b99", + "activityBarBadge.background": "#8b3915", + "activityBarBadge.foreground": "#e7e7e7", + "commandCenter.border": "#e7e7e799", + "sash.hoverBorder": "#9671ea", + "statusBar.background": "#7545e3", + "statusBar.foreground": "#e7e7e7", + "statusBarItem.hoverBackground": "#9671ea", + "statusBarItem.remoteBackground": "#7545e3", + "statusBarItem.remoteForeground": "#e7e7e7", + "tab.activeBorder": "#9671ea", + "titleBar.activeBackground": "#7545e3", + "titleBar.activeForeground": "#e7e7e7", + "titleBar.inactiveBackground": "#7545e399", + "titleBar.inactiveForeground": "#e7e7e799" + }, + "peacock.color": "#7545e3" +} diff --git a/src/app/api/bookdate/test-connection/route.ts b/src/app/api/bookdate/test-connection/route.ts index 0ba24c0..2c0d29c 100644 --- a/src/app/api/bookdate/test-connection/route.ts +++ b/src/app/api/bookdate/test-connection/route.ts @@ -10,7 +10,9 @@ import { RMABLogger } from '@/lib/utils/logger'; const logger = RMABLogger.create('API.BookDate.TestConnection'); // Fetch available Claude models from the Anthropic API -async function fetchClaudeModels(apiKey: string): Promise<{ id: string; name: string }[]> { +async function fetchClaudeModels( + apiKey: string, +): Promise<{ id: string; name: string }[]> { const allModels: { id: string; name: string }[] = []; let afterId: string | undefined; @@ -28,7 +30,7 @@ async function fetchClaudeModels(apiKey: string): Promise<{ id: string; name: st 'x-api-key': apiKey, 'anthropic-version': '2023-06-01', }, - } + }, ); if (!response.ok) { @@ -53,9 +55,12 @@ async function fetchClaudeModels(apiKey: string): Promise<{ id: string; name: st } // Fetch available Gemini models from the Google API -async function fetchGeminiModels(apiKey: string): Promise<{ id: string; name: string }[]> { +async function fetchGeminiModels( + apiKey: string, +): Promise<{ id: string; name: string }[]> { const response = await fetch( - `https://generativelanguage.googleapis.com/v1beta/models?key=${apiKey}` + 'https://generativelanguage.googleapis.com/v1beta/models', + { headers: { 'x-goog-api-key': apiKey } }, ); if (!response.ok) { @@ -67,7 +72,11 @@ async function fetchGeminiModels(apiKey: string): Promise<{ id: string; name: st const data = await response.json(); return (data.models || []) - .filter((m: any) => m.name?.startsWith('models/gemini-') && m.supportedGenerationMethods?.includes('generateContent')) + .filter( + (m: any) => + m.name?.startsWith('models/gemini-') && + m.supportedGenerationMethods?.includes('generateContent'), + ) .map((m: any) => ({ id: m.name.replace('models/', ''), name: m.displayName || m.name.replace('models/', ''), @@ -98,14 +107,17 @@ async function authenticatedHandler(req: AuthenticatedRequest) { if (!provider) { return NextResponse.json( { error: 'Provider is required' }, - { status: 400 } + { status: 400 }, ); } if (!['openai', 'claude', 'custom', 'gemini'].includes(provider)) { return NextResponse.json( - { error: 'Invalid provider. Must be "openai", "claude", "custom", or "gemini"' }, - { status: 400 } + { + error: + 'Invalid provider. Must be "openai", "claude", "custom", or "gemini"', + }, + { status: 400 }, ); } @@ -114,15 +126,18 @@ async function authenticatedHandler(req: AuthenticatedRequest) { if (!baseUrl && !useSavedKey) { return NextResponse.json( { error: 'Base URL is required for custom provider' }, - { status: 400 } + { status: 400 }, ); } const urlToValidate = useSavedKey ? null : baseUrl; // Will check saved URL later if useSavedKey if (urlToValidate && !isValidBaseUrl(urlToValidate)) { return NextResponse.json( - { error: 'Invalid base URL format. Must start with http:// or https://' }, - { status: 400 } + { + error: + 'Invalid base URL format. Must start with http:// or https://', + }, + { status: 400 }, ); } } @@ -132,14 +147,15 @@ async function authenticatedHandler(req: AuthenticatedRequest) { let testBaseUrl = baseUrl; if (useSavedKey) { const { prisma } = await import('@/lib/db'); - const { getEncryptionService } = await import('@/lib/services/encryption.service'); + const { getEncryptionService } = + await import('@/lib/services/encryption.service'); const config = await prisma.bookDateConfig.findFirst(); if (!config || !config.apiKey) { return NextResponse.json( { error: 'No saved configuration found' }, - { status: 400 } + { status: 400 }, ); } @@ -151,7 +167,7 @@ async function authenticatedHandler(req: AuthenticatedRequest) { if (provider !== 'custom') { return NextResponse.json( { error: 'Failed to decrypt saved API key' }, - { status: 500 } + { status: 500 }, ); } testApiKey = ''; @@ -162,7 +178,7 @@ async function authenticatedHandler(req: AuthenticatedRequest) { if (!testBaseUrl) { return NextResponse.json( { error: 'No saved base URL found for custom provider' }, - { status: 400 } + { status: 400 }, ); } } @@ -172,7 +188,7 @@ async function authenticatedHandler(req: AuthenticatedRequest) { if (!testApiKey && provider !== 'custom') { return NextResponse.json( { error: 'API key is required' }, - { status: 400 } + { status: 400 }, ); } @@ -182,7 +198,7 @@ async function authenticatedHandler(req: AuthenticatedRequest) { // OpenAI: Fetch models from API const response = await fetch('https://api.openai.com/v1/models', { headers: { - 'Authorization': `Bearer ${testApiKey}`, + Authorization: `Bearer ${testApiKey}`, }, }); @@ -191,7 +207,7 @@ async function authenticatedHandler(req: AuthenticatedRequest) { logger.error('OpenAI API error', { error: errorText }); return NextResponse.json( { error: 'Invalid OpenAI API key or connection failed' }, - { status: 400 } + { status: 400 }, ); } @@ -205,7 +221,6 @@ async function authenticatedHandler(req: AuthenticatedRequest) { name: m.id, })) .sort((a: any, b: any) => a.name.localeCompare(b.name)); - } else if (provider === 'claude') { // Claude: Fetch models dynamically from the Anthropic Models API try { @@ -213,7 +228,7 @@ async function authenticatedHandler(req: AuthenticatedRequest) { } catch { return NextResponse.json( { error: 'Invalid Claude API key or connection failed' }, - { status: 400 } + { status: 400 }, ); } } else if (provider === 'gemini') { @@ -223,7 +238,7 @@ async function authenticatedHandler(req: AuthenticatedRequest) { } catch { return NextResponse.json( { error: 'Invalid Gemini API key or connection failed' }, - { status: 400 } + { status: 400 }, ); } } else if (provider === 'custom') { @@ -244,11 +259,15 @@ async function authenticatedHandler(req: AuthenticatedRequest) { if (!response.ok) { const errorText = await response.text(); - logger.error('Custom provider connection error', { error: errorText }); + logger.error('Custom provider connection error', { + error: errorText, + }); // Return 400 (not the external service's status) to prevent triggering logout on 401 return NextResponse.json( - { error: `Failed to connect to custom provider: ${response.status} ${errorText}` }, - { status: 400 } + { + error: `Failed to connect to custom provider: ${response.status} ${errorText}`, + }, + { status: 400 }, ); } @@ -273,7 +292,8 @@ async function authenticatedHandler(req: AuthenticatedRequest) { return NextResponse.json({ success: true, models: [], - message: 'Connected successfully but could not parse models list. You may need to enter model name manually.', + message: + 'Connected successfully but could not parse models list. You may need to enter model name manually.', }); } @@ -281,8 +301,10 @@ async function authenticatedHandler(req: AuthenticatedRequest) { } catch (error: any) { logger.error('Custom provider network error', { error: error.message }); return NextResponse.json( - { error: `Network error connecting to custom provider: ${error.message}` }, - { status: 500 } + { + error: `Network error connecting to custom provider: ${error.message}`, + }, + { status: 500 }, ); } } @@ -292,12 +314,13 @@ async function authenticatedHandler(req: AuthenticatedRequest) { models, provider, }); - } catch (error: any) { - logger.error('Test connection error', { error: error instanceof Error ? error.message : String(error) }); + logger.error('Test connection error', { + error: error instanceof Error ? error.message : String(error), + }); return NextResponse.json( { error: error.message || 'Connection test failed' }, - { status: 500 } + { status: 500 }, ); } } @@ -312,7 +335,7 @@ async function unauthenticatedHandler(req: NextRequest) { if (useSavedKey) { return NextResponse.json( { error: 'Authentication required to use saved API key' }, - { status: 401 } + { status: 401 }, ); } @@ -320,14 +343,17 @@ async function unauthenticatedHandler(req: NextRequest) { if (!provider) { return NextResponse.json( { error: 'Provider is required' }, - { status: 400 } + { status: 400 }, ); } if (!['openai', 'claude', 'custom', 'gemini'].includes(provider)) { return NextResponse.json( - { error: 'Invalid provider. Must be "openai", "claude", "custom", or "gemini"' }, - { status: 400 } + { + error: + 'Invalid provider. Must be "openai", "claude", "custom", or "gemini"', + }, + { status: 400 }, ); } @@ -336,14 +362,17 @@ async function unauthenticatedHandler(req: NextRequest) { if (!baseUrl) { return NextResponse.json( { error: 'Base URL is required for custom provider' }, - { status: 400 } + { status: 400 }, ); } if (!isValidBaseUrl(baseUrl)) { return NextResponse.json( - { error: 'Invalid base URL format. Must start with http:// or https://' }, - { status: 400 } + { + error: + 'Invalid base URL format. Must start with http:// or https://', + }, + { status: 400 }, ); } } @@ -352,7 +381,7 @@ async function unauthenticatedHandler(req: NextRequest) { if (!apiKey && provider !== 'custom') { return NextResponse.json( { error: 'API key is required' }, - { status: 400 } + { status: 400 }, ); } @@ -362,7 +391,7 @@ async function unauthenticatedHandler(req: NextRequest) { // OpenAI: Fetch models from API const response = await fetch('https://api.openai.com/v1/models', { headers: { - 'Authorization': `Bearer ${apiKey}`, + Authorization: `Bearer ${apiKey}`, }, }); @@ -371,7 +400,7 @@ async function unauthenticatedHandler(req: NextRequest) { logger.error('OpenAI API error', { error: errorText }); return NextResponse.json( { error: 'Invalid OpenAI API key or connection failed' }, - { status: 400 } + { status: 400 }, ); } @@ -385,7 +414,6 @@ async function unauthenticatedHandler(req: NextRequest) { name: m.id, })) .sort((a: any, b: any) => a.name.localeCompare(b.name)); - } else if (provider === 'claude') { // Claude: Fetch models dynamically from the Anthropic Models API try { @@ -393,7 +421,7 @@ async function unauthenticatedHandler(req: NextRequest) { } catch { return NextResponse.json( { error: 'Invalid Claude API key or connection failed' }, - { status: 400 } + { status: 400 }, ); } } else if (provider === 'gemini') { @@ -403,7 +431,7 @@ async function unauthenticatedHandler(req: NextRequest) { } catch { return NextResponse.json( { error: 'Invalid Gemini API key or connection failed' }, - { status: 400 } + { status: 400 }, ); } } else if (provider === 'custom') { @@ -424,11 +452,15 @@ async function unauthenticatedHandler(req: NextRequest) { if (!response.ok) { const errorText = await response.text(); - logger.error('Custom provider connection error', { error: errorText }); + logger.error('Custom provider connection error', { + error: errorText, + }); // Return 400 (not the external service's status) to prevent triggering logout on 401 return NextResponse.json( - { error: `Failed to connect to custom provider: ${response.status} ${errorText}` }, - { status: 400 } + { + error: `Failed to connect to custom provider: ${response.status} ${errorText}`, + }, + { status: 400 }, ); } @@ -453,7 +485,8 @@ async function unauthenticatedHandler(req: NextRequest) { return NextResponse.json({ success: true, models: [], - message: 'Connected successfully but could not parse models list. You may need to enter model name manually.', + message: + 'Connected successfully but could not parse models list. You may need to enter model name manually.', }); } @@ -461,8 +494,10 @@ async function unauthenticatedHandler(req: NextRequest) { } catch (error: any) { logger.error('Custom provider network error', { error: error.message }); return NextResponse.json( - { error: `Network error connecting to custom provider: ${error.message}` }, - { status: 500 } + { + error: `Network error connecting to custom provider: ${error.message}`, + }, + { status: 500 }, ); } } @@ -472,12 +507,13 @@ async function unauthenticatedHandler(req: NextRequest) { models, provider, }); - } catch (error: any) { - logger.error('Test connection error', { error: error instanceof Error ? error.message : String(error) }); + logger.error('Test connection error', { + error: error instanceof Error ? error.message : String(error), + }); return NextResponse.json( { error: error.message || 'Connection test failed' }, - { status: 500 } + { status: 500 }, ); } } diff --git a/src/lib/bookdate/helpers.ts b/src/lib/bookdate/helpers.ts index 5faf3a8..c5e5d19 100644 --- a/src/lib/bookdate/helpers.ts +++ b/src/lib/bookdate/helpers.ts @@ -50,7 +50,7 @@ export interface AIRecommendation { */ async function enrichWithUserRatings( userId: string, - cachedBooks: CachedLibraryBook[] + cachedBooks: CachedLibraryBook[], ): Promise { try { // Get user's Plex token, plexId, and role @@ -61,7 +61,7 @@ async function enrichWithUserRatings( if (!user) { logger.warn('User not found'); - return cachedBooks.map(book => ({ + return cachedBooks.map((book) => ({ title: book.title, author: book.author, narrator: book.narrator || undefined, @@ -72,22 +72,28 @@ async function enrichWithUserRatings( // Local admin users: Use cached ratings (from system Plex token) // Local admins authenticate with username/password, not Plex OAuth if (user.plexId.startsWith('local-')) { - logger.info('User is local admin, using cached ratings (from system Plex token)'); - return cachedBooks.map(book => ({ + logger.info( + 'User is local admin, using cached ratings (from system Plex token)', + ); + return cachedBooks.map((book) => ({ title: book.title, author: book.author, narrator: book.narrator || undefined, - rating: book.userRating ? parseFloat(book.userRating.toString()) : undefined, + rating: book.userRating + ? parseFloat(book.userRating.toString()) + : undefined, })); } // Plex-authenticated users (including admins): Fetch library with their token to get personal ratings // Note: /library/sections/{id}/all returns items with the authenticated user's ratings - logger.info('User is Plex-authenticated, fetching library with user token to get personal ratings'); + logger.info( + 'User is Plex-authenticated, fetching library with user token to get personal ratings', + ); if (!user.authToken) { logger.warn('User has no Plex auth token'); - return cachedBooks.map(book => ({ + return cachedBooks.map((book) => ({ title: book.title, author: book.author, narrator: book.narrator || undefined, @@ -101,7 +107,7 @@ async function enrichWithUserRatings( if (!plexConfig.serverUrl || !plexConfig.libraryId) { logger.warn('No Plex server URL or library ID configured'); - return cachedBooks.map(book => ({ + return cachedBooks.map((book) => ({ title: book.title, author: book.author, narrator: book.narrator || undefined, @@ -130,7 +136,7 @@ async function enrichWithUserRatings( // Get server machine ID from stored config (no need to access system token) if (!plexConfig.machineIdentifier) { logger.error('Server machine identifier not configured'); - return cachedBooks.map(book => ({ + return cachedBooks.map((book) => ({ title: book.title, author: book.author, narrator: book.narrator || undefined, @@ -141,12 +147,14 @@ async function enrichWithUserRatings( const serverMachineId = plexConfig.machineIdentifier; const serverAccessToken = await plexService.getServerAccessToken( serverMachineId, - userPlexToken + userPlexToken, ); if (!serverAccessToken) { - logger.warn('Could not get server access token for user (may not have server access)'); - return cachedBooks.map(book => ({ + logger.warn( + 'Could not get server access token for user (may not have server access)', + ); + return cachedBooks.map((book) => ({ title: book.title, author: book.author, narrator: book.narrator || undefined, @@ -160,14 +168,16 @@ async function enrichWithUserRatings( const userLibrary = await plexService.getLibraryContent( plexConfig.serverUrl, serverAccessToken, - plexConfig.libraryId + plexConfig.libraryId, ); - logger.info(`Fetched ${userLibrary.length} items from Plex with user's token`); + logger.info( + `Fetched ${userLibrary.length} items from Plex with user's token`, + ); // Create a map of guid/ratingKey -> userRating for quick lookup const ratingsMap = new Map(); - userLibrary.forEach(item => { + userLibrary.forEach((item) => { if (item.userRating) { // Try to match by guid first (most reliable) if (item.guid) { @@ -183,7 +193,7 @@ async function enrichWithUserRatings( logger.info(`Found ${ratingsMap.size} rated items for non-admin user`); // Enrich cached books with user's ratings from the fetched library - return cachedBooks.map(book => { + return cachedBooks.map((book) => { // Try to find rating by guid first (most reliable), then ratingKey let rating: number | undefined; if (book.plexGuid) { @@ -200,27 +210,37 @@ async function enrichWithUserRatings( rating: rating, }; }); - } catch (fetchError: any) { - if (fetchError?.response?.status === 401 || fetchError?.message?.includes('401')) { - logger.warn('User token unauthorized for library access (shared users may not have direct API access)'); + if ( + fetchError?.response?.status === 401 || + fetchError?.message?.includes('401') + ) { + logger.warn( + 'User token unauthorized for library access (shared users may not have direct API access)', + ); logger.warn('Falling back to recommendations without user ratings'); } else { - logger.error('Failed to fetch library with user token', { error: fetchError instanceof Error ? fetchError.message : String(fetchError) }); + logger.error('Failed to fetch library with user token', { + error: + fetchError instanceof Error + ? fetchError.message + : String(fetchError), + }); } // Fallback: return books without ratings - return cachedBooks.map(book => ({ + return cachedBooks.map((book) => ({ title: book.title, author: book.author, narrator: book.narrator || undefined, rating: undefined, })); } - } catch (error) { - logger.error('Error enriching books with user ratings', { error: error instanceof Error ? error.message : String(error) }); + logger.error('Error enriching books with user ratings', { + error: error instanceof Error ? error.message : String(error), + }); // Fallback: return books without ratings on error - return cachedBooks.map(book => ({ + return cachedBooks.map((book) => ({ title: book.title, author: book.author, narrator: book.narrator || undefined, @@ -237,7 +257,7 @@ async function enrichWithUserRatings( */ export async function getUserLibraryBooks( userId: string, - scope: 'full' | 'listened' | 'rated' | 'favorites' + scope: 'full' | 'listened' | 'rated' | 'favorites', ): Promise { try { const configService = getConfigService(); @@ -245,7 +265,9 @@ export async function getUserLibraryBooks( // Early validation: audiobookshelf doesn't support ratings if (backendMode === 'audiobookshelf' && scope === 'rated') { - logger.warn('Audiobookshelf does not support ratings, falling back to full library'); + logger.warn( + 'Audiobookshelf does not support ratings, falling back to full library', + ); scope = 'full'; } @@ -261,13 +283,17 @@ export async function getUserLibraryBooks( : []; if (favoriteIds.length === 0) { - logger.warn('Favorites scope selected but no favorites stored, falling back to full library'); + logger.warn( + 'Favorites scope selected but no favorites stored, falling back to full library', + ); scope = 'full'; } else { // Get library ID for filtering let libraryId: string; if (backendMode === 'audiobookshelf') { - const absLibraryId = await configService.get('audiobookshelf.library_id'); + const absLibraryId = await configService.get( + 'audiobookshelf.library_id', + ); if (!absLibraryId) { logger.warn('No Audiobookshelf library ID configured'); return []; @@ -299,7 +325,9 @@ export async function getUserLibraryBooks( orderBy: { addedAt: 'desc' }, }); - logger.info(`Fetched ${cachedBooks.length} favorite books for user ${userId}`); + logger.info( + `Fetched ${cachedBooks.length} favorite books for user ${userId}`, + ); // For Plex: Enrich with user's personal ratings // For Audiobookshelf: Skip enrichment (no rating support) @@ -307,7 +335,7 @@ export async function getUserLibraryBooks( return await enrichWithUserRatings(userId, cachedBooks); } else { // Audiobookshelf: Map to LibraryBook without ratings - return cachedBooks.map(book => ({ + return cachedBooks.map((book) => ({ title: book.title, author: book.author, narrator: book.narrator || undefined, @@ -382,23 +410,24 @@ export async function getUserLibraryBooks( // Filter to rated books if scope is 'rated' if (scope === 'rated') { - const ratedBooks = enrichedBooks.filter(book => book.rating != null); + const ratedBooks = enrichedBooks.filter((book) => book.rating != null); return isLocalAdmin ? ratedBooks : ratedBooks.slice(0, 40); } return enrichedBooks; } else { // Audiobookshelf: Map to LibraryBook without ratings - return cachedBooks.map(book => ({ + return cachedBooks.map((book) => ({ title: book.title, author: book.author, narrator: book.narrator || undefined, rating: undefined, // ABS doesn't support ratings })); } - } catch (error) { - logger.error('Error fetching library books', { error: error instanceof Error ? error.message : String(error) }); + logger.error('Error fetching library books', { + error: error instanceof Error ? error.message : String(error), + }); return []; } } @@ -412,7 +441,7 @@ export async function getUserLibraryBooks( */ export async function getUserRecentSwipes( userId: string, - limit: number = 10 + limit: number = 10, ): Promise { try { // First, get the most recent non-dismiss swipes (left=reject, right=like/request) @@ -458,11 +487,11 @@ export async function getUserRecentSwipes( // Combine both lists, maintaining chronological order (most recent first) const allSwipes = [...nonDismissSwipes, ...dismissSwipes].sort( - (a, b) => b.createdAt.getTime() - a.createdAt.getTime() + (a, b) => b.createdAt.getTime() - a.createdAt.getTime(), ); logger.info( - `Fetched ${allSwipes.length} swipes: ${nonDismissSwipes.length} non-dismiss, ${dismissSwipes.length} dismiss` + `Fetched ${allSwipes.length} swipes: ${nonDismissSwipes.length} non-dismiss, ${dismissSwipes.length} dismiss`, ); return allSwipes.map((s) => ({ @@ -471,9 +500,10 @@ export async function getUserRecentSwipes( action: s.action, markedAsKnown: s.markedAsKnown, })); - } catch (error) { - logger.error('Error fetching swipe history', { error: error instanceof Error ? error.message : String(error) }); + logger.error('Error fetching swipe history', { + error: error instanceof Error ? error.message : String(error), + }); return []; } } @@ -486,11 +516,11 @@ export async function getUserRecentSwipes( */ export async function buildAIPrompt( userId: string, - config: { libraryScope: string; customPrompt?: string | null } + config: { libraryScope: string; customPrompt?: string | null }, ): Promise { const libraryBooks = await getUserLibraryBooks( userId, - config.libraryScope as 'full' | 'listened' | 'rated' | 'favorites' + config.libraryScope as 'full' | 'listened' | 'rated' | 'favorites', ); const swipeHistory = await getUserRecentSwipes(userId, 10); @@ -505,7 +535,7 @@ export async function buildAIPrompt( let instructions = 'Recommend 15-20 audiobooks the user would enjoy based on their library and swipe history. ' + 'CRITICAL RULES:\n' + - '1. DO NOT recommend any books already in the user\'s library (check titles carefully)\n' + + "1. DO NOT recommend any books already in the user's library (check titles carefully)\n" + '2. DO NOT recommend any books from the swipe history (whether requested, rejected, dismissed, or marked_as_liked)\n' + '3. You must provide 15-20 diverse recommendations, not just 3-5\n' + '4. Focus on variety across genres, authors, and styles\n' + @@ -517,8 +547,11 @@ export async function buildAIPrompt( // Add special instruction for favorites scope if (config.libraryScope === 'favorites') { - instructions += '\n\n' + - 'IMPORTANT: The user has specifically handpicked these ' + libraryBooks.length + ' books as their personal favorites. ' + + instructions += + '\n\n' + + 'IMPORTANT: The user has specifically handpicked these ' + + libraryBooks.length + + ' books as their personal favorites. ' + 'These represent their preferred genres, authors, themes, and styles. Use these as PRIMARY INSPIRATION for your recommendations. ' + 'Find books that capture the essence of what makes these favorites special to the user.'; } @@ -527,12 +560,17 @@ export async function buildAIPrompt( task: 'recommend_audiobooks', user_context: { library_books: libraryBooks.slice(0, 40), - swipe_history: swipeHistory.map(s => ({ + swipe_history: swipeHistory.map((s) => ({ title: s.title, author: s.author, - user_action: s.action === 'right' - ? (s.markedAsKnown ? 'marked_as_liked' : 'requested') - : s.action === 'left' ? 'rejected' : 'dismissed', + user_action: + s.action === 'right' + ? s.markedAsKnown + ? 'marked_as_liked' + : 'requested' + : s.action === 'left' + ? 'rejected' + : 'dismissed', })), custom_preferences: config.customPrompt || null, }, @@ -547,7 +585,7 @@ export async function buildAIPrompt( /** * Call AI API to get recommendations - * @param provider - 'openai' | 'claude' + * @param provider - 'openai' | 'claude' | 'gemini' | 'custom' * @param model - Model ID * @param encryptedApiKey - Encrypted API key * @param prompt - JSON prompt string @@ -558,7 +596,7 @@ export async function callAI( model: string, encryptedApiKey: string, prompt: string, - baseUrl?: string | null + baseUrl?: string | null, ): Promise<{ recommendations: AIRecommendation[] }> { const encryptionService = getEncryptionService(); let apiKey = ''; @@ -604,10 +642,11 @@ export async function callAI( }, }; - const systemMessage = 'You are an expert audiobook recommender. ' + + const systemMessage = + 'You are an expert audiobook recommender. ' + 'Your task is to recommend 15-20 NEW audiobooks that the user would enjoy. ' + - 'NEVER recommend books that are already in the user\'s library or swipe history. ' + - 'Focus on discovering books they haven\'t seen yet.'; + "NEVER recommend books that are already in the user's library or swipe history. " + + "Focus on discovering books they haven't seen yet."; if (provider === 'openai') { const requestBody = { @@ -630,7 +669,7 @@ export async function callAI( const response = await fetch('https://api.openai.com/v1/chat/completions', { method: 'POST', headers: { - 'Authorization': `Bearer ${apiKey}`, + Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify(requestBody), @@ -638,7 +677,10 @@ export async function callAI( if (!response.ok) { const errorText = await response.text(); - logger.error('OpenAI API error', { status: response.status, error: errorText }); + logger.error('OpenAI API error', { + status: response.status, + error: errorText, + }); throw new Error(`OpenAI API error: ${response.status} ${errorText}`); } @@ -646,7 +688,6 @@ export async function callAI( const content = data.choices[0].message.content; logger.debug('OpenAI response:', { content }); return JSON.parse(content); - } else if (provider === 'claude') { const userMessage = `${systemMessage}\n\n${prompt}\n\nIMPORTANT: Provide exactly 15-20 recommendations. Return ONLY valid JSON with no additional text or formatting.`; const requestBody = { @@ -674,7 +715,10 @@ export async function callAI( if (!response.ok) { const errorText = await response.text(); - logger.error('Claude API error', { status: response.status, error: errorText }); + logger.error('Claude API error', { + status: response.status, + error: errorText, + }); throw new Error(`Claude API error: ${response.status} ${errorText}`); } @@ -690,7 +734,6 @@ export async function callAI( logger.debug('Claude cleaned response:', { cleanedContent }); return JSON.parse(cleanedContent); - } else if (provider === 'gemini') { const requestBody = { systemInstruction: { @@ -702,41 +745,48 @@ export async function callAI( }, ], generationConfig: { - responseMimeType: "application/json", + responseMimeType: 'application/json', responseSchema: { - type: "OBJECT", + type: 'OBJECT', properties: { recommendations: { - type: "ARRAY", + type: 'ARRAY', items: { - type: "OBJECT", + type: 'OBJECT', properties: { - title: { type: "STRING" }, - author: { type: "STRING" }, - reason: { type: "STRING" }, + title: { type: 'STRING' }, + author: { type: 'STRING' }, + reason: { type: 'STRING' }, }, - required: ["title", "author", "reason"], + required: ['title', 'author', 'reason'], }, }, }, - required: ["recommendations"], + required: ['recommendations'], }, }, }; logger.debug('Gemini request body:', { requestBody }); - const response = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${apiKey}`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', + const response = await fetch( + `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-goog-api-key': apiKey, + }, + body: JSON.stringify(requestBody), }, - body: JSON.stringify(requestBody), - }); + ); if (!response.ok) { const errorText = await response.text(); - logger.error('Gemini API error', { status: response.status, error: errorText }); + logger.error('Gemini API error', { + status: response.status, + error: errorText, + }); throw new Error(`Gemini API error: ${response.status} ${errorText}`); } @@ -757,7 +807,6 @@ export async function callAI( logger.debug('Gemini cleaned response:', { cleanedContent }); return JSON.parse(cleanedContent); - } else if (provider === 'custom') { if (!baseUrl) { throw new Error('Base URL is required for custom provider'); @@ -801,13 +850,23 @@ export async function callAI( if (!response.ok) { const errorText = await response.text(); - logger.error('Custom provider API error', { status: response.status, error: errorText }); + logger.error('Custom provider API error', { + status: response.status, + error: errorText, + }); // If response_format not supported, retry without it and add instructions to prompt - if (errorText.includes('response_format') || errorText.includes('json_schema')) { - logger.info('Retrying without response_format (provider does not support structured outputs)'); + if ( + errorText.includes('response_format') || + errorText.includes('json_schema') + ) { + logger.info( + 'Retrying without response_format (provider does not support structured outputs)', + ); delete requestBody.response_format; - requestBody.messages[0].content = systemMessage + ' Return ONLY valid JSON with no additional text or formatting.'; + requestBody.messages[0].content = + systemMessage + + ' Return ONLY valid JSON with no additional text or formatting.'; const retryResponse = await fetch(endpoint, { method: 'POST', @@ -817,7 +876,9 @@ export async function callAI( if (!retryResponse.ok) { const retryErrorText = await retryResponse.text(); - throw new Error(`Custom provider API error: ${retryResponse.status} ${retryErrorText}`); + throw new Error( + `Custom provider API error: ${retryResponse.status} ${retryErrorText}`, + ); } const retryData = await retryResponse.json(); @@ -829,11 +890,15 @@ export async function callAI( .replace(/\s*```$/i, '') .trim(); - logger.debug('Custom provider cleaned response (fallback):', { cleanedContent }); + logger.debug('Custom provider cleaned response (fallback):', { + cleanedContent, + }); return JSON.parse(cleanedContent); } - throw new Error(`Custom provider API error: ${response.status} ${errorText}`); + throw new Error( + `Custom provider API error: ${response.status} ${errorText}`, + ); } const data = await response.json(); @@ -847,12 +912,10 @@ export async function callAI( .trim(); return JSON.parse(cleanedContent); - } catch (error: any) { logger.error('Custom provider error:', error); throw new Error(`Custom provider error: ${error.message}`); } - } else { throw new Error(`Invalid provider: ${provider}`); } @@ -866,7 +929,7 @@ export async function callAI( */ export async function matchToAudnexus( title: string, - author: string + author: string, ): Promise<{ asin: string; title: string; @@ -918,7 +981,9 @@ export async function matchToAudnexus( } // Step 2: Search Audible.com for the book - logger.info(`Not in cache, searching Audible for "${title}" by ${author}...`); + logger.info( + `Not in cache, searching Audible for "${title}" by ${author}...`, + ); const audibleService = new AudibleService(); const searchQuery = `${title} ${author}`; const searchResults = await audibleService.search(searchQuery, 1); @@ -930,7 +995,9 @@ export async function matchToAudnexus( // Take the first result (best match) const firstResult = searchResults.results[0]; - logger.info(`Found on Audible: "${firstResult.title}" (ASIN: ${firstResult.asin})`); + logger.info( + `Found on Audible: "${firstResult.title}" (ASIN: ${firstResult.asin})`, + ); // Step 3: Use ASIN to fetch full details from Audnexus (or Audible as fallback) const details = await audibleService.getAudiobookDetails(firstResult.asin); @@ -951,9 +1018,10 @@ export async function matchToAudnexus( description: details.description || null, coverUrl: details.coverArtUrl || null, }; - } catch (error) { - logger.error(`Audnexus matching error for "${title}"`, { error: error instanceof Error ? error.message : String(error) }); + logger.error(`Audnexus matching error for "${title}"`, { + error: error instanceof Error ? error.message : String(error), + }); return null; } } @@ -971,7 +1039,7 @@ export async function isInLibrary( userId: string, title: string, author: string, - asin?: string + asin?: string, ): Promise { try { // Use the centralized matching algorithm from audiobook-matcher.ts @@ -983,12 +1051,16 @@ export async function isInLibrary( }); if (match) { - logger.info(`Book "${title}" by ${author} found in library (matched to: "${match.title}")`); + logger.info( + `Book "${title}" by ${author} found in library (matched to: "${match.title}")`, + ); } return !!match; } catch (error) { - logger.error(`Error checking library for "${title}"`, { error: error instanceof Error ? error.message : String(error) }); + logger.error(`Error checking library for "${title}"`, { + error: error instanceof Error ? error.message : String(error), + }); return false; } } @@ -1001,7 +1073,7 @@ export async function isInLibrary( */ export async function isAlreadyRequested( userId: string, - asin: string + asin: string, ): Promise { const request = await prisma.request.findFirst({ where: { @@ -1027,7 +1099,7 @@ export async function isAlreadyRequested( export async function isAlreadySwiped( userId: string, title: string, - author: string + author: string, ): Promise { const swipe = await prisma.bookDateSwipe.findFirst({ where: {