mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +00:00
Add extensible notification providers + UI/API
Introduce a provider-based notification system and wire it through the API and admin UI. Added INotificationProvider + notification service implementation and providers (apprise, discord, ntfy, pushover), plus a GET /api/admin/notifications/providers endpoint to expose provider metadata. Refactored code to use provider type strings (removed enum coupling), updated masking/encryption calls, and simplified the test notification endpoint to accept backendId or type+config and call sendToBackend directly. UI: NotificationsTab now fetches provider metadata and renders provider cards and dynamic config forms (fields driven by provider metadata). Added config field rendering, improved backend cards, and edit/delete actions. APIs: New providers route, updated admin notification CRUD routes to validate provider types dynamically, updated test route schema. Added download-client categories POST API to fetch categories from clients and wired postImportCategory handling in download-client routes. Other notable changes: BookDate now fetches Claude models dynamically from Anthropic's Models API; added paginated model fetch helper. Added ALLOW_WEAK_PASSWORD flag exposure to auth providers and password change logic. Doc updates and various tests added/updated. File-organization doc clarifies EPERM fix using stream-based copy.
This commit is contained in:
@@ -6,6 +6,25 @@ import { fetchWithAuth } from '@/lib/utils/api';
|
||||
|
||||
const logger = RMABLogger.create('NotificationsTab');
|
||||
|
||||
interface ProviderConfigField {
|
||||
name: string;
|
||||
label: string;
|
||||
type: 'text' | 'password' | 'select' | 'number';
|
||||
required: boolean;
|
||||
placeholder?: string;
|
||||
defaultValue?: string | number;
|
||||
options?: { label: string; value: string | number }[];
|
||||
}
|
||||
|
||||
interface ProviderMetadata {
|
||||
type: string;
|
||||
displayName: string;
|
||||
description: string;
|
||||
iconLabel: string;
|
||||
iconColor: string;
|
||||
configFields: ProviderConfigField[];
|
||||
}
|
||||
|
||||
interface NotificationBackend {
|
||||
id: string;
|
||||
type: string;
|
||||
@@ -24,15 +43,6 @@ interface ModalState {
|
||||
backend?: NotificationBackend;
|
||||
}
|
||||
|
||||
const typeColors: Record<string, string> = {
|
||||
discord: 'bg-indigo-500',
|
||||
pushover: 'bg-blue-500',
|
||||
email: 'bg-green-500',
|
||||
slack: 'bg-purple-500',
|
||||
telegram: 'bg-sky-500',
|
||||
webhook: 'bg-gray-500',
|
||||
};
|
||||
|
||||
const eventLabels: Record<string, string> = {
|
||||
request_pending_approval: 'Request Pending Approval',
|
||||
request_approved: 'Request Approved',
|
||||
@@ -42,6 +52,7 @@ const eventLabels: Record<string, string> = {
|
||||
|
||||
export function NotificationsTab() {
|
||||
const [backends, setBackends] = useState<NotificationBackend[]>([]);
|
||||
const [providerMetadata, setProviderMetadata] = useState<ProviderMetadata[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [modalState, setModalState] = useState<ModalState>({
|
||||
isOpen: false,
|
||||
@@ -59,8 +70,23 @@ export function NotificationsTab() {
|
||||
|
||||
useEffect(() => {
|
||||
fetchBackends();
|
||||
fetchProviderMetadata();
|
||||
}, []);
|
||||
|
||||
const fetchProviderMetadata = async () => {
|
||||
try {
|
||||
const response = await fetchWithAuth('/api/admin/notifications/providers');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
setProviderMetadata(data.providers);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch provider metadata', { error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
};
|
||||
|
||||
const fetchBackends = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
@@ -83,11 +109,23 @@ export function NotificationsTab() {
|
||||
}
|
||||
};
|
||||
|
||||
const getMetadataForType = (type: string): ProviderMetadata | undefined => {
|
||||
return providerMetadata.find((p) => p.type === type);
|
||||
};
|
||||
|
||||
const openAddModal = (type: string) => {
|
||||
const meta = getMetadataForType(type);
|
||||
const defaultConfig: Record<string, any> = {};
|
||||
if (meta) {
|
||||
for (const field of meta.configFields) {
|
||||
defaultConfig[field.name] = field.defaultValue ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
setModalState({ isOpen: true, mode: 'add', selectedType: type });
|
||||
setFormData({
|
||||
name: `${type.charAt(0).toUpperCase() + type.slice(1)} Notifications`,
|
||||
config: type === 'discord' ? { webhookUrl: '', username: 'ReadMeABook', avatarUrl: '' } : { userKey: '', appToken: '', device: '', priority: 0 },
|
||||
name: `${meta?.displayName ?? type} Notifications`,
|
||||
config: defaultConfig,
|
||||
events: ['request_available', 'request_error'],
|
||||
enabled: true,
|
||||
});
|
||||
@@ -193,6 +231,49 @@ export function NotificationsTab() {
|
||||
}
|
||||
};
|
||||
|
||||
const renderConfigField = (field: ProviderConfigField) => {
|
||||
if (field.type === 'select' && field.options) {
|
||||
return (
|
||||
<div key={field.name}>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{field.label}{field.required ? ' *' : ''}
|
||||
</label>
|
||||
<select
|
||||
value={formData.config[field.name] ?? field.defaultValue ?? ''}
|
||||
onChange={(e) => {
|
||||
const value = field.options?.some((o) => typeof o.value === 'number')
|
||||
? Number(e.target.value)
|
||||
: e.target.value;
|
||||
setFormData({ ...formData, config: { ...formData.config, [field.name]: value } });
|
||||
}}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
>
|
||||
{field.options.map((opt) => (
|
||||
<option key={String(opt.value)} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={field.name}>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{field.label}{field.required ? ' *' : field.label.includes('optional') ? '' : ' (optional)'}
|
||||
</label>
|
||||
<input
|
||||
type={field.type === 'password' ? 'password' : 'text'}
|
||||
value={formData.config[field.name] ?? ''}
|
||||
onChange={(e) => setFormData({ ...formData, config: { ...formData.config, [field.name]: e.target.value } })}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
placeholder={field.placeholder}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const currentMeta = modalState.selectedType ? getMetadataForType(modalState.selectedType) : undefined;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
@@ -206,32 +287,22 @@ export function NotificationsTab() {
|
||||
{/* Type Selector */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">Add Notification Backend</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<button
|
||||
onClick={() => openAddModal('discord')}
|
||||
className="flex items-center p-4 bg-white dark:bg-gray-800 rounded-lg border-2 border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600 transition-colors"
|
||||
>
|
||||
<div className="flex-shrink-0 w-12 h-12 bg-indigo-500 rounded-lg flex items-center justify-center text-white font-bold text-2xl">
|
||||
D
|
||||
</div>
|
||||
<div className="ml-4 text-left">
|
||||
<div className="font-semibold text-gray-900 dark:text-white">Discord</div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">Send notifications via Discord webhook</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => openAddModal('pushover')}
|
||||
className="flex items-center p-4 bg-white dark:bg-gray-800 rounded-lg border-2 border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600 transition-colors"
|
||||
>
|
||||
<div className="flex-shrink-0 w-12 h-12 bg-blue-500 rounded-lg flex items-center justify-center text-white font-bold text-2xl">
|
||||
P
|
||||
</div>
|
||||
<div className="ml-4 text-left">
|
||||
<div className="font-semibold text-gray-900 dark:text-white">Pushover</div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">Send notifications via Pushover API</div>
|
||||
</div>
|
||||
</button>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{providerMetadata.map((meta) => (
|
||||
<button
|
||||
key={meta.type}
|
||||
onClick={() => openAddModal(meta.type)}
|
||||
className="flex items-center p-4 bg-white dark:bg-gray-800 rounded-lg border-2 border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600 transition-colors"
|
||||
>
|
||||
<div className={`flex-shrink-0 w-12 h-12 ${meta.iconColor} rounded-lg flex items-center justify-center text-white font-bold text-2xl`}>
|
||||
{meta.iconLabel}
|
||||
</div>
|
||||
<div className="ml-4 text-left">
|
||||
<div className="font-semibold text-gray-900 dark:text-white">{meta.displayName}</div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">{meta.description}</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -244,43 +315,46 @@ export function NotificationsTab() {
|
||||
<p className="text-gray-600 dark:text-gray-400">No notification backends configured.</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{backends.map((backend) => (
|
||||
<div key={backend.id} className="bg-white dark:bg-gray-800 rounded-lg shadow-md border border-gray-200 dark:border-gray-700 p-4 hover:shadow-lg transition-shadow">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className={`w-10 h-10 ${typeColors[backend.type]} rounded-lg flex items-center justify-center text-white font-bold`}>
|
||||
{backend.type.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-semibold text-gray-900 dark:text-white truncate">{backend.name}</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 capitalize">{backend.type}</div>
|
||||
{backends.map((backend) => {
|
||||
const meta = getMetadataForType(backend.type);
|
||||
return (
|
||||
<div key={backend.id} className="bg-white dark:bg-gray-800 rounded-lg shadow-md border border-gray-200 dark:border-gray-700 p-4 hover:shadow-lg transition-shadow">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className={`w-10 h-10 ${meta?.iconColor ?? 'bg-gray-500'} rounded-lg flex items-center justify-center text-white font-bold`}>
|
||||
{meta?.iconLabel ?? backend.type.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-semibold text-gray-900 dark:text-white truncate">{backend.name}</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">{meta?.displayName ?? backend.type}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2 mb-3">
|
||||
<div className={`inline-block px-2 py-1 rounded text-xs ${backend.enabled ? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200' : 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300'}`}>
|
||||
{backend.enabled ? 'Enabled' : 'Disabled'}
|
||||
<div className="space-y-2 mb-3">
|
||||
<div className={`inline-block px-2 py-1 rounded text-xs ${backend.enabled ? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200' : 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300'}`}>
|
||||
{backend.enabled ? 'Enabled' : 'Disabled'}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{backend.events.length} {backend.events.length === 1 ? 'event' : 'events'} subscribed
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{backend.events.length} {backend.events.length === 1 ? 'event' : 'events'} subscribed
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={() => openEditModal(backend)}
|
||||
className="flex-1 px-3 py-1.5 text-sm bg-blue-600 hover:bg-blue-700 text-white rounded transition-colors"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(backend.id)}
|
||||
className="flex-1 px-3 py-1.5 text-sm bg-red-600 hover:bg-red-700 text-white rounded transition-colors"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={() => openEditModal(backend)}
|
||||
className="flex-1 px-3 py-1.5 text-sm bg-blue-600 hover:bg-blue-700 text-white rounded transition-colors"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(backend.id)}
|
||||
className="flex-1 px-3 py-1.5 text-sm bg-red-600 hover:bg-red-700 text-white rounded transition-colors"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -292,7 +366,7 @@ export function NotificationsTab() {
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-xl font-bold text-gray-900 dark:text-white">
|
||||
{modalState.mode === 'add' ? 'Add' : 'Edit'} {modalState.selectedType.charAt(0).toUpperCase() + modalState.selectedType.slice(1)} Notification
|
||||
{modalState.mode === 'add' ? 'Add' : 'Edit'} {currentMeta?.displayName ?? modalState.selectedType} Notification
|
||||
</h3>
|
||||
<button onClick={closeModal} className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200">
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -314,70 +388,8 @@ export function NotificationsTab() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Config Fields */}
|
||||
{modalState.selectedType === 'discord' && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Webhook URL *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.config.webhookUrl}
|
||||
onChange={(e) => setFormData({ ...formData, config: { ...formData.config, webhookUrl: e.target.value } })}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
placeholder="https://discord.com/api/webhooks/..."
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Username (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.config.username}
|
||||
onChange={(e) => setFormData({ ...formData, config: { ...formData.config, username: e.target.value } })}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
placeholder="ReadMeABook"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{modalState.selectedType === 'pushover' && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">User Key *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.config.userKey}
|
||||
onChange={(e) => setFormData({ ...formData, config: { ...formData.config, userKey: e.target.value } })}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
placeholder="Your Pushover user key"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">App Token *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.config.appToken}
|
||||
onChange={(e) => setFormData({ ...formData, config: { ...formData.config, appToken: e.target.value } })}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
placeholder="Your Pushover app token"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Priority</label>
|
||||
<select
|
||||
value={formData.config.priority}
|
||||
onChange={(e) => setFormData({ ...formData, config: { ...formData.config, priority: Number(e.target.value) } })}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
>
|
||||
<option value="-2">Lowest</option>
|
||||
<option value="-1">Low</option>
|
||||
<option value="0">Normal</option>
|
||||
<option value="1">High</option>
|
||||
<option value="2">Emergency</option>
|
||||
</select>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{/* Dynamic Config Fields */}
|
||||
{currentMeta?.configFields.map((field) => renderConfigField(field))}
|
||||
|
||||
{/* Events */}
|
||||
<div>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { getNotificationService, NotificationBackendType } from '@/lib/services/notification.service';
|
||||
import { getNotificationService } from '@/lib/services/notification';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import { z } from 'zod';
|
||||
|
||||
@@ -50,7 +50,7 @@ export async function GET(
|
||||
success: true,
|
||||
backend: {
|
||||
...backend,
|
||||
config: notificationService.maskConfig(backend.type as NotificationBackendType, backend.config),
|
||||
config: notificationService.maskConfig(backend.type, backend.config),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -114,7 +114,7 @@ export async function PUT(
|
||||
});
|
||||
|
||||
// Encrypt new/changed values
|
||||
finalConfig = notificationService.encryptConfig(existing.type as NotificationBackendType, updatedConfig);
|
||||
finalConfig = notificationService.encryptConfig(existing.type, updatedConfig);
|
||||
}
|
||||
|
||||
// Update backend
|
||||
@@ -139,7 +139,7 @@ export async function PUT(
|
||||
success: true,
|
||||
backend: {
|
||||
...updated,
|
||||
config: notificationService.maskConfig(updated.type as NotificationBackendType, updated.config),
|
||||
config: notificationService.maskConfig(updated.type, updated.config),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Component: Notification Providers Metadata API
|
||||
* Documentation: documentation/backend/services/notifications.md
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { getAllProviderMetadata } from '@/lib/services/notification';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
|
||||
const logger = RMABLogger.create('API.Admin.Notifications.Providers');
|
||||
|
||||
/**
|
||||
* GET /api/admin/notifications/providers
|
||||
* Returns metadata for all registered notification providers
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
return requireAdmin(req, async () => {
|
||||
try {
|
||||
const providers = getAllProviderMetadata();
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
providers,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch provider metadata', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'FetchError',
|
||||
message: 'Failed to fetch provider metadata',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -6,14 +6,14 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { getNotificationService, NotificationBackendType } from '@/lib/services/notification.service';
|
||||
import { getNotificationService, getRegisteredProviderTypes } from '@/lib/services/notification';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import { z } from 'zod';
|
||||
|
||||
const logger = RMABLogger.create('API.Admin.Notifications');
|
||||
|
||||
const CreateBackendSchema = z.object({
|
||||
type: z.enum(['discord', 'pushover', 'email', 'slack', 'telegram', 'webhook']),
|
||||
type: z.string().refine((val) => getRegisteredProviderTypes().includes(val), { message: 'Unsupported notification provider type' }),
|
||||
name: z.string().min(1),
|
||||
config: z.record(z.any()),
|
||||
events: z.array(z.enum(['request_pending_approval', 'request_approved', 'request_available', 'request_error'])).min(1),
|
||||
@@ -37,7 +37,7 @@ export async function GET(request: NextRequest) {
|
||||
// Mask sensitive config values
|
||||
const maskedBackends = backends.map((backend) => ({
|
||||
...backend,
|
||||
config: notificationService.maskConfig(backend.type as NotificationBackendType, backend.config),
|
||||
config: notificationService.maskConfig(backend.type, backend.config),
|
||||
}));
|
||||
|
||||
return NextResponse.json({
|
||||
|
||||
@@ -5,31 +5,17 @@
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { getNotificationService, NotificationBackendType, NotificationPayload } from '@/lib/services/notification.service';
|
||||
import { getNotificationService, getRegisteredProviderTypes, NotificationPayload } from '@/lib/services/notification';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import { z } from 'zod';
|
||||
import { prisma } from '@/lib/db';
|
||||
|
||||
const logger = RMABLogger.create('API.Admin.Notifications.Test');
|
||||
|
||||
const TestNotificationSchema = z.discriminatedUnion('mode', [
|
||||
// Test existing backend by ID (uses stored config)
|
||||
z.object({
|
||||
mode: z.literal('backend'),
|
||||
backendId: z.string(),
|
||||
}),
|
||||
// Test new config before saving
|
||||
z.object({
|
||||
mode: z.literal('config'),
|
||||
type: z.enum(['discord', 'pushover', 'email', 'slack', 'telegram', 'webhook']),
|
||||
config: z.record(z.any()),
|
||||
}),
|
||||
]);
|
||||
|
||||
// Support legacy format without mode
|
||||
const LegacyTestNotificationSchema = z.object({
|
||||
// Flexible schema: supports both backendId and type+config formats
|
||||
const TestNotificationSchema = z.object({
|
||||
backendId: z.string().optional(),
|
||||
type: z.enum(['discord', 'pushover', 'email', 'slack', 'telegram', 'webhook']).optional(),
|
||||
type: z.string().refine((val) => getRegisteredProviderTypes().includes(val), { message: 'Unsupported notification provider type' }).optional(),
|
||||
config: z.record(z.any()).optional(),
|
||||
});
|
||||
|
||||
@@ -42,66 +28,37 @@ export async function POST(request: NextRequest) {
|
||||
return requireAdmin(req, async () => {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const parsed = TestNotificationSchema.parse(body);
|
||||
|
||||
// Support legacy format for backward compatibility
|
||||
const legacyParsed = LegacyTestNotificationSchema.safeParse(body);
|
||||
|
||||
let type: NotificationBackendType;
|
||||
let type: string;
|
||||
let encryptedConfig: any;
|
||||
|
||||
const notificationService = getNotificationService();
|
||||
|
||||
if (legacyParsed.success) {
|
||||
// Legacy format
|
||||
if (legacyParsed.data.backendId) {
|
||||
// Test existing backend
|
||||
const backend = await prisma.notificationBackend.findUnique({
|
||||
where: { id: legacyParsed.data.backendId },
|
||||
});
|
||||
if (parsed.backendId) {
|
||||
// Test existing backend by ID (uses stored config)
|
||||
const backend = await prisma.notificationBackend.findUnique({
|
||||
where: { id: parsed.backendId },
|
||||
});
|
||||
|
||||
if (!backend) {
|
||||
return NextResponse.json(
|
||||
{ error: 'NotFound', message: 'Backend not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
type = backend.type as NotificationBackendType;
|
||||
encryptedConfig = backend.config; // Already encrypted in DB
|
||||
} else if (legacyParsed.data.type && legacyParsed.data.config) {
|
||||
// Test new config
|
||||
type = legacyParsed.data.type as NotificationBackendType;
|
||||
encryptedConfig = notificationService.encryptConfig(type, legacyParsed.data.config);
|
||||
} else {
|
||||
if (!backend) {
|
||||
return NextResponse.json(
|
||||
{ error: 'ValidationError', message: 'Must provide either backendId or type+config' },
|
||||
{ status: 400 }
|
||||
{ error: 'NotFound', message: 'Backend not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
type = backend.type;
|
||||
encryptedConfig = backend.config; // Already encrypted in DB
|
||||
} else if (parsed.type && parsed.config) {
|
||||
// Test new config before saving
|
||||
type = parsed.type;
|
||||
encryptedConfig = notificationService.encryptConfig(type, parsed.config);
|
||||
} else {
|
||||
// New format with discriminated union
|
||||
const parsed = TestNotificationSchema.parse(body);
|
||||
|
||||
if (parsed.mode === 'backend') {
|
||||
// Test existing backend
|
||||
const backend = await prisma.notificationBackend.findUnique({
|
||||
where: { id: parsed.backendId },
|
||||
});
|
||||
|
||||
if (!backend) {
|
||||
return NextResponse.json(
|
||||
{ error: 'NotFound', message: 'Backend not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
type = backend.type as NotificationBackendType;
|
||||
encryptedConfig = backend.config; // Already encrypted in DB
|
||||
} else {
|
||||
// Test new config
|
||||
type = parsed.type;
|
||||
encryptedConfig = notificationService.encryptConfig(type, parsed.config);
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: 'ValidationError', message: 'Must provide either backendId or type+config' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Create test payload
|
||||
@@ -117,7 +74,7 @@ export async function POST(request: NextRequest) {
|
||||
// Send test notification synchronously (not via job queue)
|
||||
try {
|
||||
// Call sendToBackend directly
|
||||
await (notificationService as any).sendToBackend(type, encryptedConfig, testPayload);
|
||||
await notificationService.sendToBackend(type, encryptedConfig, testPayload);
|
||||
|
||||
logger.info(`Test notification sent successfully for ${type}`, {
|
||||
adminId: req.user?.sub,
|
||||
|
||||
@@ -38,6 +38,7 @@ export async function PUT(
|
||||
localPath,
|
||||
category,
|
||||
customPath,
|
||||
postImportCategory,
|
||||
} = body;
|
||||
|
||||
const config = await getConfigService();
|
||||
@@ -76,6 +77,7 @@ export async function PUT(
|
||||
localPath: localPath !== undefined ? localPath : existingClient.localPath,
|
||||
category: category !== undefined ? category : existingClient.category,
|
||||
customPath: customPath !== undefined ? (customPath || undefined) : existingClient.customPath,
|
||||
postImportCategory: postImportCategory !== undefined ? (postImportCategory || undefined) : existingClient.postImportCategory,
|
||||
};
|
||||
|
||||
// Validate path mapping if enabled
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Component: Fetch Download Client Categories API
|
||||
* Documentation: documentation/phase3/download-clients.md
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { getConfigService } from '@/lib/services/config.service';
|
||||
import { getDownloadClientManager, DownloadClientConfig } from '@/lib/services/download-client-manager.service';
|
||||
import { SUPPORTED_CLIENT_TYPES } from '@/lib/interfaces/download-client.interface';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
|
||||
const logger = RMABLogger.create('API.Admin.Settings.DownloadClients.Categories');
|
||||
|
||||
/**
|
||||
* POST - Fetch categories from a download client
|
||||
* Accepts same connection config as the test endpoint
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
return requireAdmin(req, async () => {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const {
|
||||
clientId,
|
||||
type,
|
||||
name: clientName,
|
||||
url,
|
||||
username,
|
||||
password,
|
||||
disableSSLVerify,
|
||||
remotePathMappingEnabled,
|
||||
remotePath,
|
||||
localPath,
|
||||
} = body;
|
||||
|
||||
if (!SUPPORTED_CLIENT_TYPES.includes(type)) {
|
||||
return NextResponse.json(
|
||||
{ error: `Invalid client type. Must be one of: ${SUPPORTED_CLIENT_TYPES.join(', ')}` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!url) {
|
||||
return NextResponse.json(
|
||||
{ error: 'URL is required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const config = await getConfigService();
|
||||
const manager = getDownloadClientManager(config);
|
||||
|
||||
// If editing and password not provided, use stored password
|
||||
let effectivePassword = password;
|
||||
let effectiveUsername = username;
|
||||
|
||||
if (clientId && !password) {
|
||||
const existingClients = await manager.getAllClients();
|
||||
const existingClient = existingClients.find(c => c.id === clientId);
|
||||
|
||||
if (!existingClient) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Client not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
effectivePassword = existingClient.password;
|
||||
if (!username && existingClient.username) {
|
||||
effectiveUsername = existingClient.username;
|
||||
}
|
||||
}
|
||||
|
||||
const testConfig: DownloadClientConfig = {
|
||||
id: 'categories-fetch',
|
||||
type,
|
||||
name: clientName || type,
|
||||
enabled: true,
|
||||
url,
|
||||
username: effectiveUsername || '',
|
||||
password: effectivePassword || '',
|
||||
disableSSLVerify: disableSSLVerify || false,
|
||||
remotePathMappingEnabled: remotePathMappingEnabled || false,
|
||||
remotePath: remotePath || undefined,
|
||||
localPath: localPath || undefined,
|
||||
category: 'readmeabook',
|
||||
};
|
||||
|
||||
const service = await manager.createClientFromConfig(testConfig);
|
||||
const categories = await service.getCategories();
|
||||
|
||||
return NextResponse.json({ success: true, categories });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
logger.error('Failed to fetch categories', { error: message });
|
||||
return NextResponse.json(
|
||||
{ success: false, error: message },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -63,6 +63,7 @@ export async function POST(request: NextRequest) {
|
||||
localPath,
|
||||
category,
|
||||
customPath,
|
||||
postImportCategory,
|
||||
} = body;
|
||||
|
||||
// Validate type
|
||||
@@ -138,6 +139,7 @@ export async function POST(request: NextRequest) {
|
||||
localPath: localPath || undefined,
|
||||
category: category || 'readmeabook',
|
||||
customPath: customPath || undefined,
|
||||
postImportCategory: postImportCategory || undefined,
|
||||
};
|
||||
|
||||
// Test connection before saving
|
||||
|
||||
@@ -86,7 +86,6 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
// Search Prowlarr for each group and combine results
|
||||
const prowlarr = await getProwlarrService();
|
||||
const searchQuery = title; // Title only - cast wide net
|
||||
const allResults = [];
|
||||
|
||||
for (let i = 0; i < groups.length; i++) {
|
||||
@@ -94,7 +93,7 @@ export async function POST(request: NextRequest) {
|
||||
logger.debug(`Searching group ${i + 1}/${groups.length}: ${getGroupDescription(group)}`);
|
||||
|
||||
try {
|
||||
const groupResults = await prowlarr.search(searchQuery, {
|
||||
const groupResults = await prowlarr.searchWithVariations(title, author, {
|
||||
categories: group.categories,
|
||||
indexerIds: group.indexerIds,
|
||||
maxResults: 100, // Limit per group
|
||||
|
||||
@@ -39,7 +39,8 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
// Validate new password length
|
||||
if (newPassword.length < 8) {
|
||||
const allowWeakPassword = process.env.ALLOW_WEAK_PASSWORD === 'true';
|
||||
if (!allowWeakPassword && newPassword.length < 8) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
|
||||
@@ -18,6 +18,9 @@ export async function GET() {
|
||||
// Check if local login is disabled via environment variable
|
||||
const localLoginDisabled = process.env.DISABLE_LOCAL_LOGIN === 'true';
|
||||
|
||||
// Check if weak passwords are allowed via environment variable
|
||||
const allowWeakPassword = process.env.ALLOW_WEAK_PASSWORD === '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');
|
||||
@@ -47,6 +50,7 @@ export async function GET() {
|
||||
hasLocalUsers,
|
||||
oidcProviderName: oidcEnabled ? oidcProviderName : null,
|
||||
localLoginDisabled,
|
||||
allowWeakPassword,
|
||||
automationEnabled,
|
||||
});
|
||||
} else {
|
||||
@@ -65,6 +69,7 @@ export async function GET() {
|
||||
hasLocalUsers,
|
||||
oidcProviderName: null,
|
||||
localLoginDisabled,
|
||||
allowWeakPassword,
|
||||
automationEnabled,
|
||||
});
|
||||
}
|
||||
@@ -72,6 +77,7 @@ export async function GET() {
|
||||
logger.error('Failed to fetch auth providers', { error: error instanceof Error ? error.message : String(error) });
|
||||
// Default to Plex mode if config can't be read
|
||||
const localLoginDisabled = process.env.DISABLE_LOCAL_LOGIN === 'true';
|
||||
const allowWeakPassword = process.env.ALLOW_WEAK_PASSWORD === 'true';
|
||||
return NextResponse.json({
|
||||
backendMode: 'plex',
|
||||
providers: ['plex'],
|
||||
@@ -79,6 +85,7 @@ export async function GET() {
|
||||
hasLocalUsers: false,
|
||||
oidcProviderName: null,
|
||||
localLoginDisabled,
|
||||
allowWeakPassword,
|
||||
automationEnabled: false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -9,6 +9,49 @@ import { RMABLogger } from '@/lib/utils/logger';
|
||||
|
||||
const logger = RMABLogger.create('API.BookDate.TestConnection');
|
||||
|
||||
// Fetch available Claude models from the Anthropic API
|
||||
async function fetchClaudeModels(apiKey: string): Promise<{ id: string; name: string }[]> {
|
||||
const allModels: { id: string; name: string }[] = [];
|
||||
let afterId: string | undefined;
|
||||
|
||||
// Paginate through all available models
|
||||
do {
|
||||
const params = new URLSearchParams({ limit: '1000' });
|
||||
if (afterId) {
|
||||
params.set('after_id', afterId);
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`https://api.anthropic.com/v1/models?${params.toString()}`,
|
||||
{
|
||||
headers: {
|
||||
'x-api-key': apiKey,
|
||||
'anthropic-version': '2023-06-01',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
logger.error('Claude API error', { error: errorText });
|
||||
throw new Error('Invalid Claude API key or connection failed');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
for (const model of data.data) {
|
||||
allModels.push({
|
||||
id: model.id,
|
||||
name: model.display_name || model.id,
|
||||
});
|
||||
}
|
||||
|
||||
afterId = data.has_more ? data.last_id : undefined;
|
||||
} while (afterId);
|
||||
|
||||
return allModels;
|
||||
}
|
||||
|
||||
// Helper functions for custom provider
|
||||
function isValidBaseUrl(url: string): boolean {
|
||||
try {
|
||||
@@ -141,32 +184,10 @@ async function authenticatedHandler(req: AuthenticatedRequest) {
|
||||
.sort((a: any, b: any) => a.name.localeCompare(b.name));
|
||||
|
||||
} else if (provider === 'claude') {
|
||||
// Claude: Hardcoded list (Anthropic doesn't have a public models API endpoint)
|
||||
models = [
|
||||
{ id: 'claude-sonnet-4-5-20250929', name: 'Claude Sonnet 4.5 (Latest)' },
|
||||
{ id: 'claude-3-7-sonnet-20250219', name: 'Claude 3.7 Sonnet' },
|
||||
{ id: 'claude-opus-4-20250514', name: 'Claude Opus 4' },
|
||||
{ id: 'claude-3-5-haiku-20241022', name: 'Claude 3.5 Haiku' },
|
||||
];
|
||||
|
||||
// Test connection with a simple API call
|
||||
const response = await fetch('https://api.anthropic.com/v1/messages', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'x-api-key': testApiKey,
|
||||
'anthropic-version': '2023-06-01',
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'claude-3-5-haiku-20241022',
|
||||
max_tokens: 10,
|
||||
messages: [{ role: 'user', content: 'Test' }],
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
logger.error('Claude API error', { error: errorText });
|
||||
// Claude: Fetch models dynamically from the Anthropic Models API
|
||||
try {
|
||||
models = await fetchClaudeModels(testApiKey);
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid Claude API key or connection failed' },
|
||||
{ status: 400 }
|
||||
@@ -333,32 +354,10 @@ async function unauthenticatedHandler(req: NextRequest) {
|
||||
.sort((a: any, b: any) => a.name.localeCompare(b.name));
|
||||
|
||||
} else if (provider === 'claude') {
|
||||
// Claude: Hardcoded list (Anthropic doesn't have a public models API endpoint)
|
||||
models = [
|
||||
{ id: 'claude-sonnet-4-5-20250929', name: 'Claude Sonnet 4.5 (Latest)' },
|
||||
{ id: 'claude-3-7-sonnet-20250219', name: 'Claude 3.7 Sonnet' },
|
||||
{ id: 'claude-opus-4-20250514', name: 'Claude Opus 4' },
|
||||
{ id: 'claude-3-5-haiku-20241022', name: 'Claude 3.5 Haiku' },
|
||||
];
|
||||
|
||||
// Test connection with a simple API call
|
||||
const response = await fetch('https://api.anthropic.com/v1/messages', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'x-api-key': apiKey,
|
||||
'anthropic-version': '2023-06-01',
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'claude-3-5-haiku-20241022',
|
||||
max_tokens: 10,
|
||||
messages: [{ role: 'user', content: 'Test' }],
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
logger.error('Claude API error', { error: errorText });
|
||||
// Claude: Fetch models dynamically from the Anthropic Models API
|
||||
try {
|
||||
models = await fetchClaudeModels(apiKey);
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid Claude API key or connection failed' },
|
||||
{ status: 400 }
|
||||
|
||||
@@ -8,6 +8,7 @@ import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { getProwlarrService } from '@/lib/integrations/prowlarr.service';
|
||||
import { rankTorrents } from '@/lib/utils/ranking-algorithm';
|
||||
import { groupIndexersByCategories, getGroupDescription } from '@/lib/utils/indexer-grouping';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import { resolveInteractiveSearchAccess } from '@/lib/utils/permissions';
|
||||
|
||||
@@ -97,9 +98,8 @@ export async function POST(
|
||||
}
|
||||
|
||||
const indexersConfig = JSON.parse(indexersConfigStr);
|
||||
const enabledIndexerIds = indexersConfig.map((indexer: any) => indexer.id);
|
||||
|
||||
if (enabledIndexerIds.length === 0) {
|
||||
if (indexersConfig.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'ConfigError', message: 'No indexers enabled. Please enable at least one indexer in settings.' },
|
||||
{ status: 400 }
|
||||
@@ -115,22 +115,53 @@ export async function POST(
|
||||
const flagConfigStr = await configService.get('indexer_flag_config');
|
||||
const flagConfigs = flagConfigStr ? JSON.parse(flagConfigStr) : [];
|
||||
|
||||
// Search Prowlarr for torrents - ONLY enabled indexers
|
||||
const prowlarr = await getProwlarrService();
|
||||
// Use custom title if provided, otherwise use audiobook's title
|
||||
const searchQuery = customTitle || requestRecord.audiobook.title;
|
||||
// Group indexers by their category configuration
|
||||
const { groups, skippedIndexers } = groupIndexersByCategories(indexersConfig);
|
||||
|
||||
logger.info(`Searching ${enabledIndexerIds.length} enabled indexers`, { searchQuery });
|
||||
if (skippedIndexers.length > 0) {
|
||||
const skippedNames = skippedIndexers.map(idx => idx.name).join(', ');
|
||||
logger.info(`Skipping ${skippedIndexers.length} indexer(s) with no audiobook categories: ${skippedNames}`);
|
||||
}
|
||||
|
||||
// Use custom title if provided, otherwise use audiobook's title
|
||||
const searchTitle = customTitle || requestRecord.audiobook.title;
|
||||
const searchAuthor = requestRecord.audiobook.author;
|
||||
|
||||
logger.info(`Searching ${indexersConfig.length - skippedIndexers.length} enabled indexers in ${groups.length} group${groups.length > 1 ? 's' : ''}`, { searchTitle });
|
||||
if (customTitle) {
|
||||
logger.debug('Using custom search title', { customTitle, originalTitle: requestRecord.audiobook.title });
|
||||
}
|
||||
|
||||
const results = await prowlarr.search(searchQuery, {
|
||||
indexerIds: enabledIndexerIds,
|
||||
maxResults: 100, // Increased limit for broader search
|
||||
// Log each group for transparency
|
||||
groups.forEach((group, index) => {
|
||||
logger.debug(`Group ${index + 1}: ${getGroupDescription(group)}`);
|
||||
});
|
||||
|
||||
logger.debug(`Found ${results.length} raw results`, { requestId: id });
|
||||
// Search Prowlarr for each group and combine results
|
||||
const prowlarr = await getProwlarrService();
|
||||
const allResults = [];
|
||||
|
||||
for (let i = 0; i < groups.length; i++) {
|
||||
const group = groups[i];
|
||||
logger.debug(`Searching group ${i + 1}/${groups.length}: ${getGroupDescription(group)}`);
|
||||
|
||||
try {
|
||||
const groupResults = await prowlarr.searchWithVariations(searchTitle, searchAuthor, {
|
||||
categories: group.categories,
|
||||
indexerIds: group.indexerIds,
|
||||
maxResults: 100,
|
||||
});
|
||||
|
||||
logger.debug(`Group ${i + 1} returned ${groupResults.length} results`);
|
||||
allResults.push(...groupResults);
|
||||
} catch (error) {
|
||||
logger.error(`Group ${i + 1} search failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
// Continue with other groups even if one fails
|
||||
}
|
||||
}
|
||||
|
||||
const results = allResults;
|
||||
logger.info(`Found ${results.length} total results from ${groups.length} group${groups.length > 1 ? 's' : ''}`, { requestId: id });
|
||||
|
||||
if (results.length === 0) {
|
||||
return NextResponse.json({
|
||||
@@ -140,12 +171,31 @@ export async function POST(
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch runtime from Audnexus if ASIN available (for size-based scoring)
|
||||
let durationMinutes: number | undefined;
|
||||
if (requestRecord.audiobook.audibleAsin) {
|
||||
try {
|
||||
const { getAudibleService } = await import('@/lib/integrations/audible.service');
|
||||
const audibleService = getAudibleService();
|
||||
const runtime = await audibleService.getRuntime(requestRecord.audiobook.audibleAsin);
|
||||
if (runtime) {
|
||||
durationMinutes = runtime;
|
||||
logger.info(`Fetched runtime: ${runtime} minutes for ASIN ${requestRecord.audiobook.audibleAsin}`);
|
||||
} else {
|
||||
logger.debug(`No runtime found for ASIN ${requestRecord.audiobook.audibleAsin}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.debug(`Failed to fetch runtime for ASIN ${requestRecord.audiobook.audibleAsin}: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Rank torrents using the ranking algorithm with indexer priorities and flag configs
|
||||
// Always use the audiobook's title/author for ranking (not custom search query)
|
||||
// requireAuthor: false - interactive mode, show all results for user decision
|
||||
const rankedResults = rankTorrents(results, {
|
||||
title: requestRecord.audiobook.title,
|
||||
author: requestRecord.audiobook.author,
|
||||
durationMinutes,
|
||||
}, {
|
||||
indexerPriorities,
|
||||
flagConfigs,
|
||||
@@ -160,17 +210,23 @@ export async function POST(
|
||||
const top3 = rankedResults.slice(0, 3);
|
||||
if (top3.length > 0) {
|
||||
logger.debug('==================== RANKING DEBUG ====================');
|
||||
logger.debug('Search parameters', { searchQuery, requestedTitle: requestRecord.audiobook.title, requestedAuthor: requestRecord.audiobook.author });
|
||||
logger.debug('Search parameters', { searchTitle, requestedTitle: requestRecord.audiobook.title, requestedAuthor: requestRecord.audiobook.author });
|
||||
logger.debug(`Top ${top3.length} results (out of ${rankedResults.length} total)`);
|
||||
logger.debug('--------------------------------------------------------');
|
||||
top3.forEach((result, index) => {
|
||||
const sizeMB = (result.size / (1024 * 1024)).toFixed(1);
|
||||
const mbPerMin = durationMinutes ? ((result.size / (1024 * 1024)) / durationMinutes).toFixed(2) : 'N/A';
|
||||
|
||||
logger.debug(`${index + 1}. "${result.title}"`, {
|
||||
indexer: result.indexer,
|
||||
indexerId: result.indexerId,
|
||||
baseScore: `${result.score.toFixed(1)}/100`,
|
||||
matchScore: `${result.breakdown.matchScore.toFixed(1)}/60`,
|
||||
formatScore: `${result.breakdown.formatScore.toFixed(1)}/25 (${result.format || 'unknown'})`,
|
||||
seederScore: `${result.breakdown.seederScore.toFixed(1)}/15 (${result.seeders} seeders)`,
|
||||
formatScore: `${result.breakdown.formatScore.toFixed(1)}/10 (${result.format || 'unknown'})`,
|
||||
sizeScore: durationMinutes
|
||||
? `${result.breakdown.sizeScore.toFixed(1)}/15 (${sizeMB} MB, ${mbPerMin} MB/min)`
|
||||
: 'N/A (no runtime)',
|
||||
seederScore: `${result.breakdown.seederScore.toFixed(1)}/15 (${result.seeders !== undefined ? result.seeders + ' seeders' : 'N/A for Usenet'})`,
|
||||
bonusPoints: `+${result.bonusPoints.toFixed(1)}`,
|
||||
bonusModifiers: result.bonusModifiers.map(mod => `${mod.reason}: +${mod.points.toFixed(1)}`),
|
||||
finalScore: result.finalScore.toFixed(1),
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Component: Setup Wizard Download Client Categories API
|
||||
* Documentation: documentation/setup-wizard.md
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getConfigService } from '@/lib/services/config.service';
|
||||
import { getDownloadClientManager, DownloadClientConfig } from '@/lib/services/download-client-manager.service';
|
||||
import { SUPPORTED_CLIENT_TYPES } from '@/lib/interfaces/download-client.interface';
|
||||
import { requireSetupIncomplete } from '@/lib/middleware/auth';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
|
||||
const logger = RMABLogger.create('API.Setup.DownloadClientCategories');
|
||||
|
||||
/**
|
||||
* POST - Fetch categories from a download client during setup wizard
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
return requireSetupIncomplete(request, async (req) => {
|
||||
try {
|
||||
const { type, name, url, username, password, disableSSLVerify } = await req.json();
|
||||
|
||||
if (!type || !url) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Type and URL are required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!SUPPORTED_CLIENT_TYPES.includes(type)) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: `Invalid client type. Must be one of: ${SUPPORTED_CLIENT_TYPES.join(', ')}` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const testConfig: DownloadClientConfig = {
|
||||
id: 'setup-categories',
|
||||
type,
|
||||
name: name || type,
|
||||
enabled: true,
|
||||
url,
|
||||
username: username || '',
|
||||
password: password || '',
|
||||
disableSSLVerify: disableSSLVerify || false,
|
||||
remotePathMappingEnabled: false,
|
||||
};
|
||||
|
||||
const configService = getConfigService();
|
||||
const manager = getDownloadClientManager(configService);
|
||||
const service = await manager.createClientFromConfig(testConfig);
|
||||
const categories = await service.getCategories();
|
||||
|
||||
return NextResponse.json({ success: true, categories });
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch categories', { error: error instanceof Error ? error.message : String(error) });
|
||||
return NextResponse.json(
|
||||
{ success: false, error: error instanceof Error ? error.message : 'Failed to fetch categories' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -38,6 +38,7 @@ function LoginContent() {
|
||||
hasLocalUsers: boolean;
|
||||
oidcProviderName: string | null;
|
||||
localLoginDisabled: boolean;
|
||||
allowWeakPassword: boolean;
|
||||
automationEnabled: boolean;
|
||||
} | null>(null);
|
||||
const [showRegisterForm, setShowRegisterForm] = useState(false);
|
||||
@@ -78,6 +79,7 @@ function LoginContent() {
|
||||
hasLocalUsers: false,
|
||||
oidcProviderName: null,
|
||||
localLoginDisabled: false,
|
||||
allowWeakPassword: false,
|
||||
automationEnabled: false,
|
||||
});
|
||||
}
|
||||
@@ -345,7 +347,7 @@ function LoginContent() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (registerPassword.length < 8) {
|
||||
if (!authProviders?.allowWeakPassword && registerPassword.length < 8) {
|
||||
setError('Password must be at least 8 characters');
|
||||
setIsLoggingIn(false);
|
||||
return;
|
||||
@@ -639,10 +641,12 @@ function LoginContent() {
|
||||
className="w-full px-4 py-3 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
minLength={8}
|
||||
minLength={authProviders?.allowWeakPassword ? 1 : 8}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">At least 8 characters</p>
|
||||
{!authProviders?.allowWeakPassword && (
|
||||
<p className="text-xs text-gray-500 mt-1">At least 8 characters</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="register-confirm-password" className="block text-sm font-medium text-gray-300 mb-2">
|
||||
@@ -656,7 +660,7 @@ function LoginContent() {
|
||||
className="w-full px-4 py-3 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
minLength={8}
|
||||
minLength={authProviders?.allowWeakPassword ? 1 : 8}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -27,7 +27,13 @@ import { AudibleRegion } from '@/lib/types/audible';
|
||||
interface SelectedIndexer {
|
||||
id: number;
|
||||
name: string;
|
||||
protocol: string;
|
||||
priority: number;
|
||||
seedingTimeMinutes?: number;
|
||||
removeAfterProcessing?: boolean;
|
||||
rssEnabled: boolean;
|
||||
audiobookCategories: number[];
|
||||
ebookCategories: number[];
|
||||
}
|
||||
|
||||
interface SetupState {
|
||||
@@ -86,6 +92,14 @@ interface SetupState {
|
||||
bookdateApiKey: string;
|
||||
bookdateModel: string;
|
||||
bookdateConfigured: boolean;
|
||||
|
||||
// Cached UI state for back-navigation persistence
|
||||
plexLibraries: { id: string; title: string; type: string }[];
|
||||
absLibraries: { id: string; name: string; itemCount: number }[];
|
||||
oidcTested: boolean;
|
||||
pathsTested: boolean;
|
||||
bookdateModels: { id: string; name: string }[];
|
||||
|
||||
validated: {
|
||||
plex: boolean;
|
||||
prowlarr: boolean;
|
||||
@@ -152,6 +166,14 @@ export default function SetupWizard() {
|
||||
bookdateApiKey: '',
|
||||
bookdateModel: '',
|
||||
bookdateConfigured: false,
|
||||
|
||||
// Cached UI state for back-navigation persistence
|
||||
plexLibraries: [],
|
||||
absLibraries: [],
|
||||
oidcTested: false,
|
||||
pathsTested: false,
|
||||
bookdateModels: [],
|
||||
|
||||
validated: {
|
||||
plex: false,
|
||||
prowlarr: false,
|
||||
@@ -379,6 +401,7 @@ export default function SetupWizard() {
|
||||
plexToken={state.plexToken}
|
||||
plexLibraryId={state.plexLibraryId}
|
||||
plexTriggerScanAfterImport={state.plexTriggerScanAfterImport}
|
||||
plexLibraries={state.plexLibraries}
|
||||
onUpdate={updateField}
|
||||
onNext={() => goToStep(currentStepNumber + 1)}
|
||||
onBack={() => goToStep(currentStepNumber - 1)}
|
||||
@@ -397,6 +420,7 @@ export default function SetupWizard() {
|
||||
absApiToken={state.absApiToken}
|
||||
absLibraryId={state.absLibraryId}
|
||||
absTriggerScanAfterImport={state.absTriggerScanAfterImport}
|
||||
absLibraries={state.absLibraries}
|
||||
onUpdate={updateField}
|
||||
onNext={() => goToStep(currentStepNumber + 1)}
|
||||
onBack={() => goToStep(currentStepNumber - 1)}
|
||||
@@ -435,6 +459,7 @@ export default function SetupWizard() {
|
||||
oidcAdminClaimEnabled={state.oidcAdminClaimEnabled}
|
||||
oidcAdminClaimName={state.oidcAdminClaimName}
|
||||
oidcAdminClaimValue={state.oidcAdminClaimValue}
|
||||
oidcTested={state.oidcTested}
|
||||
onUpdate={updateField}
|
||||
onNext={() => goToStep(currentStepNumber + 1)}
|
||||
onBack={() => goToStep(currentStepNumber - 1)}
|
||||
@@ -482,6 +507,7 @@ export default function SetupWizard() {
|
||||
<ProwlarrStep
|
||||
prowlarrUrl={state.prowlarrUrl}
|
||||
prowlarrApiKey={state.prowlarrApiKey}
|
||||
prowlarrIndexers={state.prowlarrIndexers}
|
||||
onUpdate={updateField}
|
||||
onNext={() => goToStep(currentStepNumber + 1)}
|
||||
onBack={() => goToStep(currentStepNumber - 1)}
|
||||
@@ -512,6 +538,7 @@ export default function SetupWizard() {
|
||||
mediaDir={state.mediaDir}
|
||||
metadataTaggingEnabled={state.metadataTaggingEnabled}
|
||||
chapterMergingEnabled={state.chapterMergingEnabled}
|
||||
pathsTested={state.pathsTested}
|
||||
onUpdate={updateField}
|
||||
onNext={() => goToStep(currentStepNumber + 1)}
|
||||
onBack={() => goToStep(currentStepNumber - 1)}
|
||||
@@ -528,6 +555,7 @@ export default function SetupWizard() {
|
||||
bookdateApiKey={state.bookdateApiKey}
|
||||
bookdateModel={state.bookdateModel}
|
||||
bookdateConfigured={state.bookdateConfigured}
|
||||
bookdateModels={state.bookdateModels}
|
||||
onUpdate={updateField}
|
||||
onNext={() => goToStep(currentStepNumber + 1)}
|
||||
onSkip={() => goToStep(currentStepNumber + 1)}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
|
||||
interface AdminAccountStepProps {
|
||||
@@ -25,6 +25,23 @@ export function AdminAccountStep({
|
||||
}: AdminAccountStepProps) {
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [errors, setErrors] = useState<{ username?: string; password?: string; confirm?: string }>({});
|
||||
const [allowWeakPassword, setAllowWeakPassword] = useState(false);
|
||||
|
||||
// Fetch password policy
|
||||
useEffect(() => {
|
||||
const fetchPolicy = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/auth/providers');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setAllowWeakPassword(data.allowWeakPassword === true);
|
||||
}
|
||||
} catch {
|
||||
// Default to strict validation on error
|
||||
}
|
||||
};
|
||||
fetchPolicy();
|
||||
}, []);
|
||||
|
||||
const validate = () => {
|
||||
const newErrors: { username?: string; password?: string; confirm?: string } = {};
|
||||
@@ -35,7 +52,9 @@ export function AdminAccountStep({
|
||||
}
|
||||
|
||||
// Validate password
|
||||
if (!adminPassword || adminPassword.length < 8) {
|
||||
if (!adminPassword) {
|
||||
newErrors.password = 'Password is required';
|
||||
} else if (!allowWeakPassword && adminPassword.length < 8) {
|
||||
newErrors.password = 'Password must be at least 8 characters';
|
||||
}
|
||||
|
||||
@@ -104,7 +123,7 @@ export function AdminAccountStep({
|
||||
<p className="mt-1 text-sm text-red-400">{errors.password}</p>
|
||||
)}
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Choose a strong password (minimum 8 characters)
|
||||
{allowWeakPassword ? 'Choose a password' : 'Choose a strong password (minimum 8 characters)'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -14,7 +14,8 @@ interface AudiobookshelfStepProps {
|
||||
absApiToken: string;
|
||||
absLibraryId: string;
|
||||
absTriggerScanAfterImport: boolean;
|
||||
onUpdate: (field: string, value: string | boolean) => void;
|
||||
absLibraries: Library[];
|
||||
onUpdate: (field: string, value: any) => void;
|
||||
onNext: () => void;
|
||||
onBack: () => void;
|
||||
}
|
||||
@@ -30,6 +31,7 @@ export function AudiobookshelfStep({
|
||||
absApiToken,
|
||||
absLibraryId,
|
||||
absTriggerScanAfterImport,
|
||||
absLibraries,
|
||||
onUpdate,
|
||||
onNext,
|
||||
onBack,
|
||||
@@ -39,8 +41,12 @@ export function AudiobookshelfStep({
|
||||
success: boolean;
|
||||
message?: string;
|
||||
libraries?: Library[];
|
||||
} | null>(null);
|
||||
const [libraries, setLibraries] = useState<Library[]>([]);
|
||||
} | null>(
|
||||
absLibraries.length > 0
|
||||
? { success: true, message: 'Connection verified previously.' }
|
||||
: null
|
||||
);
|
||||
const [libraries, setLibraries] = useState<Library[]>(absLibraries);
|
||||
|
||||
const testConnection = async () => {
|
||||
setTesting(true);
|
||||
@@ -56,12 +62,14 @@ export function AudiobookshelfStep({
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok && data.success) {
|
||||
const libs = data.libraries || [];
|
||||
setTestResult({
|
||||
success: true,
|
||||
message: 'Connection successful!',
|
||||
libraries: data.libraries || [],
|
||||
libraries: libs,
|
||||
});
|
||||
setLibraries(data.libraries || []);
|
||||
setLibraries(libs);
|
||||
onUpdate('absLibraries', libs);
|
||||
} else {
|
||||
setTestResult({
|
||||
success: false,
|
||||
|
||||
@@ -12,6 +12,7 @@ interface BookDateStepProps {
|
||||
bookdateApiKey: string;
|
||||
bookdateModel: string;
|
||||
bookdateConfigured: boolean;
|
||||
bookdateModels: ModelOption[];
|
||||
onUpdate: (field: string, value: any) => void;
|
||||
onNext: () => void;
|
||||
onSkip: () => void;
|
||||
@@ -28,6 +29,7 @@ export function BookDateStep({
|
||||
bookdateApiKey,
|
||||
bookdateModel,
|
||||
bookdateConfigured,
|
||||
bookdateModels,
|
||||
onUpdate,
|
||||
onNext,
|
||||
onSkip,
|
||||
@@ -35,7 +37,7 @@ export function BookDateStep({
|
||||
}: BookDateStepProps) {
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [tested, setTested] = useState(bookdateConfigured);
|
||||
const [models, setModels] = useState<ModelOption[]>([]);
|
||||
const [models, setModels] = useState<ModelOption[]>(bookdateModels);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleTestConnection = async () => {
|
||||
@@ -65,19 +67,22 @@ export function BookDateStep({
|
||||
throw new Error(data.error || 'Connection test failed');
|
||||
}
|
||||
|
||||
setModels(data.models || []);
|
||||
const fetchedModels = data.models || [];
|
||||
setModels(fetchedModels);
|
||||
setTested(true);
|
||||
onUpdate('bookdateConfigured', true);
|
||||
onUpdate('bookdateModels', fetchedModels);
|
||||
|
||||
// Auto-select first model if none selected
|
||||
if (!bookdateModel && data.models?.length > 0) {
|
||||
onUpdate('bookdateModel', data.models[0].id);
|
||||
if (!bookdateModel && fetchedModels.length > 0) {
|
||||
onUpdate('bookdateModel', fetchedModels[0].id);
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Connection test failed');
|
||||
setTested(false);
|
||||
onUpdate('bookdateConfigured', false);
|
||||
onUpdate('bookdateModels', []);
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
@@ -123,6 +128,7 @@ export function BookDateStep({
|
||||
setTested(false);
|
||||
setModels([]);
|
||||
onUpdate('bookdateConfigured', false);
|
||||
onUpdate('bookdateModels', []);
|
||||
}}
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
@@ -144,6 +150,7 @@ export function BookDateStep({
|
||||
setTested(false);
|
||||
setModels([]);
|
||||
onUpdate('bookdateConfigured', false);
|
||||
onUpdate('bookdateModels', []);
|
||||
}}
|
||||
placeholder={bookdateProvider === 'openai' ? 'sk-...' : 'sk-ant-...'}
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500"
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { DownloadClientManagement } from '@/components/admin/download-clients/DownloadClientManagement';
|
||||
import { DownloadClientType } from '@/lib/interfaces/download-client.interface';
|
||||
@@ -24,6 +24,7 @@ interface DownloadClient {
|
||||
localPath?: string;
|
||||
category?: string;
|
||||
customPath?: string;
|
||||
postImportCategory?: string;
|
||||
}
|
||||
|
||||
interface DownloadClientStepProps {
|
||||
|
||||
@@ -22,6 +22,7 @@ interface OIDCConfigStepProps {
|
||||
oidcAdminClaimEnabled: boolean;
|
||||
oidcAdminClaimName: string;
|
||||
oidcAdminClaimValue: string;
|
||||
oidcTested: boolean;
|
||||
onUpdate: (field: string, value: any) => void;
|
||||
onNext: () => void;
|
||||
onBack: () => void;
|
||||
@@ -40,6 +41,7 @@ export function OIDCConfigStep({
|
||||
oidcAdminClaimEnabled,
|
||||
oidcAdminClaimName,
|
||||
oidcAdminClaimValue,
|
||||
oidcTested,
|
||||
onUpdate,
|
||||
onNext,
|
||||
onBack,
|
||||
@@ -48,7 +50,11 @@ export function OIDCConfigStep({
|
||||
const [testResult, setTestResult] = useState<{
|
||||
success: boolean;
|
||||
message: string;
|
||||
} | null>(null);
|
||||
} | null>(
|
||||
oidcTested
|
||||
? { success: true, message: 'OIDC configuration verified previously.' }
|
||||
: null
|
||||
);
|
||||
|
||||
const testConnection = async () => {
|
||||
setTesting(true);
|
||||
@@ -72,17 +78,20 @@ export function OIDCConfigStep({
|
||||
success: true,
|
||||
message: 'OIDC discovery successful! Provider configuration validated.',
|
||||
});
|
||||
onUpdate('oidcTested', true);
|
||||
} else {
|
||||
setTestResult({
|
||||
success: false,
|
||||
message: data.error || 'OIDC discovery failed',
|
||||
});
|
||||
onUpdate('oidcTested', false);
|
||||
}
|
||||
} catch (error) {
|
||||
setTestResult({
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : 'Connection test failed',
|
||||
});
|
||||
onUpdate('oidcTested', false);
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
|
||||
@@ -14,7 +14,8 @@ interface PathsStepProps {
|
||||
mediaDir: string;
|
||||
metadataTaggingEnabled: boolean;
|
||||
chapterMergingEnabled: boolean;
|
||||
onUpdate: (field: string, value: string | boolean) => void;
|
||||
pathsTested: boolean;
|
||||
onUpdate: (field: string, value: any) => void;
|
||||
onNext: () => void;
|
||||
onBack: () => void;
|
||||
}
|
||||
@@ -24,6 +25,7 @@ export function PathsStep({
|
||||
mediaDir,
|
||||
metadataTaggingEnabled,
|
||||
chapterMergingEnabled,
|
||||
pathsTested,
|
||||
onUpdate,
|
||||
onNext,
|
||||
onBack,
|
||||
@@ -34,7 +36,11 @@ export function PathsStep({
|
||||
message: string;
|
||||
downloadDirValid?: boolean;
|
||||
mediaDirValid?: boolean;
|
||||
} | null>(null);
|
||||
} | null>(
|
||||
pathsTested
|
||||
? { success: true, message: 'Paths validated previously.', downloadDirValid: true, mediaDirValid: true }
|
||||
: null
|
||||
);
|
||||
|
||||
const testPaths = async () => {
|
||||
setTesting(true);
|
||||
@@ -59,6 +65,7 @@ export function PathsStep({
|
||||
downloadDirValid: data.downloadDirValid,
|
||||
mediaDirValid: data.mediaDirValid,
|
||||
});
|
||||
onUpdate('pathsTested', true);
|
||||
} else {
|
||||
setTestResult({
|
||||
success: false,
|
||||
@@ -66,12 +73,14 @@ export function PathsStep({
|
||||
downloadDirValid: data.downloadDirValid,
|
||||
mediaDirValid: data.mediaDirValid,
|
||||
});
|
||||
onUpdate('pathsTested', false);
|
||||
}
|
||||
} catch (error) {
|
||||
setTestResult({
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : 'Path validation failed',
|
||||
});
|
||||
onUpdate('pathsTested', false);
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
|
||||
@@ -14,7 +14,8 @@ interface PlexStepProps {
|
||||
plexToken: string;
|
||||
plexLibraryId: string;
|
||||
plexTriggerScanAfterImport: boolean;
|
||||
onUpdate: (field: string, value: string | boolean) => void;
|
||||
plexLibraries: PlexLibrary[];
|
||||
onUpdate: (field: string, value: any) => void;
|
||||
onNext: () => void;
|
||||
onBack: () => void;
|
||||
}
|
||||
@@ -30,6 +31,7 @@ export function PlexStep({
|
||||
plexToken,
|
||||
plexLibraryId,
|
||||
plexTriggerScanAfterImport,
|
||||
plexLibraries,
|
||||
onUpdate,
|
||||
onNext,
|
||||
onBack,
|
||||
@@ -39,8 +41,12 @@ export function PlexStep({
|
||||
success: boolean;
|
||||
message: string;
|
||||
libraries?: PlexLibrary[];
|
||||
} | null>(null);
|
||||
const [libraries, setLibraries] = useState<PlexLibrary[]>([]);
|
||||
} | null>(
|
||||
plexLibraries.length > 0
|
||||
? { success: true, message: 'Connection verified previously.' }
|
||||
: null
|
||||
);
|
||||
const [libraries, setLibraries] = useState<PlexLibrary[]>(plexLibraries);
|
||||
|
||||
const testConnection = async () => {
|
||||
setTesting(true);
|
||||
@@ -56,12 +62,14 @@ export function PlexStep({
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok && data.success) {
|
||||
const libs = data.libraries || [];
|
||||
setTestResult({
|
||||
success: true,
|
||||
message: `Connected to ${data.serverName || 'Plex server'} successfully!`,
|
||||
libraries: data.libraries || [],
|
||||
libraries: libs,
|
||||
});
|
||||
setLibraries(data.libraries || []);
|
||||
setLibraries(libs);
|
||||
onUpdate('plexLibraries', libs);
|
||||
} else {
|
||||
setTestResult({
|
||||
success: false,
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { IndexerManagement } from '@/components/admin/indexers/IndexerManagement';
|
||||
@@ -13,6 +13,7 @@ import { IndexerManagement } from '@/components/admin/indexers/IndexerManagement
|
||||
interface ProwlarrStepProps {
|
||||
prowlarrUrl: string;
|
||||
prowlarrApiKey: string;
|
||||
prowlarrIndexers: SelectedIndexer[];
|
||||
onUpdate: (field: string, value: any) => void;
|
||||
onNext: () => void;
|
||||
onBack: () => void;
|
||||
@@ -33,17 +34,19 @@ interface SelectedIndexer {
|
||||
export function ProwlarrStep({
|
||||
prowlarrUrl,
|
||||
prowlarrApiKey,
|
||||
prowlarrIndexers,
|
||||
onUpdate,
|
||||
onNext,
|
||||
onBack,
|
||||
}: ProwlarrStepProps) {
|
||||
const [configuredIndexers, setConfiguredIndexers] = useState<SelectedIndexer[]>([]);
|
||||
const [configuredIndexers, setConfiguredIndexers] = useState<SelectedIndexer[]>(prowlarrIndexers);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
// Sync configured indexers with parent
|
||||
useEffect(() => {
|
||||
onUpdate('prowlarrIndexers', configuredIndexers);
|
||||
}, [configuredIndexers, onUpdate]);
|
||||
// Update both local and parent state when indexers change
|
||||
const handleIndexersChange = (indexers: SelectedIndexer[]) => {
|
||||
setConfiguredIndexers(indexers);
|
||||
onUpdate('prowlarrIndexers', indexers);
|
||||
};
|
||||
|
||||
const handleNext = () => {
|
||||
setErrorMessage(null);
|
||||
@@ -136,7 +139,7 @@ export function ProwlarrStep({
|
||||
prowlarrApiKey={prowlarrApiKey}
|
||||
mode="wizard"
|
||||
initialIndexers={configuredIndexers}
|
||||
onIndexersChange={setConfiguredIndexers}
|
||||
onIndexersChange={handleIndexersChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user