Undo formatting noise

This commit is contained in:
Rob Walsh
2026-03-02 13:58:11 -07:00
parent bff74446fe
commit 7891e31893
3 changed files with 146 additions and 275 deletions
-23
View File
@@ -1,23 +0,0 @@
{
"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"
}
+52 -87
View File
@@ -10,9 +10,7 @@ import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('API.BookDate.TestConnection'); const logger = RMABLogger.create('API.BookDate.TestConnection');
// Fetch available Claude models from the Anthropic API // Fetch available Claude models from the Anthropic API
async function fetchClaudeModels( async function fetchClaudeModels(apiKey: string): Promise<{ id: string; name: string }[]> {
apiKey: string,
): Promise<{ id: string; name: string }[]> {
const allModels: { id: string; name: string }[] = []; const allModels: { id: string; name: string }[] = [];
let afterId: string | undefined; let afterId: string | undefined;
@@ -30,7 +28,7 @@ async function fetchClaudeModels(
'x-api-key': apiKey, 'x-api-key': apiKey,
'anthropic-version': '2023-06-01', 'anthropic-version': '2023-06-01',
}, },
}, }
); );
if (!response.ok) { if (!response.ok) {
@@ -55,12 +53,10 @@ async function fetchClaudeModels(
} }
// Fetch available Gemini models from the Google API // Fetch available Gemini models from the Google API
async function fetchGeminiModels( async function fetchGeminiModels(apiKey: string): Promise<{ id: string; name: string }[]> {
apiKey: string,
): Promise<{ id: string; name: string }[]> {
const response = await fetch( const response = await fetch(
'https://generativelanguage.googleapis.com/v1beta/models', 'https://generativelanguage.googleapis.com/v1beta/models',
{ headers: { 'x-goog-api-key': apiKey } }, { headers: { 'x-goog-api-key': apiKey } }
); );
if (!response.ok) { if (!response.ok) {
@@ -72,11 +68,7 @@ async function fetchGeminiModels(
const data = await response.json(); const data = await response.json();
return (data.models || []) return (data.models || [])
.filter( .filter((m: any) => m.name?.startsWith('models/gemini-') && m.supportedGenerationMethods?.includes('generateContent'))
(m: any) =>
m.name?.startsWith('models/gemini-') &&
m.supportedGenerationMethods?.includes('generateContent'),
)
.map((m: any) => ({ .map((m: any) => ({
id: m.name.replace('models/', ''), id: m.name.replace('models/', ''),
name: m.displayName || m.name.replace('models/', ''), name: m.displayName || m.name.replace('models/', ''),
@@ -107,17 +99,14 @@ async function authenticatedHandler(req: AuthenticatedRequest) {
if (!provider) { if (!provider) {
return NextResponse.json( return NextResponse.json(
{ error: 'Provider is required' }, { error: 'Provider is required' },
{ status: 400 }, { status: 400 }
); );
} }
if (!['openai', 'claude', 'custom', 'gemini'].includes(provider)) { if (!['openai', 'claude', 'custom', 'gemini'].includes(provider)) {
return NextResponse.json( return NextResponse.json(
{ { error: 'Invalid provider. Must be "openai", "claude", "custom", or "gemini"' },
error: { status: 400 }
'Invalid provider. Must be "openai", "claude", "custom", or "gemini"',
},
{ status: 400 },
); );
} }
@@ -126,18 +115,15 @@ async function authenticatedHandler(req: AuthenticatedRequest) {
if (!baseUrl && !useSavedKey) { if (!baseUrl && !useSavedKey) {
return NextResponse.json( return NextResponse.json(
{ error: 'Base URL is required for custom provider' }, { error: 'Base URL is required for custom provider' },
{ status: 400 }, { status: 400 }
); );
} }
const urlToValidate = useSavedKey ? null : baseUrl; // Will check saved URL later if useSavedKey const urlToValidate = useSavedKey ? null : baseUrl; // Will check saved URL later if useSavedKey
if (urlToValidate && !isValidBaseUrl(urlToValidate)) { if (urlToValidate && !isValidBaseUrl(urlToValidate)) {
return NextResponse.json( return NextResponse.json(
{ { error: 'Invalid base URL format. Must start with http:// or https://' },
error: { status: 400 }
'Invalid base URL format. Must start with http:// or https://',
},
{ status: 400 },
); );
} }
} }
@@ -147,15 +133,14 @@ async function authenticatedHandler(req: AuthenticatedRequest) {
let testBaseUrl = baseUrl; let testBaseUrl = baseUrl;
if (useSavedKey) { if (useSavedKey) {
const { prisma } = await import('@/lib/db'); const { prisma } = await import('@/lib/db');
const { getEncryptionService } = const { getEncryptionService } = await import('@/lib/services/encryption.service');
await import('@/lib/services/encryption.service');
const config = await prisma.bookDateConfig.findFirst(); const config = await prisma.bookDateConfig.findFirst();
if (!config || !config.apiKey) { if (!config || !config.apiKey) {
return NextResponse.json( return NextResponse.json(
{ error: 'No saved configuration found' }, { error: 'No saved configuration found' },
{ status: 400 }, { status: 400 }
); );
} }
@@ -167,7 +152,7 @@ async function authenticatedHandler(req: AuthenticatedRequest) {
if (provider !== 'custom') { if (provider !== 'custom') {
return NextResponse.json( return NextResponse.json(
{ error: 'Failed to decrypt saved API key' }, { error: 'Failed to decrypt saved API key' },
{ status: 500 }, { status: 500 }
); );
} }
testApiKey = ''; testApiKey = '';
@@ -178,7 +163,7 @@ async function authenticatedHandler(req: AuthenticatedRequest) {
if (!testBaseUrl) { if (!testBaseUrl) {
return NextResponse.json( return NextResponse.json(
{ error: 'No saved base URL found for custom provider' }, { error: 'No saved base URL found for custom provider' },
{ status: 400 }, { status: 400 }
); );
} }
} }
@@ -188,7 +173,7 @@ async function authenticatedHandler(req: AuthenticatedRequest) {
if (!testApiKey && provider !== 'custom') { if (!testApiKey && provider !== 'custom') {
return NextResponse.json( return NextResponse.json(
{ error: 'API key is required' }, { error: 'API key is required' },
{ status: 400 }, { status: 400 }
); );
} }
@@ -198,7 +183,7 @@ async function authenticatedHandler(req: AuthenticatedRequest) {
// OpenAI: Fetch models from API // OpenAI: Fetch models from API
const response = await fetch('https://api.openai.com/v1/models', { const response = await fetch('https://api.openai.com/v1/models', {
headers: { headers: {
Authorization: `Bearer ${testApiKey}`, 'Authorization': `Bearer ${testApiKey}`,
}, },
}); });
@@ -207,7 +192,7 @@ async function authenticatedHandler(req: AuthenticatedRequest) {
logger.error('OpenAI API error', { error: errorText }); logger.error('OpenAI API error', { error: errorText });
return NextResponse.json( return NextResponse.json(
{ error: 'Invalid OpenAI API key or connection failed' }, { error: 'Invalid OpenAI API key or connection failed' },
{ status: 400 }, { status: 400 }
); );
} }
@@ -221,6 +206,7 @@ async function authenticatedHandler(req: AuthenticatedRequest) {
name: m.id, name: m.id,
})) }))
.sort((a: any, b: any) => a.name.localeCompare(b.name)); .sort((a: any, b: any) => a.name.localeCompare(b.name));
} else if (provider === 'claude') { } else if (provider === 'claude') {
// Claude: Fetch models dynamically from the Anthropic Models API // Claude: Fetch models dynamically from the Anthropic Models API
try { try {
@@ -228,7 +214,7 @@ async function authenticatedHandler(req: AuthenticatedRequest) {
} catch { } catch {
return NextResponse.json( return NextResponse.json(
{ error: 'Invalid Claude API key or connection failed' }, { error: 'Invalid Claude API key or connection failed' },
{ status: 400 }, { status: 400 }
); );
} }
} else if (provider === 'gemini') { } else if (provider === 'gemini') {
@@ -238,7 +224,7 @@ async function authenticatedHandler(req: AuthenticatedRequest) {
} catch { } catch {
return NextResponse.json( return NextResponse.json(
{ error: 'Invalid Gemini API key or connection failed' }, { error: 'Invalid Gemini API key or connection failed' },
{ status: 400 }, { status: 400 }
); );
} }
} else if (provider === 'custom') { } else if (provider === 'custom') {
@@ -259,15 +245,11 @@ async function authenticatedHandler(req: AuthenticatedRequest) {
if (!response.ok) { if (!response.ok) {
const errorText = await response.text(); const errorText = await response.text();
logger.error('Custom provider connection error', { logger.error('Custom provider connection error', { error: errorText });
error: errorText,
});
// Return 400 (not the external service's status) to prevent triggering logout on 401 // Return 400 (not the external service's status) to prevent triggering logout on 401
return NextResponse.json( return NextResponse.json(
{ { error: `Failed to connect to custom provider: ${response.status} ${errorText}` },
error: `Failed to connect to custom provider: ${response.status} ${errorText}`, { status: 400 }
},
{ status: 400 },
); );
} }
@@ -292,8 +274,7 @@ async function authenticatedHandler(req: AuthenticatedRequest) {
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
models: [], models: [],
message: message: 'Connected successfully but could not parse models list. You may need to enter model name manually.',
'Connected successfully but could not parse models list. You may need to enter model name manually.',
}); });
} }
@@ -301,10 +282,8 @@ async function authenticatedHandler(req: AuthenticatedRequest) {
} catch (error: any) { } catch (error: any) {
logger.error('Custom provider network error', { error: error.message }); logger.error('Custom provider network error', { error: error.message });
return NextResponse.json( return NextResponse.json(
{ { error: `Network error connecting to custom provider: ${error.message}` },
error: `Network error connecting to custom provider: ${error.message}`, { status: 500 }
},
{ status: 500 },
); );
} }
} }
@@ -314,13 +293,12 @@ async function authenticatedHandler(req: AuthenticatedRequest) {
models, models,
provider, provider,
}); });
} catch (error: any) { } catch (error: any) {
logger.error('Test connection error', { logger.error('Test connection error', { error: error instanceof Error ? error.message : String(error) });
error: error instanceof Error ? error.message : String(error),
});
return NextResponse.json( return NextResponse.json(
{ error: error.message || 'Connection test failed' }, { error: error.message || 'Connection test failed' },
{ status: 500 }, { status: 500 }
); );
} }
} }
@@ -335,7 +313,7 @@ async function unauthenticatedHandler(req: NextRequest) {
if (useSavedKey) { if (useSavedKey) {
return NextResponse.json( return NextResponse.json(
{ error: 'Authentication required to use saved API key' }, { error: 'Authentication required to use saved API key' },
{ status: 401 }, { status: 401 }
); );
} }
@@ -343,17 +321,14 @@ async function unauthenticatedHandler(req: NextRequest) {
if (!provider) { if (!provider) {
return NextResponse.json( return NextResponse.json(
{ error: 'Provider is required' }, { error: 'Provider is required' },
{ status: 400 }, { status: 400 }
); );
} }
if (!['openai', 'claude', 'custom', 'gemini'].includes(provider)) { if (!['openai', 'claude', 'custom', 'gemini'].includes(provider)) {
return NextResponse.json( return NextResponse.json(
{ { error: 'Invalid provider. Must be "openai", "claude", "custom", or "gemini"' },
error: { status: 400 }
'Invalid provider. Must be "openai", "claude", "custom", or "gemini"',
},
{ status: 400 },
); );
} }
@@ -362,17 +337,14 @@ async function unauthenticatedHandler(req: NextRequest) {
if (!baseUrl) { if (!baseUrl) {
return NextResponse.json( return NextResponse.json(
{ error: 'Base URL is required for custom provider' }, { error: 'Base URL is required for custom provider' },
{ status: 400 }, { status: 400 }
); );
} }
if (!isValidBaseUrl(baseUrl)) { if (!isValidBaseUrl(baseUrl)) {
return NextResponse.json( return NextResponse.json(
{ { error: 'Invalid base URL format. Must start with http:// or https://' },
error: { status: 400 }
'Invalid base URL format. Must start with http:// or https://',
},
{ status: 400 },
); );
} }
} }
@@ -381,7 +353,7 @@ async function unauthenticatedHandler(req: NextRequest) {
if (!apiKey && provider !== 'custom') { if (!apiKey && provider !== 'custom') {
return NextResponse.json( return NextResponse.json(
{ error: 'API key is required' }, { error: 'API key is required' },
{ status: 400 }, { status: 400 }
); );
} }
@@ -391,7 +363,7 @@ async function unauthenticatedHandler(req: NextRequest) {
// OpenAI: Fetch models from API // OpenAI: Fetch models from API
const response = await fetch('https://api.openai.com/v1/models', { const response = await fetch('https://api.openai.com/v1/models', {
headers: { headers: {
Authorization: `Bearer ${apiKey}`, 'Authorization': `Bearer ${apiKey}`,
}, },
}); });
@@ -400,7 +372,7 @@ async function unauthenticatedHandler(req: NextRequest) {
logger.error('OpenAI API error', { error: errorText }); logger.error('OpenAI API error', { error: errorText });
return NextResponse.json( return NextResponse.json(
{ error: 'Invalid OpenAI API key or connection failed' }, { error: 'Invalid OpenAI API key or connection failed' },
{ status: 400 }, { status: 400 }
); );
} }
@@ -414,6 +386,7 @@ async function unauthenticatedHandler(req: NextRequest) {
name: m.id, name: m.id,
})) }))
.sort((a: any, b: any) => a.name.localeCompare(b.name)); .sort((a: any, b: any) => a.name.localeCompare(b.name));
} else if (provider === 'claude') { } else if (provider === 'claude') {
// Claude: Fetch models dynamically from the Anthropic Models API // Claude: Fetch models dynamically from the Anthropic Models API
try { try {
@@ -421,7 +394,7 @@ async function unauthenticatedHandler(req: NextRequest) {
} catch { } catch {
return NextResponse.json( return NextResponse.json(
{ error: 'Invalid Claude API key or connection failed' }, { error: 'Invalid Claude API key or connection failed' },
{ status: 400 }, { status: 400 }
); );
} }
} else if (provider === 'gemini') { } else if (provider === 'gemini') {
@@ -431,7 +404,7 @@ async function unauthenticatedHandler(req: NextRequest) {
} catch { } catch {
return NextResponse.json( return NextResponse.json(
{ error: 'Invalid Gemini API key or connection failed' }, { error: 'Invalid Gemini API key or connection failed' },
{ status: 400 }, { status: 400 }
); );
} }
} else if (provider === 'custom') { } else if (provider === 'custom') {
@@ -452,15 +425,11 @@ async function unauthenticatedHandler(req: NextRequest) {
if (!response.ok) { if (!response.ok) {
const errorText = await response.text(); const errorText = await response.text();
logger.error('Custom provider connection error', { logger.error('Custom provider connection error', { error: errorText });
error: errorText,
});
// Return 400 (not the external service's status) to prevent triggering logout on 401 // Return 400 (not the external service's status) to prevent triggering logout on 401
return NextResponse.json( return NextResponse.json(
{ { error: `Failed to connect to custom provider: ${response.status} ${errorText}` },
error: `Failed to connect to custom provider: ${response.status} ${errorText}`, { status: 400 }
},
{ status: 400 },
); );
} }
@@ -485,8 +454,7 @@ async function unauthenticatedHandler(req: NextRequest) {
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
models: [], models: [],
message: message: 'Connected successfully but could not parse models list. You may need to enter model name manually.',
'Connected successfully but could not parse models list. You may need to enter model name manually.',
}); });
} }
@@ -494,10 +462,8 @@ async function unauthenticatedHandler(req: NextRequest) {
} catch (error: any) { } catch (error: any) {
logger.error('Custom provider network error', { error: error.message }); logger.error('Custom provider network error', { error: error.message });
return NextResponse.json( return NextResponse.json(
{ { error: `Network error connecting to custom provider: ${error.message}` },
error: `Network error connecting to custom provider: ${error.message}`, { status: 500 }
},
{ status: 500 },
); );
} }
} }
@@ -507,13 +473,12 @@ async function unauthenticatedHandler(req: NextRequest) {
models, models,
provider, provider,
}); });
} catch (error: any) { } catch (error: any) {
logger.error('Test connection error', { logger.error('Test connection error', { error: error instanceof Error ? error.message : String(error) });
error: error instanceof Error ? error.message : String(error),
});
return NextResponse.json( return NextResponse.json(
{ error: error.message || 'Connection test failed' }, { error: error.message || 'Connection test failed' },
{ status: 500 }, { status: 500 }
); );
} }
} }
+94 -165
View File
@@ -50,7 +50,7 @@ export interface AIRecommendation {
*/ */
async function enrichWithUserRatings( async function enrichWithUserRatings(
userId: string, userId: string,
cachedBooks: CachedLibraryBook[], cachedBooks: CachedLibraryBook[]
): Promise<LibraryBook[]> { ): Promise<LibraryBook[]> {
try { try {
// Get user's Plex token, plexId, and role // Get user's Plex token, plexId, and role
@@ -61,7 +61,7 @@ async function enrichWithUserRatings(
if (!user) { if (!user) {
logger.warn('User not found'); logger.warn('User not found');
return cachedBooks.map((book) => ({ return cachedBooks.map(book => ({
title: book.title, title: book.title,
author: book.author, author: book.author,
narrator: book.narrator || undefined, narrator: book.narrator || undefined,
@@ -72,28 +72,22 @@ async function enrichWithUserRatings(
// Local admin users: Use cached ratings (from system Plex token) // Local admin users: Use cached ratings (from system Plex token)
// Local admins authenticate with username/password, not Plex OAuth // Local admins authenticate with username/password, not Plex OAuth
if (user.plexId.startsWith('local-')) { if (user.plexId.startsWith('local-')) {
logger.info( logger.info('User is local admin, using cached ratings (from system Plex token)');
'User is local admin, using cached ratings (from system Plex token)', return cachedBooks.map(book => ({
);
return cachedBooks.map((book) => ({
title: book.title, title: book.title,
author: book.author, author: book.author,
narrator: book.narrator || undefined, narrator: book.narrator || undefined,
rating: book.userRating rating: book.userRating ? parseFloat(book.userRating.toString()) : undefined,
? parseFloat(book.userRating.toString())
: undefined,
})); }));
} }
// Plex-authenticated users (including admins): Fetch library with their token to get personal ratings // 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 // Note: /library/sections/{id}/all returns items with the authenticated user's ratings
logger.info( logger.info('User is Plex-authenticated, fetching library with user token to get personal ratings');
'User is Plex-authenticated, fetching library with user token to get personal ratings',
);
if (!user.authToken) { if (!user.authToken) {
logger.warn('User has no Plex auth token'); logger.warn('User has no Plex auth token');
return cachedBooks.map((book) => ({ return cachedBooks.map(book => ({
title: book.title, title: book.title,
author: book.author, author: book.author,
narrator: book.narrator || undefined, narrator: book.narrator || undefined,
@@ -107,7 +101,7 @@ async function enrichWithUserRatings(
if (!plexConfig.serverUrl || !plexConfig.libraryId) { if (!plexConfig.serverUrl || !plexConfig.libraryId) {
logger.warn('No Plex server URL or library ID configured'); logger.warn('No Plex server URL or library ID configured');
return cachedBooks.map((book) => ({ return cachedBooks.map(book => ({
title: book.title, title: book.title,
author: book.author, author: book.author,
narrator: book.narrator || undefined, narrator: book.narrator || undefined,
@@ -136,7 +130,7 @@ async function enrichWithUserRatings(
// Get server machine ID from stored config (no need to access system token) // Get server machine ID from stored config (no need to access system token)
if (!plexConfig.machineIdentifier) { if (!plexConfig.machineIdentifier) {
logger.error('Server machine identifier not configured'); logger.error('Server machine identifier not configured');
return cachedBooks.map((book) => ({ return cachedBooks.map(book => ({
title: book.title, title: book.title,
author: book.author, author: book.author,
narrator: book.narrator || undefined, narrator: book.narrator || undefined,
@@ -147,14 +141,12 @@ async function enrichWithUserRatings(
const serverMachineId = plexConfig.machineIdentifier; const serverMachineId = plexConfig.machineIdentifier;
const serverAccessToken = await plexService.getServerAccessToken( const serverAccessToken = await plexService.getServerAccessToken(
serverMachineId, serverMachineId,
userPlexToken, userPlexToken
); );
if (!serverAccessToken) { if (!serverAccessToken) {
logger.warn( logger.warn('Could not get server access token for user (may not have server access)');
'Could not get server access token for user (may not have server access)', return cachedBooks.map(book => ({
);
return cachedBooks.map((book) => ({
title: book.title, title: book.title,
author: book.author, author: book.author,
narrator: book.narrator || undefined, narrator: book.narrator || undefined,
@@ -168,16 +160,14 @@ async function enrichWithUserRatings(
const userLibrary = await plexService.getLibraryContent( const userLibrary = await plexService.getLibraryContent(
plexConfig.serverUrl, plexConfig.serverUrl,
serverAccessToken, serverAccessToken,
plexConfig.libraryId, plexConfig.libraryId
); );
logger.info( logger.info(`Fetched ${userLibrary.length} items from Plex with user's token`);
`Fetched ${userLibrary.length} items from Plex with user's token`,
);
// Create a map of guid/ratingKey -> userRating for quick lookup // Create a map of guid/ratingKey -> userRating for quick lookup
const ratingsMap = new Map<string, number>(); const ratingsMap = new Map<string, number>();
userLibrary.forEach((item) => { userLibrary.forEach(item => {
if (item.userRating) { if (item.userRating) {
// Try to match by guid first (most reliable) // Try to match by guid first (most reliable)
if (item.guid) { if (item.guid) {
@@ -193,7 +183,7 @@ async function enrichWithUserRatings(
logger.info(`Found ${ratingsMap.size} rated items for non-admin user`); logger.info(`Found ${ratingsMap.size} rated items for non-admin user`);
// Enrich cached books with user's ratings from the fetched library // 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 // Try to find rating by guid first (most reliable), then ratingKey
let rating: number | undefined; let rating: number | undefined;
if (book.plexGuid) { if (book.plexGuid) {
@@ -210,37 +200,27 @@ async function enrichWithUserRatings(
rating: rating, rating: rating,
}; };
}); });
} catch (fetchError: any) { } catch (fetchError: any) {
if ( if (fetchError?.response?.status === 401 || fetchError?.message?.includes('401')) {
fetchError?.response?.status === 401 || logger.warn('User token unauthorized for library access (shared users may not have direct API access)');
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'); logger.warn('Falling back to recommendations without user ratings');
} else { } else {
logger.error('Failed to fetch library with user token', { logger.error('Failed to fetch library with user token', { error: fetchError instanceof Error ? fetchError.message : String(fetchError) });
error:
fetchError instanceof Error
? fetchError.message
: String(fetchError),
});
} }
// Fallback: return books without ratings // Fallback: return books without ratings
return cachedBooks.map((book) => ({ return cachedBooks.map(book => ({
title: book.title, title: book.title,
author: book.author, author: book.author,
narrator: book.narrator || undefined, narrator: book.narrator || undefined,
rating: undefined, rating: undefined,
})); }));
} }
} catch (error) { } catch (error) {
logger.error('Error enriching books with user ratings', { logger.error('Error enriching books with user ratings', { error: error instanceof Error ? error.message : String(error) });
error: error instanceof Error ? error.message : String(error),
});
// Fallback: return books without ratings on error // Fallback: return books without ratings on error
return cachedBooks.map((book) => ({ return cachedBooks.map(book => ({
title: book.title, title: book.title,
author: book.author, author: book.author,
narrator: book.narrator || undefined, narrator: book.narrator || undefined,
@@ -257,7 +237,7 @@ async function enrichWithUserRatings(
*/ */
export async function getUserLibraryBooks( export async function getUserLibraryBooks(
userId: string, userId: string,
scope: 'full' | 'listened' | 'rated' | 'favorites', scope: 'full' | 'listened' | 'rated' | 'favorites'
): Promise<LibraryBook[]> { ): Promise<LibraryBook[]> {
try { try {
const configService = getConfigService(); const configService = getConfigService();
@@ -265,9 +245,7 @@ export async function getUserLibraryBooks(
// Early validation: audiobookshelf doesn't support ratings // Early validation: audiobookshelf doesn't support ratings
if (backendMode === 'audiobookshelf' && scope === 'rated') { if (backendMode === 'audiobookshelf' && scope === 'rated') {
logger.warn( logger.warn('Audiobookshelf does not support ratings, falling back to full library');
'Audiobookshelf does not support ratings, falling back to full library',
);
scope = 'full'; scope = 'full';
} }
@@ -283,17 +261,13 @@ export async function getUserLibraryBooks(
: []; : [];
if (favoriteIds.length === 0) { if (favoriteIds.length === 0) {
logger.warn( logger.warn('Favorites scope selected but no favorites stored, falling back to full library');
'Favorites scope selected but no favorites stored, falling back to full library',
);
scope = 'full'; scope = 'full';
} else { } else {
// Get library ID for filtering // Get library ID for filtering
let libraryId: string; let libraryId: string;
if (backendMode === 'audiobookshelf') { if (backendMode === 'audiobookshelf') {
const absLibraryId = await configService.get( const absLibraryId = await configService.get('audiobookshelf.library_id');
'audiobookshelf.library_id',
);
if (!absLibraryId) { if (!absLibraryId) {
logger.warn('No Audiobookshelf library ID configured'); logger.warn('No Audiobookshelf library ID configured');
return []; return [];
@@ -325,9 +299,7 @@ export async function getUserLibraryBooks(
orderBy: { addedAt: 'desc' }, orderBy: { addedAt: 'desc' },
}); });
logger.info( logger.info(`Fetched ${cachedBooks.length} favorite books for user ${userId}`);
`Fetched ${cachedBooks.length} favorite books for user ${userId}`,
);
// For Plex: Enrich with user's personal ratings // For Plex: Enrich with user's personal ratings
// For Audiobookshelf: Skip enrichment (no rating support) // For Audiobookshelf: Skip enrichment (no rating support)
@@ -335,7 +307,7 @@ export async function getUserLibraryBooks(
return await enrichWithUserRatings(userId, cachedBooks); return await enrichWithUserRatings(userId, cachedBooks);
} else { } else {
// Audiobookshelf: Map to LibraryBook without ratings // Audiobookshelf: Map to LibraryBook without ratings
return cachedBooks.map((book) => ({ return cachedBooks.map(book => ({
title: book.title, title: book.title,
author: book.author, author: book.author,
narrator: book.narrator || undefined, narrator: book.narrator || undefined,
@@ -410,24 +382,23 @@ export async function getUserLibraryBooks(
// Filter to rated books if scope is 'rated' // Filter to rated books if scope is 'rated'
if (scope === '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 isLocalAdmin ? ratedBooks : ratedBooks.slice(0, 40);
} }
return enrichedBooks; return enrichedBooks;
} else { } else {
// Audiobookshelf: Map to LibraryBook without ratings // Audiobookshelf: Map to LibraryBook without ratings
return cachedBooks.map((book) => ({ return cachedBooks.map(book => ({
title: book.title, title: book.title,
author: book.author, author: book.author,
narrator: book.narrator || undefined, narrator: book.narrator || undefined,
rating: undefined, // ABS doesn't support ratings rating: undefined, // ABS doesn't support ratings
})); }));
} }
} catch (error) { } catch (error) {
logger.error('Error fetching library books', { logger.error('Error fetching library books', { error: error instanceof Error ? error.message : String(error) });
error: error instanceof Error ? error.message : String(error),
});
return []; return [];
} }
} }
@@ -441,7 +412,7 @@ export async function getUserLibraryBooks(
*/ */
export async function getUserRecentSwipes( export async function getUserRecentSwipes(
userId: string, userId: string,
limit: number = 10, limit: number = 10
): Promise<SwipeHistory[]> { ): Promise<SwipeHistory[]> {
try { try {
// First, get the most recent non-dismiss swipes (left=reject, right=like/request) // First, get the most recent non-dismiss swipes (left=reject, right=like/request)
@@ -487,11 +458,11 @@ export async function getUserRecentSwipes(
// Combine both lists, maintaining chronological order (most recent first) // Combine both lists, maintaining chronological order (most recent first)
const allSwipes = [...nonDismissSwipes, ...dismissSwipes].sort( const allSwipes = [...nonDismissSwipes, ...dismissSwipes].sort(
(a, b) => b.createdAt.getTime() - a.createdAt.getTime(), (a, b) => b.createdAt.getTime() - a.createdAt.getTime()
); );
logger.info( 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) => ({ return allSwipes.map((s) => ({
@@ -500,10 +471,9 @@ export async function getUserRecentSwipes(
action: s.action, action: s.action,
markedAsKnown: s.markedAsKnown, markedAsKnown: s.markedAsKnown,
})); }));
} catch (error) { } catch (error) {
logger.error('Error fetching swipe history', { logger.error('Error fetching swipe history', { error: error instanceof Error ? error.message : String(error) });
error: error instanceof Error ? error.message : String(error),
});
return []; return [];
} }
} }
@@ -516,11 +486,11 @@ export async function getUserRecentSwipes(
*/ */
export async function buildAIPrompt( export async function buildAIPrompt(
userId: string, userId: string,
config: { libraryScope: string; customPrompt?: string | null }, config: { libraryScope: string; customPrompt?: string | null }
): Promise<string> { ): Promise<string> {
const libraryBooks = await getUserLibraryBooks( const libraryBooks = await getUserLibraryBooks(
userId, userId,
config.libraryScope as 'full' | 'listened' | 'rated' | 'favorites', config.libraryScope as 'full' | 'listened' | 'rated' | 'favorites'
); );
const swipeHistory = await getUserRecentSwipes(userId, 10); const swipeHistory = await getUserRecentSwipes(userId, 10);
@@ -535,7 +505,7 @@ export async function buildAIPrompt(
let instructions = let instructions =
'Recommend 15-20 audiobooks the user would enjoy based on their library and swipe history. ' + 'Recommend 15-20 audiobooks the user would enjoy based on their library and swipe history. ' +
'CRITICAL RULES:\n' + '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' + '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' + '3. You must provide 15-20 diverse recommendations, not just 3-5\n' +
'4. Focus on variety across genres, authors, and styles\n' + '4. Focus on variety across genres, authors, and styles\n' +
@@ -547,11 +517,8 @@ export async function buildAIPrompt(
// Add special instruction for favorites scope // Add special instruction for favorites scope
if (config.libraryScope === 'favorites') { if (config.libraryScope === 'favorites') {
instructions += instructions += '\n\n' +
'\n\n' + 'IMPORTANT: The user has specifically handpicked these ' + libraryBooks.length + ' books as their personal favorites. ' +
'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. ' + '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.'; 'Find books that capture the essence of what makes these favorites special to the user.';
} }
@@ -560,17 +527,12 @@ export async function buildAIPrompt(
task: 'recommend_audiobooks', task: 'recommend_audiobooks',
user_context: { user_context: {
library_books: libraryBooks.slice(0, 40), library_books: libraryBooks.slice(0, 40),
swipe_history: swipeHistory.map((s) => ({ swipe_history: swipeHistory.map(s => ({
title: s.title, title: s.title,
author: s.author, author: s.author,
user_action: user_action: s.action === 'right'
s.action === 'right' ? (s.markedAsKnown ? 'marked_as_liked' : 'requested')
? s.markedAsKnown : s.action === 'left' ? 'rejected' : 'dismissed',
? 'marked_as_liked'
: 'requested'
: s.action === 'left'
? 'rejected'
: 'dismissed',
})), })),
custom_preferences: config.customPrompt || null, custom_preferences: config.customPrompt || null,
}, },
@@ -596,7 +558,7 @@ export async function callAI(
model: string, model: string,
encryptedApiKey: string, encryptedApiKey: string,
prompt: string, prompt: string,
baseUrl?: string | null, baseUrl?: string | null
): Promise<{ recommendations: AIRecommendation[] }> { ): Promise<{ recommendations: AIRecommendation[] }> {
const encryptionService = getEncryptionService(); const encryptionService = getEncryptionService();
let apiKey = ''; let apiKey = '';
@@ -642,11 +604,10 @@ export async function callAI(
}, },
}; };
const systemMessage = const systemMessage = 'You are an expert audiobook recommender. ' +
'You are an expert audiobook recommender. ' +
'Your task is to recommend 15-20 NEW audiobooks that the user would enjoy. ' + '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. " + 'NEVER recommend books that are already in the user\'s library or swipe history. ' +
"Focus on discovering books they haven't seen yet."; 'Focus on discovering books they haven\'t seen yet.';
if (provider === 'openai') { if (provider === 'openai') {
const requestBody = { const requestBody = {
@@ -669,7 +630,7 @@ export async function callAI(
const response = await fetch('https://api.openai.com/v1/chat/completions', { const response = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST', method: 'POST',
headers: { headers: {
Authorization: `Bearer ${apiKey}`, 'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify(requestBody), body: JSON.stringify(requestBody),
@@ -677,10 +638,7 @@ export async function callAI(
if (!response.ok) { if (!response.ok) {
const errorText = await response.text(); const errorText = await response.text();
logger.error('OpenAI API error', { logger.error('OpenAI API error', { status: response.status, error: errorText });
status: response.status,
error: errorText,
});
throw new Error(`OpenAI API error: ${response.status} ${errorText}`); throw new Error(`OpenAI API error: ${response.status} ${errorText}`);
} }
@@ -688,6 +646,7 @@ export async function callAI(
const content = data.choices[0].message.content; const content = data.choices[0].message.content;
logger.debug('OpenAI response:', { content }); logger.debug('OpenAI response:', { content });
return JSON.parse(content); return JSON.parse(content);
} else if (provider === 'claude') { } 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 userMessage = `${systemMessage}\n\n${prompt}\n\nIMPORTANT: Provide exactly 15-20 recommendations. Return ONLY valid JSON with no additional text or formatting.`;
const requestBody = { const requestBody = {
@@ -715,10 +674,7 @@ export async function callAI(
if (!response.ok) { if (!response.ok) {
const errorText = await response.text(); const errorText = await response.text();
logger.error('Claude API error', { logger.error('Claude API error', { status: response.status, error: errorText });
status: response.status,
error: errorText,
});
throw new Error(`Claude API error: ${response.status} ${errorText}`); throw new Error(`Claude API error: ${response.status} ${errorText}`);
} }
@@ -734,6 +690,7 @@ export async function callAI(
logger.debug('Claude cleaned response:', { cleanedContent }); logger.debug('Claude cleaned response:', { cleanedContent });
return JSON.parse(cleanedContent); return JSON.parse(cleanedContent);
} else if (provider === 'gemini') { } else if (provider === 'gemini') {
const requestBody = { const requestBody = {
systemInstruction: { systemInstruction: {
@@ -745,48 +702,42 @@ export async function callAI(
}, },
], ],
generationConfig: { generationConfig: {
responseMimeType: 'application/json', responseMimeType: "application/json",
responseSchema: { responseSchema: {
type: 'OBJECT', type: "OBJECT",
properties: { properties: {
recommendations: { recommendations: {
type: 'ARRAY', type: "ARRAY",
items: { items: {
type: 'OBJECT', type: "OBJECT",
properties: { properties: {
title: { type: 'STRING' }, title: { type: "STRING" },
author: { type: 'STRING' }, author: { type: "STRING" },
reason: { type: 'STRING' }, reason: { type: "STRING" },
}, },
required: ['title', 'author', 'reason'], required: ["title", "author", "reason"],
}, },
}, },
}, },
required: ['recommendations'], required: ["recommendations"],
}, },
}, },
}; };
logger.debug('Gemini request body:', { requestBody }); logger.debug('Gemini request body:', { requestBody });
const response = await fetch( const response = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent`, {
`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent`, method: 'POST',
{ headers: {
method: 'POST', 'Content-Type': 'application/json',
headers: { 'x-goog-api-key': apiKey,
'Content-Type': 'application/json',
'x-goog-api-key': apiKey,
},
body: JSON.stringify(requestBody),
}, },
); body: JSON.stringify(requestBody),
});
if (!response.ok) { if (!response.ok) {
const errorText = await response.text(); const errorText = await response.text();
logger.error('Gemini API error', { logger.error('Gemini API error', { status: response.status, error: errorText });
status: response.status,
error: errorText,
});
throw new Error(`Gemini API error: ${response.status} ${errorText}`); throw new Error(`Gemini API error: ${response.status} ${errorText}`);
} }
@@ -807,6 +758,7 @@ export async function callAI(
logger.debug('Gemini cleaned response:', { cleanedContent }); logger.debug('Gemini cleaned response:', { cleanedContent });
return JSON.parse(cleanedContent); return JSON.parse(cleanedContent);
} else if (provider === 'custom') { } else if (provider === 'custom') {
if (!baseUrl) { if (!baseUrl) {
throw new Error('Base URL is required for custom provider'); throw new Error('Base URL is required for custom provider');
@@ -850,23 +802,13 @@ export async function callAI(
if (!response.ok) { if (!response.ok) {
const errorText = await response.text(); const errorText = await response.text();
logger.error('Custom provider API error', { logger.error('Custom provider API error', { status: response.status, error: errorText });
status: response.status,
error: errorText,
});
// If response_format not supported, retry without it and add instructions to prompt // If response_format not supported, retry without it and add instructions to prompt
if ( if (errorText.includes('response_format') || errorText.includes('json_schema')) {
errorText.includes('response_format') || logger.info('Retrying without response_format (provider does not support structured outputs)');
errorText.includes('json_schema')
) {
logger.info(
'Retrying without response_format (provider does not support structured outputs)',
);
delete requestBody.response_format; delete requestBody.response_format;
requestBody.messages[0].content = requestBody.messages[0].content = systemMessage + ' Return ONLY valid JSON with no additional text or formatting.';
systemMessage +
' Return ONLY valid JSON with no additional text or formatting.';
const retryResponse = await fetch(endpoint, { const retryResponse = await fetch(endpoint, {
method: 'POST', method: 'POST',
@@ -876,9 +818,7 @@ export async function callAI(
if (!retryResponse.ok) { if (!retryResponse.ok) {
const retryErrorText = await retryResponse.text(); const retryErrorText = await retryResponse.text();
throw new Error( throw new Error(`Custom provider API error: ${retryResponse.status} ${retryErrorText}`);
`Custom provider API error: ${retryResponse.status} ${retryErrorText}`,
);
} }
const retryData = await retryResponse.json(); const retryData = await retryResponse.json();
@@ -890,15 +830,11 @@ export async function callAI(
.replace(/\s*```$/i, '') .replace(/\s*```$/i, '')
.trim(); .trim();
logger.debug('Custom provider cleaned response (fallback):', { logger.debug('Custom provider cleaned response (fallback):', { cleanedContent });
cleanedContent,
});
return JSON.parse(cleanedContent); return JSON.parse(cleanedContent);
} }
throw new Error( throw new Error(`Custom provider API error: ${response.status} ${errorText}`);
`Custom provider API error: ${response.status} ${errorText}`,
);
} }
const data = await response.json(); const data = await response.json();
@@ -912,10 +848,12 @@ export async function callAI(
.trim(); .trim();
return JSON.parse(cleanedContent); return JSON.parse(cleanedContent);
} catch (error: any) { } catch (error: any) {
logger.error('Custom provider error:', error); logger.error('Custom provider error:', error);
throw new Error(`Custom provider error: ${error.message}`); throw new Error(`Custom provider error: ${error.message}`);
} }
} else { } else {
throw new Error(`Invalid provider: ${provider}`); throw new Error(`Invalid provider: ${provider}`);
} }
@@ -929,7 +867,7 @@ export async function callAI(
*/ */
export async function matchToAudnexus( export async function matchToAudnexus(
title: string, title: string,
author: string, author: string
): Promise<{ ): Promise<{
asin: string; asin: string;
title: string; title: string;
@@ -981,9 +919,7 @@ export async function matchToAudnexus(
} }
// Step 2: Search Audible.com for the book // Step 2: Search Audible.com for the book
logger.info( logger.info(`Not in cache, searching Audible for "${title}" by ${author}...`);
`Not in cache, searching Audible for "${title}" by ${author}...`,
);
const audibleService = new AudibleService(); const audibleService = new AudibleService();
const searchQuery = `${title} ${author}`; const searchQuery = `${title} ${author}`;
const searchResults = await audibleService.search(searchQuery, 1); const searchResults = await audibleService.search(searchQuery, 1);
@@ -995,9 +931,7 @@ export async function matchToAudnexus(
// Take the first result (best match) // Take the first result (best match)
const firstResult = searchResults.results[0]; const firstResult = searchResults.results[0];
logger.info( logger.info(`Found on Audible: "${firstResult.title}" (ASIN: ${firstResult.asin})`);
`Found on Audible: "${firstResult.title}" (ASIN: ${firstResult.asin})`,
);
// Step 3: Use ASIN to fetch full details from Audnexus (or Audible as fallback) // Step 3: Use ASIN to fetch full details from Audnexus (or Audible as fallback)
const details = await audibleService.getAudiobookDetails(firstResult.asin); const details = await audibleService.getAudiobookDetails(firstResult.asin);
@@ -1018,10 +952,9 @@ export async function matchToAudnexus(
description: details.description || null, description: details.description || null,
coverUrl: details.coverArtUrl || null, coverUrl: details.coverArtUrl || null,
}; };
} catch (error) { } catch (error) {
logger.error(`Audnexus matching error for "${title}"`, { logger.error(`Audnexus matching error for "${title}"`, { error: error instanceof Error ? error.message : String(error) });
error: error instanceof Error ? error.message : String(error),
});
return null; return null;
} }
} }
@@ -1039,7 +972,7 @@ export async function isInLibrary(
userId: string, userId: string,
title: string, title: string,
author: string, author: string,
asin?: string, asin?: string
): Promise<boolean> { ): Promise<boolean> {
try { try {
// Use the centralized matching algorithm from audiobook-matcher.ts // Use the centralized matching algorithm from audiobook-matcher.ts
@@ -1051,16 +984,12 @@ export async function isInLibrary(
}); });
if (match) { if (match) {
logger.info( logger.info(`Book "${title}" by ${author} found in library (matched to: "${match.title}")`);
`Book "${title}" by ${author} found in library (matched to: "${match.title}")`,
);
} }
return !!match; return !!match;
} catch (error) { } catch (error) {
logger.error(`Error checking library for "${title}"`, { logger.error(`Error checking library for "${title}"`, { error: error instanceof Error ? error.message : String(error) });
error: error instanceof Error ? error.message : String(error),
});
return false; return false;
} }
} }
@@ -1073,7 +1002,7 @@ export async function isInLibrary(
*/ */
export async function isAlreadyRequested( export async function isAlreadyRequested(
userId: string, userId: string,
asin: string, asin: string
): Promise<boolean> { ): Promise<boolean> {
const request = await prisma.request.findFirst({ const request = await prisma.request.findFirst({
where: { where: {
@@ -1099,7 +1028,7 @@ export async function isAlreadyRequested(
export async function isAlreadySwiped( export async function isAlreadySwiped(
userId: string, userId: string,
title: string, title: string,
author: string, author: string
): Promise<boolean> { ): Promise<boolean> {
const swipe = await prisma.bookDateSwipe.findFirst({ const swipe = await prisma.bookDateSwipe.findFirst({
where: { where: {