From 50fb5a68af16c97a8be89c7056ff679e3963591d Mon Sep 17 00:00:00 2001 From: kikootwo Date: Mon, 12 Jan 2026 17:11:39 -0500 Subject: [PATCH] Add custom AI provider support and improve qBittorrent auth Introduces support for custom OpenAI-compatible AI providers with configurable base URLs, including UI, backend validation, and connection testing. Enhances qBittorrent integration to support HTTP Basic Auth for reverse proxies, adds detailed debug logging, and updates documentation for both features. Also improves login page description logic and AI prompt generation for recommendations. --- documentation/backend/services/auth.md | 2 + documentation/frontend/pages/login.md | 24 +- documentation/phase3/qbittorrent.md | 7 + prisma/schema.prisma | 3 +- src/app/admin/settings/page.tsx | 96 ++++++-- src/app/api/auth/providers/route.ts | 8 + src/app/api/bookdate/config/route.ts | 52 ++++- src/app/api/bookdate/generate/route.ts | 2 +- src/app/api/bookdate/recommendations/route.ts | 2 +- src/app/api/bookdate/test-connection/route.ts | 213 +++++++++++++++++- src/app/login/page.tsx | 22 +- src/lib/bookdate/helpers.ts | 179 +++++++++++++-- src/lib/integrations/qbittorrent.service.ts | 128 ++++++++++- 13 files changed, 664 insertions(+), 74 deletions(-) diff --git a/documentation/backend/services/auth.md b/documentation/backend/services/auth.md index 5c35a22..779bcba 100644 --- a/documentation/backend/services/auth.md +++ b/documentation/backend/services/auth.md @@ -216,6 +216,8 @@ Automatically grants admin permissions based on OIDC claims (e.g., group members - **GET /api/auth/oidc/login** - Initiate OIDC flow, redirect to provider - **GET /api/auth/oidc/callback** - Handle OAuth callback, create/update user, return JWT - **GET /api/auth/providers** - List enabled auth providers for login page + - Returns: `backendMode`, `providers[]`, `registrationEnabled`, `hasLocalUsers`, `oidcProviderName`, `localLoginDisabled`, `automationEnabled` + - `automationEnabled`: true if Prowlarr/indexer configured (used for dynamic login page description) ### Configuration Keys ``` diff --git a/documentation/frontend/pages/login.md b/documentation/frontend/pages/login.md index b286cf3..668685f 100644 --- a/documentation/frontend/pages/login.md +++ b/documentation/frontend/pages/login.md @@ -2,7 +2,7 @@ **Status:** ✅ Implemented | Real floating book covers with professional animations -Stylized entry point with Plex OAuth integration, animated floating popular audiobook covers, and prominent "Login with Plex" CTA. +Stylized entry point with Plex/Audiobookshelf authentication, animated floating popular audiobook covers, and dynamic description based on backend configuration. ## Design @@ -13,6 +13,7 @@ Stylized entry point with Plex OAuth integration, animated floating popular audi - Multi-layer depth effect with z-index layering (0-20) - Dark theme optimized with glassmorphism card - Professional streaming service aesthetic +- **Dynamic description** based on backend mode (Plex/Audiobookshelf) and automation status ## Authentication Flow @@ -53,6 +54,18 @@ Stylized entry point with Plex OAuth integration, animated floating popular audi - Seed multipliers (7, 13, 17, 23, 29, 31) prevent pattern repetition - Math.sin() based pseudo-random for deterministic results +## Dynamic Description + +Description text adapts to backend configuration: + +**Plex + Automation Enabled:** "Request audiobooks and they'll automatically download and appear in your Plex library" +**Plex + No Automation:** "Request audiobooks for your Plex library" +**Audiobookshelf + Automation:** "Request audiobooks and they'll automatically download and appear in your Audiobookshelf library" +**Audiobookshelf + No Automation:** "Request audiobooks for your Audiobookshelf library" +**Loading State:** "Your Personal Audiobook Library Manager" + +Automation is detected by checking for configured indexer (Prowlarr) via `/api/auth/providers` endpoint. + ## State ```typescript @@ -65,6 +78,15 @@ interface LoginPageState { showAdminLogin: boolean; adminUsername: string; adminPassword: string; + authProviders: { + backendMode: string; + providers: string[]; + registrationEnabled: boolean; + hasLocalUsers: boolean; + oidcProviderName: string | null; + localLoginDisabled: boolean; + automationEnabled: boolean; + } | null; } interface BookCover { diff --git a/documentation/phase3/qbittorrent.md b/documentation/phase3/qbittorrent.md index 96d96a5..e9e1403 100644 --- a/documentation/phase3/qbittorrent.md +++ b/documentation/phase3/qbittorrent.md @@ -181,6 +181,13 @@ type TorrentState = 'downloading' | 'uploading' | 'stalledDL' | - Headers set to qBittorrent base URL (e.g., `https://seedbox.example.com:443/qbittorrent`) - Applied to both `login()` and `testConnectionWithCredentials()` methods - Works with all qBittorrent versions and configurations + - Enhanced debug logging for troubleshooting authentication issues (enable with `LOG_LEVEL=debug`) +**13. Nginx/Apache reverse proxy HTTP Basic Auth** - Many seedboxes use nginx or Apache reverse proxy with HTTP Basic Authentication in front of qBittorrent. This causes HTTP 401 errors with `www-authenticate: Basic` header. Browsers handle this by prompting for credentials and sending `Authorization: Basic` header. Fixed by: + - Adding HTTP Basic Auth to all axios requests using `auth` parameter + - Same credentials used for both Basic Auth (nginx/Apache) and qBittorrent Web UI authentication + - Applied to axios client instance and all standalone requests + - Works transparently with or without reverse proxy + - Compatible with popular seedbox providers (seedit4.me, etc.) ## Tech Stack diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 76ce013..a4c16e3 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -355,9 +355,10 @@ model ScheduledJob { model BookDateConfig { id String @id @default(uuid()) - provider String // 'openai' | 'claude' + provider String // 'openai' | 'claude' | 'custom' apiKey String @map("api_key") @db.Text // Encrypted at rest (AES-256) model String // e.g., 'gpt-4o', 'claude-sonnet-4-5-20250929' + baseUrl String? @map("base_url") @db.Text // Base URL for custom provider (OpenAI-compatible endpoints) libraryScope String? @map("library_scope") // DEPRECATED: Now per-user (User.bookDateLibraryScope) customPrompt String? @map("custom_prompt") @db.Text // DEPRECATED: Now per-user (User.bookDateCustomPrompt) isVerified Boolean @default(false) @map("is_verified") diff --git a/src/app/admin/settings/page.tsx b/src/app/admin/settings/page.tsx index cb6b6fa..3d116d5 100644 --- a/src/app/admin/settings/page.tsx +++ b/src/app/admin/settings/page.tsx @@ -150,6 +150,7 @@ export default function AdminSettings() { const [bookdateProvider, setBookdateProvider] = useState('openai'); const [bookdateApiKey, setBookdateApiKey] = useState(''); const [bookdateModel, setBookdateModel] = useState(''); + const [bookdateBaseUrl, setBookdateBaseUrl] = useState(''); const [bookdateEnabled, setBookdateEnabled] = useState(true); const [bookdateConfigured, setBookdateConfigured] = useState(false); const [bookdateModels, setBookdateModels] = useState<{ id: string; name: string }[]>([]); @@ -341,6 +342,7 @@ export default function AdminSettings() { if (data.config) { setBookdateProvider(data.config.provider || 'openai'); setBookdateModel(data.config.model || ''); + setBookdateBaseUrl(data.config.baseUrl || ''); setBookdateEnabled(data.config.isEnabled !== false); // Default to true setBookdateConfigured(data.config.isVerified || false); } @@ -352,10 +354,18 @@ export default function AdminSettings() { const handleTestBookdateConnection = async () => { const hasApiKey = bookdateApiKey.trim().length > 0; - // Allow testing with saved API key if already configured - if (!hasApiKey && !bookdateConfigured) { - setMessage({ type: 'error', text: 'Please enter an API key' }); - return; + // Validation + if (bookdateProvider === 'custom') { + if (!bookdateBaseUrl.trim()) { + setMessage({ type: 'error', text: 'Please enter a base URL for custom provider' }); + return; + } + } else { + // Allow testing with saved API key if already configured + if (!hasApiKey && !bookdateConfigured) { + setMessage({ type: 'error', text: 'Please enter an API key' }); + return; + } } setTestingBookdate(true); @@ -369,10 +379,15 @@ export default function AdminSettings() { // Include API key if user entered a new one, otherwise use saved key if (hasApiKey) { payload.apiKey = bookdateApiKey; - } else { + } else if (bookdateProvider !== 'custom') { payload.useSavedKey = true; } + // Include baseUrl for custom provider + if (bookdateProvider === 'custom') { + payload.baseUrl = bookdateBaseUrl; + } + const response = await fetchWithAuth('/api/bookdate/test-connection', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -406,17 +421,26 @@ export default function AdminSettings() { return; } - // Only require API key if not already configured OR if user entered one - const hasApiKey = bookdateApiKey.trim().length > 0; - if (!bookdateConfigured && !hasApiKey) { - setMessage({ type: 'error', text: 'Please enter an API key for initial setup' }); - return; + // Validate: baseUrl required for custom provider + if (bookdateProvider === 'custom') { + if (!bookdateBaseUrl.trim()) { + setMessage({ type: 'error', text: 'Please enter a base URL for custom provider' }); + return; + } + } else { + // Only require API key if not already configured OR if user entered one + const hasApiKey = bookdateApiKey.trim().length > 0; + if (!bookdateConfigured && !hasApiKey) { + setMessage({ type: 'error', text: 'Please enter an API key for initial setup' }); + return; + } } setSaving(true); setMessage(null); try { + const hasApiKey = bookdateApiKey.trim().length > 0; const payload: any = { provider: bookdateProvider, model: bookdateModel, @@ -428,6 +452,11 @@ export default function AdminSettings() { payload.apiKey = bookdateApiKey; } + // Include baseUrl for custom provider + if (bookdateProvider === 'custom') { + payload.baseUrl = bookdateBaseUrl; + } + const response = await fetchWithAuth('/api/bookdate/config', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -2325,18 +2354,45 @@ export default function AdminSettings() { onChange={(e) => { setBookdateProvider(e.target.value); setBookdateModels([]); + setBookdateBaseUrl(''); }} className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500" > + + {/* Base URL Input - Show for Custom Provider */} + {bookdateProvider === 'custom' && ( +
+ + { + setBookdateBaseUrl(e.target.value); + setBookdateModels([]); + }} + placeholder="http://localhost:11434/v1" + /> +

+ Examples: +
• Ollama: http://localhost:11434/v1 +
• LM Studio: http://localhost:1234/v1 +
• vLLM: http://localhost:8000/v1 +

+
+ )} + {/* API Key */}

- The API key is stored securely and encrypted. Leave blank to keep existing key. + {bookdateProvider === 'custom' + ? 'Optional: Leave blank if your endpoint does not require authentication (e.g., Ollama, LM Studio)' + : 'The API key is stored securely and encrypted. Leave blank to keep existing key.'}

@@ -2360,7 +2420,11 @@ export default function AdminSettings() {