mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-02 20:30:10 +00:00
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.
This commit is contained in:
@@ -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
|
||||
```
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -150,6 +150,7 @@ export default function AdminSettings() {
|
||||
const [bookdateProvider, setBookdateProvider] = useState<string>('openai');
|
||||
const [bookdateApiKey, setBookdateApiKey] = useState<string>('');
|
||||
const [bookdateModel, setBookdateModel] = useState<string>('');
|
||||
const [bookdateBaseUrl, setBookdateBaseUrl] = useState<string>('');
|
||||
const [bookdateEnabled, setBookdateEnabled] = useState<boolean>(true);
|
||||
const [bookdateConfigured, setBookdateConfigured] = useState<boolean>(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,11 +354,19 @@ export default function AdminSettings() {
|
||||
const handleTestBookdateConnection = async () => {
|
||||
const hasApiKey = bookdateApiKey.trim().length > 0;
|
||||
|
||||
// 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);
|
||||
setMessage(null);
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
// 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"
|
||||
>
|
||||
<option value="openai">OpenAI</option>
|
||||
<option value="claude">Claude (Anthropic)</option>
|
||||
<option value="custom">Custom (OpenAI-compatible)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Base URL Input - Show for Custom Provider */}
|
||||
{bookdateProvider === 'custom' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Base URL <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={bookdateBaseUrl}
|
||||
onChange={(e) => {
|
||||
setBookdateBaseUrl(e.target.value);
|
||||
setBookdateModels([]);
|
||||
}}
|
||||
placeholder="http://localhost:11434/v1"
|
||||
/>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-2">
|
||||
Examples:
|
||||
<br />• Ollama: <code>http://localhost:11434/v1</code>
|
||||
<br />• LM Studio: <code>http://localhost:1234/v1</code>
|
||||
<br />• vLLM: <code>http://localhost:8000/v1</code>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* API Key */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
API Key
|
||||
{bookdateProvider === 'custom' ? 'API Key (Optional for local models)' : 'API Key'}
|
||||
{bookdateProvider !== 'custom' && <span className="text-red-500 ml-1">*</span>}
|
||||
</label>
|
||||
<Input
|
||||
type="password"
|
||||
@@ -2346,13 +2402,17 @@ export default function AdminSettings() {
|
||||
setBookdateModels([]);
|
||||
}}
|
||||
placeholder={
|
||||
bookdateConfigured
|
||||
bookdateProvider === 'custom'
|
||||
? 'Leave blank for local models'
|
||||
: bookdateConfigured
|
||||
? '••••••••••••••••'
|
||||
: (bookdateProvider === 'openai' ? 'sk-...' : 'sk-ant-...')
|
||||
}
|
||||
/>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
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.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -2360,7 +2420,11 @@ export default function AdminSettings() {
|
||||
<Button
|
||||
onClick={handleTestBookdateConnection}
|
||||
loading={testingBookdate}
|
||||
disabled={!bookdateApiKey.trim() && !bookdateConfigured}
|
||||
disabled={
|
||||
bookdateProvider === 'custom'
|
||||
? !bookdateBaseUrl.trim()
|
||||
: (!bookdateApiKey.trim() && !bookdateConfigured)
|
||||
}
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
>
|
||||
|
||||
@@ -18,6 +18,11 @@ export async function GET() {
|
||||
// Check if local login is disabled via environment variable
|
||||
const localLoginDisabled = process.env.DISABLE_LOCAL_LOGIN === 'true';
|
||||
|
||||
// Check if automation (Phase 3) is configured by checking for Prowlarr/indexer config
|
||||
const indexerType = await configService.get('indexer.type');
|
||||
const prowlarrUrl = await configService.get('indexer.prowlarr_url');
|
||||
const automationEnabled = !!(indexerType || prowlarrUrl);
|
||||
|
||||
if (backendMode === 'audiobookshelf') {
|
||||
// Audiobookshelf mode - check which auth methods are enabled
|
||||
const oidcEnabled = (await configService.get('oidc.enabled')) === 'true';
|
||||
@@ -41,6 +46,7 @@ export async function GET() {
|
||||
hasLocalUsers,
|
||||
oidcProviderName: oidcEnabled ? oidcProviderName : null,
|
||||
localLoginDisabled,
|
||||
automationEnabled,
|
||||
});
|
||||
} else {
|
||||
// Plex mode - check if local admin exists (setup admin)
|
||||
@@ -58,6 +64,7 @@ export async function GET() {
|
||||
hasLocalUsers,
|
||||
oidcProviderName: null,
|
||||
localLoginDisabled,
|
||||
automationEnabled,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -71,6 +78,7 @@ export async function GET() {
|
||||
hasLocalUsers: false,
|
||||
oidcProviderName: null,
|
||||
localLoginDisabled,
|
||||
automationEnabled: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,13 +39,13 @@ async function getConfig(req: AuthenticatedRequest) {
|
||||
async function saveConfig(req: AuthenticatedRequest) {
|
||||
try {
|
||||
const body = await req.json();
|
||||
const { provider, apiKey, model, libraryScope, customPrompt, isEnabled } = body;
|
||||
const { provider, apiKey, model, baseUrl, libraryScope, customPrompt, isEnabled } = body;
|
||||
|
||||
// Check if config exists
|
||||
const existingConfig = await prisma.bookDateConfig.findFirst();
|
||||
|
||||
// Validation - API key only required for new configs
|
||||
if (!existingConfig && !apiKey) {
|
||||
// Validation - API key only required for new configs (except custom provider)
|
||||
if (!existingConfig && !apiKey && provider !== 'custom') {
|
||||
return NextResponse.json(
|
||||
{ error: 'API key is required for initial setup' },
|
||||
{ status: 400 }
|
||||
@@ -59,13 +59,39 @@ async function saveConfig(req: AuthenticatedRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
if (!['openai', 'claude'].includes(provider)) {
|
||||
if (!['openai', 'claude', 'custom'].includes(provider)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid provider. Must be "openai" or "claude"' },
|
||||
{ error: 'Invalid provider. Must be "openai", "claude", or "custom"' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Custom provider requires baseUrl
|
||||
if (provider === 'custom') {
|
||||
if (!baseUrl) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Base URL is required for custom provider' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Validate URL format
|
||||
try {
|
||||
const parsed = new URL(baseUrl);
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid base URL. Must use http:// or https://' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid base URL format' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Determine which API key to use
|
||||
let encryptedApiKeyToUse: string;
|
||||
|
||||
@@ -73,13 +99,17 @@ async function saveConfig(req: AuthenticatedRequest) {
|
||||
// New API key provided - encrypt it
|
||||
const encryptionService = getEncryptionService();
|
||||
encryptedApiKeyToUse = encryptionService.encrypt(apiKey);
|
||||
} else if (provider === 'custom' && !apiKey && !existingConfig) {
|
||||
// Custom provider with no API key (local model) - encrypt empty string
|
||||
const encryptionService = getEncryptionService();
|
||||
encryptedApiKeyToUse = encryptionService.encrypt('');
|
||||
} else if (existingConfig) {
|
||||
// No new API key, use existing one
|
||||
encryptedApiKeyToUse = existingConfig.apiKey;
|
||||
} else {
|
||||
// This shouldn't happen due to validation above, but just in case
|
||||
// API key required for OpenAI/Claude
|
||||
return NextResponse.json(
|
||||
{ error: 'API key is required for new configuration' },
|
||||
{ error: 'API key is required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
@@ -100,6 +130,13 @@ async function saveConfig(req: AuthenticatedRequest) {
|
||||
updateData.apiKey = encryptedApiKeyToUse;
|
||||
}
|
||||
|
||||
// Update or clear baseUrl based on provider
|
||||
if (provider === 'custom') {
|
||||
updateData.baseUrl = baseUrl;
|
||||
} else {
|
||||
updateData.baseUrl = null; // Clear baseUrl when switching away from custom
|
||||
}
|
||||
|
||||
config = await prisma.bookDateConfig.update({
|
||||
where: { id: existingConfig.id },
|
||||
data: updateData,
|
||||
@@ -111,6 +148,7 @@ async function saveConfig(req: AuthenticatedRequest) {
|
||||
data: {
|
||||
provider,
|
||||
model,
|
||||
baseUrl: provider === 'custom' ? baseUrl : null,
|
||||
libraryScope: 'full', // Default value for backwards compatibility
|
||||
customPrompt: null,
|
||||
isEnabled: isEnabled !== undefined ? isEnabled : true,
|
||||
|
||||
@@ -59,7 +59,7 @@ async function handler(req: AuthenticatedRequest) {
|
||||
// Build prompt and call AI (same as recommendations endpoint, but doesn't check cache)
|
||||
logger.info('Force generating new recommendations for user', { userId });
|
||||
const prompt = await buildAIPrompt(userId, userPreferences);
|
||||
const aiResponse = await callAI(config.provider, config.model, config.apiKey, prompt);
|
||||
const aiResponse = await callAI(config.provider, config.model, config.apiKey, prompt, config.baseUrl);
|
||||
|
||||
if (!aiResponse.recommendations || !Array.isArray(aiResponse.recommendations)) {
|
||||
throw new Error('Invalid AI response format: missing recommendations array');
|
||||
|
||||
@@ -80,7 +80,7 @@ async function handler(req: AuthenticatedRequest) {
|
||||
// Build prompt and call AI
|
||||
logger.info('Generating new recommendations for user', { userId });
|
||||
const prompt = await buildAIPrompt(userId, userPreferences);
|
||||
const aiResponse = await callAI(config.provider, config.model, config.apiKey, prompt);
|
||||
const aiResponse = await callAI(config.provider, config.model, config.apiKey, prompt, config.baseUrl);
|
||||
|
||||
if (!aiResponse.recommendations || !Array.isArray(aiResponse.recommendations)) {
|
||||
throw new Error('Invalid AI response format: missing recommendations array');
|
||||
|
||||
@@ -9,10 +9,24 @@ import { RMABLogger } from '@/lib/utils/logger';
|
||||
|
||||
const logger = RMABLogger.create('API.BookDate.TestConnection');
|
||||
|
||||
// Helper functions for custom provider
|
||||
function isValidBaseUrl(url: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeBaseUrl(url: string): string {
|
||||
return url.replace(/\/$/, ''); // Remove trailing slash
|
||||
}
|
||||
|
||||
async function authenticatedHandler(req: AuthenticatedRequest) {
|
||||
try {
|
||||
const body = await req.json();
|
||||
const { provider, apiKey, useSavedKey } = body;
|
||||
const { provider, apiKey, baseUrl, useSavedKey } = body;
|
||||
|
||||
// Validate provider
|
||||
if (!provider) {
|
||||
@@ -22,16 +36,35 @@ async function authenticatedHandler(req: AuthenticatedRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
if (!['openai', 'claude'].includes(provider)) {
|
||||
if (!['openai', 'claude', 'custom'].includes(provider)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid provider. Must be "openai" or "claude"' },
|
||||
{ error: 'Invalid provider. Must be "openai", "claude", or "custom"' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Get API key from saved global config if useSavedKey is true
|
||||
// Custom provider requires baseUrl
|
||||
if (provider === 'custom') {
|
||||
if (!baseUrl && !useSavedKey) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Base URL is required for custom provider' },
|
||||
{ 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Get API key and baseUrl from saved global config if useSavedKey is true
|
||||
let testApiKey = apiKey;
|
||||
if (useSavedKey && !testApiKey) {
|
||||
let testBaseUrl = baseUrl;
|
||||
if (useSavedKey) {
|
||||
const { prisma } = await import('@/lib/db');
|
||||
const { getEncryptionService } = await import('@/lib/services/encryption.service');
|
||||
|
||||
@@ -39,16 +72,38 @@ async function authenticatedHandler(req: AuthenticatedRequest) {
|
||||
|
||||
if (!config || !config.apiKey) {
|
||||
return NextResponse.json(
|
||||
{ error: 'No saved API key found' },
|
||||
{ error: 'No saved configuration found' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const encryptionService = getEncryptionService();
|
||||
try {
|
||||
testApiKey = encryptionService.decrypt(config.apiKey);
|
||||
} catch {
|
||||
// Allow empty API key for custom provider
|
||||
if (provider !== 'custom') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to decrypt saved API key' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
testApiKey = '';
|
||||
}
|
||||
|
||||
if (!testApiKey) {
|
||||
if (provider === 'custom') {
|
||||
testBaseUrl = config.baseUrl || '';
|
||||
if (!testBaseUrl) {
|
||||
return NextResponse.json(
|
||||
{ error: 'No saved base URL found for custom provider' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// API key required for OpenAI and Claude
|
||||
if (!testApiKey && provider !== 'custom') {
|
||||
return NextResponse.json(
|
||||
{ error: 'API key is required' },
|
||||
{ status: 400 }
|
||||
@@ -117,6 +172,64 @@ async function authenticatedHandler(req: AuthenticatedRequest) {
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
} else if (provider === 'custom') {
|
||||
// Custom: Fetch models from custom OpenAI-compatible endpoint
|
||||
const normalizedUrl = normalizeBaseUrl(testBaseUrl);
|
||||
const modelsEndpoint = normalizedUrl + '/models';
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
if (testApiKey) {
|
||||
headers['Authorization'] = `Bearer ${testApiKey}`;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(modelsEndpoint, {
|
||||
method: 'GET',
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
logger.error('Custom provider connection error', { error: errorText });
|
||||
return NextResponse.json(
|
||||
{ error: `Failed to connect to custom provider: ${response.status} ${errorText}` },
|
||||
{ status: response.status }
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Handle multiple response formats
|
||||
let modelsList = [];
|
||||
if (Array.isArray(data?.data)) {
|
||||
// OpenAI format: { data: [...] }
|
||||
modelsList = data.data.map((m: any) => ({
|
||||
id: m.id,
|
||||
name: m.name || m.id,
|
||||
}));
|
||||
} else if (Array.isArray(data)) {
|
||||
// Direct array format
|
||||
modelsList = data.map((m: any) => ({
|
||||
id: m.id || m,
|
||||
name: m.name || m.id || m,
|
||||
}));
|
||||
} else {
|
||||
// Unable to parse, but connection successful
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
models: [],
|
||||
message: 'Connected successfully but could not parse models list. You may need to enter model name manually.',
|
||||
});
|
||||
}
|
||||
|
||||
models = modelsList;
|
||||
} 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
@@ -138,7 +251,7 @@ async function authenticatedHandler(req: AuthenticatedRequest) {
|
||||
async function unauthenticatedHandler(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json();
|
||||
const { provider, apiKey, useSavedKey } = body;
|
||||
const { provider, apiKey, baseUrl, useSavedKey } = body;
|
||||
|
||||
// During setup, useSavedKey should not be used (no auth context)
|
||||
if (useSavedKey) {
|
||||
@@ -156,14 +269,32 @@ async function unauthenticatedHandler(req: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
if (!['openai', 'claude'].includes(provider)) {
|
||||
if (!['openai', 'claude', 'custom'].includes(provider)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid provider. Must be "openai" or "claude"' },
|
||||
{ error: 'Invalid provider. Must be "openai", "claude", or "custom"' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!apiKey) {
|
||||
// Custom provider requires baseUrl
|
||||
if (provider === 'custom') {
|
||||
if (!baseUrl) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Base URL is required for custom provider' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!isValidBaseUrl(baseUrl)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid base URL format. Must start with http:// or https://' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// API key required for OpenAI and Claude
|
||||
if (!apiKey && provider !== 'custom') {
|
||||
return NextResponse.json(
|
||||
{ error: 'API key is required' },
|
||||
{ status: 400 }
|
||||
@@ -232,6 +363,64 @@ async function unauthenticatedHandler(req: NextRequest) {
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
} else if (provider === 'custom') {
|
||||
// Custom: Fetch models from custom OpenAI-compatible endpoint
|
||||
const normalizedUrl = normalizeBaseUrl(baseUrl);
|
||||
const modelsEndpoint = normalizedUrl + '/models';
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
if (apiKey) {
|
||||
headers['Authorization'] = `Bearer ${apiKey}`;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(modelsEndpoint, {
|
||||
method: 'GET',
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
logger.error('Custom provider connection error', { error: errorText });
|
||||
return NextResponse.json(
|
||||
{ error: `Failed to connect to custom provider: ${response.status} ${errorText}` },
|
||||
{ status: response.status }
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Handle multiple response formats
|
||||
let modelsList = [];
|
||||
if (Array.isArray(data?.data)) {
|
||||
// OpenAI format: { data: [...] }
|
||||
modelsList = data.data.map((m: any) => ({
|
||||
id: m.id,
|
||||
name: m.name || m.id,
|
||||
}));
|
||||
} else if (Array.isArray(data)) {
|
||||
// Direct array format
|
||||
modelsList = data.map((m: any) => ({
|
||||
id: m.id || m,
|
||||
name: m.name || m.id || m,
|
||||
}));
|
||||
} else {
|
||||
// Unable to parse, but connection successful
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
models: [],
|
||||
message: 'Connected successfully but could not parse models list. You may need to enter model name manually.',
|
||||
});
|
||||
}
|
||||
|
||||
models = modelsList;
|
||||
} 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
|
||||
+21
-1
@@ -38,6 +38,7 @@ function LoginContent() {
|
||||
hasLocalUsers: boolean;
|
||||
oidcProviderName: string | null;
|
||||
localLoginDisabled: boolean;
|
||||
automationEnabled: boolean;
|
||||
} | null>(null);
|
||||
const [showRegisterForm, setShowRegisterForm] = useState(false);
|
||||
const [registerUsername, setRegisterUsername] = useState('');
|
||||
@@ -77,6 +78,7 @@ function LoginContent() {
|
||||
hasLocalUsers: false,
|
||||
oidcProviderName: null,
|
||||
localLoginDisabled: false,
|
||||
automationEnabled: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -519,7 +521,25 @@ function LoginContent() {
|
||||
{/* Description */}
|
||||
<div className="mb-6 sm:mb-8 text-center">
|
||||
<p className="text-gray-400 text-sm sm:text-base">
|
||||
Request audiobooks and they'll automatically download and appear in your Plex library
|
||||
{(() => {
|
||||
if (!authProviders) return 'Your Personal Audiobook Library Manager';
|
||||
|
||||
const { backendMode, automationEnabled } = authProviders;
|
||||
|
||||
// Audiobookshelf mode
|
||||
if (backendMode === 'audiobookshelf') {
|
||||
if (automationEnabled) {
|
||||
return "Request audiobooks and they'll automatically download and appear in your Audiobookshelf library";
|
||||
}
|
||||
return "Request audiobooks for your Audiobookshelf library";
|
||||
}
|
||||
|
||||
// Plex mode (default)
|
||||
if (automationEnabled) {
|
||||
return "Request audiobooks and they'll automatically download and appear in your Plex library";
|
||||
}
|
||||
return "Request audiobooks for your Plex library";
|
||||
})()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
+155
-24
@@ -448,25 +448,17 @@ export async function buildAIPrompt(
|
||||
custom_preferences: config.customPrompt || null,
|
||||
},
|
||||
instructions:
|
||||
'Based on the user\'s library and swipe history, recommend 20 audiobooks they would enjoy. ' +
|
||||
'Important rules:\n' +
|
||||
'1. DO NOT recommend any books already in the user\'s library\n' +
|
||||
'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' +
|
||||
'2. DO NOT recommend any books from the swipe history (whether requested, rejected, dismissed, or marked_as_liked)\n' +
|
||||
'3. Focus on variety and quality\n' +
|
||||
'4. Consider user ratings if available (0-10 scale, higher = liked more)\n' +
|
||||
'5. Learn from rejected books to avoid similar recommendations\n' +
|
||||
'6. Learn from requested books to find similar ones\n' +
|
||||
'7. Pay special attention to "marked_as_liked" books - these are books the user has already read/listened to elsewhere and enjoyed. Find similar books to these.\n' +
|
||||
'Return ONLY valid JSON with no additional text or formatting.',
|
||||
response_format: {
|
||||
recommendations: [
|
||||
{
|
||||
title: 'string',
|
||||
author: 'string',
|
||||
reason: '1-2 sentence explanation',
|
||||
},
|
||||
],
|
||||
},
|
||||
'3. You must provide 15-20 diverse recommendations, not just 3-5\n' +
|
||||
'4. Focus on variety across genres, authors, and styles\n' +
|
||||
'5. Consider user ratings if available (0-10 scale, higher = liked more)\n' +
|
||||
'6. Learn from rejected books to avoid similar recommendations\n' +
|
||||
'7. Learn from requested books to find similar ones\n' +
|
||||
'8. Pay special attention to "marked_as_liked" books - these are books the user has already read/listened to elsewhere and enjoyed. Find similar books to these.\n' +
|
||||
'9. Each recommendation should be a NEW book not mentioned anywhere in the user context',
|
||||
};
|
||||
|
||||
const promptString = JSON.stringify(prompt);
|
||||
@@ -487,18 +479,62 @@ export async function callAI(
|
||||
provider: string,
|
||||
model: string,
|
||||
encryptedApiKey: string,
|
||||
prompt: string
|
||||
prompt: string,
|
||||
baseUrl?: string | null
|
||||
): Promise<{ recommendations: AIRecommendation[] }> {
|
||||
const encryptionService = getEncryptionService();
|
||||
const apiKey = encryptionService.decrypt(encryptedApiKey);
|
||||
let apiKey = '';
|
||||
try {
|
||||
apiKey = encryptionService.decrypt(encryptedApiKey);
|
||||
} catch (error) {
|
||||
// Allow empty API key for custom provider (local models)
|
||||
if (provider !== 'custom') {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Calling AI provider: ${provider}, model: ${model}`);
|
||||
|
||||
// Define JSON schema for structured output
|
||||
const responseSchema = {
|
||||
type: 'json_schema',
|
||||
json_schema: {
|
||||
name: 'audiobook_recommendations',
|
||||
strict: true,
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
recommendations: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
title: { type: 'string' },
|
||||
author: { type: 'string' },
|
||||
reason: { type: 'string' },
|
||||
},
|
||||
required: ['title', 'author', 'reason'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
minItems: 15,
|
||||
maxItems: 20,
|
||||
},
|
||||
},
|
||||
required: ['recommendations'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
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.';
|
||||
|
||||
if (provider === 'openai') {
|
||||
const systemMessage = 'You are an expert audiobook recommender. Analyze user preferences and suggest audiobooks they will love. Return ONLY valid JSON.';
|
||||
const requestBody = {
|
||||
model,
|
||||
response_format: { type: 'json_object' },
|
||||
response_format: responseSchema,
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
@@ -534,10 +570,10 @@ export async function callAI(
|
||||
return JSON.parse(content);
|
||||
|
||||
} else if (provider === 'claude') {
|
||||
const userMessage = `${prompt}\n\nReturn 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 = {
|
||||
model,
|
||||
max_tokens: 4096,
|
||||
max_tokens: 8192,
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
@@ -577,6 +613,101 @@ export async function callAI(
|
||||
logger.debug('Claude cleaned response:', { cleanedContent });
|
||||
return JSON.parse(cleanedContent);
|
||||
|
||||
} else if (provider === 'custom') {
|
||||
if (!baseUrl) {
|
||||
throw new Error('Base URL is required for custom provider');
|
||||
}
|
||||
|
||||
// Try with json_schema first
|
||||
let requestBody: any = {
|
||||
model,
|
||||
response_format: responseSchema,
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: systemMessage,
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: prompt,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
logger.debug('Custom provider request body:', { requestBody, baseUrl });
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
// Only add Authorization header if API key provided
|
||||
if (apiKey) {
|
||||
headers['Authorization'] = `Bearer ${apiKey}`;
|
||||
}
|
||||
|
||||
const endpoint = baseUrl.replace(/\/$/, '') + '/chat/completions';
|
||||
|
||||
try {
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
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)');
|
||||
delete requestBody.response_format;
|
||||
requestBody.messages[0].content = systemMessage + ' Return ONLY valid JSON with no additional text or formatting.';
|
||||
|
||||
const retryResponse = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
if (!retryResponse.ok) {
|
||||
const retryErrorText = await retryResponse.text();
|
||||
throw new Error(`Custom provider API error: ${retryResponse.status} ${retryErrorText}`);
|
||||
}
|
||||
|
||||
const retryData = await retryResponse.json();
|
||||
const retryContent = retryData.choices[0].message.content;
|
||||
|
||||
// Clean markdown code blocks
|
||||
const cleanedContent = retryContent
|
||||
.replace(/^```json\s*/i, '')
|
||||
.replace(/\s*```$/i, '')
|
||||
.trim();
|
||||
|
||||
logger.debug('Custom provider cleaned response (fallback):', { cleanedContent });
|
||||
return JSON.parse(cleanedContent);
|
||||
}
|
||||
|
||||
throw new Error(`Custom provider API error: ${response.status} ${errorText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const content = data.choices[0].message.content;
|
||||
logger.debug('Custom provider response:', { content });
|
||||
|
||||
// Clean potential markdown wrapping (some providers still wrap even with json_schema)
|
||||
const cleanedContent = content
|
||||
.replace(/^```json\s*/i, '')
|
||||
.replace(/\s*```$/i, '')
|
||||
.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}`);
|
||||
}
|
||||
|
||||
@@ -115,6 +115,11 @@ export class QBittorrentService {
|
||||
baseURL: `${this.baseUrl}/api/v2`,
|
||||
timeout: 30000,
|
||||
httpsAgent: this.httpsAgent,
|
||||
// Support nginx/Apache reverse proxy with HTTP Basic Auth
|
||||
auth: {
|
||||
username: this.username,
|
||||
password: this.password,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -122,9 +127,20 @@ export class QBittorrentService {
|
||||
* Authenticate and establish session
|
||||
*/
|
||||
async login(): Promise<void> {
|
||||
const loginUrl = `${this.baseUrl}/api/v2/auth/login`;
|
||||
|
||||
logger.debug('[QBittorrent] Attempting login', {
|
||||
url: loginUrl,
|
||||
baseUrl: this.baseUrl,
|
||||
username: this.username,
|
||||
hasPassword: !!this.password,
|
||||
passwordLength: this.password?.length,
|
||||
sslVerifyDisabled: this.disableSSLVerify,
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await axios.post(
|
||||
`${this.baseUrl}/api/v2/auth/login`,
|
||||
loginUrl,
|
||||
new URLSearchParams({
|
||||
username: this.username,
|
||||
password: this.password,
|
||||
@@ -136,22 +152,52 @@ export class QBittorrentService {
|
||||
'Origin': this.baseUrl,
|
||||
},
|
||||
httpsAgent: this.httpsAgent,
|
||||
// Support nginx/Apache reverse proxy with HTTP Basic Auth
|
||||
auth: {
|
||||
username: this.username,
|
||||
password: this.password,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
logger.debug('[QBittorrent] Login response received', {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
data: response.data,
|
||||
hasSetCookie: !!response.headers['set-cookie'],
|
||||
setCookieCount: response.headers['set-cookie']?.length || 0,
|
||||
});
|
||||
|
||||
// Extract cookie from response
|
||||
const cookies = response.headers['set-cookie'];
|
||||
if (cookies && cookies.length > 0) {
|
||||
this.cookie = cookies[0].split(';')[0];
|
||||
logger.debug('[QBittorrent] Cookie extracted', {
|
||||
cookieName: this.cookie.split('=')[0],
|
||||
cookieLength: this.cookie.length,
|
||||
});
|
||||
}
|
||||
|
||||
if (!this.cookie) {
|
||||
logger.error('[QBittorrent] No cookie received in response');
|
||||
throw new Error('Failed to authenticate with qBittorrent');
|
||||
}
|
||||
|
||||
logger.info('Successfully authenticated');
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
logger.error('[QBittorrent] Login failed with axios error', {
|
||||
message: error.message,
|
||||
code: error.code,
|
||||
status: error.response?.status,
|
||||
statusText: error.response?.statusText,
|
||||
responseData: error.response?.data,
|
||||
requestUrl: error.config?.url,
|
||||
requestHeaders: error.config?.headers,
|
||||
});
|
||||
} else {
|
||||
logger.error('Login failed', { error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
throw new Error('Failed to authenticate with qBittorrent');
|
||||
}
|
||||
}
|
||||
@@ -687,6 +733,7 @@ export class QBittorrentService {
|
||||
disableSSLVerify: boolean = false
|
||||
): Promise<string> {
|
||||
const baseUrl = url.replace(/\/$/, '');
|
||||
const loginUrl = `${baseUrl}/api/v2/auth/login`;
|
||||
|
||||
// Create HTTPS agent if SSL verification is disabled
|
||||
let httpsAgent: https.Agent | undefined;
|
||||
@@ -697,36 +744,97 @@ export class QBittorrentService {
|
||||
logger.info('[QBittorrent] SSL certificate verification disabled for test connection');
|
||||
}
|
||||
|
||||
logger.debug('[QBittorrent] Test connection attempt', {
|
||||
loginUrl,
|
||||
baseUrl,
|
||||
username,
|
||||
hasPassword: !!password,
|
||||
passwordLength: password?.length,
|
||||
sslVerifyDisabled: disableSSLVerify,
|
||||
hasHttpsAgent: !!httpsAgent,
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await axios.post(
|
||||
`${baseUrl}/api/v2/auth/login`,
|
||||
new URLSearchParams({ username, password }),
|
||||
{
|
||||
headers: {
|
||||
const requestBody = new URLSearchParams({ username, password });
|
||||
const requestHeaders = {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'Referer': baseUrl,
|
||||
'Origin': baseUrl,
|
||||
},
|
||||
};
|
||||
|
||||
logger.debug('[QBittorrent] Sending login request', {
|
||||
body: requestBody.toString(),
|
||||
headers: requestHeaders,
|
||||
});
|
||||
|
||||
const response = await axios.post(
|
||||
loginUrl,
|
||||
requestBody,
|
||||
{
|
||||
headers: requestHeaders,
|
||||
httpsAgent,
|
||||
// Support nginx/Apache reverse proxy with HTTP Basic Auth
|
||||
auth: {
|
||||
username,
|
||||
password,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
logger.debug('[QBittorrent] Login response received', {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
data: response.data,
|
||||
hasSetCookie: !!response.headers['set-cookie'],
|
||||
setCookieCount: response.headers['set-cookie']?.length || 0,
|
||||
allHeaders: Object.keys(response.headers),
|
||||
});
|
||||
|
||||
// Get version to confirm connection
|
||||
const cookies = response.headers['set-cookie'];
|
||||
if (!cookies || cookies.length === 0) {
|
||||
logger.error('[QBittorrent] No cookies in response', {
|
||||
responseHeaders: response.headers,
|
||||
});
|
||||
throw new Error('Failed to authenticate - no session cookie received');
|
||||
}
|
||||
|
||||
const cookie = cookies[0].split(';')[0];
|
||||
logger.debug('[QBittorrent] Cookie extracted', {
|
||||
cookieName: cookie.split('=')[0],
|
||||
cookieLength: cookie.length,
|
||||
});
|
||||
|
||||
const versionResponse = await axios.get(`${baseUrl}/api/v2/app/version`, {
|
||||
headers: { Cookie: cookie },
|
||||
httpsAgent,
|
||||
// Support nginx/Apache reverse proxy with HTTP Basic Auth
|
||||
auth: {
|
||||
username,
|
||||
password,
|
||||
},
|
||||
});
|
||||
|
||||
logger.info('[QBittorrent] Version check successful', {
|
||||
version: versionResponse.data,
|
||||
});
|
||||
|
||||
return versionResponse.data || 'Connected';
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
logger.error('[QBittorrent] Test connection failed with axios error', {
|
||||
message: error.message,
|
||||
code: error.code,
|
||||
status: error.response?.status,
|
||||
statusText: error.response?.statusText,
|
||||
responseData: error.response?.data,
|
||||
requestUrl: error.config?.url,
|
||||
requestHeaders: error.config?.headers,
|
||||
responseHeaders: error.response?.headers,
|
||||
});
|
||||
} else {
|
||||
logger.error('Connection test failed', { error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
|
||||
// Enhanced error messages for common issues
|
||||
if (axios.isAxiosError(error)) {
|
||||
|
||||
Reference in New Issue
Block a user