mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-02 20:30:10 +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:
@@ -75,7 +75,7 @@ docker-compose logs -f app
|
||||
## 📊 Feature Highlights
|
||||
|
||||
### AI-Powered Recommendations
|
||||
- **Providers:** OpenAI (GPT-4o+) or Claude (Sonnet 4.5, Opus 4, Haiku)
|
||||
- **Providers:** OpenAI (GPT-4+) or Claude (dynamically fetched from Anthropic Models API)
|
||||
- **Personalization:** Based on your Plex library + swipe history
|
||||
- **Context:** Max 50 books (40 library + 10 swipes)
|
||||
- **Filtering:** Excludes books already in library, already requested, or already swiped
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
# Notification System
|
||||
|
||||
**Status:** ✅ Implemented | Extensible notification system with Discord and Pushover support
|
||||
**Status:** ✅ Implemented | Extensible notification system with Discord, ntfy, and Pushover support
|
||||
|
||||
## Overview
|
||||
Sends notifications for audiobook request events (pending approval, approved, available, error) to configured backends. Non-blocking, atomic per-backend failure handling. Proper notification timing for all request flows including interactive search.
|
||||
|
||||
## Key Details
|
||||
- **Backends:** Discord (webhooks), Pushover (API)
|
||||
- **Backends:** Apprise (API), Discord (webhooks), ntfy (API), Pushover (API)
|
||||
- **Events:** request_pending_approval, request_approved, request_available, request_error
|
||||
- **Encryption:** AES-256-GCM for sensitive config (webhook URLs, API keys)
|
||||
- **Encryption:** AES-256-GCM for sensitive config (webhook URLs, API keys, notification URLs)
|
||||
- **Delivery:** Async via Bull job queue (priority 5)
|
||||
- **Failure Handling:** Non-blocking, Promise.allSettled (one backend fails, others succeed)
|
||||
|
||||
@@ -17,7 +17,7 @@ Sends notifications for audiobook request events (pending approval, approved, av
|
||||
```prisma
|
||||
model NotificationBackend {
|
||||
id String @id @default(uuid())
|
||||
type String // 'discord' | 'pushover'
|
||||
type String // 'apprise' | 'discord' | 'ntfy' | 'pushover'
|
||||
name String // User-friendly label
|
||||
config Json // Encrypted sensitive values
|
||||
events Json // Array of subscribed events
|
||||
@@ -70,7 +70,9 @@ model NotificationBackend {
|
||||
## Configuration Encryption
|
||||
|
||||
**Encrypted Values:**
|
||||
- Apprise: `urls`, `authToken`
|
||||
- Discord: `webhookUrl`
|
||||
- ntfy: `accessToken`
|
||||
- Pushover: `userKey`, `appToken`
|
||||
|
||||
**Pattern:** `iv:authTag:encryptedData` (base64)
|
||||
@@ -81,12 +83,26 @@ model NotificationBackend {
|
||||
|
||||
## Message Formatting
|
||||
|
||||
**Apprise (JSON via Apprise API):**
|
||||
- Type: info (pending), success (approved/available), failure (error)
|
||||
- Modes: Stateless (send URLs directly) or Stateful (use persistent configKey, optional tag filter)
|
||||
- Endpoint: `{serverUrl}/notify/` (stateless) or `{serverUrl}/notify/{configKey}` (stateful)
|
||||
- Auth: Optional Bearer token via `authToken` config field
|
||||
- Format: Event title + book details + user + error (if applicable)
|
||||
|
||||
**Discord (Rich Embeds):**
|
||||
- Color-coded by event (yellow=pending, green=approved, blue=available, red=error)
|
||||
- Fields: Title, Author, Requested By, Error (if applicable)
|
||||
- Footer: Request ID
|
||||
- Timestamp: Event time
|
||||
|
||||
**ntfy (JSON with Tags):**
|
||||
- Tags: mailbox_with_mail, white_check_mark, tada, x (rendered as emojis by ntfy)
|
||||
- Priority: Default (3) for pending/approved, High (4) for available/error
|
||||
- Format: Event title + book details + user + error (if applicable)
|
||||
- Auth: Optional Bearer token via `accessToken` config field
|
||||
- Server: Configurable `serverUrl` (default: https://ntfy.sh)
|
||||
|
||||
**Pushover (Plain Text with Emojis):**
|
||||
- Emojis: 📬 📬 🎉 ❌
|
||||
- Priority: Normal (0) for pending/approved, High (1) for available/error
|
||||
@@ -154,15 +170,49 @@ model NotificationBackend {
|
||||
|
||||
**Queue Method:** `addNotificationJob(event, requestId, title, author, userName, message?)`
|
||||
|
||||
## Architecture
|
||||
|
||||
**Provider Pattern:** `INotificationProvider` interface + registry (matches `IAuthProvider` pattern)
|
||||
|
||||
```
|
||||
src/lib/services/notification/
|
||||
INotificationProvider.ts # Interface + shared types
|
||||
notification.service.ts # Core service with registry
|
||||
index.ts # Re-exports
|
||||
providers/
|
||||
apprise.provider.ts # Apprise API (100+ services)
|
||||
discord.provider.ts # Discord webhook
|
||||
ntfy.provider.ts # ntfy API
|
||||
pushover.provider.ts # Pushover API
|
||||
```
|
||||
|
||||
**Registry:** Module-level `Map<string, INotificationProvider>` with `registerProvider()` / `getProvider()`
|
||||
|
||||
**INotificationProvider interface:**
|
||||
- `type: string` — provider identifier (registry key)
|
||||
- `sensitiveFields: string[]` — fields needing encryption/masking
|
||||
- `metadata: ProviderMetadata` — self-describing UI/validation metadata
|
||||
- `send(config, payload): Promise<void>` — receives decrypted config
|
||||
|
||||
**ProviderMetadata:** `{ type, displayName, description, iconLabel, iconColor, configFields[] }`
|
||||
**ProviderConfigField:** `{ name, label, type, required, placeholder?, defaultValue?, options? }`
|
||||
|
||||
**Helper functions:**
|
||||
- `getRegisteredProviderTypes(): string[]` — all registered type keys
|
||||
- `getAllProviderMetadata(): ProviderMetadata[]` — metadata for all providers
|
||||
|
||||
**API Endpoint:** `GET /api/admin/notifications/providers` — returns all provider metadata (admin-only)
|
||||
|
||||
## Extensibility
|
||||
|
||||
**Adding New Backend (e.g., Email):**
|
||||
1. Add 'email' to NotificationBackendType enum
|
||||
2. Create EmailConfig interface
|
||||
3. Add encryption logic for smtpPassword
|
||||
4. Implement sendEmail() method in NotificationService
|
||||
5. Add email card to type selector (green "E" badge)
|
||||
6. Add email form fields to modal
|
||||
**Adding New Backend (2 steps):**
|
||||
1. Create `providers/email.provider.ts` implementing `INotificationProvider`:
|
||||
- Set `type = 'email'`, `sensitiveFields = ['smtpPassword']`
|
||||
- Set `metadata` with displayName, description, iconLabel, iconColor, configFields
|
||||
- Implement `send()` with email-specific logic
|
||||
2. Register in `notification.service.ts`: `registerProvider(new EmailProvider())` + re-export from `index.ts`
|
||||
|
||||
No UI changes, no API route changes, no Zod schema changes needed — the UI renders dynamically from provider metadata.
|
||||
|
||||
**Adding New Event (e.g., download_complete):**
|
||||
1. Add 'download_complete' to NotificationEvent enum
|
||||
@@ -173,7 +223,7 @@ model NotificationBackend {
|
||||
## Tech Stack
|
||||
- Bull (job queue)
|
||||
- Node.js crypto (AES-256-GCM encryption)
|
||||
- Discord webhooks, Pushover API
|
||||
- Apprise API, Discord webhooks, ntfy API, Pushover API
|
||||
- React (UI), Tailwind CSS (styling)
|
||||
|
||||
## Related
|
||||
|
||||
@@ -200,32 +200,23 @@ export async function POST(req: NextRequest) {
|
||||
.map((m: any) => ({ id: m.id, name: m.id }));
|
||||
|
||||
} else if (provider === 'claude') {
|
||||
// Claude: Hardcoded list (Anthropic doesn't have a models API endpoint)
|
||||
models = [
|
||||
{ id: 'claude-sonnet-4-5-20250929', name: 'Claude Sonnet 4.5' },
|
||||
{ id: 'claude-3-7-sonnet-20250219', name: 'Claude 3.7 Sonnet' },
|
||||
{ id: 'claude-3-5-haiku-20241022', name: 'Claude 3.5 Haiku' },
|
||||
{ id: 'claude-opus-4-20250514', name: 'Claude Opus 4' },
|
||||
];
|
||||
|
||||
// Test connection with a simple API call
|
||||
const response = await fetch('https://api.anthropic.com/v1/messages', {
|
||||
method: 'POST',
|
||||
// Claude: Fetch models dynamically from the Anthropic Models API
|
||||
const response = await fetch('https://api.anthropic.com/v1/models?limit=1000', {
|
||||
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: 'Hi' }]
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return NextResponse.json({ error: 'Invalid Claude API key' }, { status: 400 });
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
models = data.data.map((m: any) => ({
|
||||
id: m.id,
|
||||
name: m.display_name || m.id,
|
||||
}));
|
||||
} else {
|
||||
return NextResponse.json({ error: 'Invalid provider' }, { status: 400 });
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
Personalized audiobook discovery using OpenAI/Claude APIs. Admin configures AI provider globally. Users swipe through recommendations based on their individual Plex library + swipe history. Right swipe creates request, left rejects, up dismisses.
|
||||
|
||||
## Key Details
|
||||
- **AI Providers:** OpenAI (GPT-4o+), Claude (Sonnet 4.5, Opus 4, Haiku)
|
||||
- **AI Providers:** OpenAI (GPT-4+), Claude (dynamically fetched from Anthropic Models API)
|
||||
- **Configuration:** Global admin-managed (provider, model, API key), per-user preferences (library scope, custom prompt)
|
||||
- **Personalization:** Each user receives recommendations based on their own library, ratings, swipe history, and custom preferences
|
||||
- **Library Scopes (per-user):**
|
||||
|
||||
@@ -208,7 +208,7 @@ async function organize(
|
||||
|
||||
## Fixed Issues ✅
|
||||
|
||||
**1. EPERM errors** - Fixed with `fs.readFile/writeFile` instead of `copyFile`
|
||||
**1. EPERM errors** - Fixed with stream-based copy (`pipeline` + `createReadStream`/`createWriteStream`) instead of `fs.copyFile()` which uses `copy_file_range()` — a syscall that returns EPERM on cross-export NFS4 and some FUSE mounts
|
||||
**2. Immediate deletion** - Changed to copy-only, scheduled cleanup after seeding
|
||||
**3. Files moved not copied** - Now copies to support seeding
|
||||
**4. Single file downloads** - Now supports files directly in downloads folder (not just directories)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -16,6 +16,7 @@ interface DownloadClientCardProps {
|
||||
url: string;
|
||||
enabled: boolean;
|
||||
customPath?: string;
|
||||
postImportCategory?: string;
|
||||
};
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
@@ -62,6 +63,11 @@ export function DownloadClientCard({ client, onEdit, onDelete }: DownloadClientC
|
||||
Path: {client.customPath}
|
||||
</p>
|
||||
)}
|
||||
{client.postImportCategory && (
|
||||
<p className="text-xs text-purple-600 dark:text-purple-400 truncate" title={`Post-import category: ${client.postImportCategory}`}>
|
||||
Post-import: {client.postImportCategory}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ interface DownloadClient {
|
||||
localPath?: string;
|
||||
category?: string;
|
||||
customPath?: string;
|
||||
postImportCategory?: string;
|
||||
}
|
||||
|
||||
interface DownloadClientManagementProps {
|
||||
@@ -72,20 +73,6 @@ export function DownloadClientManagement({
|
||||
}
|
||||
}, [downloadDirProp]);
|
||||
|
||||
// Sync with parent when clients change
|
||||
useEffect(() => {
|
||||
if (onClientsChange) {
|
||||
onClientsChange(clients);
|
||||
}
|
||||
}, [clients, onClientsChange]);
|
||||
|
||||
// Sync with initialClients prop changes (wizard mode)
|
||||
useEffect(() => {
|
||||
if (mode === 'wizard') {
|
||||
setClients(initialClients);
|
||||
}
|
||||
}, [initialClients, mode]);
|
||||
|
||||
const fetchClients = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
@@ -172,7 +159,9 @@ export function DownloadClientManagement({
|
||||
await fetchClients(); // Refresh list
|
||||
} else {
|
||||
// Local removal for wizard mode
|
||||
setClients(clients.filter(c => c.id !== deleteConfirm.clientId));
|
||||
const updated = clients.filter(c => c.id !== deleteConfirm.clientId);
|
||||
setClients(updated);
|
||||
onClientsChange?.(updated);
|
||||
}
|
||||
|
||||
setDeleteConfirm({ isOpen: false });
|
||||
@@ -219,15 +208,18 @@ export function DownloadClientManagement({
|
||||
}
|
||||
} else {
|
||||
// Local update for wizard mode
|
||||
let updated: DownloadClient[];
|
||||
if (modalState.mode === 'add') {
|
||||
const newClient = {
|
||||
...clientData,
|
||||
id: `temp-${Date.now()}`, // Temporary ID for wizard mode
|
||||
};
|
||||
setClients([...clients, newClient]);
|
||||
updated = [...clients, newClient];
|
||||
} else {
|
||||
setClients(clients.map(c => (c.id === clientData.id ? { ...c, ...clientData } : c)));
|
||||
updated = clients.map(c => (c.id === clientData.id ? { ...c, ...clientData } : c));
|
||||
}
|
||||
setClients(updated);
|
||||
onClientsChange?.(updated);
|
||||
}
|
||||
|
||||
setModalState({ isOpen: false, mode: 'add' });
|
||||
|
||||
@@ -10,7 +10,7 @@ import { Modal } from '@/components/ui/Modal';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { fetchWithAuth } from '@/lib/utils/api';
|
||||
import { DownloadClientType, getClientDisplayName } from '@/lib/interfaces/download-client.interface';
|
||||
import { DownloadClientType, getClientDisplayName, CLIENT_PROTOCOL_MAP } from '@/lib/interfaces/download-client.interface';
|
||||
|
||||
interface DownloadClientModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -31,6 +31,7 @@ interface DownloadClientModalProps {
|
||||
localPath?: string;
|
||||
category?: string;
|
||||
customPath?: string;
|
||||
postImportCategory?: string;
|
||||
};
|
||||
onSave: (client: any) => Promise<void>;
|
||||
apiMode: 'wizard' | 'settings';
|
||||
@@ -62,6 +63,9 @@ export function DownloadClientModal({
|
||||
const [localPath, setLocalPath] = useState('');
|
||||
const [category, setCategory] = useState('readmeabook');
|
||||
const [customPath, setCustomPath] = useState('');
|
||||
const [postImportCategory, setPostImportCategory] = useState('');
|
||||
const [availableCategories, setAvailableCategories] = useState<string[]>([]);
|
||||
const [fetchingCategories, setFetchingCategories] = useState(false);
|
||||
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -85,6 +89,7 @@ export function DownloadClientModal({
|
||||
setLocalPath(initialClient.localPath || '');
|
||||
setCategory(initialClient.category || 'readmeabook');
|
||||
setCustomPath(initialClient.customPath || '');
|
||||
setPostImportCategory(initialClient.postImportCategory || '');
|
||||
} else {
|
||||
// Add mode defaults
|
||||
setName(typeName);
|
||||
@@ -98,9 +103,12 @@ export function DownloadClientModal({
|
||||
setLocalPath('');
|
||||
setCategory('readmeabook');
|
||||
setCustomPath('');
|
||||
setPostImportCategory('');
|
||||
}
|
||||
setTestResult(null);
|
||||
setErrors({});
|
||||
setAvailableCategories([]);
|
||||
setFetchingCategories(false);
|
||||
}
|
||||
}, [isOpen, mode, initialClient, type]);
|
||||
|
||||
@@ -137,6 +145,50 @@ export function DownloadClientModal({
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const fetchCategories = async () => {
|
||||
setFetchingCategories(true);
|
||||
try {
|
||||
const isPasswordMasked = password === '********';
|
||||
const categoryData = {
|
||||
type,
|
||||
name,
|
||||
url,
|
||||
username: username || undefined,
|
||||
password: isPasswordMasked ? undefined : password,
|
||||
...(mode === 'edit' && initialClient && isPasswordMasked ? { clientId: initialClient.id } : {}),
|
||||
disableSSLVerify,
|
||||
remotePathMappingEnabled,
|
||||
remotePath: remotePathMappingEnabled ? remotePath : undefined,
|
||||
localPath: remotePathMappingEnabled ? localPath : undefined,
|
||||
};
|
||||
|
||||
const endpoint = apiMode === 'wizard'
|
||||
? '/api/setup/download-client-categories'
|
||||
: '/api/admin/settings/download-clients/categories';
|
||||
|
||||
const response = apiMode === 'wizard'
|
||||
? await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(categoryData),
|
||||
})
|
||||
: await fetchWithAuth(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(categoryData),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (response.ok && data.success) {
|
||||
setAvailableCategories(data.categories || []);
|
||||
}
|
||||
} catch {
|
||||
// Non-critical — categories are optional
|
||||
} finally {
|
||||
setFetchingCategories(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestConnection = async () => {
|
||||
if (!validate()) {
|
||||
return;
|
||||
@@ -187,6 +239,11 @@ export function DownloadClientModal({
|
||||
// Handle both endpoint response formats (settings returns message, wizard returns version)
|
||||
const message = data.message || (data.version ? `Connected successfully (v${data.version})` : 'Connection successful');
|
||||
setTestResult({ success: true, message });
|
||||
|
||||
// Fetch categories for torrent clients after successful connection
|
||||
if (type && CLIENT_PROTOCOL_MAP[type] === 'torrent') {
|
||||
fetchCategories();
|
||||
}
|
||||
} else {
|
||||
setTestResult({ success: false, message: data.error || 'Connection test failed' });
|
||||
}
|
||||
@@ -230,6 +287,7 @@ export function DownloadClientModal({
|
||||
localPath: remotePathMappingEnabled ? localPath : undefined,
|
||||
category,
|
||||
customPath: sanitizedCustomPath || undefined,
|
||||
postImportCategory,
|
||||
};
|
||||
|
||||
if (mode === 'edit' && initialClient) {
|
||||
@@ -384,6 +442,37 @@ export function DownloadClientModal({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Post-Import Category (torrent clients only) */}
|
||||
{type && CLIENT_PROTOCOL_MAP[type] === 'torrent' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Post-Import Category
|
||||
</label>
|
||||
{type === 'qbittorrent' && availableCategories.length > 0 ? (
|
||||
<select
|
||||
value={postImportCategory}
|
||||
onChange={(e) => setPostImportCategory(e.target.value)}
|
||||
className="w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
<option value="">None (keep original)</option>
|
||||
{availableCategories.map((cat) => (
|
||||
<option key={cat} value={cat}>{cat}</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<Input
|
||||
value={postImportCategory}
|
||||
onChange={(e) => setPostImportCategory(e.target.value)}
|
||||
placeholder="e.g. completed"
|
||||
disabled={fetchingCategories}
|
||||
/>
|
||||
)}
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
After import, change the download's category/label in the client. Leave empty to skip.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Remote Path Mapping */}
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||
<div className="flex items-start mb-3">
|
||||
|
||||
@@ -63,17 +63,14 @@ export function IndexerManagement({
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Sync with parent when configuredIndexers changes
|
||||
// In settings mode, the parent fetches indexers asynchronously and passes them
|
||||
// as initialIndexers after mount. This effect picks up that late-arriving data.
|
||||
// Wizard mode doesn't need this — it initializes correctly via useState above.
|
||||
useEffect(() => {
|
||||
if (onIndexersChange) {
|
||||
onIndexersChange(configuredIndexers);
|
||||
if (mode === 'settings') {
|
||||
setConfiguredIndexers(initialIndexers);
|
||||
}
|
||||
}, [configuredIndexers, onIndexersChange]);
|
||||
|
||||
// Sync with initialIndexers prop changes
|
||||
useEffect(() => {
|
||||
setConfiguredIndexers(initialIndexers);
|
||||
}, [initialIndexers]);
|
||||
}, [initialIndexers, mode]);
|
||||
|
||||
const fetchIndexers = async () => {
|
||||
setLoading(true);
|
||||
@@ -149,17 +146,16 @@ export function IndexerManagement({
|
||||
};
|
||||
|
||||
const handleSave = (config: SavedIndexerConfig) => {
|
||||
let updated: SavedIndexerConfig[];
|
||||
if (modalState.mode === 'add') {
|
||||
// Add new indexer
|
||||
setConfiguredIndexers([...configuredIndexers, config]);
|
||||
updated = [...configuredIndexers, config];
|
||||
} else {
|
||||
// Update existing indexer
|
||||
setConfiguredIndexers(
|
||||
configuredIndexers.map((idx) =>
|
||||
idx.id === config.id ? config : idx
|
||||
)
|
||||
updated = configuredIndexers.map((idx) =>
|
||||
idx.id === config.id ? config : idx
|
||||
);
|
||||
}
|
||||
setConfiguredIndexers(updated);
|
||||
onIndexersChange?.(updated);
|
||||
};
|
||||
|
||||
const handleDelete = (id: number) => {
|
||||
@@ -175,9 +171,9 @@ export function IndexerManagement({
|
||||
|
||||
const confirmDelete = () => {
|
||||
if (deleteModalState.indexerId) {
|
||||
setConfiguredIndexers(
|
||||
configuredIndexers.filter((idx) => idx.id !== deleteModalState.indexerId)
|
||||
);
|
||||
const updated = configuredIndexers.filter((idx) => idx.id !== deleteModalState.indexerId);
|
||||
setConfiguredIndexers(updated);
|
||||
onIndexersChange?.(updated);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Modal } from './Modal';
|
||||
import { Input } from './Input';
|
||||
import { Button } from './Button';
|
||||
@@ -22,6 +22,24 @@ export function ChangePasswordModal({ isOpen, onClose }: ChangePasswordModalProp
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [allowWeakPassword, setAllowWeakPassword] = useState(false);
|
||||
|
||||
// Fetch password policy when modal opens
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
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();
|
||||
}, [isOpen]);
|
||||
|
||||
// Validation errors for individual fields
|
||||
const [errors, setErrors] = useState({
|
||||
@@ -47,7 +65,7 @@ export function ChangePasswordModal({ isOpen, onClose }: ChangePasswordModalProp
|
||||
if (!newPassword) {
|
||||
newErrors.newPassword = 'New password is required';
|
||||
isValid = false;
|
||||
} else if (newPassword.length < 8) {
|
||||
} else if (!allowWeakPassword && newPassword.length < 8) {
|
||||
newErrors.newPassword = 'Password must be at least 8 characters';
|
||||
isValid = false;
|
||||
} else if (newPassword === currentPassword) {
|
||||
@@ -211,7 +229,7 @@ export function ChangePasswordModal({ isOpen, onClose }: ChangePasswordModalProp
|
||||
}}
|
||||
placeholder="Enter your new password"
|
||||
autoComplete="new-password"
|
||||
helperText="Must be at least 8 characters"
|
||||
helperText={allowWeakPassword ? undefined : 'Must be at least 8 characters'}
|
||||
error={errors.newPassword}
|
||||
disabled={loading || success}
|
||||
/>
|
||||
|
||||
@@ -406,6 +406,16 @@ export class NZBGetService implements IDownloadClient {
|
||||
}
|
||||
}
|
||||
|
||||
/** Not applicable for usenet clients */
|
||||
async getCategories(): Promise<string[]> {
|
||||
return [];
|
||||
}
|
||||
|
||||
/** Not applicable for usenet clients */
|
||||
async setCategory(_id: string, _category: string): Promise<void> {
|
||||
// No-op: post-import category is scoped to torrent clients
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Category Management
|
||||
// =========================================================================
|
||||
|
||||
@@ -208,6 +208,55 @@ export class ProwlarrService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search with multiple query variations to increase coverage
|
||||
* Fires 2 queries per call: "title author" and "title", then deduplicates by guid
|
||||
*/
|
||||
async searchWithVariations(
|
||||
title: string,
|
||||
author: string,
|
||||
filters?: SearchFilters
|
||||
): Promise<TorrentResult[]> {
|
||||
const queries = [
|
||||
`${title} ${author}`,
|
||||
title,
|
||||
];
|
||||
|
||||
logger.info(`Searching with ${queries.length} query variations`, { queries });
|
||||
|
||||
const allResults: TorrentResult[] = [];
|
||||
|
||||
for (const query of queries) {
|
||||
try {
|
||||
const results = await this.search(query, filters);
|
||||
logger.info(`Query "${query}" returned ${results.length} results`);
|
||||
allResults.push(...results);
|
||||
} catch (error) {
|
||||
logger.error(`Query "${query}" failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
// Continue with other queries even if one fails
|
||||
}
|
||||
}
|
||||
|
||||
const deduplicated = this.deduplicateResults(allResults);
|
||||
logger.info(`Multi-query search: ${allResults.length} total → ${deduplicated.length} after dedup (${allResults.length - deduplicated.length} duplicates removed)`);
|
||||
|
||||
return deduplicated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deduplicate results by guid, preserving order (first occurrence wins)
|
||||
*/
|
||||
private deduplicateResults(results: TorrentResult[]): TorrentResult[] {
|
||||
const seen = new Set<string>();
|
||||
return results.filter(result => {
|
||||
if (seen.has(result.guid)) {
|
||||
return false;
|
||||
}
|
||||
seen.add(result.guid);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of configured indexers
|
||||
*/
|
||||
|
||||
@@ -729,6 +729,26 @@ export class QBittorrentService implements IDownloadClient {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all configured categories from qBittorrent
|
||||
*/
|
||||
async getCategories(): Promise<string[]> {
|
||||
if (!this.cookie) {
|
||||
await this.login();
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await this.client.get('/torrents/categories', {
|
||||
headers: { Cookie: this.cookie },
|
||||
});
|
||||
|
||||
return Object.keys(response.data || {});
|
||||
} catch (error) {
|
||||
logger.error('Failed to get categories', { error: error instanceof Error ? error.message : String(error) });
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set category for torrent
|
||||
*/
|
||||
|
||||
@@ -825,6 +825,16 @@ export class SABnzbdService implements IDownloadClient {
|
||||
await this.archiveCompletedNZB(id);
|
||||
}
|
||||
|
||||
/** Not applicable for usenet clients */
|
||||
async getCategories(): Promise<string[]> {
|
||||
return [];
|
||||
}
|
||||
|
||||
/** Not applicable for usenet clients */
|
||||
async setCategory(_id: string, _category: string): Promise<void> {
|
||||
// No-op: post-import category is scoped to torrent clients
|
||||
}
|
||||
|
||||
/**
|
||||
* Map NZBInfo to the unified DownloadInfo format.
|
||||
*/
|
||||
|
||||
@@ -441,6 +441,29 @@ export class TransmissionService implements IDownloadClient {
|
||||
// No-op: torrents are managed by the seeding cleanup scheduler
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available categories/labels.
|
||||
* Transmission uses free-form labels — no predefined list to fetch.
|
||||
*/
|
||||
async getCategories(): Promise<string[]> {
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the label for a torrent.
|
||||
* Uses the torrent-set RPC method to replace the labels array.
|
||||
*/
|
||||
async setCategory(id: string, category: string): Promise<void> {
|
||||
try {
|
||||
const torrent = await this.getTorrentByHash(id);
|
||||
await this.rpc('torrent-set', { ids: [torrent.hashString], labels: [category] });
|
||||
logger.info(`Set label for torrent ${id}: ${category}`);
|
||||
} catch (error) {
|
||||
logger.error('Failed to set label', { error: error instanceof Error ? error.message : String(error) });
|
||||
throw new Error('Failed to set torrent label');
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Internal Helpers
|
||||
// =========================================================================
|
||||
|
||||
@@ -177,4 +177,22 @@ export interface IDownloadClient {
|
||||
* @param id - Download ID
|
||||
*/
|
||||
postProcess(id: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Get available categories/labels from the download client.
|
||||
* - qBittorrent: Returns configured category names
|
||||
* - Transmission: Returns empty array (uses free-form labels)
|
||||
* - Usenet clients: Returns empty array (feature scoped to torrent clients)
|
||||
*/
|
||||
getCategories(): Promise<string[]>;
|
||||
|
||||
/**
|
||||
* Set the category/label for a download.
|
||||
* - qBittorrent: Sets torrent category
|
||||
* - Transmission: Sets torrent label
|
||||
* - Usenet clients: No-op
|
||||
* @param id - Download ID
|
||||
* @param category - Category/label name to assign
|
||||
*/
|
||||
setCategory(id: string, category: string): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -180,6 +180,9 @@ export async function processOrganizeFiles(payload: OrganizeFilesPayload): Promi
|
||||
},
|
||||
});
|
||||
|
||||
// Apply post-import category to torrent client if configured
|
||||
await applyPostImportCategory(requestId, logger);
|
||||
|
||||
logger.info(`Request ${requestId} completed successfully - status: downloaded`, {
|
||||
success: true,
|
||||
message: 'Files organized successfully',
|
||||
@@ -606,6 +609,9 @@ async function processEbookOrganization(
|
||||
},
|
||||
});
|
||||
|
||||
// Apply post-import category to torrent client if configured
|
||||
await applyPostImportCategory(requestId, logger);
|
||||
|
||||
logger.info(`Ebook request ${requestId} completed - status: downloaded (terminal)`);
|
||||
|
||||
// Send "available" notification for ebooks at downloaded state
|
||||
@@ -753,6 +759,59 @@ async function createEbookRequestIfEnabled(
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// POST-IMPORT CATEGORY
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Apply post-import category to the download client after successful import.
|
||||
* Only applies to torrent clients (qBittorrent/Transmission) when configured.
|
||||
* Non-fatal: logs a warning on failure but does not fail the job.
|
||||
*/
|
||||
async function applyPostImportCategory(
|
||||
requestId: string,
|
||||
logger: RMABLogger
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Get download history to find client type and download ID
|
||||
const downloadHistory = await prisma.downloadHistory.findFirst({
|
||||
where: { requestId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
if (!downloadHistory?.downloadClientId || !downloadHistory?.downloadClient) {
|
||||
return;
|
||||
}
|
||||
|
||||
const clientType = downloadHistory.downloadClient as DownloadClientType;
|
||||
|
||||
// Only applies to torrent clients
|
||||
const protocol = CLIENT_PROTOCOL_MAP[clientType];
|
||||
if (protocol !== 'torrent') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get client config and check if postImportCategory is set
|
||||
const configService = getConfigService();
|
||||
const manager = getDownloadClientManager(configService);
|
||||
const clients = await manager.getAllClients();
|
||||
const clientConfig = clients.find(c => c.enabled && c.type === clientType);
|
||||
|
||||
if (!clientConfig?.postImportCategory) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`Applying post-import category "${clientConfig.postImportCategory}" to download ${downloadHistory.downloadClientId}`);
|
||||
|
||||
const service = await manager.createClientFromConfig(clientConfig);
|
||||
await service.setCategory(downloadHistory.downloadClientId, clientConfig.postImportCategory);
|
||||
|
||||
logger.info(`Post-import category applied successfully`);
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to apply post-import category: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// DOWNLOAD CLEANUP
|
||||
// =========================================================================
|
||||
|
||||
@@ -75,10 +75,7 @@ export async function processSearchIndexers(payload: SearchIndexersPayload): Pro
|
||||
// Get Prowlarr service
|
||||
const prowlarr = await getProwlarrService();
|
||||
|
||||
// Build search query (title only - cast wide net, let ranking filter)
|
||||
const searchQuery = audiobook.title;
|
||||
|
||||
logger.info(`Searching for: "${searchQuery}"`);
|
||||
logger.info(`Searching for: "${audiobook.title}" by "${audiobook.author}"`);
|
||||
|
||||
// Search Prowlarr for each group and combine results
|
||||
const allResults = [];
|
||||
@@ -88,7 +85,7 @@ export async function processSearchIndexers(payload: SearchIndexersPayload): Pro
|
||||
logger.info(`Searching group ${i + 1}/${groups.length}: ${getGroupDescription(group)}`);
|
||||
|
||||
try {
|
||||
const groupResults = await prowlarr.search(searchQuery, {
|
||||
const groupResults = await prowlarr.searchWithVariations(audiobook.title, audiobook.author, {
|
||||
categories: group.categories,
|
||||
indexerIds: group.indexerIds,
|
||||
minSeeders: 1, // Only torrents with at least 1 seeder
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* to all enabled backends subscribed to the event.
|
||||
*/
|
||||
|
||||
import { getNotificationService } from '../services/notification.service';
|
||||
import { getNotificationService } from '../services/notification';
|
||||
import { RMABLogger } from '../utils/logger';
|
||||
|
||||
export interface SendNotificationPayload {
|
||||
|
||||
@@ -150,7 +150,11 @@ export class LocalAuthProvider implements IAuthProvider {
|
||||
return { success: false, error: 'Username must be at least 3 characters' };
|
||||
}
|
||||
|
||||
if (!password || password.length < 8) {
|
||||
const allowWeakPassword = process.env.ALLOW_WEAK_PASSWORD === 'true';
|
||||
if (!password) {
|
||||
return { success: false, error: 'Password is required' };
|
||||
}
|
||||
if (!allowWeakPassword && password.length < 8) {
|
||||
return { success: false, error: 'Password must be at least 8 characters' };
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ export interface DownloadClientConfig {
|
||||
localPath?: string;
|
||||
category?: string; // Default: 'readmeabook'
|
||||
customPath?: string; // Relative sub-path appended to download_dir
|
||||
postImportCategory?: string; // Category to assign after import (torrent clients only)
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,380 +0,0 @@
|
||||
/**
|
||||
* Component: Notification Service
|
||||
* Documentation: documentation/backend/services/notifications.md
|
||||
*/
|
||||
|
||||
import { getEncryptionService } from './encryption.service';
|
||||
import { RMABLogger } from '../utils/logger';
|
||||
import { prisma } from '../db';
|
||||
|
||||
const logger = RMABLogger.create('NotificationService');
|
||||
|
||||
// Event types
|
||||
export type NotificationEvent =
|
||||
| 'request_pending_approval'
|
||||
| 'request_approved'
|
||||
| 'request_available'
|
||||
| 'request_error';
|
||||
|
||||
// Backend types
|
||||
export type NotificationBackendType =
|
||||
| 'discord'
|
||||
| 'pushover'
|
||||
| 'email'
|
||||
| 'slack'
|
||||
| 'telegram'
|
||||
| 'webhook';
|
||||
|
||||
// Config interfaces
|
||||
export interface DiscordConfig {
|
||||
webhookUrl: string;
|
||||
username?: string;
|
||||
avatarUrl?: string;
|
||||
}
|
||||
|
||||
export interface PushoverConfig {
|
||||
userKey: string;
|
||||
appToken: string;
|
||||
device?: string;
|
||||
priority?: number;
|
||||
}
|
||||
|
||||
export type NotificationConfig = DiscordConfig | PushoverConfig;
|
||||
|
||||
// Notification payload
|
||||
export interface NotificationPayload {
|
||||
event: NotificationEvent;
|
||||
requestId: string;
|
||||
title: string;
|
||||
author: string;
|
||||
userName: string;
|
||||
message?: string; // For error events
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
// Discord embed colors by event type
|
||||
const DISCORD_COLORS = {
|
||||
request_pending_approval: 0xfbbf24, // yellow-400
|
||||
request_approved: 0x22c55e, // green-500
|
||||
request_available: 0x3b82f6, // blue-500
|
||||
request_error: 0xef4444, // red-500
|
||||
};
|
||||
|
||||
// Discord embed titles
|
||||
const DISCORD_TITLES = {
|
||||
request_pending_approval: '📬 New Request Pending Approval',
|
||||
request_approved: '✅ Request Approved',
|
||||
request_available: '🎉 Audiobook Available',
|
||||
request_error: '❌ Request Error',
|
||||
};
|
||||
|
||||
// Pushover priorities
|
||||
const PUSHOVER_PRIORITIES = {
|
||||
request_pending_approval: 0, // Normal
|
||||
request_approved: 0, // Normal
|
||||
request_available: 1, // High
|
||||
request_error: 1, // High
|
||||
};
|
||||
|
||||
export class NotificationService {
|
||||
private encryptionService = getEncryptionService();
|
||||
|
||||
/**
|
||||
* Send notification to all enabled backends subscribed to the event
|
||||
*/
|
||||
async sendNotification(payload: NotificationPayload): Promise<void> {
|
||||
try {
|
||||
// Get all enabled backends subscribed to this event
|
||||
const backends = await prisma.notificationBackend.findMany({
|
||||
where: {
|
||||
enabled: true,
|
||||
events: {
|
||||
array_contains: payload.event,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (backends.length === 0) {
|
||||
logger.debug(`No backends subscribed to event: ${payload.event}`);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`Sending notification to ${backends.length} backend(s)`, {
|
||||
event: payload.event,
|
||||
requestId: payload.requestId,
|
||||
});
|
||||
|
||||
// Send to all backends in parallel (atomic per-backend)
|
||||
const results = await Promise.allSettled(
|
||||
backends.map((backend) =>
|
||||
this.sendToBackend(backend.type as NotificationBackendType, backend.config, payload)
|
||||
)
|
||||
);
|
||||
|
||||
// Log results
|
||||
const successful = results.filter((r) => r.status === 'fulfilled').length;
|
||||
const failed = results.filter((r) => r.status === 'rejected').length;
|
||||
|
||||
logger.info(`Notification sent: ${successful} succeeded, ${failed} failed`, {
|
||||
event: payload.event,
|
||||
requestId: payload.requestId,
|
||||
});
|
||||
|
||||
// Log individual failures
|
||||
results.forEach((result, index) => {
|
||||
if (result.status === 'rejected') {
|
||||
logger.error(`Failed to send to backend ${backends[index].name}`, {
|
||||
error: result.reason instanceof Error ? result.reason.message : String(result.reason),
|
||||
backend: backends[index].type,
|
||||
});
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to send notifications', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
event: payload.event,
|
||||
requestId: payload.requestId,
|
||||
});
|
||||
// Don't throw - non-blocking
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Route notification to type-specific sender
|
||||
*/
|
||||
private async sendToBackend(
|
||||
type: NotificationBackendType,
|
||||
config: any,
|
||||
payload: NotificationPayload
|
||||
): Promise<void> {
|
||||
// Decrypt config
|
||||
const decryptedConfig = this.decryptConfig(config);
|
||||
|
||||
switch (type) {
|
||||
case 'discord':
|
||||
return this.sendDiscord(decryptedConfig as DiscordConfig, payload);
|
||||
case 'pushover':
|
||||
return this.sendPushover(decryptedConfig as PushoverConfig, payload);
|
||||
default:
|
||||
throw new Error(`Unsupported backend type: ${type}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send Discord webhook notification
|
||||
*/
|
||||
private async sendDiscord(config: DiscordConfig, payload: NotificationPayload): Promise<void> {
|
||||
const embed = this.formatDiscordEmbed(payload);
|
||||
|
||||
const body = {
|
||||
username: config.username || 'ReadMeABook',
|
||||
avatar_url: config.avatarUrl,
|
||||
embeds: [embed],
|
||||
};
|
||||
|
||||
const response = await fetch(config.webhookUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text().catch(() => 'Unknown error');
|
||||
throw new Error(`Discord webhook failed: ${response.status} ${errorText}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send Pushover notification
|
||||
*/
|
||||
private async sendPushover(config: PushoverConfig, payload: NotificationPayload): Promise<void> {
|
||||
const { title, message } = this.formatPushoverMessage(payload);
|
||||
|
||||
const body = new URLSearchParams({
|
||||
token: config.appToken,
|
||||
user: config.userKey,
|
||||
title,
|
||||
message,
|
||||
priority: String(config.priority ?? PUSHOVER_PRIORITIES[payload.event]),
|
||||
...(config.device && { device: config.device }),
|
||||
});
|
||||
|
||||
const response = await fetch('https://api.pushover.net/1/messages.json', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: body.toString(),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text().catch(() => 'Unknown error');
|
||||
throw new Error(`Pushover API failed: ${response.status} ${errorText}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
if (result.status !== 1) {
|
||||
throw new Error(`Pushover API error: ${JSON.stringify(result.errors || 'Unknown error')}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format Discord rich embed
|
||||
*/
|
||||
private formatDiscordEmbed(payload: NotificationPayload): any {
|
||||
const { event, title, author, userName, message, requestId, timestamp } = payload;
|
||||
|
||||
const fields = [
|
||||
{ name: 'Title', value: title, inline: false },
|
||||
{ name: 'Author', value: author, inline: true },
|
||||
{ name: 'Requested By', value: userName, inline: true },
|
||||
];
|
||||
|
||||
if (message) {
|
||||
fields.push({ name: 'Error', value: message, inline: false });
|
||||
}
|
||||
|
||||
return {
|
||||
title: DISCORD_TITLES[event],
|
||||
color: DISCORD_COLORS[event],
|
||||
fields,
|
||||
footer: {
|
||||
text: `Request ID: ${requestId}`,
|
||||
},
|
||||
timestamp: timestamp.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Format Pushover message
|
||||
*/
|
||||
private formatPushoverMessage(payload: NotificationPayload): { title: string; message: string } {
|
||||
const { event, title, author, userName, message } = payload;
|
||||
|
||||
let eventTitle = '';
|
||||
let eventEmoji = '';
|
||||
|
||||
switch (event) {
|
||||
case 'request_pending_approval':
|
||||
eventTitle = 'New Request Pending Approval';
|
||||
eventEmoji = '📬';
|
||||
break;
|
||||
case 'request_approved':
|
||||
eventTitle = 'Request Approved';
|
||||
eventEmoji = '✅';
|
||||
break;
|
||||
case 'request_available':
|
||||
eventTitle = 'Audiobook Available';
|
||||
eventEmoji = '🎉';
|
||||
break;
|
||||
case 'request_error':
|
||||
eventTitle = 'Request Error';
|
||||
eventEmoji = '❌';
|
||||
break;
|
||||
}
|
||||
|
||||
const messageLines = [
|
||||
`${eventEmoji} ${eventTitle}`,
|
||||
'',
|
||||
`📚 ${title}`,
|
||||
`✍️ ${author}`,
|
||||
`👤 Requested by: ${userName}`,
|
||||
];
|
||||
|
||||
if (message) {
|
||||
messageLines.push('', `⚠️ Error: ${message}`);
|
||||
}
|
||||
|
||||
return {
|
||||
title: eventTitle,
|
||||
message: messageLines.join('\n'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt sensitive config values
|
||||
*/
|
||||
private decryptConfig(config: any): any {
|
||||
const decrypted = { ...config };
|
||||
|
||||
// Discord: decrypt webhookUrl
|
||||
if (decrypted.webhookUrl && this.isEncrypted(decrypted.webhookUrl)) {
|
||||
decrypted.webhookUrl = this.encryptionService.decrypt(decrypted.webhookUrl);
|
||||
}
|
||||
|
||||
// Pushover: decrypt userKey and appToken
|
||||
if (decrypted.userKey && this.isEncrypted(decrypted.userKey)) {
|
||||
decrypted.userKey = this.encryptionService.decrypt(decrypted.userKey);
|
||||
}
|
||||
if (decrypted.appToken && this.isEncrypted(decrypted.appToken)) {
|
||||
decrypted.appToken = this.encryptionService.decrypt(decrypted.appToken);
|
||||
}
|
||||
|
||||
return decrypted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a value is encrypted (has iv:authTag:data format)
|
||||
*/
|
||||
private isEncrypted(value: string): boolean {
|
||||
return value.includes(':') && value.split(':').length === 3;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt sensitive config values before saving
|
||||
*/
|
||||
encryptConfig(type: NotificationBackendType, config: any): any {
|
||||
const encrypted = { ...config };
|
||||
|
||||
switch (type) {
|
||||
case 'discord':
|
||||
if (encrypted.webhookUrl && !this.isEncrypted(encrypted.webhookUrl)) {
|
||||
encrypted.webhookUrl = this.encryptionService.encrypt(encrypted.webhookUrl);
|
||||
}
|
||||
break;
|
||||
case 'pushover':
|
||||
if (encrypted.userKey && !this.isEncrypted(encrypted.userKey)) {
|
||||
encrypted.userKey = this.encryptionService.encrypt(encrypted.userKey);
|
||||
}
|
||||
if (encrypted.appToken && !this.isEncrypted(encrypted.appToken)) {
|
||||
encrypted.appToken = this.encryptionService.encrypt(encrypted.appToken);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return encrypted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mask sensitive config values for API responses
|
||||
*/
|
||||
maskConfig(type: NotificationBackendType, config: any): any {
|
||||
const masked = { ...config };
|
||||
|
||||
switch (type) {
|
||||
case 'discord':
|
||||
if (masked.webhookUrl) {
|
||||
masked.webhookUrl = '••••••••';
|
||||
}
|
||||
break;
|
||||
case 'pushover':
|
||||
if (masked.userKey) {
|
||||
masked.userKey = '••••••••';
|
||||
}
|
||||
if (masked.appToken) {
|
||||
masked.appToken = '••••••••';
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return masked;
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
let notificationService: NotificationService | null = null;
|
||||
|
||||
export function getNotificationService(): NotificationService {
|
||||
if (!notificationService) {
|
||||
notificationService = new NotificationService();
|
||||
}
|
||||
return notificationService;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Notification Provider Interface
|
||||
* Documentation: documentation/backend/services/notifications.md
|
||||
*/
|
||||
|
||||
// Event types
|
||||
export type NotificationEvent =
|
||||
| 'request_pending_approval'
|
||||
| 'request_approved'
|
||||
| 'request_available'
|
||||
| 'request_error';
|
||||
|
||||
// Backend type — string-based, registry is the runtime source of truth
|
||||
export type NotificationBackendType = string;
|
||||
|
||||
// Notification payload
|
||||
export interface NotificationPayload {
|
||||
event: NotificationEvent;
|
||||
requestId: string;
|
||||
title: string;
|
||||
author: string;
|
||||
userName: string;
|
||||
message?: string; // For error events
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
// Provider config field definition for dynamic UI rendering
|
||||
export interface ProviderConfigField {
|
||||
name: string;
|
||||
label: string;
|
||||
type: 'text' | 'password' | 'select' | 'number';
|
||||
required: boolean;
|
||||
placeholder?: string;
|
||||
defaultValue?: string | number;
|
||||
options?: { label: string; value: string | number }[];
|
||||
}
|
||||
|
||||
// Provider metadata for self-describing providers
|
||||
export interface ProviderMetadata {
|
||||
type: string;
|
||||
displayName: string;
|
||||
description: string;
|
||||
iconLabel: string;
|
||||
iconColor: string;
|
||||
configFields: ProviderConfigField[];
|
||||
}
|
||||
|
||||
export interface INotificationProvider {
|
||||
/** Provider identifier */
|
||||
type: string;
|
||||
|
||||
/** Config field names that need encryption/masking */
|
||||
sensitiveFields: string[];
|
||||
|
||||
/** Self-describing metadata for UI and validation */
|
||||
metadata: ProviderMetadata;
|
||||
|
||||
/** Send notification with already-decrypted config */
|
||||
send(config: Record<string, any>, payload: NotificationPayload): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Notification Service - Public API
|
||||
* Documentation: documentation/backend/services/notifications.md
|
||||
*/
|
||||
|
||||
// Interface + shared types
|
||||
export type {
|
||||
INotificationProvider,
|
||||
NotificationEvent,
|
||||
NotificationBackendType,
|
||||
NotificationPayload,
|
||||
ProviderConfigField,
|
||||
ProviderMetadata,
|
||||
} from './INotificationProvider';
|
||||
|
||||
// Core service
|
||||
export {
|
||||
NotificationService,
|
||||
getNotificationService,
|
||||
registerProvider,
|
||||
getProvider,
|
||||
getRegisteredProviderTypes,
|
||||
getAllProviderMetadata,
|
||||
} from './notification.service';
|
||||
|
||||
// Provider types
|
||||
export type { AppriseConfig } from './providers/apprise.provider';
|
||||
export type { DiscordConfig } from './providers/discord.provider';
|
||||
export type { NtfyConfig } from './providers/ntfy.provider';
|
||||
export type { PushoverConfig } from './providers/pushover.provider';
|
||||
|
||||
// Provider classes
|
||||
export { AppriseProvider } from './providers/apprise.provider';
|
||||
export { DiscordProvider } from './providers/discord.provider';
|
||||
export { NtfyProvider } from './providers/ntfy.provider';
|
||||
export { PushoverProvider } from './providers/pushover.provider';
|
||||
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* Component: Notification Service
|
||||
* Documentation: documentation/backend/services/notifications.md
|
||||
*/
|
||||
|
||||
import { getEncryptionService } from '../encryption.service';
|
||||
import { RMABLogger } from '../../utils/logger';
|
||||
import { prisma } from '../../db';
|
||||
import { INotificationProvider, NotificationPayload, ProviderMetadata } from './INotificationProvider';
|
||||
import { AppriseProvider } from './providers/apprise.provider';
|
||||
import { DiscordProvider } from './providers/discord.provider';
|
||||
import { NtfyProvider } from './providers/ntfy.provider';
|
||||
import { PushoverProvider } from './providers/pushover.provider';
|
||||
|
||||
const logger = RMABLogger.create('NotificationService');
|
||||
|
||||
// Provider registry
|
||||
const providers = new Map<string, INotificationProvider>();
|
||||
|
||||
export function registerProvider(provider: INotificationProvider): void {
|
||||
providers.set(provider.type, provider);
|
||||
}
|
||||
|
||||
export function getProvider(type: string): INotificationProvider | undefined {
|
||||
return providers.get(type);
|
||||
}
|
||||
|
||||
// Register built-in providers
|
||||
registerProvider(new AppriseProvider());
|
||||
registerProvider(new DiscordProvider());
|
||||
registerProvider(new NtfyProvider());
|
||||
registerProvider(new PushoverProvider());
|
||||
|
||||
export function getRegisteredProviderTypes(): string[] {
|
||||
return Array.from(providers.keys());
|
||||
}
|
||||
|
||||
export function getAllProviderMetadata(): ProviderMetadata[] {
|
||||
return Array.from(providers.values()).map((p) => p.metadata);
|
||||
}
|
||||
|
||||
export class NotificationService {
|
||||
private encryptionService = getEncryptionService();
|
||||
|
||||
/**
|
||||
* Send notification to all enabled backends subscribed to the event
|
||||
*/
|
||||
async sendNotification(payload: NotificationPayload): Promise<void> {
|
||||
try {
|
||||
// Get all enabled backends subscribed to this event
|
||||
const backends = await prisma.notificationBackend.findMany({
|
||||
where: {
|
||||
enabled: true,
|
||||
events: {
|
||||
array_contains: payload.event,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (backends.length === 0) {
|
||||
logger.debug(`No backends subscribed to event: ${payload.event}`);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`Sending notification to ${backends.length} backend(s)`, {
|
||||
event: payload.event,
|
||||
requestId: payload.requestId,
|
||||
});
|
||||
|
||||
// Send to all backends in parallel (atomic per-backend)
|
||||
const results = await Promise.allSettled(
|
||||
backends.map((backend) =>
|
||||
this.sendToBackend(backend.type, backend.config, payload)
|
||||
)
|
||||
);
|
||||
|
||||
// Log results
|
||||
const successful = results.filter((r) => r.status === 'fulfilled').length;
|
||||
const failed = results.filter((r) => r.status === 'rejected').length;
|
||||
|
||||
logger.info(`Notification sent: ${successful} succeeded, ${failed} failed`, {
|
||||
event: payload.event,
|
||||
requestId: payload.requestId,
|
||||
});
|
||||
|
||||
// Log individual failures
|
||||
results.forEach((result, index) => {
|
||||
if (result.status === 'rejected') {
|
||||
logger.error(`Failed to send to backend ${backends[index].name}`, {
|
||||
error: result.reason instanceof Error ? result.reason.message : String(result.reason),
|
||||
backend: backends[index].type,
|
||||
});
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to send notifications', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
event: payload.event,
|
||||
requestId: payload.requestId,
|
||||
});
|
||||
// Don't throw - non-blocking
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Route notification to type-specific provider
|
||||
*/
|
||||
async sendToBackend(
|
||||
type: string,
|
||||
config: any,
|
||||
payload: NotificationPayload
|
||||
): Promise<void> {
|
||||
const provider = getProvider(type);
|
||||
if (!provider) {
|
||||
throw new Error(`Unsupported backend type: ${type}`);
|
||||
}
|
||||
|
||||
const decryptedConfig = this.decryptConfig(provider.sensitiveFields, config);
|
||||
return provider.send(decryptedConfig, payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt sensitive config values before saving
|
||||
*/
|
||||
encryptConfig(type: string, config: any): any {
|
||||
const provider = getProvider(type);
|
||||
if (!provider) {
|
||||
return { ...config };
|
||||
}
|
||||
|
||||
const encrypted = { ...config };
|
||||
for (const field of provider.sensitiveFields) {
|
||||
if (encrypted[field] && !this.isEncrypted(encrypted[field])) {
|
||||
encrypted[field] = this.encryptionService.encrypt(encrypted[field]);
|
||||
}
|
||||
}
|
||||
return encrypted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mask sensitive config values for API responses
|
||||
*/
|
||||
maskConfig(type: string, config: any): any {
|
||||
const provider = getProvider(type);
|
||||
if (!provider) {
|
||||
return { ...config };
|
||||
}
|
||||
|
||||
const masked = { ...config };
|
||||
for (const field of provider.sensitiveFields) {
|
||||
if (masked[field]) {
|
||||
masked[field] = '••••••••';
|
||||
}
|
||||
}
|
||||
return masked;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt sensitive config values
|
||||
*/
|
||||
private decryptConfig(sensitiveFields: string[], config: any): any {
|
||||
const decrypted = { ...config };
|
||||
for (const field of sensitiveFields) {
|
||||
if (decrypted[field] && this.isEncrypted(decrypted[field])) {
|
||||
decrypted[field] = this.encryptionService.decrypt(decrypted[field]);
|
||||
}
|
||||
}
|
||||
return decrypted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a value is encrypted (has iv:authTag:data format)
|
||||
*/
|
||||
private isEncrypted(value: string): boolean {
|
||||
return value.includes(':') && value.split(':').length === 3;
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
let notificationService: NotificationService | null = null;
|
||||
|
||||
export function getNotificationService(): NotificationService {
|
||||
if (!notificationService) {
|
||||
notificationService = new NotificationService();
|
||||
}
|
||||
return notificationService;
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Component: Apprise Notification Provider
|
||||
* Documentation: documentation/backend/services/notifications.md
|
||||
*/
|
||||
|
||||
import { INotificationProvider, NotificationPayload, ProviderMetadata } from '../INotificationProvider';
|
||||
|
||||
export interface AppriseConfig {
|
||||
serverUrl: string;
|
||||
urls?: string;
|
||||
configKey?: string;
|
||||
tag?: string;
|
||||
authToken?: string;
|
||||
}
|
||||
|
||||
// Apprise notification types by event
|
||||
const APPRISE_TYPES: Record<string, string> = {
|
||||
request_pending_approval: 'info',
|
||||
request_approved: 'success',
|
||||
request_available: 'success',
|
||||
request_error: 'failure',
|
||||
};
|
||||
|
||||
export class AppriseProvider implements INotificationProvider {
|
||||
type = 'apprise' as const;
|
||||
sensitiveFields = ['urls', 'authToken'];
|
||||
metadata: ProviderMetadata = {
|
||||
type: 'apprise',
|
||||
displayName: 'Apprise',
|
||||
description: 'Send notifications via Apprise API to 100+ services',
|
||||
iconLabel: 'A',
|
||||
iconColor: 'bg-purple-500',
|
||||
configFields: [
|
||||
{ name: 'serverUrl', label: 'Server URL', type: 'text', required: true, placeholder: 'http://apprise:8000' },
|
||||
{ name: 'urls', label: 'Notification URLs', type: 'password', required: false, placeholder: 'slack://token, discord://webhook_id/token, ...' },
|
||||
{ name: 'configKey', label: 'Config Key', type: 'text', required: false, placeholder: 'Persistent configuration key' },
|
||||
{ name: 'tag', label: 'Tag', type: 'text', required: false, placeholder: 'Filter tag for stateful config' },
|
||||
{ name: 'authToken', label: 'Auth Token', type: 'password', required: false, placeholder: 'Optional API auth token' },
|
||||
],
|
||||
};
|
||||
|
||||
async send(config: Record<string, any>, payload: NotificationPayload): Promise<void> {
|
||||
const appriseConfig = config as unknown as AppriseConfig;
|
||||
const { title, body } = this.formatMessage(payload);
|
||||
|
||||
const serverUrl = appriseConfig.serverUrl.replace(/\/+$/, '');
|
||||
const notificationType = APPRISE_TYPES[payload.event] || 'info';
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
if (appriseConfig.authToken) {
|
||||
headers['Authorization'] = `Bearer ${appriseConfig.authToken}`;
|
||||
}
|
||||
|
||||
// Stateful mode: use configKey endpoint
|
||||
if (appriseConfig.configKey) {
|
||||
const url = `${serverUrl}/notify/${appriseConfig.configKey}`;
|
||||
const requestBody: Record<string, string> = {
|
||||
title,
|
||||
body,
|
||||
type: notificationType,
|
||||
};
|
||||
|
||||
if (appriseConfig.tag) {
|
||||
requestBody.tag = appriseConfig.tag;
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text().catch(() => 'Unknown error');
|
||||
throw new Error(`Apprise API failed: ${response.status} ${errorText}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Stateless mode: send URLs directly
|
||||
if (!appriseConfig.urls) {
|
||||
throw new Error('Apprise requires either notification URLs or a config key');
|
||||
}
|
||||
|
||||
const url = `${serverUrl}/notify/`;
|
||||
const requestBody = {
|
||||
urls: appriseConfig.urls,
|
||||
title,
|
||||
body,
|
||||
type: notificationType,
|
||||
};
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text().catch(() => 'Unknown error');
|
||||
throw new Error(`Apprise API failed: ${response.status} ${errorText}`);
|
||||
}
|
||||
}
|
||||
|
||||
private formatMessage(payload: NotificationPayload): { title: string; body: string } {
|
||||
const { event, title, author, userName, message } = payload;
|
||||
|
||||
const eventTitles: Record<string, string> = {
|
||||
request_pending_approval: 'New Request Pending Approval',
|
||||
request_approved: 'Request Approved',
|
||||
request_available: 'Audiobook Available',
|
||||
request_error: 'Request Error',
|
||||
};
|
||||
|
||||
const messageLines = [
|
||||
`📚 ${title}`,
|
||||
`✍️ ${author}`,
|
||||
`👤 Requested by: ${userName}`,
|
||||
];
|
||||
|
||||
if (message) {
|
||||
messageLines.push(`⚠️ Error: ${message}`);
|
||||
}
|
||||
|
||||
return {
|
||||
title: eventTitles[event],
|
||||
body: messageLines.join('\n'),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Component: Discord Notification Provider
|
||||
* Documentation: documentation/backend/services/notifications.md
|
||||
*/
|
||||
|
||||
import { INotificationProvider, NotificationPayload, ProviderMetadata } from '../INotificationProvider';
|
||||
|
||||
export interface DiscordConfig {
|
||||
webhookUrl: string;
|
||||
username?: string;
|
||||
avatarUrl?: string;
|
||||
}
|
||||
|
||||
// Discord embed colors by event type
|
||||
const DISCORD_COLORS = {
|
||||
request_pending_approval: 0xfbbf24, // yellow-400
|
||||
request_approved: 0x22c55e, // green-500
|
||||
request_available: 0x3b82f6, // blue-500
|
||||
request_error: 0xef4444, // red-500
|
||||
};
|
||||
|
||||
// Discord embed titles
|
||||
const DISCORD_TITLES = {
|
||||
request_pending_approval: '📬 New Request Pending Approval',
|
||||
request_approved: '✅ Request Approved',
|
||||
request_available: '🎉 Audiobook Available',
|
||||
request_error: '❌ Request Error',
|
||||
};
|
||||
|
||||
export class DiscordProvider implements INotificationProvider {
|
||||
type = 'discord' as const;
|
||||
sensitiveFields = ['webhookUrl'];
|
||||
metadata: ProviderMetadata = {
|
||||
type: 'discord',
|
||||
displayName: 'Discord',
|
||||
description: 'Send notifications via Discord webhook',
|
||||
iconLabel: 'D',
|
||||
iconColor: 'bg-indigo-500',
|
||||
configFields: [
|
||||
{ name: 'webhookUrl', label: 'Webhook URL', type: 'text', required: true, placeholder: 'https://discord.com/api/webhooks/...' },
|
||||
{ name: 'username', label: 'Username', type: 'text', required: false, placeholder: 'ReadMeABook', defaultValue: 'ReadMeABook' },
|
||||
{ name: 'avatarUrl', label: 'Avatar URL', type: 'text', required: false, placeholder: 'https://example.com/avatar.png', defaultValue: '' },
|
||||
],
|
||||
};
|
||||
|
||||
async send(config: Record<string, any>, payload: NotificationPayload): Promise<void> {
|
||||
const discordConfig = config as unknown as DiscordConfig;
|
||||
const embed = this.formatEmbed(payload);
|
||||
|
||||
const body = {
|
||||
username: discordConfig.username || 'ReadMeABook',
|
||||
avatar_url: discordConfig.avatarUrl,
|
||||
embeds: [embed],
|
||||
};
|
||||
|
||||
const response = await fetch(discordConfig.webhookUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text().catch(() => 'Unknown error');
|
||||
throw new Error(`Discord webhook failed: ${response.status} ${errorText}`);
|
||||
}
|
||||
}
|
||||
|
||||
private formatEmbed(payload: NotificationPayload): any {
|
||||
const { event, title, author, userName, message, requestId, timestamp } = payload;
|
||||
|
||||
const fields = [
|
||||
{ name: 'Title', value: title, inline: false },
|
||||
{ name: 'Author', value: author, inline: true },
|
||||
{ name: 'Requested By', value: userName, inline: true },
|
||||
];
|
||||
|
||||
if (message) {
|
||||
fields.push({ name: 'Error', value: message, inline: false });
|
||||
}
|
||||
|
||||
return {
|
||||
title: DISCORD_TITLES[event],
|
||||
color: DISCORD_COLORS[event],
|
||||
fields,
|
||||
footer: {
|
||||
text: `Request ID: ${requestId}`,
|
||||
},
|
||||
timestamp: timestamp.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Component: ntfy Notification Provider
|
||||
* Documentation: documentation/backend/services/notifications.md
|
||||
*/
|
||||
|
||||
import { INotificationProvider, NotificationPayload, ProviderMetadata } from '../INotificationProvider';
|
||||
|
||||
export interface NtfyConfig {
|
||||
serverUrl?: string;
|
||||
topic: string;
|
||||
accessToken?: string;
|
||||
priority?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_SERVER_URL = 'https://ntfy.sh';
|
||||
|
||||
// ntfy priorities by event type (1=min, 2=low, 3=default, 4=high, 5=urgent)
|
||||
const NTFY_PRIORITIES = {
|
||||
request_pending_approval: 3, // Default
|
||||
request_approved: 3, // Default
|
||||
request_available: 4, // High
|
||||
request_error: 4, // High
|
||||
};
|
||||
|
||||
// ntfy tags (emojis) by event type
|
||||
const NTFY_TAGS = {
|
||||
request_pending_approval: ['mailbox_with_mail'],
|
||||
request_approved: ['white_check_mark'],
|
||||
request_available: ['tada'],
|
||||
request_error: ['x'],
|
||||
};
|
||||
|
||||
export class NtfyProvider implements INotificationProvider {
|
||||
type = 'ntfy' as const;
|
||||
sensitiveFields = ['accessToken'];
|
||||
metadata: ProviderMetadata = {
|
||||
type: 'ntfy',
|
||||
displayName: 'ntfy',
|
||||
description: 'Send notifications via ntfy pub/sub',
|
||||
iconLabel: 'N',
|
||||
iconColor: 'bg-teal-500',
|
||||
configFields: [
|
||||
{ name: 'serverUrl', label: 'Server URL', type: 'text', required: false, placeholder: 'https://ntfy.sh', defaultValue: 'https://ntfy.sh' },
|
||||
{ name: 'topic', label: 'Topic', type: 'text', required: true, placeholder: 'readmeabook' },
|
||||
{ name: 'accessToken', label: 'Access Token', type: 'password', required: false, placeholder: 'tk_...' },
|
||||
],
|
||||
};
|
||||
|
||||
async send(config: Record<string, any>, payload: NotificationPayload): Promise<void> {
|
||||
const ntfyConfig = config as unknown as NtfyConfig;
|
||||
const { title, message } = this.formatMessage(payload);
|
||||
|
||||
const serverUrl = (ntfyConfig.serverUrl || DEFAULT_SERVER_URL).replace(/\/+$/, '');
|
||||
const url = `${serverUrl}/${ntfyConfig.topic}`;
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
if (ntfyConfig.accessToken) {
|
||||
headers['Authorization'] = `Bearer ${ntfyConfig.accessToken}`;
|
||||
}
|
||||
|
||||
const body = {
|
||||
topic: ntfyConfig.topic,
|
||||
title,
|
||||
message,
|
||||
priority: ntfyConfig.priority ?? NTFY_PRIORITIES[payload.event],
|
||||
tags: NTFY_TAGS[payload.event],
|
||||
};
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text().catch(() => 'Unknown error');
|
||||
throw new Error(`ntfy API failed: ${response.status} ${errorText}`);
|
||||
}
|
||||
}
|
||||
|
||||
private formatMessage(payload: NotificationPayload): { title: string; message: string } {
|
||||
const { event, title, author, userName, message } = payload;
|
||||
|
||||
const eventTitles = {
|
||||
request_pending_approval: 'New Request Pending Approval',
|
||||
request_approved: 'Request Approved',
|
||||
request_available: 'Audiobook Available',
|
||||
request_error: 'Request Error',
|
||||
};
|
||||
|
||||
const messageLines = [
|
||||
`📚 ${title}`,
|
||||
`✍️ ${author}`,
|
||||
`👤 Requested by: ${userName}`,
|
||||
];
|
||||
|
||||
if (message) {
|
||||
messageLines.push(`⚠️ Error: ${message}`);
|
||||
}
|
||||
|
||||
return {
|
||||
title: eventTitles[event],
|
||||
message: messageLines.join('\n'),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Component: Pushover Notification Provider
|
||||
* Documentation: documentation/backend/services/notifications.md
|
||||
*/
|
||||
|
||||
import { INotificationProvider, NotificationPayload, ProviderMetadata } from '../INotificationProvider';
|
||||
|
||||
export interface PushoverConfig {
|
||||
userKey: string;
|
||||
appToken: string;
|
||||
device?: string;
|
||||
priority?: number;
|
||||
}
|
||||
|
||||
// Pushover priorities by event type
|
||||
const PUSHOVER_PRIORITIES = {
|
||||
request_pending_approval: 0, // Normal
|
||||
request_approved: 0, // Normal
|
||||
request_available: 1, // High
|
||||
request_error: 1, // High
|
||||
};
|
||||
|
||||
export class PushoverProvider implements INotificationProvider {
|
||||
type = 'pushover' as const;
|
||||
sensitiveFields = ['userKey', 'appToken'];
|
||||
metadata: ProviderMetadata = {
|
||||
type: 'pushover',
|
||||
displayName: 'Pushover',
|
||||
description: 'Send notifications via Pushover API',
|
||||
iconLabel: 'P',
|
||||
iconColor: 'bg-blue-500',
|
||||
configFields: [
|
||||
{ name: 'userKey', label: 'User Key', type: 'text', required: true, placeholder: 'Your Pushover user key' },
|
||||
{ name: 'appToken', label: 'App Token', type: 'text', required: true, placeholder: 'Your Pushover app token' },
|
||||
{ name: 'device', label: 'Device', type: 'text', required: false, placeholder: 'Optional device name' },
|
||||
{
|
||||
name: 'priority', label: 'Priority', type: 'select', required: false, defaultValue: 0,
|
||||
options: [
|
||||
{ label: 'Lowest', value: -2 },
|
||||
{ label: 'Low', value: -1 },
|
||||
{ label: 'Normal', value: 0 },
|
||||
{ label: 'High', value: 1 },
|
||||
{ label: 'Emergency', value: 2 },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
async send(config: Record<string, any>, payload: NotificationPayload): Promise<void> {
|
||||
const pushoverConfig = config as unknown as PushoverConfig;
|
||||
const { title, message } = this.formatMessage(payload);
|
||||
|
||||
const body = new URLSearchParams({
|
||||
token: pushoverConfig.appToken,
|
||||
user: pushoverConfig.userKey,
|
||||
title,
|
||||
message,
|
||||
priority: String(pushoverConfig.priority ?? PUSHOVER_PRIORITIES[payload.event]),
|
||||
...(pushoverConfig.device && { device: pushoverConfig.device }),
|
||||
});
|
||||
|
||||
const response = await fetch('https://api.pushover.net/1/messages.json', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: body.toString(),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text().catch(() => 'Unknown error');
|
||||
throw new Error(`Pushover API failed: ${response.status} ${errorText}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
if (result.status !== 1) {
|
||||
throw new Error(`Pushover API error: ${JSON.stringify(result.errors || 'Unknown error')}`);
|
||||
}
|
||||
}
|
||||
|
||||
private formatMessage(payload: NotificationPayload): { title: string; message: string } {
|
||||
const { event, title, author, userName, message } = payload;
|
||||
|
||||
let eventTitle = '';
|
||||
let eventEmoji = '';
|
||||
|
||||
switch (event) {
|
||||
case 'request_pending_approval':
|
||||
eventTitle = 'New Request Pending Approval';
|
||||
eventEmoji = '📬';
|
||||
break;
|
||||
case 'request_approved':
|
||||
eventTitle = 'Request Approved';
|
||||
eventEmoji = '✅';
|
||||
break;
|
||||
case 'request_available':
|
||||
eventTitle = 'Audiobook Available';
|
||||
eventEmoji = '🎉';
|
||||
break;
|
||||
case 'request_error':
|
||||
eventTitle = 'Request Error';
|
||||
eventEmoji = '❌';
|
||||
break;
|
||||
}
|
||||
|
||||
const messageLines = [
|
||||
`${eventEmoji} ${eventTitle}`,
|
||||
'',
|
||||
`📚 ${title}`,
|
||||
`✍️ ${author}`,
|
||||
`👤 Requested by: ${userName}`,
|
||||
];
|
||||
|
||||
if (message) {
|
||||
messageLines.push('', `⚠️ Error: ${message}`);
|
||||
}
|
||||
|
||||
return {
|
||||
title: eventTitle,
|
||||
message: messageLines.join('\n'),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Component: Stream-based File Copy Utility
|
||||
* Documentation: documentation/phase3/file-organization.md
|
||||
*
|
||||
* Uses read()/write() syscalls via Node.js streams instead of fs.copyFile(),
|
||||
* which relies on copy_file_range() — a syscall that fails with EPERM on
|
||||
* certain filesystem configurations (e.g. cross-export NFS4 mounts).
|
||||
*/
|
||||
|
||||
import { createReadStream, createWriteStream } from 'fs';
|
||||
import { pipeline } from 'stream/promises';
|
||||
|
||||
/**
|
||||
* Copy a file using streams.
|
||||
*
|
||||
* Equivalent to `fs.copyFile()` but uses standard read/write syscalls
|
||||
* instead of `copy_file_range()`, ensuring compatibility with NFS, FUSE,
|
||||
* and other network/virtual filesystems.
|
||||
*/
|
||||
export async function copyFile(source: string, destination: string): Promise<void> {
|
||||
await pipeline(createReadStream(source), createWriteStream(destination));
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import path from 'path';
|
||||
import axios from 'axios';
|
||||
import { tagMultipleFiles, checkFfmpegAvailable } from './metadata-tagger';
|
||||
import { RMABLogger } from './logger';
|
||||
import { copyFile } from './copy-file';
|
||||
|
||||
const moduleLogger = RMABLogger.create('FileOrganizer');
|
||||
import {
|
||||
@@ -340,8 +341,8 @@ export class FileOrganizer {
|
||||
|
||||
// Copy file (do NOT delete original - needed for seeding)
|
||||
try {
|
||||
// Copy file using streaming (handles large files >2GB)
|
||||
await fs.copyFile(sourcePath, targetFilePath);
|
||||
// Copy file via streams (avoids copy_file_range EPERM on NFS/FUSE)
|
||||
await copyFile(sourcePath, targetFilePath);
|
||||
// Set explicit permissions after copy
|
||||
await fs.chmod(targetFilePath, 0o644);
|
||||
|
||||
@@ -378,7 +379,7 @@ export class FileOrganizer {
|
||||
await logger?.info(`Attempting fallback copy of original (untagged) file: ${filename}`);
|
||||
try {
|
||||
await fs.access(originalSourcePath, fs.constants.R_OK);
|
||||
await fs.copyFile(originalSourcePath, targetFilePath);
|
||||
await copyFile(originalSourcePath, targetFilePath);
|
||||
await fs.chmod(targetFilePath, 0o644);
|
||||
result.audioFiles.push(targetFilePath);
|
||||
result.filesMovedCount++;
|
||||
@@ -413,7 +414,7 @@ export class FileOrganizer {
|
||||
|
||||
try {
|
||||
// Copy cover art (do NOT delete original)
|
||||
await fs.copyFile(sourcePath, targetCoverPath);
|
||||
await copyFile(sourcePath, targetCoverPath);
|
||||
await fs.chmod(targetCoverPath, 0o644);
|
||||
result.coverArtFile = targetCoverPath;
|
||||
result.filesMovedCount++;
|
||||
@@ -608,7 +609,7 @@ export class FileOrganizer {
|
||||
const cachedPath = path.join('/app/cache/thumbnails', filename);
|
||||
|
||||
// Copy from local cache instead of downloading
|
||||
await fs.copyFile(cachedPath, targetPath);
|
||||
await copyFile(cachedPath, targetPath);
|
||||
await fs.chmod(targetPath, 0o644);
|
||||
moduleLogger.debug(`Copied cover art from cache: ${filename}`);
|
||||
} else {
|
||||
@@ -755,7 +756,7 @@ export class FileOrganizer {
|
||||
}
|
||||
|
||||
// Copy ebook file (do NOT delete original - may need for seeding or retry)
|
||||
await fs.copyFile(sourceFilePath, targetPath);
|
||||
await copyFile(sourceFilePath, targetPath);
|
||||
await fs.chmod(targetPath, 0o644);
|
||||
|
||||
await logger?.info(`Copied ebook: ${targetFilename}`);
|
||||
|
||||
@@ -37,6 +37,14 @@ const VALID_VARIABLES = ['author', 'title', 'narrator', 'asin', 'year', 'series'
|
||||
*/
|
||||
const INVALID_PATH_CHARS = /[<>:"|?*]/;
|
||||
|
||||
/**
|
||||
* Placeholder characters for escaped braces during substitution.
|
||||
* Uses Unicode Private Use Area characters that won't appear in metadata
|
||||
* and won't be affected by path cleanup operations.
|
||||
*/
|
||||
const LBRACE_PLACEHOLDER = '\uE000';
|
||||
const RBRACE_PLACEHOLDER = '\uE001';
|
||||
|
||||
/**
|
||||
* Sanitize a path component by removing invalid characters
|
||||
* Reuses logic from file-organizer.ts
|
||||
@@ -87,6 +95,10 @@ export function substituteTemplate(
|
||||
): string {
|
||||
let result = template;
|
||||
|
||||
// Replace escaped braces with placeholders before any processing,
|
||||
// so they survive the variable substitution and path cleanup steps
|
||||
result = result.replace(/\\\{/g, LBRACE_PLACEHOLDER).replace(/\\\}/g, RBRACE_PLACEHOLDER);
|
||||
|
||||
// Substitute each variable
|
||||
for (const key of VALID_VARIABLES) {
|
||||
const value = variables[key as keyof TemplateVariables];
|
||||
@@ -120,6 +132,11 @@ export function substituteTemplate(
|
||||
.filter(part => part.length > 0)
|
||||
.join('/');
|
||||
|
||||
// Resolve escaped brace placeholders as the final step,
|
||||
// after all variable substitution and path cleanup is complete
|
||||
result = result.replace(new RegExp(LBRACE_PLACEHOLDER, 'g'), '{');
|
||||
result = result.replace(new RegExp(RBRACE_PLACEHOLDER, 'g'), '}');
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -153,16 +170,20 @@ export function validateTemplate(template: string): ValidationResult {
|
||||
};
|
||||
}
|
||||
|
||||
// Check for absolute paths
|
||||
if (template.startsWith('/') || template.startsWith('\\') || /^[a-zA-Z]:/.test(template)) {
|
||||
// Check for absolute paths (backslash followed by { or } is a brace escape, not a path)
|
||||
if (template.startsWith('/') || /^\\(?![{}])/.test(template) || /^[a-zA-Z]:/.test(template)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Template must be a relative path (no absolute paths like "/" or "C:\\")'
|
||||
};
|
||||
}
|
||||
|
||||
// Extract all variables from template
|
||||
const variableMatches = template.match(/\{[^}]+\}/g);
|
||||
// Strip escaped braces (\{ and \}) before parsing so they don't interfere
|
||||
// with variable extraction or character validation
|
||||
const templateWithoutEscapedBraces = template.replace(/\\[{}]/g, '');
|
||||
|
||||
// Extract all variables from the stripped template
|
||||
const variableMatches = templateWithoutEscapedBraces.match(/\{[^}]+\}/g);
|
||||
|
||||
if (variableMatches) {
|
||||
for (const match of variableMatches) {
|
||||
@@ -178,7 +199,7 @@ export function validateTemplate(template: string): ValidationResult {
|
||||
}
|
||||
|
||||
// Remove valid variables temporarily to check for invalid characters
|
||||
let templateWithoutVars = template;
|
||||
let templateWithoutVars = templateWithoutEscapedBraces;
|
||||
for (const varName of VALID_VARIABLES) {
|
||||
templateWithoutVars = templateWithoutVars.replace(new RegExp(`\\{${varName}\\}`, 'g'), '');
|
||||
}
|
||||
@@ -192,8 +213,9 @@ export function validateTemplate(template: string): ValidationResult {
|
||||
};
|
||||
}
|
||||
|
||||
// Check for backslashes (Windows-style paths)
|
||||
if (templateWithoutVars.includes('\\')) {
|
||||
// Check for backslashes that are not brace escapes (Windows-style paths)
|
||||
// We check the original template: any backslash NOT followed by { or } is invalid
|
||||
if (/\\(?![{}])/.test(template)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Use forward slashes (/) for path separators, not backslashes (\\)'
|
||||
|
||||
@@ -30,8 +30,9 @@ vi.mock('@/lib/middleware/auth', () => ({
|
||||
requireAdmin: requireAdminMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/notification.service', () => ({
|
||||
vi.mock('@/lib/services/notification', () => ({
|
||||
getNotificationService: () => notificationServiceMock,
|
||||
getRegisteredProviderTypes: () => ['discord', 'ntfy', 'pushover'],
|
||||
}));
|
||||
|
||||
describe('Admin notifications test route', () => {
|
||||
|
||||
@@ -35,8 +35,9 @@ vi.mock('@/lib/middleware/auth', () => ({
|
||||
requireAdmin: requireAdminMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/notification.service', () => ({
|
||||
vi.mock('@/lib/services/notification', () => ({
|
||||
getNotificationService: () => notificationServiceMock,
|
||||
getRegisteredProviderTypes: () => ['discord', 'ntfy', 'pushover'],
|
||||
}));
|
||||
|
||||
describe('Admin notifications routes', () => {
|
||||
|
||||
@@ -13,6 +13,7 @@ const configServiceMock = vi.hoisted(() => ({
|
||||
}));
|
||||
const prowlarrMock = vi.hoisted(() => ({
|
||||
search: vi.fn(),
|
||||
searchWithVariations: vi.fn(),
|
||||
}));
|
||||
const rankTorrentsMock = vi.hoisted(() => vi.fn());
|
||||
const groupIndexersMock = vi.hoisted(() => vi.fn());
|
||||
@@ -68,7 +69,7 @@ describe('Audiobooks search torrents route', () => {
|
||||
.mockResolvedValueOnce(null);
|
||||
|
||||
groupIndexersMock.mockReturnValue({ groups: [{ categories: [1], indexerIds: [1] }], skippedIndexers: [] });
|
||||
prowlarrMock.search.mockResolvedValue([{ title: 'Result', size: 100, indexer: 'Indexer', indexerId: 1 }]);
|
||||
prowlarrMock.searchWithVariations.mockResolvedValue([{ title: 'Result', size: 100, indexer: 'Indexer', indexerId: 1 }]);
|
||||
rankTorrentsMock.mockReturnValue([
|
||||
{
|
||||
title: 'Result',
|
||||
|
||||
@@ -68,6 +68,32 @@ describe('Change password route', () => {
|
||||
expect(payload.error).toMatch(/at least 8 characters/i);
|
||||
});
|
||||
|
||||
it('allows short passwords when ALLOW_WEAK_PASSWORD is enabled', async () => {
|
||||
process.env.ALLOW_WEAK_PASSWORD = 'true';
|
||||
prismaMock.user.findUnique.mockResolvedValue({
|
||||
id: 'user-1',
|
||||
authProvider: 'local',
|
||||
authToken: 'enc-hash',
|
||||
plexId: 'local-user',
|
||||
plexUsername: 'user',
|
||||
});
|
||||
encryptionMock.decrypt.mockReturnValue('hash');
|
||||
bcryptMock.compare.mockResolvedValue(true);
|
||||
bcryptMock.hash.mockResolvedValue('new-hash');
|
||||
encryptionMock.encrypt.mockReturnValue('enc-new-hash');
|
||||
prismaMock.user.update.mockResolvedValue({});
|
||||
const { POST } = await import('@/app/api/auth/change-password/route');
|
||||
|
||||
const response = await POST(
|
||||
makeRequest({ currentPassword: 'oldpass', newPassword: 'ab', confirmPassword: 'ab' }) as any
|
||||
);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(payload.success).toBe(true);
|
||||
delete process.env.ALLOW_WEAK_PASSWORD;
|
||||
});
|
||||
|
||||
it('blocks non-local users', async () => {
|
||||
prismaMock.user.findUnique.mockResolvedValue({
|
||||
id: 'user-1',
|
||||
|
||||
@@ -149,6 +149,15 @@ describe('BookDate test connection route', () => {
|
||||
it('returns Claude models for unauthenticated requests', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue({
|
||||
data: [
|
||||
{ id: 'claude-sonnet-4-5-20250929', display_name: 'Claude Sonnet 4.5', type: 'model', created_at: '2025-09-29T00:00:00Z' },
|
||||
{ id: 'claude-haiku-4-5-20251001', display_name: 'Claude Haiku 4.5', type: 'model', created_at: '2025-10-01T00:00:00Z' },
|
||||
],
|
||||
has_more: false,
|
||||
first_id: 'claude-sonnet-4-5-20250929',
|
||||
last_id: 'claude-haiku-4-5-20251001',
|
||||
}),
|
||||
text: vi.fn().mockResolvedValue('ok'),
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
@@ -161,7 +170,142 @@ describe('BookDate test connection route', () => {
|
||||
|
||||
const payload = await response.json();
|
||||
expect(payload.success).toBe(true);
|
||||
expect(payload.models.length).toBe(4);
|
||||
expect(payload.models).toEqual([
|
||||
{ id: 'claude-sonnet-4-5-20250929', name: 'Claude Sonnet 4.5' },
|
||||
{ id: 'claude-haiku-4-5-20251001', name: 'Claude Haiku 4.5' },
|
||||
]);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
expect.stringContaining('https://api.anthropic.com/v1/models'),
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({ 'x-api-key': 'key' }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('returns Claude models for authenticated requests', async () => {
|
||||
requireAuthMock.mockImplementation((_req: any, handler: any) => handler(_req));
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue({
|
||||
data: [
|
||||
{ id: 'claude-opus-4-20250514', display_name: 'Claude Opus 4', type: 'model', created_at: '2025-05-14T00:00:00Z' },
|
||||
],
|
||||
has_more: false,
|
||||
first_id: 'claude-opus-4-20250514',
|
||||
last_id: 'claude-opus-4-20250514',
|
||||
}),
|
||||
text: vi.fn().mockResolvedValue('ok'),
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const { POST } = await import('@/app/api/bookdate/test-connection/route');
|
||||
const response = await POST({
|
||||
headers: { get: () => 'Bearer token' },
|
||||
json: vi.fn().mockResolvedValue({ provider: 'claude', apiKey: 'key' }),
|
||||
} as any);
|
||||
|
||||
const payload = await response.json();
|
||||
expect(payload.success).toBe(true);
|
||||
expect(payload.models).toEqual([
|
||||
{ id: 'claude-opus-4-20250514', name: 'Claude Opus 4' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns error for invalid Claude API key', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
text: vi.fn().mockResolvedValue('{"type":"error","error":{"type":"authentication_error","message":"invalid x-api-key"}}'),
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const { POST } = await import('@/app/api/bookdate/test-connection/route');
|
||||
const response = await POST({
|
||||
headers: { get: () => null },
|
||||
json: vi.fn().mockResolvedValue({ provider: 'claude', apiKey: 'bad-key' }),
|
||||
} as any);
|
||||
|
||||
const payload = await response.json();
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toMatch(/Invalid Claude API key/i);
|
||||
});
|
||||
|
||||
it('paginates through Claude models when has_more is true', async () => {
|
||||
let callCount = 0;
|
||||
const fetchMock = vi.fn().mockImplementation(() => {
|
||||
callCount++;
|
||||
if (callCount === 1) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue({
|
||||
data: [
|
||||
{ id: 'claude-sonnet-4-5-20250929', display_name: 'Claude Sonnet 4.5', type: 'model', created_at: '2025-09-29T00:00:00Z' },
|
||||
],
|
||||
has_more: true,
|
||||
first_id: 'claude-sonnet-4-5-20250929',
|
||||
last_id: 'claude-sonnet-4-5-20250929',
|
||||
}),
|
||||
text: vi.fn().mockResolvedValue('ok'),
|
||||
});
|
||||
}
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue({
|
||||
data: [
|
||||
{ id: 'claude-haiku-4-5-20251001', display_name: 'Claude Haiku 4.5', type: 'model', created_at: '2025-10-01T00:00:00Z' },
|
||||
],
|
||||
has_more: false,
|
||||
first_id: 'claude-haiku-4-5-20251001',
|
||||
last_id: 'claude-haiku-4-5-20251001',
|
||||
}),
|
||||
text: vi.fn().mockResolvedValue('ok'),
|
||||
});
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const { POST } = await import('@/app/api/bookdate/test-connection/route');
|
||||
const response = await POST({
|
||||
headers: { get: () => null },
|
||||
json: vi.fn().mockResolvedValue({ provider: 'claude', apiKey: 'key' }),
|
||||
} as any);
|
||||
|
||||
const payload = await response.json();
|
||||
expect(payload.success).toBe(true);
|
||||
expect(payload.models).toEqual([
|
||||
{ id: 'claude-sonnet-4-5-20250929', name: 'Claude Sonnet 4.5' },
|
||||
{ id: 'claude-haiku-4-5-20251001', name: 'Claude Haiku 4.5' },
|
||||
]);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
// Second call should include after_id for pagination
|
||||
expect(fetchMock.mock.calls[1][0]).toContain('after_id=claude-sonnet-4-5-20250929');
|
||||
});
|
||||
|
||||
it('falls back to model id when display_name is missing', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue({
|
||||
data: [
|
||||
{ id: 'claude-test-model', type: 'model', created_at: '2025-01-01T00:00:00Z' },
|
||||
],
|
||||
has_more: false,
|
||||
first_id: 'claude-test-model',
|
||||
last_id: 'claude-test-model',
|
||||
}),
|
||||
text: vi.fn().mockResolvedValue('ok'),
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const { POST } = await import('@/app/api/bookdate/test-connection/route');
|
||||
const response = await POST({
|
||||
headers: { get: () => null },
|
||||
json: vi.fn().mockResolvedValue({ provider: 'claude', apiKey: 'key' }),
|
||||
} as any);
|
||||
|
||||
const payload = await response.json();
|
||||
expect(payload.success).toBe(true);
|
||||
expect(payload.models).toEqual([
|
||||
{ id: 'claude-test-model', name: 'claude-test-model' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns OpenAI error for unauthenticated requests with invalid key', async () => {
|
||||
|
||||
@@ -10,9 +10,11 @@ let authRequest: any;
|
||||
|
||||
const prismaMock = createPrismaMock();
|
||||
const requireAuthMock = vi.hoisted(() => vi.fn());
|
||||
const prowlarrMock = vi.hoisted(() => ({ search: vi.fn() }));
|
||||
const prowlarrMock = vi.hoisted(() => ({ search: vi.fn(), searchWithVariations: vi.fn() }));
|
||||
const rankTorrentsMock = vi.hoisted(() => vi.fn());
|
||||
const configServiceMock = vi.hoisted(() => ({ get: vi.fn() }));
|
||||
const groupIndexersMock = vi.hoisted(() => vi.fn());
|
||||
const groupDescriptionMock = vi.hoisted(() => vi.fn(() => 'Group'));
|
||||
const configState = vi.hoisted(() => ({
|
||||
values: new Map<string, string>(),
|
||||
}));
|
||||
@@ -23,6 +25,9 @@ const jobQueueMock = vi.hoisted(() => ({
|
||||
addSearchEbookJob: vi.fn(() => Promise.resolve()),
|
||||
}));
|
||||
const downloadEbookMock = vi.hoisted(() => vi.fn());
|
||||
const audibleServiceMock = vi.hoisted(() => ({
|
||||
getRuntime: vi.fn(),
|
||||
}));
|
||||
const fsMock = vi.hoisted(() => ({
|
||||
access: vi.fn(),
|
||||
}));
|
||||
@@ -44,6 +49,11 @@ vi.mock('@/lib/utils/ranking-algorithm', () => ({
|
||||
rankTorrents: rankTorrentsMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/utils/indexer-grouping', () => ({
|
||||
groupIndexersByCategories: groupIndexersMock,
|
||||
getGroupDescription: groupDescriptionMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/config.service', () => ({
|
||||
getConfigService: () => configServiceMock,
|
||||
}));
|
||||
@@ -56,6 +66,10 @@ vi.mock('@/lib/services/ebook-scraper', () => ({
|
||||
downloadEbook: downloadEbookMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/integrations/audible.service', () => ({
|
||||
getAudibleService: () => audibleServiceMock,
|
||||
}));
|
||||
|
||||
vi.mock('fs/promises', () => ({ default: fsMock, ...fsMock, constants: { R_OK: 4 } }));
|
||||
|
||||
describe('Request action routes', () => {
|
||||
@@ -72,22 +86,24 @@ describe('Request action routes', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('performs interactive search and ranks results', async () => {
|
||||
it('performs interactive search and ranks results with runtime from ASIN', async () => {
|
||||
authRequest.json.mockResolvedValue({});
|
||||
prismaMock.request.findUnique.mockResolvedValueOnce({
|
||||
id: 'req-1',
|
||||
userId: 'user-1',
|
||||
audiobook: { title: 'Title', author: 'Author' },
|
||||
audiobook: { title: 'Title', author: 'Author', audibleAsin: 'B00ASIN123' },
|
||||
});
|
||||
prismaMock.user.findUnique.mockResolvedValueOnce({
|
||||
role: 'user',
|
||||
interactiveSearchAccess: null,
|
||||
});
|
||||
configServiceMock.get.mockResolvedValueOnce(JSON.stringify([{ id: 1, priority: 10 }]));
|
||||
configServiceMock.get.mockResolvedValueOnce(JSON.stringify([{ id: 1, priority: 10, categories: [3030] }]));
|
||||
configServiceMock.get.mockResolvedValueOnce(null);
|
||||
prowlarrMock.search.mockResolvedValueOnce([{ title: 'Result', size: 100 }]);
|
||||
groupIndexersMock.mockReturnValue({ groups: [{ categories: [3030], indexerIds: [1] }], skippedIndexers: [] });
|
||||
prowlarrMock.searchWithVariations.mockResolvedValueOnce([{ title: 'Result', size: 500 * 1024 * 1024 }]);
|
||||
audibleServiceMock.getRuntime.mockResolvedValueOnce(600);
|
||||
rankTorrentsMock.mockReturnValueOnce([
|
||||
{ title: 'Result', score: 50, breakdown: { matchScore: 50, formatScore: 0, seederScore: 0, notes: [] }, bonusPoints: 0, bonusModifiers: [], finalScore: 50 },
|
||||
{ title: 'Result', size: 500 * 1024 * 1024, score: 50, breakdown: { matchScore: 50, formatScore: 0, sizeScore: 12, seederScore: 0, notes: [] }, bonusPoints: 0, bonusModifiers: [], finalScore: 62 },
|
||||
]);
|
||||
|
||||
const { POST } = await import('@/app/api/requests/[id]/interactive-search/route');
|
||||
@@ -96,6 +112,77 @@ describe('Request action routes', () => {
|
||||
|
||||
expect(payload.success).toBe(true);
|
||||
expect(payload.results[0].rank).toBe(1);
|
||||
expect(audibleServiceMock.getRuntime).toHaveBeenCalledWith('B00ASIN123');
|
||||
expect(rankTorrentsMock).toHaveBeenCalledWith(
|
||||
expect.any(Array),
|
||||
expect.objectContaining({ title: 'Title', author: 'Author', durationMinutes: 600 }),
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it('performs interactive search without runtime when no ASIN', async () => {
|
||||
authRequest.json.mockResolvedValue({});
|
||||
prismaMock.request.findUnique.mockResolvedValueOnce({
|
||||
id: 'req-1b',
|
||||
userId: 'user-1',
|
||||
audiobook: { title: 'Title', author: 'Author', audibleAsin: null },
|
||||
});
|
||||
prismaMock.user.findUnique.mockResolvedValueOnce({
|
||||
role: 'user',
|
||||
interactiveSearchAccess: null,
|
||||
});
|
||||
configServiceMock.get.mockResolvedValueOnce(JSON.stringify([{ id: 1, priority: 10, categories: [3030] }]));
|
||||
configServiceMock.get.mockResolvedValueOnce(null);
|
||||
groupIndexersMock.mockReturnValue({ groups: [{ categories: [3030], indexerIds: [1] }], skippedIndexers: [] });
|
||||
prowlarrMock.searchWithVariations.mockResolvedValueOnce([{ title: 'Result', size: 100 }]);
|
||||
rankTorrentsMock.mockReturnValueOnce([
|
||||
{ title: 'Result', size: 100, score: 50, breakdown: { matchScore: 50, formatScore: 0, sizeScore: 0, seederScore: 0, notes: [] }, bonusPoints: 0, bonusModifiers: [], finalScore: 50 },
|
||||
]);
|
||||
|
||||
const { POST } = await import('@/app/api/requests/[id]/interactive-search/route');
|
||||
const response = await POST({} as any, { params: Promise.resolve({ id: 'req-1b' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.success).toBe(true);
|
||||
expect(audibleServiceMock.getRuntime).not.toHaveBeenCalled();
|
||||
expect(rankTorrentsMock).toHaveBeenCalledWith(
|
||||
expect.any(Array),
|
||||
expect.objectContaining({ title: 'Title', author: 'Author', durationMinutes: undefined }),
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it('performs interactive search gracefully when runtime fetch fails', async () => {
|
||||
authRequest.json.mockResolvedValue({});
|
||||
prismaMock.request.findUnique.mockResolvedValueOnce({
|
||||
id: 'req-1c',
|
||||
userId: 'user-1',
|
||||
audiobook: { title: 'Title', author: 'Author', audibleAsin: 'B00FAIL' },
|
||||
});
|
||||
prismaMock.user.findUnique.mockResolvedValueOnce({
|
||||
role: 'user',
|
||||
interactiveSearchAccess: null,
|
||||
});
|
||||
configServiceMock.get.mockResolvedValueOnce(JSON.stringify([{ id: 1, priority: 10, categories: [3030] }]));
|
||||
configServiceMock.get.mockResolvedValueOnce(null);
|
||||
groupIndexersMock.mockReturnValue({ groups: [{ categories: [3030], indexerIds: [1] }], skippedIndexers: [] });
|
||||
prowlarrMock.searchWithVariations.mockResolvedValueOnce([{ title: 'Result', size: 100 }]);
|
||||
audibleServiceMock.getRuntime.mockRejectedValueOnce(new Error('Network error'));
|
||||
rankTorrentsMock.mockReturnValueOnce([
|
||||
{ title: 'Result', size: 100, score: 50, breakdown: { matchScore: 50, formatScore: 0, sizeScore: 0, seederScore: 0, notes: [] }, bonusPoints: 0, bonusModifiers: [], finalScore: 50 },
|
||||
]);
|
||||
|
||||
const { POST } = await import('@/app/api/requests/[id]/interactive-search/route');
|
||||
const response = await POST({} as any, { params: Promise.resolve({ id: 'req-1c' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.success).toBe(true);
|
||||
expect(payload.results).toHaveLength(1);
|
||||
expect(rankTorrentsMock).toHaveBeenCalledWith(
|
||||
expect.any(Array),
|
||||
expect.objectContaining({ durationMinutes: undefined }),
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it('triggers manual search job', async () => {
|
||||
|
||||
@@ -24,6 +24,7 @@ const AudiobookshelfHarness = ({
|
||||
absApiToken: 'token',
|
||||
absLibraryId: '',
|
||||
absTriggerScanAfterImport: false,
|
||||
absLibraries: [] as { id: string; name: string; itemCount: number }[],
|
||||
...initialState,
|
||||
});
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ const PlexHarness = ({
|
||||
plexToken: 'token',
|
||||
plexLibraryId: '',
|
||||
plexTriggerScanAfterImport: false,
|
||||
plexLibraries: [] as { id: string; title: string; type: string }[],
|
||||
...initialState,
|
||||
});
|
||||
|
||||
|
||||
@@ -100,8 +100,8 @@ describe('substituteTemplate', () => {
|
||||
expect(result).toBe('Author/Title/Narrator');
|
||||
});
|
||||
|
||||
it('should handle mixed forward and backward slashes', () => {
|
||||
const template = '{author}\\{title}/{narrator}';
|
||||
it('should resolve escaped braces to literal brace characters', () => {
|
||||
const template = '{author}/\\{{narrator}\\}/{title}';
|
||||
const variables: TemplateVariables = {
|
||||
author: 'Author',
|
||||
title: 'Title',
|
||||
@@ -109,7 +109,7 @@ describe('substituteTemplate', () => {
|
||||
};
|
||||
|
||||
const result = substituteTemplate(template, variables);
|
||||
expect(result).toBe('Author/Title/Narrator');
|
||||
expect(result).toBe('Author/{Narrator}/Title');
|
||||
});
|
||||
|
||||
it('should trim dots from path components', () => {
|
||||
@@ -145,6 +145,74 @@ describe('substituteTemplate', () => {
|
||||
const result = substituteTemplate(template, variables);
|
||||
expect(result).toBe('Audiobooks/Author/Books/Title');
|
||||
});
|
||||
|
||||
it('should resolve escaped left brace only', () => {
|
||||
const template = '{author}/\\{prefix {title}';
|
||||
const variables: TemplateVariables = {
|
||||
author: 'Author',
|
||||
title: 'Title'
|
||||
};
|
||||
|
||||
const result = substituteTemplate(template, variables);
|
||||
expect(result).toBe('Author/{prefix Title');
|
||||
});
|
||||
|
||||
it('should resolve escaped right brace only', () => {
|
||||
const template = '{author}/{title} suffix\\}';
|
||||
const variables: TemplateVariables = {
|
||||
author: 'Author',
|
||||
title: 'Title'
|
||||
};
|
||||
|
||||
const result = substituteTemplate(template, variables);
|
||||
expect(result).toBe('Author/Title suffix}');
|
||||
});
|
||||
|
||||
it('should resolve multiple escaped brace pairs', () => {
|
||||
const template = '\\{{author}\\}/\\{{title}\\}';
|
||||
const variables: TemplateVariables = {
|
||||
author: 'Author',
|
||||
title: 'Title'
|
||||
};
|
||||
|
||||
const result = substituteTemplate(template, variables);
|
||||
expect(result).toBe('{Author}/{Title}');
|
||||
});
|
||||
|
||||
it('should handle escaped braces with missing optional variable', () => {
|
||||
const template = '{author}/\\{{narrator}\\}/{title}';
|
||||
const variables: TemplateVariables = {
|
||||
author: 'Author',
|
||||
title: 'Title'
|
||||
// narrator is missing
|
||||
};
|
||||
|
||||
const result = substituteTemplate(template, variables);
|
||||
expect(result).toBe('Author/{}/Title');
|
||||
});
|
||||
|
||||
it('should handle escaped braces adjacent to path separators', () => {
|
||||
const template = '{author}/\\{{narrator}\\}/{title}';
|
||||
const variables: TemplateVariables = {
|
||||
author: 'Author',
|
||||
title: 'Title',
|
||||
narrator: 'Michael Kramer'
|
||||
};
|
||||
|
||||
const result = substituteTemplate(template, variables);
|
||||
expect(result).toBe('Author/{Michael Kramer}/Title');
|
||||
});
|
||||
|
||||
it('should handle escaped braces around static text', () => {
|
||||
const template = '{author}/\\{narrated\\}/{title}';
|
||||
const variables: TemplateVariables = {
|
||||
author: 'Author',
|
||||
title: 'Title'
|
||||
};
|
||||
|
||||
const result = substituteTemplate(template, variables);
|
||||
expect(result).toBe('Author/{narrated}/Title');
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateTemplate', () => {
|
||||
@@ -205,8 +273,8 @@ describe('validateTemplate', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should reject backslashes in template', () => {
|
||||
const result = validateTemplate('{author}\\{title}');
|
||||
it('should reject backslashes that are not brace escapes', () => {
|
||||
const result = validateTemplate('{author}\\n{title}');
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toContain('forward slashes');
|
||||
});
|
||||
@@ -230,6 +298,42 @@ describe('validateTemplate', () => {
|
||||
expect(result.error).toContain('{narrator}');
|
||||
expect(result.error).toContain('{asin}');
|
||||
});
|
||||
|
||||
it('should accept escaped braces around a variable', () => {
|
||||
const result = validateTemplate('{author}/\\{{narrator}\\}/{title}');
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('should accept escaped braces around static text', () => {
|
||||
const result = validateTemplate('{author}/\\{custom\\}/{title}');
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('should accept escaped left brace only', () => {
|
||||
const result = validateTemplate('{author}/\\{prefix {title}');
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('should accept escaped right brace only', () => {
|
||||
const result = validateTemplate('{author}/{title} suffix\\}');
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('should accept multiple escaped brace pairs', () => {
|
||||
const result = validateTemplate('\\{{author}\\}/\\{{title}\\}');
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('should accept backslash before brace but reject backslash before other characters', () => {
|
||||
const result = validateTemplate('{author}\\n/\\{{title}\\}');
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toContain('forward slashes');
|
||||
});
|
||||
|
||||
it('should accept a template that is only escaped braces', () => {
|
||||
const result = validateTemplate('\\{\\}');
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateMockPreviews', () => {
|
||||
@@ -305,6 +409,17 @@ describe('generateMockPreviews', () => {
|
||||
expect(preview).toContain(' - B');
|
||||
});
|
||||
});
|
||||
|
||||
it('should resolve escaped braces in previews', () => {
|
||||
const template = '{author}/\\{{narrator}\\}/{title}';
|
||||
const previews = generateMockPreviews(template);
|
||||
|
||||
// First two mock entries have narrators
|
||||
expect(previews[0]).toContain('{Michael Kramer}');
|
||||
expect(previews[1]).toContain('{Stephen Fry}');
|
||||
// Third mock entry has no narrator - escaped braces remain empty
|
||||
expect(previews[2]).toContain('{}');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getValidVariables', () => {
|
||||
|
||||
@@ -10,7 +10,7 @@ import { createJobQueueMock } from '../helpers/job-queue';
|
||||
const prismaMock = createPrismaMock();
|
||||
const configMock = vi.hoisted(() => ({ get: vi.fn() }));
|
||||
const jobQueueMock = createJobQueueMock();
|
||||
const prowlarrMock = vi.hoisted(() => ({ search: vi.fn() }));
|
||||
const prowlarrMock = vi.hoisted(() => ({ search: vi.fn(), searchWithVariations: vi.fn() }));
|
||||
|
||||
vi.mock('@/lib/db', () => ({
|
||||
prisma: prismaMock,
|
||||
@@ -44,7 +44,7 @@ describe('processSearchIndexers', () => {
|
||||
}
|
||||
return null;
|
||||
});
|
||||
prowlarrMock.search.mockResolvedValue([]);
|
||||
prowlarrMock.searchWithVariations.mockResolvedValue([]);
|
||||
prismaMock.request.update.mockResolvedValue({});
|
||||
|
||||
const { processSearchIndexers } = await import('@/lib/processors/search-indexers.processor');
|
||||
@@ -73,7 +73,7 @@ describe('processSearchIndexers', () => {
|
||||
return null;
|
||||
});
|
||||
|
||||
prowlarrMock.search.mockResolvedValue([
|
||||
prowlarrMock.searchWithVariations.mockResolvedValue([
|
||||
{
|
||||
indexer: 'Indexer',
|
||||
indexerId: 1,
|
||||
|
||||
@@ -9,7 +9,7 @@ const notificationServiceMock = vi.hoisted(() => ({
|
||||
sendNotification: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/notification.service', () => ({
|
||||
vi.mock('@/lib/services/notification', () => ({
|
||||
getNotificationService: () => notificationServiceMock,
|
||||
}));
|
||||
|
||||
|
||||
@@ -0,0 +1,473 @@
|
||||
/**
|
||||
* Component: Apprise Notification Provider Tests
|
||||
* Documentation: documentation/backend/services/notifications.md
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createPrismaMock } from '../helpers/prisma';
|
||||
|
||||
const prismaMock = createPrismaMock();
|
||||
prismaMock.notificationBackend = {
|
||||
findMany: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
} as any;
|
||||
|
||||
const encryptionMock = vi.hoisted(() => ({
|
||||
encrypt: vi.fn((value: string) => `enc:${value}`),
|
||||
decrypt: vi.fn((value: string) => value.replace('enc:', '')),
|
||||
}));
|
||||
|
||||
const fetchMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('@/lib/db', () => ({
|
||||
prisma: prismaMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/encryption.service', () => ({
|
||||
getEncryptionService: () => encryptionMock,
|
||||
}));
|
||||
|
||||
describe('AppriseProvider', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
});
|
||||
|
||||
describe('send — stateless mode (urls)', () => {
|
||||
it('sends notification to correct Apprise endpoint with JSON body', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
text: async () => 'ok',
|
||||
});
|
||||
|
||||
const { AppriseProvider } = await import('@/lib/services/notification');
|
||||
const provider = new AppriseProvider();
|
||||
|
||||
await provider.send(
|
||||
{
|
||||
serverUrl: 'http://apprise:8000',
|
||||
urls: 'slack://tokenA/tokenB/tokenC',
|
||||
authToken: 'mytoken123',
|
||||
},
|
||||
{
|
||||
event: 'request_approved',
|
||||
requestId: 'req-1',
|
||||
title: 'Test Book',
|
||||
author: 'Test Author',
|
||||
userName: 'Test User',
|
||||
timestamp: new Date('2024-01-01T00:00:00Z'),
|
||||
}
|
||||
);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
const fetchCall = fetchMock.mock.calls[0];
|
||||
expect(fetchCall[0]).toBe('http://apprise:8000/notify/');
|
||||
expect(fetchCall[1].method).toBe('POST');
|
||||
expect(fetchCall[1].headers['Content-Type']).toBe('application/json');
|
||||
expect(fetchCall[1].headers['Authorization']).toBe('Bearer mytoken123');
|
||||
|
||||
const body = JSON.parse(fetchCall[1].body);
|
||||
expect(body.urls).toBe('slack://tokenA/tokenB/tokenC');
|
||||
expect(body.title).toBe('Request Approved');
|
||||
expect(body.body).toContain('Test Book');
|
||||
expect(body.body).toContain('Test Author');
|
||||
expect(body.body).toContain('Test User');
|
||||
expect(body.type).toBe('success');
|
||||
});
|
||||
|
||||
it('strips trailing slashes from server URL', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
text: async () => 'ok',
|
||||
});
|
||||
|
||||
const { AppriseProvider } = await import('@/lib/services/notification');
|
||||
const provider = new AppriseProvider();
|
||||
|
||||
await provider.send(
|
||||
{ serverUrl: 'http://apprise:8000/', urls: 'slack://token' },
|
||||
{
|
||||
event: 'request_approved',
|
||||
requestId: 'req-1',
|
||||
title: 'Test Book',
|
||||
author: 'Test Author',
|
||||
userName: 'Test User',
|
||||
timestamp: new Date(),
|
||||
}
|
||||
);
|
||||
|
||||
const fetchCall = fetchMock.mock.calls[0];
|
||||
expect(fetchCall[0]).toBe('http://apprise:8000/notify/');
|
||||
});
|
||||
|
||||
it('does not include Authorization header when authToken is not provided', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
text: async () => 'ok',
|
||||
});
|
||||
|
||||
const { AppriseProvider } = await import('@/lib/services/notification');
|
||||
const provider = new AppriseProvider();
|
||||
|
||||
await provider.send(
|
||||
{ serverUrl: 'http://apprise:8000', urls: 'slack://token' },
|
||||
{
|
||||
event: 'request_approved',
|
||||
requestId: 'req-1',
|
||||
title: 'Test Book',
|
||||
author: 'Test Author',
|
||||
userName: 'Test User',
|
||||
timestamp: new Date(),
|
||||
}
|
||||
);
|
||||
|
||||
const fetchCall = fetchMock.mock.calls[0];
|
||||
expect(fetchCall[1].headers['Authorization']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('throws error when neither urls nor configKey is provided', async () => {
|
||||
const { AppriseProvider } = await import('@/lib/services/notification');
|
||||
const provider = new AppriseProvider();
|
||||
|
||||
await expect(
|
||||
provider.send(
|
||||
{ serverUrl: 'http://apprise:8000' },
|
||||
{
|
||||
event: 'request_approved',
|
||||
requestId: 'req-1',
|
||||
title: 'Test Book',
|
||||
author: 'Test Author',
|
||||
userName: 'Test User',
|
||||
timestamp: new Date(),
|
||||
}
|
||||
)
|
||||
).rejects.toThrow('Apprise requires either notification URLs or a config key');
|
||||
});
|
||||
});
|
||||
|
||||
describe('send — stateful mode (configKey)', () => {
|
||||
it('sends notification to configKey endpoint', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
text: async () => 'ok',
|
||||
});
|
||||
|
||||
const { AppriseProvider } = await import('@/lib/services/notification');
|
||||
const provider = new AppriseProvider();
|
||||
|
||||
await provider.send(
|
||||
{
|
||||
serverUrl: 'http://apprise:8000',
|
||||
configKey: 'my-config',
|
||||
tag: 'audiobooks',
|
||||
},
|
||||
{
|
||||
event: 'request_available',
|
||||
requestId: 'req-1',
|
||||
title: 'Test Book',
|
||||
author: 'Test Author',
|
||||
userName: 'Test User',
|
||||
timestamp: new Date(),
|
||||
}
|
||||
);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
const fetchCall = fetchMock.mock.calls[0];
|
||||
expect(fetchCall[0]).toBe('http://apprise:8000/notify/my-config');
|
||||
|
||||
const body = JSON.parse(fetchCall[1].body);
|
||||
expect(body.tag).toBe('audiobooks');
|
||||
expect(body.title).toBe('Audiobook Available');
|
||||
expect(body.body).toContain('Test Book');
|
||||
expect(body.type).toBe('success');
|
||||
});
|
||||
|
||||
it('omits tag from body when not provided', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
text: async () => 'ok',
|
||||
});
|
||||
|
||||
const { AppriseProvider } = await import('@/lib/services/notification');
|
||||
const provider = new AppriseProvider();
|
||||
|
||||
await provider.send(
|
||||
{ serverUrl: 'http://apprise:8000', configKey: 'my-config' },
|
||||
{
|
||||
event: 'request_approved',
|
||||
requestId: 'req-1',
|
||||
title: 'Test Book',
|
||||
author: 'Test Author',
|
||||
userName: 'Test User',
|
||||
timestamp: new Date(),
|
||||
}
|
||||
);
|
||||
|
||||
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
||||
expect(body.tag).toBeUndefined();
|
||||
});
|
||||
|
||||
it('prefers configKey over urls when both are provided', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
text: async () => 'ok',
|
||||
});
|
||||
|
||||
const { AppriseProvider } = await import('@/lib/services/notification');
|
||||
const provider = new AppriseProvider();
|
||||
|
||||
await provider.send(
|
||||
{
|
||||
serverUrl: 'http://apprise:8000',
|
||||
configKey: 'my-config',
|
||||
urls: 'slack://token',
|
||||
},
|
||||
{
|
||||
event: 'request_approved',
|
||||
requestId: 'req-1',
|
||||
title: 'Test Book',
|
||||
author: 'Test Author',
|
||||
userName: 'Test User',
|
||||
timestamp: new Date(),
|
||||
}
|
||||
);
|
||||
|
||||
const fetchCall = fetchMock.mock.calls[0];
|
||||
expect(fetchCall[0]).toBe('http://apprise:8000/notify/my-config');
|
||||
const body = JSON.parse(fetchCall[1].body);
|
||||
expect(body.urls).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('notification types by event', () => {
|
||||
it('maps event types to correct Apprise notification types', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
text: async () => 'ok',
|
||||
});
|
||||
|
||||
const { AppriseProvider } = await import('@/lib/services/notification');
|
||||
const provider = new AppriseProvider();
|
||||
|
||||
const events = [
|
||||
{ event: 'request_pending_approval', expectedType: 'info' },
|
||||
{ event: 'request_approved', expectedType: 'success' },
|
||||
{ event: 'request_available', expectedType: 'success' },
|
||||
{ event: 'request_error', expectedType: 'failure' },
|
||||
] as const;
|
||||
|
||||
for (const { event, expectedType } of events) {
|
||||
fetchMock.mockClear();
|
||||
await provider.send(
|
||||
{ serverUrl: 'http://apprise:8000', urls: 'slack://token' },
|
||||
{
|
||||
event,
|
||||
requestId: 'req-1',
|
||||
title: 'Test Book',
|
||||
author: 'Test Author',
|
||||
userName: 'Test User',
|
||||
timestamp: new Date(),
|
||||
}
|
||||
);
|
||||
|
||||
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
||||
expect(body.type).toBe(expectedType);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('throws descriptive error when API returns non-OK response', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
text: async () => 'Internal Server Error',
|
||||
});
|
||||
|
||||
const { AppriseProvider } = await import('@/lib/services/notification');
|
||||
const provider = new AppriseProvider();
|
||||
|
||||
await expect(
|
||||
provider.send(
|
||||
{ serverUrl: 'http://apprise:8000', urls: 'slack://token' },
|
||||
{
|
||||
event: 'request_approved',
|
||||
requestId: 'req-1',
|
||||
title: 'Test Book',
|
||||
author: 'Test Author',
|
||||
userName: 'Test User',
|
||||
timestamp: new Date(),
|
||||
}
|
||||
)
|
||||
).rejects.toThrow('Apprise API failed: 500 Internal Server Error');
|
||||
});
|
||||
|
||||
it('throws descriptive error on stateful mode failure', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 424,
|
||||
text: async () => 'No recipients',
|
||||
});
|
||||
|
||||
const { AppriseProvider } = await import('@/lib/services/notification');
|
||||
const provider = new AppriseProvider();
|
||||
|
||||
await expect(
|
||||
provider.send(
|
||||
{ serverUrl: 'http://apprise:8000', configKey: 'bad-key' },
|
||||
{
|
||||
event: 'request_approved',
|
||||
requestId: 'req-1',
|
||||
title: 'Test Book',
|
||||
author: 'Test Author',
|
||||
userName: 'Test User',
|
||||
timestamp: new Date(),
|
||||
}
|
||||
)
|
||||
).rejects.toThrow('Apprise API failed: 424 No recipients');
|
||||
});
|
||||
|
||||
it('includes error message in notification body for error events', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
text: async () => 'ok',
|
||||
});
|
||||
|
||||
const { AppriseProvider } = await import('@/lib/services/notification');
|
||||
const provider = new AppriseProvider();
|
||||
|
||||
await provider.send(
|
||||
{ serverUrl: 'http://apprise:8000', urls: 'slack://token' },
|
||||
{
|
||||
event: 'request_error',
|
||||
requestId: 'req-1',
|
||||
title: 'Test Book',
|
||||
author: 'Test Author',
|
||||
userName: 'Test User',
|
||||
message: 'Download timed out',
|
||||
timestamp: new Date(),
|
||||
}
|
||||
);
|
||||
|
||||
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
||||
expect(body.body).toContain('⚠️ Error: Download timed out');
|
||||
expect(body.type).toBe('failure');
|
||||
});
|
||||
});
|
||||
|
||||
describe('integration with NotificationService.sendToBackend', () => {
|
||||
it('decrypts sensitive fields and sends to Apprise', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
text: async () => 'ok',
|
||||
});
|
||||
|
||||
const { NotificationService } = await import('@/lib/services/notification');
|
||||
const service = new NotificationService();
|
||||
|
||||
// Use iv:authTag:data format to pass isEncrypted() check
|
||||
// Note: the value must have exactly 3 colon-separated segments
|
||||
await service.sendToBackend(
|
||||
'apprise',
|
||||
{
|
||||
serverUrl: 'http://apprise:8000',
|
||||
urls: 'iv:tag:encryptedUrlsData',
|
||||
authToken: 'iv:tag:mytoken123',
|
||||
},
|
||||
{
|
||||
event: 'request_approved',
|
||||
requestId: 'req-1',
|
||||
title: 'Test Book',
|
||||
author: 'Test Author',
|
||||
userName: 'Test User',
|
||||
timestamp: new Date(),
|
||||
}
|
||||
);
|
||||
|
||||
// Verify decrypt was called for the sensitive fields
|
||||
expect(encryptionMock.decrypt).toHaveBeenCalledWith('iv:tag:encryptedUrlsData');
|
||||
expect(encryptionMock.decrypt).toHaveBeenCalledWith('iv:tag:mytoken123');
|
||||
|
||||
// Verify the decrypted values reach the fetch call
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const fetchCall = fetchMock.mock.calls[0];
|
||||
expect(fetchCall[1].headers['Authorization']).toBe('Bearer iv:tag:mytoken123');
|
||||
|
||||
const body = JSON.parse(fetchCall[1].body);
|
||||
expect(body.urls).toBe('iv:tag:encryptedUrlsData');
|
||||
});
|
||||
|
||||
it('does not decrypt non-sensitive fields', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
text: async () => 'ok',
|
||||
});
|
||||
|
||||
const { NotificationService } = await import('@/lib/services/notification');
|
||||
const service = new NotificationService();
|
||||
|
||||
await service.sendToBackend(
|
||||
'apprise',
|
||||
{
|
||||
serverUrl: 'http://apprise:8000',
|
||||
configKey: 'my-config',
|
||||
},
|
||||
{
|
||||
event: 'request_approved',
|
||||
requestId: 'req-1',
|
||||
title: 'Test Book',
|
||||
author: 'Test Author',
|
||||
userName: 'Test User',
|
||||
timestamp: new Date(),
|
||||
}
|
||||
);
|
||||
|
||||
// decrypt should not be called since there are no sensitive fields with encrypted values
|
||||
expect(encryptionMock.decrypt).not.toHaveBeenCalled();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('encryptConfig and maskConfig', () => {
|
||||
it('encrypts urls and authToken', async () => {
|
||||
const { NotificationService } = await import('@/lib/services/notification');
|
||||
const service = new NotificationService();
|
||||
|
||||
const encrypted = service.encryptConfig('apprise', {
|
||||
serverUrl: 'http://apprise:8000',
|
||||
urls: 'slack://tokenA/tokenB',
|
||||
configKey: 'my-config',
|
||||
authToken: 'mytoken123',
|
||||
});
|
||||
|
||||
expect(encryptionMock.encrypt).toHaveBeenCalledWith('slack://tokenA/tokenB');
|
||||
expect(encryptionMock.encrypt).toHaveBeenCalledWith('mytoken123');
|
||||
expect(encrypted.urls).toBe('enc:slack://tokenA/tokenB');
|
||||
expect(encrypted.authToken).toBe('enc:mytoken123');
|
||||
expect(encrypted.serverUrl).toBe('http://apprise:8000'); // Not encrypted
|
||||
expect(encrypted.configKey).toBe('my-config'); // Not encrypted
|
||||
});
|
||||
|
||||
it('masks urls and authToken', async () => {
|
||||
const { NotificationService } = await import('@/lib/services/notification');
|
||||
const service = new NotificationService();
|
||||
|
||||
const masked = service.maskConfig('apprise', {
|
||||
serverUrl: 'http://apprise:8000',
|
||||
urls: 'slack://tokenA/tokenB',
|
||||
configKey: 'my-config',
|
||||
authToken: 'mytoken123',
|
||||
});
|
||||
|
||||
expect(masked.urls).toBe('••••••••');
|
||||
expect(masked.authToken).toBe('••••••••');
|
||||
expect(masked.serverUrl).toBe('http://apprise:8000'); // Not masked
|
||||
expect(masked.configKey).toBe('my-config'); // Not masked
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -191,6 +191,40 @@ describe('LocalAuthProvider', () => {
|
||||
expect(result.error).toContain('Password');
|
||||
});
|
||||
|
||||
it('allows short passwords when ALLOW_WEAK_PASSWORD is enabled', async () => {
|
||||
process.env.ALLOW_WEAK_PASSWORD = 'true';
|
||||
configMock.get.mockResolvedValueOnce('true'); // registration enabled
|
||||
configMock.get.mockResolvedValueOnce('false'); // no admin approval
|
||||
prismaMock.user.findFirst.mockResolvedValue(null);
|
||||
prismaMock.user.count.mockResolvedValue(0);
|
||||
prismaMock.user.create.mockResolvedValue({
|
||||
id: 'user-1',
|
||||
plexId: 'local-user',
|
||||
plexUsername: 'user',
|
||||
role: 'admin',
|
||||
});
|
||||
bcryptHash.mockResolvedValue('hash');
|
||||
|
||||
const { LocalAuthProvider } = await import('@/lib/services/auth/LocalAuthProvider');
|
||||
const provider = new LocalAuthProvider();
|
||||
const result = await provider.register({ username: 'user', password: 'ab' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
delete process.env.ALLOW_WEAK_PASSWORD;
|
||||
});
|
||||
|
||||
it('still rejects empty passwords when ALLOW_WEAK_PASSWORD is enabled', async () => {
|
||||
process.env.ALLOW_WEAK_PASSWORD = 'true';
|
||||
|
||||
const { LocalAuthProvider } = await import('@/lib/services/auth/LocalAuthProvider');
|
||||
const provider = new LocalAuthProvider();
|
||||
const result = await provider.register({ username: 'user', password: '' });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('required');
|
||||
delete process.env.ALLOW_WEAK_PASSWORD;
|
||||
});
|
||||
|
||||
it('rejects registration when username is taken', async () => {
|
||||
configMock.get.mockResolvedValueOnce('true');
|
||||
prismaMock.user.findFirst.mockResolvedValue({ id: 'user-10' });
|
||||
|
||||
@@ -66,7 +66,7 @@ describe('NotificationService', () => {
|
||||
json: async () => ({ success: true }),
|
||||
});
|
||||
|
||||
const { NotificationService } = await import('@/lib/services/notification.service');
|
||||
const { NotificationService } = await import('@/lib/services/notification');
|
||||
const service = new NotificationService();
|
||||
|
||||
await service.sendNotification({
|
||||
@@ -92,7 +92,7 @@ describe('NotificationService', () => {
|
||||
it('does not send if no backends are subscribed to the event', async () => {
|
||||
prismaMock.notificationBackend.findMany.mockResolvedValue([]);
|
||||
|
||||
const { NotificationService } = await import('@/lib/services/notification.service');
|
||||
const { NotificationService } = await import('@/lib/services/notification');
|
||||
const service = new NotificationService();
|
||||
|
||||
await service.sendNotification({
|
||||
@@ -139,7 +139,7 @@ describe('NotificationService', () => {
|
||||
json: async () => ({ success: true }),
|
||||
});
|
||||
|
||||
const { NotificationService } = await import('@/lib/services/notification.service');
|
||||
const { NotificationService } = await import('@/lib/services/notification');
|
||||
const service = new NotificationService();
|
||||
|
||||
await service.sendNotification({
|
||||
@@ -156,19 +156,99 @@ describe('NotificationService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('sendDiscord', () => {
|
||||
describe('sendToBackend', () => {
|
||||
it('routes to Discord provider and decrypts config', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ success: true }),
|
||||
});
|
||||
|
||||
const { NotificationService } = await import('@/lib/services/notification');
|
||||
const service = new NotificationService();
|
||||
|
||||
await service.sendToBackend(
|
||||
'discord',
|
||||
{ webhookUrl: 'enc:https://discord.com/webhook', username: 'ReadMeABook' },
|
||||
{
|
||||
event: 'request_approved',
|
||||
requestId: 'req-1',
|
||||
title: 'Test Book',
|
||||
author: 'Test Author',
|
||||
userName: 'Test User',
|
||||
timestamp: new Date('2024-01-01T00:00:00Z'),
|
||||
}
|
||||
);
|
||||
|
||||
expect(encryptionMock.decrypt).toHaveBeenCalledWith('enc:https://discord.com/webhook');
|
||||
expect(fetchMock).toHaveBeenCalled();
|
||||
|
||||
const fetchCall = fetchMock.mock.calls[0];
|
||||
// Decrypted URL should be used
|
||||
expect(fetchCall[0]).toBe('https://discord.com/webhook');
|
||||
});
|
||||
|
||||
it('routes to Pushover provider and decrypts config', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ status: 1 }),
|
||||
});
|
||||
|
||||
const { NotificationService } = await import('@/lib/services/notification');
|
||||
const service = new NotificationService();
|
||||
|
||||
// Use iv:authTag:data format to pass isEncrypted() check
|
||||
await service.sendToBackend(
|
||||
'pushover',
|
||||
{ userKey: 'iv:tag:user123', appToken: 'iv:tag:app456', priority: 1 },
|
||||
{
|
||||
event: 'request_approved',
|
||||
requestId: 'req-1',
|
||||
title: 'Test Book',
|
||||
author: 'Test Author',
|
||||
userName: 'Test User',
|
||||
timestamp: new Date(),
|
||||
}
|
||||
);
|
||||
|
||||
expect(encryptionMock.decrypt).toHaveBeenCalledWith('iv:tag:user123');
|
||||
expect(encryptionMock.decrypt).toHaveBeenCalledWith('iv:tag:app456');
|
||||
expect(fetchMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws error for unsupported backend type', async () => {
|
||||
const { NotificationService } = await import('@/lib/services/notification');
|
||||
const service = new NotificationService();
|
||||
|
||||
await expect(
|
||||
service.sendToBackend(
|
||||
'email',
|
||||
{},
|
||||
{
|
||||
event: 'request_approved',
|
||||
requestId: 'req-1',
|
||||
title: 'Test Book',
|
||||
author: 'Test Author',
|
||||
userName: 'Test User',
|
||||
timestamp: new Date(),
|
||||
}
|
||||
)
|
||||
).rejects.toThrow('Unsupported backend type: email');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DiscordProvider', () => {
|
||||
it('sends Discord webhook with rich embed', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ success: true }),
|
||||
});
|
||||
|
||||
const { NotificationService } = await import('@/lib/services/notification.service');
|
||||
const service = new NotificationService();
|
||||
const { DiscordProvider } = await import('@/lib/services/notification');
|
||||
const provider = new DiscordProvider();
|
||||
|
||||
await service.sendDiscord(
|
||||
await provider.send(
|
||||
{
|
||||
webhookUrl: 'enc:https://discord.com/webhook',
|
||||
webhookUrl: 'https://discord.com/webhook',
|
||||
username: 'ReadMeABook',
|
||||
},
|
||||
{
|
||||
@@ -181,12 +261,12 @@ describe('NotificationService', () => {
|
||||
}
|
||||
);
|
||||
|
||||
// Should call the webhook (URL decryption happens internally)
|
||||
expect(fetchMock).toHaveBeenCalled();
|
||||
|
||||
const fetchCall = fetchMock.mock.calls[0];
|
||||
const body = JSON.parse(fetchCall[1].body);
|
||||
|
||||
expect(fetchCall[0]).toBe('https://discord.com/webhook');
|
||||
expect(fetchCall[1].method).toBe('POST');
|
||||
expect(fetchCall[1].headers['Content-Type']).toBe('application/json');
|
||||
expect(body.username).toBe('ReadMeABook');
|
||||
@@ -201,12 +281,12 @@ describe('NotificationService', () => {
|
||||
json: async () => ({ success: true }),
|
||||
});
|
||||
|
||||
const { NotificationService } = await import('@/lib/services/notification.service');
|
||||
const service = new NotificationService();
|
||||
const { DiscordProvider } = await import('@/lib/services/notification');
|
||||
const provider = new DiscordProvider();
|
||||
|
||||
await service.sendDiscord(
|
||||
await provider.send(
|
||||
{
|
||||
webhookUrl: 'enc:https://discord.com/webhook',
|
||||
webhookUrl: 'https://discord.com/webhook',
|
||||
},
|
||||
{
|
||||
event: 'request_approved',
|
||||
@@ -230,12 +310,12 @@ describe('NotificationService', () => {
|
||||
text: async () => 'Bad Request',
|
||||
});
|
||||
|
||||
const { NotificationService } = await import('@/lib/services/notification.service');
|
||||
const service = new NotificationService();
|
||||
const { DiscordProvider } = await import('@/lib/services/notification');
|
||||
const provider = new DiscordProvider();
|
||||
|
||||
await expect(
|
||||
service.sendDiscord(
|
||||
{ webhookUrl: 'enc:https://discord.com/webhook' },
|
||||
provider.send(
|
||||
{ webhookUrl: 'https://discord.com/webhook' },
|
||||
{
|
||||
event: 'request_approved',
|
||||
requestId: 'req-1',
|
||||
@@ -249,20 +329,20 @@ describe('NotificationService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('sendPushover', () => {
|
||||
describe('PushoverProvider', () => {
|
||||
it('sends Pushover notification with correct payload', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ status: 1 }),
|
||||
});
|
||||
|
||||
const { NotificationService } = await import('@/lib/services/notification.service');
|
||||
const service = new NotificationService();
|
||||
const { PushoverProvider } = await import('@/lib/services/notification');
|
||||
const provider = new PushoverProvider();
|
||||
|
||||
await service.sendPushover(
|
||||
await provider.send(
|
||||
{
|
||||
userKey: 'enc:user123',
|
||||
appToken: 'enc:app456',
|
||||
userKey: 'user123',
|
||||
appToken: 'app456',
|
||||
priority: 1,
|
||||
},
|
||||
{
|
||||
@@ -275,7 +355,6 @@ describe('NotificationService', () => {
|
||||
}
|
||||
);
|
||||
|
||||
// Should call the Pushover API (credential decryption happens internally)
|
||||
expect(fetchMock).toHaveBeenCalled();
|
||||
|
||||
const fetchCall = fetchMock.mock.calls[0];
|
||||
@@ -296,13 +375,13 @@ describe('NotificationService', () => {
|
||||
json: async () => ({ status: 1 }),
|
||||
});
|
||||
|
||||
const { NotificationService } = await import('@/lib/services/notification.service');
|
||||
const service = new NotificationService();
|
||||
const { PushoverProvider } = await import('@/lib/services/notification');
|
||||
const provider = new PushoverProvider();
|
||||
|
||||
await service.sendPushover(
|
||||
await provider.send(
|
||||
{
|
||||
userKey: 'enc:user123',
|
||||
appToken: 'enc:app456',
|
||||
userKey: 'user123',
|
||||
appToken: 'app456',
|
||||
},
|
||||
{
|
||||
event: 'request_approved',
|
||||
@@ -325,12 +404,12 @@ describe('NotificationService', () => {
|
||||
text: async () => 'invalid user key',
|
||||
});
|
||||
|
||||
const { NotificationService } = await import('@/lib/services/notification.service');
|
||||
const service = new NotificationService();
|
||||
const { PushoverProvider } = await import('@/lib/services/notification');
|
||||
const provider = new PushoverProvider();
|
||||
|
||||
await expect(
|
||||
service.sendPushover(
|
||||
{ userKey: 'enc:user123', appToken: 'enc:app456' },
|
||||
provider.send(
|
||||
{ userKey: 'user123', appToken: 'app456' },
|
||||
{
|
||||
event: 'request_approved',
|
||||
requestId: 'req-1',
|
||||
@@ -344,11 +423,9 @@ describe('NotificationService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Note: formatDiscordEmbed is a private method, tested indirectly through sendDiscord
|
||||
|
||||
describe('encryptConfig', () => {
|
||||
it('encrypts sensitive Discord config values', async () => {
|
||||
const { NotificationService } = await import('@/lib/services/notification.service');
|
||||
const { NotificationService } = await import('@/lib/services/notification');
|
||||
const service = new NotificationService();
|
||||
|
||||
const encrypted = service.encryptConfig('discord', {
|
||||
@@ -362,7 +439,7 @@ describe('NotificationService', () => {
|
||||
});
|
||||
|
||||
it('encrypts sensitive Pushover config values', async () => {
|
||||
const { NotificationService } = await import('@/lib/services/notification.service');
|
||||
const { NotificationService } = await import('@/lib/services/notification');
|
||||
const service = new NotificationService();
|
||||
|
||||
const encrypted = service.encryptConfig('pushover', {
|
||||
@@ -379,11 +456,72 @@ describe('NotificationService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Note: decryptConfig is a private method, tested indirectly through sendDiscord/sendPushover
|
||||
describe('getRegisteredProviderTypes', () => {
|
||||
it('returns all registered provider type keys', async () => {
|
||||
const { getRegisteredProviderTypes } = await import('@/lib/services/notification');
|
||||
const types = getRegisteredProviderTypes();
|
||||
|
||||
expect(types).toContain('apprise');
|
||||
expect(types).toContain('discord');
|
||||
expect(types).toContain('ntfy');
|
||||
expect(types).toContain('pushover');
|
||||
expect(types).toHaveLength(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAllProviderMetadata', () => {
|
||||
it('returns metadata for all registered providers', async () => {
|
||||
const { getAllProviderMetadata } = await import('@/lib/services/notification');
|
||||
const metadata = getAllProviderMetadata();
|
||||
|
||||
expect(metadata).toHaveLength(4);
|
||||
|
||||
const apprise = metadata.find((m) => m.type === 'apprise');
|
||||
expect(apprise).toBeDefined();
|
||||
expect(apprise!.displayName).toBe('Apprise');
|
||||
expect(apprise!.iconLabel).toBe('A');
|
||||
expect(apprise!.iconColor).toBe('bg-purple-500');
|
||||
|
||||
const discord = metadata.find((m) => m.type === 'discord');
|
||||
expect(discord).toBeDefined();
|
||||
expect(discord!.displayName).toBe('Discord');
|
||||
expect(discord!.iconLabel).toBe('D');
|
||||
expect(discord!.iconColor).toBe('bg-indigo-500');
|
||||
expect(discord!.configFields.length).toBeGreaterThan(0);
|
||||
|
||||
const ntfy = metadata.find((m) => m.type === 'ntfy');
|
||||
expect(ntfy).toBeDefined();
|
||||
expect(ntfy!.displayName).toBe('ntfy');
|
||||
expect(ntfy!.iconLabel).toBe('N');
|
||||
|
||||
const pushover = metadata.find((m) => m.type === 'pushover');
|
||||
expect(pushover).toBeDefined();
|
||||
expect(pushover!.displayName).toBe('Pushover');
|
||||
expect(pushover!.iconLabel).toBe('P');
|
||||
});
|
||||
|
||||
it('includes config field definitions with correct properties', async () => {
|
||||
const { getAllProviderMetadata } = await import('@/lib/services/notification');
|
||||
const metadata = getAllProviderMetadata();
|
||||
|
||||
const discord = metadata.find((m) => m.type === 'discord')!;
|
||||
const webhookField = discord.configFields.find((f) => f.name === 'webhookUrl');
|
||||
expect(webhookField).toBeDefined();
|
||||
expect(webhookField!.required).toBe(true);
|
||||
expect(webhookField!.type).toBe('text');
|
||||
|
||||
const pushover = metadata.find((m) => m.type === 'pushover')!;
|
||||
const priorityField = pushover.configFields.find((f) => f.name === 'priority');
|
||||
expect(priorityField).toBeDefined();
|
||||
expect(priorityField!.type).toBe('select');
|
||||
expect(priorityField!.options).toBeDefined();
|
||||
expect(priorityField!.options!.length).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('maskConfig', () => {
|
||||
it('masks sensitive Discord config values', async () => {
|
||||
const { NotificationService } = await import('@/lib/services/notification.service');
|
||||
const { NotificationService } = await import('@/lib/services/notification');
|
||||
const service = new NotificationService();
|
||||
|
||||
const masked = service.maskConfig('discord', {
|
||||
@@ -396,7 +534,7 @@ describe('NotificationService', () => {
|
||||
});
|
||||
|
||||
it('masks sensitive Pushover config values', async () => {
|
||||
const { NotificationService } = await import('@/lib/services/notification.service');
|
||||
const { NotificationService } = await import('@/lib/services/notification');
|
||||
const service = new NotificationService();
|
||||
|
||||
const masked = service.maskConfig('pushover', {
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
/**
|
||||
* Component: ntfy Notification Provider Tests
|
||||
* Documentation: documentation/backend/services/notifications.md
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createPrismaMock } from '../helpers/prisma';
|
||||
|
||||
const prismaMock = createPrismaMock();
|
||||
prismaMock.notificationBackend = {
|
||||
findMany: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
} as any;
|
||||
|
||||
const encryptionMock = vi.hoisted(() => ({
|
||||
encrypt: vi.fn((value: string) => `enc:${value}`),
|
||||
decrypt: vi.fn((value: string) => value.replace('enc:', '')),
|
||||
}));
|
||||
|
||||
const fetchMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('@/lib/db', () => ({
|
||||
prisma: prismaMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/encryption.service', () => ({
|
||||
getEncryptionService: () => encryptionMock,
|
||||
}));
|
||||
|
||||
describe('NtfyProvider', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
});
|
||||
|
||||
describe('send', () => {
|
||||
it('sends notification to correct ntfy endpoint with JSON body', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ id: 'msg123' }),
|
||||
});
|
||||
|
||||
const { NtfyProvider } = await import('@/lib/services/notification');
|
||||
const provider = new NtfyProvider();
|
||||
|
||||
await provider.send(
|
||||
{
|
||||
serverUrl: 'https://ntfy.example.com',
|
||||
topic: 'audiobooks',
|
||||
accessToken: 'tk_mytoken123',
|
||||
},
|
||||
{
|
||||
event: 'request_approved',
|
||||
requestId: 'req-1',
|
||||
title: 'Test Book',
|
||||
author: 'Test Author',
|
||||
userName: 'Test User',
|
||||
timestamp: new Date('2024-01-01T00:00:00Z'),
|
||||
}
|
||||
);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
const fetchCall = fetchMock.mock.calls[0];
|
||||
expect(fetchCall[0]).toBe('https://ntfy.example.com/audiobooks');
|
||||
expect(fetchCall[1].method).toBe('POST');
|
||||
expect(fetchCall[1].headers['Content-Type']).toBe('application/json');
|
||||
expect(fetchCall[1].headers['Authorization']).toBe('Bearer tk_mytoken123');
|
||||
|
||||
const body = JSON.parse(fetchCall[1].body);
|
||||
expect(body.topic).toBe('audiobooks');
|
||||
expect(body.title).toBe('Request Approved');
|
||||
expect(body.message).toContain('Test Book');
|
||||
expect(body.message).toContain('Test Author');
|
||||
expect(body.message).toContain('Test User');
|
||||
expect(body.priority).toBe(3);
|
||||
expect(body.tags).toEqual(['white_check_mark']);
|
||||
});
|
||||
|
||||
it('uses default server URL (https://ntfy.sh) when not provided', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ id: 'msg123' }),
|
||||
});
|
||||
|
||||
const { NtfyProvider } = await import('@/lib/services/notification');
|
||||
const provider = new NtfyProvider();
|
||||
|
||||
await provider.send(
|
||||
{
|
||||
topic: 'audiobooks',
|
||||
},
|
||||
{
|
||||
event: 'request_available',
|
||||
requestId: 'req-1',
|
||||
title: 'Test Book',
|
||||
author: 'Test Author',
|
||||
userName: 'Test User',
|
||||
timestamp: new Date(),
|
||||
}
|
||||
);
|
||||
|
||||
const fetchCall = fetchMock.mock.calls[0];
|
||||
expect(fetchCall[0]).toBe('https://ntfy.sh/audiobooks');
|
||||
});
|
||||
|
||||
it('does not include Authorization header when accessToken is not provided', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ id: 'msg123' }),
|
||||
});
|
||||
|
||||
const { NtfyProvider } = await import('@/lib/services/notification');
|
||||
const provider = new NtfyProvider();
|
||||
|
||||
await provider.send(
|
||||
{
|
||||
topic: 'audiobooks',
|
||||
},
|
||||
{
|
||||
event: 'request_approved',
|
||||
requestId: 'req-1',
|
||||
title: 'Test Book',
|
||||
author: 'Test Author',
|
||||
userName: 'Test User',
|
||||
timestamp: new Date(),
|
||||
}
|
||||
);
|
||||
|
||||
const fetchCall = fetchMock.mock.calls[0];
|
||||
expect(fetchCall[1].headers['Authorization']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('uses default priority based on event type when not configured', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ id: 'msg123' }),
|
||||
});
|
||||
|
||||
const { NtfyProvider } = await import('@/lib/services/notification');
|
||||
const provider = new NtfyProvider();
|
||||
|
||||
// request_error should default to priority 4 (high)
|
||||
await provider.send(
|
||||
{ topic: 'audiobooks' },
|
||||
{
|
||||
event: 'request_error',
|
||||
requestId: 'req-1',
|
||||
title: 'Test Book',
|
||||
author: 'Test Author',
|
||||
userName: 'Test User',
|
||||
message: 'Download failed',
|
||||
timestamp: new Date(),
|
||||
}
|
||||
);
|
||||
|
||||
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
||||
expect(body.priority).toBe(4);
|
||||
expect(body.tags).toEqual(['x']);
|
||||
expect(body.message).toContain('Download failed');
|
||||
});
|
||||
|
||||
it('uses configured priority over default', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ id: 'msg123' }),
|
||||
});
|
||||
|
||||
const { NtfyProvider } = await import('@/lib/services/notification');
|
||||
const provider = new NtfyProvider();
|
||||
|
||||
await provider.send(
|
||||
{ topic: 'audiobooks', priority: 5 },
|
||||
{
|
||||
event: 'request_approved',
|
||||
requestId: 'req-1',
|
||||
title: 'Test Book',
|
||||
author: 'Test Author',
|
||||
userName: 'Test User',
|
||||
timestamp: new Date(),
|
||||
}
|
||||
);
|
||||
|
||||
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
||||
expect(body.priority).toBe(5);
|
||||
});
|
||||
|
||||
it('strips trailing slashes from server URL', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ id: 'msg123' }),
|
||||
});
|
||||
|
||||
const { NtfyProvider } = await import('@/lib/services/notification');
|
||||
const provider = new NtfyProvider();
|
||||
|
||||
await provider.send(
|
||||
{ serverUrl: 'https://ntfy.example.com/', topic: 'audiobooks' },
|
||||
{
|
||||
event: 'request_approved',
|
||||
requestId: 'req-1',
|
||||
title: 'Test Book',
|
||||
author: 'Test Author',
|
||||
userName: 'Test User',
|
||||
timestamp: new Date(),
|
||||
}
|
||||
);
|
||||
|
||||
const fetchCall = fetchMock.mock.calls[0];
|
||||
expect(fetchCall[0]).toBe('https://ntfy.example.com/audiobooks');
|
||||
});
|
||||
|
||||
it('throws descriptive error when API returns non-OK response', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 401,
|
||||
text: async () => 'unauthorized',
|
||||
});
|
||||
|
||||
const { NtfyProvider } = await import('@/lib/services/notification');
|
||||
const provider = new NtfyProvider();
|
||||
|
||||
await expect(
|
||||
provider.send(
|
||||
{ topic: 'audiobooks', accessToken: 'bad_token' },
|
||||
{
|
||||
event: 'request_approved',
|
||||
requestId: 'req-1',
|
||||
title: 'Test Book',
|
||||
author: 'Test Author',
|
||||
userName: 'Test User',
|
||||
timestamp: new Date(),
|
||||
}
|
||||
)
|
||||
).rejects.toThrow('ntfy API failed: 401 unauthorized');
|
||||
});
|
||||
|
||||
it('includes error message in notification body for error events', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ id: 'msg123' }),
|
||||
});
|
||||
|
||||
const { NtfyProvider } = await import('@/lib/services/notification');
|
||||
const provider = new NtfyProvider();
|
||||
|
||||
await provider.send(
|
||||
{ topic: 'audiobooks' },
|
||||
{
|
||||
event: 'request_error',
|
||||
requestId: 'req-1',
|
||||
title: 'Test Book',
|
||||
author: 'Test Author',
|
||||
userName: 'Test User',
|
||||
message: 'Download timed out',
|
||||
timestamp: new Date(),
|
||||
}
|
||||
);
|
||||
|
||||
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
||||
expect(body.message).toContain('⚠️ Error: Download timed out');
|
||||
});
|
||||
});
|
||||
|
||||
describe('integration with NotificationService.sendToBackend', () => {
|
||||
it('decrypts accessToken and sends to ntfy', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ id: 'msg123' }),
|
||||
});
|
||||
|
||||
const { NotificationService } = await import('@/lib/services/notification');
|
||||
const service = new NotificationService();
|
||||
|
||||
// Use iv:authTag:data format to pass isEncrypted() check
|
||||
await service.sendToBackend(
|
||||
'ntfy',
|
||||
{
|
||||
serverUrl: 'https://ntfy.example.com',
|
||||
topic: 'audiobooks',
|
||||
accessToken: 'iv:tag:tk_mytoken123',
|
||||
},
|
||||
{
|
||||
event: 'request_approved',
|
||||
requestId: 'req-1',
|
||||
title: 'Test Book',
|
||||
author: 'Test Author',
|
||||
userName: 'Test User',
|
||||
timestamp: new Date(),
|
||||
}
|
||||
);
|
||||
|
||||
// Verify decrypt was called for the sensitive field
|
||||
expect(encryptionMock.decrypt).toHaveBeenCalledWith('iv:tag:tk_mytoken123');
|
||||
|
||||
// Verify the decrypted value reaches the fetch call
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const fetchCall = fetchMock.mock.calls[0];
|
||||
expect(fetchCall[1].headers['Authorization']).toBe('Bearer iv:tag:tk_mytoken123');
|
||||
});
|
||||
|
||||
it('does not decrypt non-sensitive fields', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ id: 'msg123' }),
|
||||
});
|
||||
|
||||
const { NotificationService } = await import('@/lib/services/notification');
|
||||
const service = new NotificationService();
|
||||
|
||||
await service.sendToBackend(
|
||||
'ntfy',
|
||||
{
|
||||
serverUrl: 'https://ntfy.example.com',
|
||||
topic: 'audiobooks',
|
||||
},
|
||||
{
|
||||
event: 'request_approved',
|
||||
requestId: 'req-1',
|
||||
title: 'Test Book',
|
||||
author: 'Test Author',
|
||||
userName: 'Test User',
|
||||
timestamp: new Date(),
|
||||
}
|
||||
);
|
||||
|
||||
// decrypt should not be called since there's no accessToken
|
||||
expect(encryptionMock.decrypt).not.toHaveBeenCalled();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('encryptConfig and maskConfig', () => {
|
||||
it('encrypts accessToken', async () => {
|
||||
const { NotificationService } = await import('@/lib/services/notification');
|
||||
const service = new NotificationService();
|
||||
|
||||
const encrypted = service.encryptConfig('ntfy', {
|
||||
serverUrl: 'https://ntfy.example.com',
|
||||
topic: 'audiobooks',
|
||||
accessToken: 'tk_mytoken123',
|
||||
});
|
||||
|
||||
expect(encryptionMock.encrypt).toHaveBeenCalledWith('tk_mytoken123');
|
||||
expect(encrypted.accessToken).toBe('enc:tk_mytoken123');
|
||||
expect(encrypted.serverUrl).toBe('https://ntfy.example.com'); // Not encrypted
|
||||
expect(encrypted.topic).toBe('audiobooks'); // Not encrypted
|
||||
});
|
||||
|
||||
it('masks accessToken', async () => {
|
||||
const { NotificationService } = await import('@/lib/services/notification');
|
||||
const service = new NotificationService();
|
||||
|
||||
const masked = service.maskConfig('ntfy', {
|
||||
serverUrl: 'https://ntfy.example.com',
|
||||
topic: 'audiobooks',
|
||||
accessToken: 'tk_mytoken123',
|
||||
});
|
||||
|
||||
expect(masked.accessToken).toBe('••••••••');
|
||||
expect(masked.serverUrl).toBe('https://ntfy.example.com'); // Not masked
|
||||
expect(masked.topic).toBe('audiobooks'); // Not masked
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Component: Stream-based File Copy Utility Tests
|
||||
* Documentation: documentation/phase3/file-organization.md
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
import { Readable, Writable } from 'stream';
|
||||
|
||||
const pipelineMock = vi.hoisted(() => vi.fn());
|
||||
const createReadStreamMock = vi.hoisted(() => vi.fn());
|
||||
const createWriteStreamMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('stream/promises', () => ({
|
||||
pipeline: pipelineMock,
|
||||
}));
|
||||
|
||||
vi.mock('fs', () => ({
|
||||
createReadStream: createReadStreamMock,
|
||||
createWriteStream: createWriteStreamMock,
|
||||
}));
|
||||
|
||||
import { copyFile } from '@/lib/utils/copy-file';
|
||||
|
||||
describe('copyFile', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('pipes source to destination via pipeline', async () => {
|
||||
const mockReadStream = new Readable({ read() {} });
|
||||
const mockWriteStream = new Writable({ write(_, __, cb) { cb(); } });
|
||||
|
||||
createReadStreamMock.mockReturnValue(mockReadStream);
|
||||
createWriteStreamMock.mockReturnValue(mockWriteStream);
|
||||
pipelineMock.mockResolvedValue(undefined);
|
||||
|
||||
await copyFile('/source/file.m4b', '/dest/file.m4b');
|
||||
|
||||
expect(createReadStreamMock).toHaveBeenCalledWith('/source/file.m4b');
|
||||
expect(createWriteStreamMock).toHaveBeenCalledWith('/dest/file.m4b');
|
||||
expect(pipelineMock).toHaveBeenCalledWith(mockReadStream, mockWriteStream);
|
||||
});
|
||||
|
||||
it('propagates read errors', async () => {
|
||||
const mockReadStream = new Readable({ read() {} });
|
||||
const mockWriteStream = new Writable({ write(_, __, cb) { cb(); } });
|
||||
|
||||
createReadStreamMock.mockReturnValue(mockReadStream);
|
||||
createWriteStreamMock.mockReturnValue(mockWriteStream);
|
||||
pipelineMock.mockRejectedValue(
|
||||
Object.assign(new Error('ENOENT: no such file or directory'), { code: 'ENOENT' })
|
||||
);
|
||||
|
||||
await expect(copyFile('/missing/file.m4b', '/dest/file.m4b'))
|
||||
.rejects.toThrow('ENOENT');
|
||||
});
|
||||
|
||||
it('propagates write errors', async () => {
|
||||
const mockReadStream = new Readable({ read() {} });
|
||||
const mockWriteStream = new Writable({ write(_, __, cb) { cb(); } });
|
||||
|
||||
createReadStreamMock.mockReturnValue(mockReadStream);
|
||||
createWriteStreamMock.mockReturnValue(mockWriteStream);
|
||||
pipelineMock.mockRejectedValue(
|
||||
Object.assign(new Error('EACCES: permission denied'), { code: 'EACCES' })
|
||||
);
|
||||
|
||||
await expect(copyFile('/source/file.m4b', '/readonly/file.m4b'))
|
||||
.rejects.toThrow('EACCES');
|
||||
});
|
||||
|
||||
it('propagates EPERM errors (the original bug scenario)', async () => {
|
||||
const mockReadStream = new Readable({ read() {} });
|
||||
const mockWriteStream = new Writable({ write(_, __, cb) { cb(); } });
|
||||
|
||||
createReadStreamMock.mockReturnValue(mockReadStream);
|
||||
createWriteStreamMock.mockReturnValue(mockWriteStream);
|
||||
pipelineMock.mockRejectedValue(
|
||||
Object.assign(new Error('EPERM: operation not permitted'), { code: 'EPERM' })
|
||||
);
|
||||
|
||||
await expect(copyFile('/nfs/source.m4b', '/nfs/dest.m4b'))
|
||||
.rejects.toThrow('EPERM');
|
||||
});
|
||||
});
|
||||
@@ -11,7 +11,6 @@ const fsMock = vi.hoisted(() => ({
|
||||
access: vi.fn(),
|
||||
stat: vi.fn(),
|
||||
mkdir: vi.fn(),
|
||||
copyFile: vi.fn(),
|
||||
chmod: vi.fn(),
|
||||
unlink: vi.fn(),
|
||||
writeFile: vi.fn(),
|
||||
@@ -71,11 +70,17 @@ const ebookMock = vi.hoisted(() => ({
|
||||
downloadEbook: vi.fn(),
|
||||
}));
|
||||
|
||||
const copyFileMock = vi.hoisted(() => ({
|
||||
copyFile: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('fs/promises', () => ({
|
||||
default: fsMock,
|
||||
...fsMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/utils/copy-file', () => copyFileMock);
|
||||
|
||||
vi.mock('axios', () => ({
|
||||
default: axiosMock,
|
||||
...axiosMock,
|
||||
@@ -109,7 +114,7 @@ describe('file organizer', () => {
|
||||
throw new Error('missing');
|
||||
});
|
||||
fsMock.mkdir.mockResolvedValue(undefined);
|
||||
fsMock.copyFile.mockResolvedValue(undefined);
|
||||
copyFileMock.copyFile.mockResolvedValue(undefined);
|
||||
fsMock.chmod.mockResolvedValue(undefined);
|
||||
|
||||
const organizer = new FileOrganizer('/media', '/tmp');
|
||||
@@ -174,7 +179,7 @@ describe('file organizer', () => {
|
||||
throw new Error('missing');
|
||||
});
|
||||
fsMock.mkdir.mockResolvedValue(undefined);
|
||||
fsMock.copyFile.mockResolvedValue(undefined);
|
||||
copyFileMock.copyFile.mockResolvedValue(undefined);
|
||||
fsMock.chmod.mockResolvedValue(undefined);
|
||||
|
||||
const organizer = new FileOrganizer('/media', '/tmp');
|
||||
@@ -216,7 +221,7 @@ describe('file organizer', () => {
|
||||
throw new Error('missing');
|
||||
});
|
||||
fsMock.mkdir.mockResolvedValue(undefined);
|
||||
fsMock.copyFile.mockResolvedValue(undefined);
|
||||
copyFileMock.copyFile.mockResolvedValue(undefined);
|
||||
fsMock.chmod.mockResolvedValue(undefined);
|
||||
fsMock.unlink.mockResolvedValue(undefined);
|
||||
|
||||
@@ -235,7 +240,7 @@ describe('file organizer', () => {
|
||||
const expectedDir = path.join('/media', 'Author', 'Book');
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.targetPath).toBe(expectedDir);
|
||||
expect(fsMock.copyFile).toHaveBeenCalledWith('/tmp/tagged.m4b', path.join(expectedDir, 'book.m4b'));
|
||||
expect(copyFileMock.copyFile).toHaveBeenCalledWith('/tmp/tagged.m4b', path.join(expectedDir, 'book.m4b'));
|
||||
expect(fsMock.unlink).toHaveBeenCalledWith('/tmp/tagged.m4b');
|
||||
});
|
||||
|
||||
@@ -261,7 +266,7 @@ describe('file organizer', () => {
|
||||
throw new Error('missing');
|
||||
});
|
||||
fsMock.mkdir.mockResolvedValue(undefined);
|
||||
fsMock.copyFile.mockResolvedValue(undefined);
|
||||
copyFileMock.copyFile.mockResolvedValue(undefined);
|
||||
fsMock.chmod.mockResolvedValue(undefined);
|
||||
|
||||
const result = await organizer.organize('/downloads/book', {
|
||||
@@ -272,7 +277,7 @@ describe('file organizer', () => {
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.errors).toContain('Metadata tagging skipped: ffmpeg not available');
|
||||
expect(metadataMock.tagMultipleFiles).not.toHaveBeenCalled();
|
||||
expect(fsMock.copyFile).toHaveBeenCalledWith(sourcePath, targetFile);
|
||||
expect(copyFileMock.copyFile).toHaveBeenCalledWith(sourcePath, targetFile);
|
||||
});
|
||||
|
||||
it('downloads remote cover art when no local cover exists', async () => {
|
||||
@@ -294,7 +299,7 @@ describe('file organizer', () => {
|
||||
throw new Error('missing');
|
||||
});
|
||||
fsMock.mkdir.mockResolvedValue(undefined);
|
||||
fsMock.copyFile.mockResolvedValue(undefined);
|
||||
copyFileMock.copyFile.mockResolvedValue(undefined);
|
||||
fsMock.chmod.mockResolvedValue(undefined);
|
||||
fsMock.writeFile.mockResolvedValue(undefined);
|
||||
|
||||
@@ -316,7 +321,7 @@ describe('file organizer', () => {
|
||||
// NOTE: Ebook downloads are now handled as first-class requests through the job queue
|
||||
// The file organizer no longer downloads ebooks inline
|
||||
expect(ebookMock.downloadEbook).not.toHaveBeenCalled();
|
||||
expect(fsMock.copyFile).toHaveBeenCalledWith(sourcePath, targetFile);
|
||||
expect(copyFileMock.copyFile).toHaveBeenCalledWith(sourcePath, targetFile);
|
||||
expect(result.filesMovedCount).toBe(1);
|
||||
});
|
||||
|
||||
@@ -340,7 +345,7 @@ describe('file organizer', () => {
|
||||
throw new Error('missing');
|
||||
});
|
||||
fsMock.mkdir.mockResolvedValue(undefined);
|
||||
fsMock.copyFile.mockResolvedValue(undefined);
|
||||
copyFileMock.copyFile.mockResolvedValue(undefined);
|
||||
fsMock.chmod.mockResolvedValue(undefined);
|
||||
axiosMock.get.mockRejectedValue(new Error('cover failed'));
|
||||
|
||||
@@ -352,7 +357,7 @@ describe('file organizer', () => {
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.errors.join(' ')).toContain('Failed to download cover art');
|
||||
expect(fsMock.copyFile).toHaveBeenCalledWith(sourcePath, targetFile);
|
||||
expect(copyFileMock.copyFile).toHaveBeenCalledWith(sourcePath, targetFile);
|
||||
});
|
||||
|
||||
it('continues when chapter analysis returns no valid chapters', async () => {
|
||||
@@ -378,7 +383,7 @@ describe('file organizer', () => {
|
||||
throw new Error('missing');
|
||||
});
|
||||
fsMock.mkdir.mockResolvedValue(undefined);
|
||||
fsMock.copyFile.mockResolvedValue(undefined);
|
||||
copyFileMock.copyFile.mockResolvedValue(undefined);
|
||||
fsMock.chmod.mockResolvedValue(undefined);
|
||||
|
||||
const result = await organizer.organize('/downloads/book', {
|
||||
@@ -415,7 +420,7 @@ describe('file organizer', () => {
|
||||
throw new Error('missing');
|
||||
});
|
||||
fsMock.mkdir.mockResolvedValue(undefined);
|
||||
fsMock.copyFile.mockResolvedValue(undefined);
|
||||
copyFileMock.copyFile.mockResolvedValue(undefined);
|
||||
fsMock.chmod.mockResolvedValue(undefined);
|
||||
fsMock.unlink.mockResolvedValue(undefined);
|
||||
|
||||
@@ -502,7 +507,7 @@ describe('file organizer', () => {
|
||||
expect(result.audioFiles).toEqual([]);
|
||||
expect(result.errors.join(' ')).toContain('Source file not found');
|
||||
expect(result.errors.join(' ')).toContain('No audio files were successfully copied');
|
||||
expect(fsMock.copyFile).not.toHaveBeenCalled();
|
||||
expect(copyFileMock.copyFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips copying when target files already exist', async () => {
|
||||
@@ -535,7 +540,7 @@ describe('file organizer', () => {
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.audioFiles).toEqual([targetPath]);
|
||||
expect(result.filesMovedCount).toBe(0);
|
||||
expect(fsMock.copyFile).not.toHaveBeenCalled();
|
||||
expect(copyFileMock.copyFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('continues when metadata tagging throws', async () => {
|
||||
@@ -557,7 +562,7 @@ describe('file organizer', () => {
|
||||
throw new Error('missing');
|
||||
});
|
||||
fsMock.mkdir.mockResolvedValue(undefined);
|
||||
fsMock.copyFile.mockResolvedValue(undefined);
|
||||
copyFileMock.copyFile.mockResolvedValue(undefined);
|
||||
fsMock.chmod.mockResolvedValue(undefined);
|
||||
|
||||
const result = await organizer.organize('/downloads/book', {
|
||||
@@ -567,7 +572,7 @@ describe('file organizer', () => {
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.errors.join(' ')).toContain('Metadata tagging failed');
|
||||
expect(fsMock.copyFile).toHaveBeenCalled();
|
||||
expect(copyFileMock.copyFile).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('validates paths and reports multiple issues', async () => {
|
||||
@@ -668,7 +673,7 @@ describe('file organizer', () => {
|
||||
throw new Error('missing');
|
||||
});
|
||||
fsMock.mkdir.mockResolvedValue(undefined);
|
||||
fsMock.copyFile.mockRejectedValue(
|
||||
copyFileMock.copyFile.mockRejectedValue(
|
||||
Object.assign(new Error('EPERM: operation not permitted, copyfile'), { code: 'EPERM' })
|
||||
);
|
||||
|
||||
@@ -705,7 +710,7 @@ describe('file organizer', () => {
|
||||
throw new Error('missing');
|
||||
});
|
||||
fsMock.mkdir.mockResolvedValue(undefined);
|
||||
fsMock.copyFile.mockImplementation(async (src: string, dest: string) => {
|
||||
copyFileMock.copyFile.mockImplementation(async (src: string, dest: string) => {
|
||||
// Tagged file copy fails with EPERM
|
||||
if (path.normalize(src) === path.normalize(taggedPath)) {
|
||||
throw Object.assign(new Error('EPERM: operation not permitted'), { code: 'EPERM' });
|
||||
@@ -736,7 +741,7 @@ describe('file organizer', () => {
|
||||
// Tagged temp file should be cleaned up
|
||||
expect(fsMock.unlink).toHaveBeenCalledWith(taggedPath);
|
||||
// Fallback copy should use the original source
|
||||
expect(fsMock.copyFile).toHaveBeenCalledWith(sourcePath, targetFile);
|
||||
expect(copyFileMock.copyFile).toHaveBeenCalledWith(sourcePath, targetFile);
|
||||
// Should record that tagged copy failed
|
||||
expect(result.errors.join(' ')).toContain('Tagged copy failed');
|
||||
expect(result.errors.join(' ')).toContain('without metadata tags');
|
||||
@@ -760,7 +765,7 @@ describe('file organizer', () => {
|
||||
});
|
||||
fsMock.mkdir.mockResolvedValue(undefined);
|
||||
// Both tagged and original copies fail
|
||||
fsMock.copyFile.mockRejectedValue(
|
||||
copyFileMock.copyFile.mockRejectedValue(
|
||||
Object.assign(new Error('EPERM: operation not permitted'), { code: 'EPERM' })
|
||||
);
|
||||
fsMock.unlink.mockResolvedValue(undefined);
|
||||
@@ -808,7 +813,7 @@ describe('file organizer', () => {
|
||||
throw new Error('missing');
|
||||
});
|
||||
fsMock.mkdir.mockResolvedValue(undefined);
|
||||
fsMock.copyFile.mockImplementation(async (src: string) => {
|
||||
copyFileMock.copyFile.mockImplementation(async (src: string) => {
|
||||
// First file succeeds, second fails
|
||||
if (path.normalize(src) === path.normalize(source2)) {
|
||||
throw Object.assign(new Error('EPERM: operation not permitted'), { code: 'EPERM' });
|
||||
@@ -847,7 +852,7 @@ describe('file organizer', () => {
|
||||
throw new Error('missing');
|
||||
});
|
||||
fsMock.mkdir.mockResolvedValue(undefined);
|
||||
fsMock.copyFile.mockResolvedValue(undefined);
|
||||
copyFileMock.copyFile.mockResolvedValue(undefined);
|
||||
fsMock.chmod.mockResolvedValue(undefined);
|
||||
fsMock.writeFile.mockResolvedValue(undefined);
|
||||
axiosMock.get.mockResolvedValue({ data: Buffer.from('cover') });
|
||||
@@ -881,7 +886,7 @@ describe('file organizer', () => {
|
||||
throw new Error('missing');
|
||||
});
|
||||
fsMock.mkdir.mockResolvedValue(undefined);
|
||||
fsMock.copyFile.mockRejectedValue(
|
||||
copyFileMock.copyFile.mockRejectedValue(
|
||||
Object.assign(new Error('EPERM: operation not permitted'), { code: 'EPERM' })
|
||||
);
|
||||
fsMock.writeFile.mockResolvedValue(undefined);
|
||||
|
||||
Reference in New Issue
Block a user