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:
kikootwo
2026-02-10 15:06:20 -05:00
parent 4a38dd3da8
commit af0eaceb98
73 changed files with 3421 additions and 866 deletions
+1 -1
View File
@@ -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
+62 -12
View File
@@ -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 });
}
+1 -1
View File
@@ -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):**
+1 -1
View File
@@ -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 }
);
}
});
});
}
+3 -3
View File
@@ -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({
+26 -69
View File
@@ -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
+2 -1
View File
@@ -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,
+7
View File
@@ -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,
});
}
+51 -52
View File
@@ -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 }
);
}
});
}
+8 -4
View File
@@ -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>
+28
View File
@@ -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)}
+22 -3
View File
@@ -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>
+13 -5
View File
@@ -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,
+11 -4
View File
@@ -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"
+2 -1
View File
@@ -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 {
+10 -1
View File
@@ -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);
}
+11 -2
View File
@@ -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);
}
+13 -5
View File
@@ -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,
+10 -7
View File
@@ -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&apos;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);
}
};
+21 -3
View File
@@ -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}
/>
+10
View File
@@ -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
// =========================================================================
+49
View File
@@ -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
*/
+10
View File
@@ -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 {
+5 -1
View File
@@ -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)
}
-380
View File
@@ -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>;
}
+36
View File
@@ -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'),
};
}
}
+22
View File
@@ -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));
}
+7 -6
View File
@@ -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}`);
+29 -7
View File
@@ -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', () => {
+2 -1
View File
@@ -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', () => {
+2 -1
View File
@@ -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 () => {
+93 -6
View File
@@ -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,
});
+1
View File
@@ -24,6 +24,7 @@ const PlexHarness = ({
plexToken: 'token',
plexLibraryId: '',
plexTriggerScanAfterImport: false,
plexLibraries: [] as { id: string; title: string; type: string }[],
...initialState,
});
+120 -5
View File
@@ -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,
}));
+473
View File
@@ -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' });
+178 -40
View File
@@ -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', {
+368
View File
@@ -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
});
});
});
+85
View File
@@ -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');
});
});
+29 -24
View File
@@ -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);