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 ## 📊 Feature Highlights
### AI-Powered Recommendations ### 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 - **Personalization:** Based on your Plex library + swipe history
- **Context:** Max 50 books (40 library + 10 swipes) - **Context:** Max 50 books (40 library + 10 swipes)
- **Filtering:** Excludes books already in library, already requested, or already swiped - **Filtering:** Excludes books already in library, already requested, or already swiped
+62 -12
View File
@@ -1,14 +1,14 @@
# Notification System # Notification System
**Status:** ✅ Implemented | Extensible notification system with Discord and Pushover support **Status:** ✅ Implemented | Extensible notification system with Discord, ntfy, and Pushover support
## Overview ## 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. 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 ## 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 - **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) - **Delivery:** Async via Bull job queue (priority 5)
- **Failure Handling:** Non-blocking, Promise.allSettled (one backend fails, others succeed) - **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 ```prisma
model NotificationBackend { model NotificationBackend {
id String @id @default(uuid()) id String @id @default(uuid())
type String // 'discord' | 'pushover' type String // 'apprise' | 'discord' | 'ntfy' | 'pushover'
name String // User-friendly label name String // User-friendly label
config Json // Encrypted sensitive values config Json // Encrypted sensitive values
events Json // Array of subscribed events events Json // Array of subscribed events
@@ -70,7 +70,9 @@ model NotificationBackend {
## Configuration Encryption ## Configuration Encryption
**Encrypted Values:** **Encrypted Values:**
- Apprise: `urls`, `authToken`
- Discord: `webhookUrl` - Discord: `webhookUrl`
- ntfy: `accessToken`
- Pushover: `userKey`, `appToken` - Pushover: `userKey`, `appToken`
**Pattern:** `iv:authTag:encryptedData` (base64) **Pattern:** `iv:authTag:encryptedData` (base64)
@@ -81,12 +83,26 @@ model NotificationBackend {
## Message Formatting ## 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):** **Discord (Rich Embeds):**
- Color-coded by event (yellow=pending, green=approved, blue=available, red=error) - Color-coded by event (yellow=pending, green=approved, blue=available, red=error)
- Fields: Title, Author, Requested By, Error (if applicable) - Fields: Title, Author, Requested By, Error (if applicable)
- Footer: Request ID - Footer: Request ID
- Timestamp: Event time - 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):** **Pushover (Plain Text with Emojis):**
- Emojis: 📬 📬 🎉 ❌ - Emojis: 📬 📬 🎉 ❌
- Priority: Normal (0) for pending/approved, High (1) for available/error - 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?)` **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 ## Extensibility
**Adding New Backend (e.g., Email):** **Adding New Backend (2 steps):**
1. Add 'email' to NotificationBackendType enum 1. Create `providers/email.provider.ts` implementing `INotificationProvider`:
2. Create EmailConfig interface - Set `type = 'email'`, `sensitiveFields = ['smtpPassword']`
3. Add encryption logic for smtpPassword - Set `metadata` with displayName, description, iconLabel, iconColor, configFields
4. Implement sendEmail() method in NotificationService - Implement `send()` with email-specific logic
5. Add email card to type selector (green "E" badge) 2. Register in `notification.service.ts`: `registerProvider(new EmailProvider())` + re-export from `index.ts`
6. Add email form fields to modal
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):** **Adding New Event (e.g., download_complete):**
1. Add 'download_complete' to NotificationEvent enum 1. Add 'download_complete' to NotificationEvent enum
@@ -173,7 +223,7 @@ model NotificationBackend {
## Tech Stack ## Tech Stack
- Bull (job queue) - Bull (job queue)
- Node.js crypto (AES-256-GCM encryption) - Node.js crypto (AES-256-GCM encryption)
- Discord webhooks, Pushover API - Apprise API, Discord webhooks, ntfy API, Pushover API
- React (UI), Tailwind CSS (styling) - React (UI), Tailwind CSS (styling)
## Related ## Related
@@ -200,32 +200,23 @@ export async function POST(req: NextRequest) {
.map((m: any) => ({ id: m.id, name: m.id })); .map((m: any) => ({ id: m.id, name: m.id }));
} else if (provider === 'claude') { } else if (provider === 'claude') {
// Claude: Hardcoded list (Anthropic doesn't have a models API endpoint) // Claude: Fetch models dynamically from the Anthropic Models API
models = [ const response = await fetch('https://api.anthropic.com/v1/models?limit=1000', {
{ 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',
headers: { headers: {
'x-api-key': apiKey, 'x-api-key': apiKey,
'anthropic-version': '2023-06-01', '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) { if (!response.ok) {
return NextResponse.json({ error: 'Invalid Claude API key' }, { status: 400 }); 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 { } else {
return NextResponse.json({ error: 'Invalid provider' }, { status: 400 }); 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. 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 ## 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) - **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 - **Personalization:** Each user receives recommendations based on their own library, ratings, swipe history, and custom preferences
- **Library Scopes (per-user):** - **Library Scopes (per-user):**
+1 -1
View File
@@ -208,7 +208,7 @@ async function organize(
## Fixed Issues ✅ ## 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 **2. Immediate deletion** - Changed to copy-only, scheduled cleanup after seeding
**3. Files moved not copied** - Now copies to support 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) **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'); 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 { interface NotificationBackend {
id: string; id: string;
type: string; type: string;
@@ -24,15 +43,6 @@ interface ModalState {
backend?: NotificationBackend; 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> = { const eventLabels: Record<string, string> = {
request_pending_approval: 'Request Pending Approval', request_pending_approval: 'Request Pending Approval',
request_approved: 'Request Approved', request_approved: 'Request Approved',
@@ -42,6 +52,7 @@ const eventLabels: Record<string, string> = {
export function NotificationsTab() { export function NotificationsTab() {
const [backends, setBackends] = useState<NotificationBackend[]>([]); const [backends, setBackends] = useState<NotificationBackend[]>([]);
const [providerMetadata, setProviderMetadata] = useState<ProviderMetadata[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [modalState, setModalState] = useState<ModalState>({ const [modalState, setModalState] = useState<ModalState>({
isOpen: false, isOpen: false,
@@ -59,8 +70,23 @@ export function NotificationsTab() {
useEffect(() => { useEffect(() => {
fetchBackends(); 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 () => { const fetchBackends = async () => {
try { try {
setLoading(true); 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 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 }); setModalState({ isOpen: true, mode: 'add', selectedType: type });
setFormData({ setFormData({
name: `${type.charAt(0).toUpperCase() + type.slice(1)} Notifications`, name: `${meta?.displayName ?? type} Notifications`,
config: type === 'discord' ? { webhookUrl: '', username: 'ReadMeABook', avatarUrl: '' } : { userKey: '', appToken: '', device: '', priority: 0 }, config: defaultConfig,
events: ['request_available', 'request_error'], events: ['request_available', 'request_error'],
enabled: true, 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 ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Header */} {/* Header */}
@@ -206,32 +287,22 @@ export function NotificationsTab() {
{/* Type Selector */} {/* Type Selector */}
<div> <div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">Add Notification Backend</h3> <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"> <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<button {providerMetadata.map((meta) => (
onClick={() => openAddModal('discord')} <button
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" key={meta.type}
> onClick={() => openAddModal(meta.type)}
<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"> 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"
D >
</div> <div className={`flex-shrink-0 w-12 h-12 ${meta.iconColor} rounded-lg flex items-center justify-center text-white font-bold text-2xl`}>
<div className="ml-4 text-left"> {meta.iconLabel}
<div className="font-semibold text-gray-900 dark:text-white">Discord</div> </div>
<div className="text-sm text-gray-600 dark:text-gray-400">Send notifications via Discord webhook</div> <div className="ml-4 text-left">
</div> <div className="font-semibold text-gray-900 dark:text-white">{meta.displayName}</div>
</button> <div className="text-sm text-gray-600 dark:text-gray-400">{meta.description}</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> </div>
</div> </div>
@@ -244,43 +315,46 @@ export function NotificationsTab() {
<p className="text-gray-600 dark:text-gray-400">No notification backends configured.</p> <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"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{backends.map((backend) => ( {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"> const meta = getMetadataForType(backend.type);
<div className="flex items-start justify-between mb-3"> return (
<div className="flex items-center space-x-3"> <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={`w-10 h-10 ${typeColors[backend.type]} rounded-lg flex items-center justify-center text-white font-bold`}> <div className="flex items-start justify-between mb-3">
{backend.type.charAt(0).toUpperCase()} <div className="flex items-center space-x-3">
</div> <div className={`w-10 h-10 ${meta?.iconColor ?? 'bg-gray-500'} rounded-lg flex items-center justify-center text-white font-bold`}>
<div> {meta?.iconLabel ?? backend.type.charAt(0).toUpperCase()}
<div className="font-semibold text-gray-900 dark:text-white truncate">{backend.name}</div> </div>
<div className="text-xs text-gray-500 dark:text-gray-400 capitalize">{backend.type}</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>
</div> <div className="space-y-2 mb-3">
<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'}`}>
<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'}
{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>
<div className="text-sm text-gray-600 dark:text-gray-400"> <div className="flex space-x-2">
{backend.events.length} {backend.events.length === 1 ? 'event' : 'events'} subscribed <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>
<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>
)} )}
</div> </div>
@@ -292,7 +366,7 @@ export function NotificationsTab() {
<div className="p-6"> <div className="p-6">
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<h3 className="text-xl font-bold text-gray-900 dark:text-white"> <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> </h3>
<button onClick={closeModal} className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"> <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"> <svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -314,70 +388,8 @@ export function NotificationsTab() {
/> />
</div> </div>
{/* Config Fields */} {/* Dynamic Config Fields */}
{modalState.selectedType === 'discord' && ( {currentMeta?.configFields.map((field) => renderConfigField(field))}
<>
<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>
</>
)}
{/* Events */} {/* Events */}
<div> <div>
@@ -6,7 +6,7 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth'; import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db'; 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 { RMABLogger } from '@/lib/utils/logger';
import { z } from 'zod'; import { z } from 'zod';
@@ -50,7 +50,7 @@ export async function GET(
success: true, success: true,
backend: { backend: {
...backend, ...backend,
config: notificationService.maskConfig(backend.type as NotificationBackendType, backend.config), config: notificationService.maskConfig(backend.type, backend.config),
}, },
}); });
} catch (error) { } catch (error) {
@@ -114,7 +114,7 @@ export async function PUT(
}); });
// Encrypt new/changed values // Encrypt new/changed values
finalConfig = notificationService.encryptConfig(existing.type as NotificationBackendType, updatedConfig); finalConfig = notificationService.encryptConfig(existing.type, updatedConfig);
} }
// Update backend // Update backend
@@ -139,7 +139,7 @@ export async function PUT(
success: true, success: true,
backend: { backend: {
...updated, ...updated,
config: notificationService.maskConfig(updated.type as NotificationBackendType, updated.config), config: notificationService.maskConfig(updated.type, updated.config),
}, },
}); });
} catch (error) { } 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 { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth'; import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db'; 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 { RMABLogger } from '@/lib/utils/logger';
import { z } from 'zod'; import { z } from 'zod';
const logger = RMABLogger.create('API.Admin.Notifications'); const logger = RMABLogger.create('API.Admin.Notifications');
const CreateBackendSchema = z.object({ 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), name: z.string().min(1),
config: z.record(z.any()), config: z.record(z.any()),
events: z.array(z.enum(['request_pending_approval', 'request_approved', 'request_available', 'request_error'])).min(1), 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 // Mask sensitive config values
const maskedBackends = backends.map((backend) => ({ const maskedBackends = backends.map((backend) => ({
...backend, ...backend,
config: notificationService.maskConfig(backend.type as NotificationBackendType, backend.config), config: notificationService.maskConfig(backend.type, backend.config),
})); }));
return NextResponse.json({ return NextResponse.json({
+26 -69
View File
@@ -5,31 +5,17 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth'; 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 { RMABLogger } from '@/lib/utils/logger';
import { z } from 'zod'; import { z } from 'zod';
import { prisma } from '@/lib/db'; import { prisma } from '@/lib/db';
const logger = RMABLogger.create('API.Admin.Notifications.Test'); const logger = RMABLogger.create('API.Admin.Notifications.Test');
const TestNotificationSchema = z.discriminatedUnion('mode', [ // Flexible schema: supports both backendId and type+config formats
// Test existing backend by ID (uses stored config) const TestNotificationSchema = z.object({
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({
backendId: z.string().optional(), 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(), config: z.record(z.any()).optional(),
}); });
@@ -42,66 +28,37 @@ export async function POST(request: NextRequest) {
return requireAdmin(req, async () => { return requireAdmin(req, async () => {
try { try {
const body = await request.json(); const body = await request.json();
const parsed = TestNotificationSchema.parse(body);
// Support legacy format for backward compatibility let type: string;
const legacyParsed = LegacyTestNotificationSchema.safeParse(body);
let type: NotificationBackendType;
let encryptedConfig: any; let encryptedConfig: any;
const notificationService = getNotificationService(); const notificationService = getNotificationService();
if (legacyParsed.success) { if (parsed.backendId) {
// Legacy format // Test existing backend by ID (uses stored config)
if (legacyParsed.data.backendId) { const backend = await prisma.notificationBackend.findUnique({
// Test existing backend where: { id: parsed.backendId },
const backend = await prisma.notificationBackend.findUnique({ });
where: { id: legacyParsed.data.backendId },
});
if (!backend) { 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 {
return NextResponse.json( return NextResponse.json(
{ error: 'ValidationError', message: 'Must provide either backendId or type+config' }, { error: 'NotFound', message: 'Backend not found' },
{ status: 400 } { 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 { } else {
// New format with discriminated union return NextResponse.json(
const parsed = TestNotificationSchema.parse(body); { error: 'ValidationError', message: 'Must provide either backendId or type+config' },
{ status: 400 }
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);
}
} }
// Create test payload // Create test payload
@@ -117,7 +74,7 @@ export async function POST(request: NextRequest) {
// Send test notification synchronously (not via job queue) // Send test notification synchronously (not via job queue)
try { try {
// Call sendToBackend directly // 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}`, { logger.info(`Test notification sent successfully for ${type}`, {
adminId: req.user?.sub, adminId: req.user?.sub,
@@ -38,6 +38,7 @@ export async function PUT(
localPath, localPath,
category, category,
customPath, customPath,
postImportCategory,
} = body; } = body;
const config = await getConfigService(); const config = await getConfigService();
@@ -76,6 +77,7 @@ export async function PUT(
localPath: localPath !== undefined ? localPath : existingClient.localPath, localPath: localPath !== undefined ? localPath : existingClient.localPath,
category: category !== undefined ? category : existingClient.category, category: category !== undefined ? category : existingClient.category,
customPath: customPath !== undefined ? (customPath || undefined) : existingClient.customPath, customPath: customPath !== undefined ? (customPath || undefined) : existingClient.customPath,
postImportCategory: postImportCategory !== undefined ? (postImportCategory || undefined) : existingClient.postImportCategory,
}; };
// Validate path mapping if enabled // 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, localPath,
category, category,
customPath, customPath,
postImportCategory,
} = body; } = body;
// Validate type // Validate type
@@ -138,6 +139,7 @@ export async function POST(request: NextRequest) {
localPath: localPath || undefined, localPath: localPath || undefined,
category: category || 'readmeabook', category: category || 'readmeabook',
customPath: customPath || undefined, customPath: customPath || undefined,
postImportCategory: postImportCategory || undefined,
}; };
// Test connection before saving // Test connection before saving
@@ -86,7 +86,6 @@ export async function POST(request: NextRequest) {
// Search Prowlarr for each group and combine results // Search Prowlarr for each group and combine results
const prowlarr = await getProwlarrService(); const prowlarr = await getProwlarrService();
const searchQuery = title; // Title only - cast wide net
const allResults = []; const allResults = [];
for (let i = 0; i < groups.length; i++) { 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)}`); logger.debug(`Searching group ${i + 1}/${groups.length}: ${getGroupDescription(group)}`);
try { try {
const groupResults = await prowlarr.search(searchQuery, { const groupResults = await prowlarr.searchWithVariations(title, author, {
categories: group.categories, categories: group.categories,
indexerIds: group.indexerIds, indexerIds: group.indexerIds,
maxResults: 100, // Limit per group maxResults: 100, // Limit per group
+2 -1
View File
@@ -39,7 +39,8 @@ export async function POST(request: NextRequest) {
} }
// Validate new password length // Validate new password length
if (newPassword.length < 8) { const allowWeakPassword = process.env.ALLOW_WEAK_PASSWORD === 'true';
if (!allowWeakPassword && newPassword.length < 8) {
return NextResponse.json( return NextResponse.json(
{ {
success: false, success: false,
+7
View File
@@ -18,6 +18,9 @@ export async function GET() {
// Check if local login is disabled via environment variable // Check if local login is disabled via environment variable
const localLoginDisabled = process.env.DISABLE_LOCAL_LOGIN === 'true'; 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 // Check if automation (Phase 3) is configured by checking for Prowlarr/indexer config
const indexerType = await configService.get('indexer.type'); const indexerType = await configService.get('indexer.type');
const prowlarrUrl = await configService.get('indexer.prowlarr_url'); const prowlarrUrl = await configService.get('indexer.prowlarr_url');
@@ -47,6 +50,7 @@ export async function GET() {
hasLocalUsers, hasLocalUsers,
oidcProviderName: oidcEnabled ? oidcProviderName : null, oidcProviderName: oidcEnabled ? oidcProviderName : null,
localLoginDisabled, localLoginDisabled,
allowWeakPassword,
automationEnabled, automationEnabled,
}); });
} else { } else {
@@ -65,6 +69,7 @@ export async function GET() {
hasLocalUsers, hasLocalUsers,
oidcProviderName: null, oidcProviderName: null,
localLoginDisabled, localLoginDisabled,
allowWeakPassword,
automationEnabled, automationEnabled,
}); });
} }
@@ -72,6 +77,7 @@ export async function GET() {
logger.error('Failed to fetch auth providers', { error: error instanceof Error ? error.message : String(error) }); 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 // Default to Plex mode if config can't be read
const localLoginDisabled = process.env.DISABLE_LOCAL_LOGIN === 'true'; const localLoginDisabled = process.env.DISABLE_LOCAL_LOGIN === 'true';
const allowWeakPassword = process.env.ALLOW_WEAK_PASSWORD === 'true';
return NextResponse.json({ return NextResponse.json({
backendMode: 'plex', backendMode: 'plex',
providers: ['plex'], providers: ['plex'],
@@ -79,6 +85,7 @@ export async function GET() {
hasLocalUsers: false, hasLocalUsers: false,
oidcProviderName: null, oidcProviderName: null,
localLoginDisabled, localLoginDisabled,
allowWeakPassword,
automationEnabled: false, automationEnabled: false,
}); });
} }
+51 -52
View File
@@ -9,6 +9,49 @@ import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('API.BookDate.TestConnection'); 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 // Helper functions for custom provider
function isValidBaseUrl(url: string): boolean { function isValidBaseUrl(url: string): boolean {
try { try {
@@ -141,32 +184,10 @@ async function authenticatedHandler(req: AuthenticatedRequest) {
.sort((a: any, b: any) => a.name.localeCompare(b.name)); .sort((a: any, b: any) => a.name.localeCompare(b.name));
} else if (provider === 'claude') { } else if (provider === 'claude') {
// Claude: Hardcoded list (Anthropic doesn't have a public models API endpoint) // Claude: Fetch models dynamically from the Anthropic Models API
models = [ try {
{ id: 'claude-sonnet-4-5-20250929', name: 'Claude Sonnet 4.5 (Latest)' }, models = await fetchClaudeModels(testApiKey);
{ id: 'claude-3-7-sonnet-20250219', name: 'Claude 3.7 Sonnet' }, } catch {
{ 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 });
return NextResponse.json( return NextResponse.json(
{ error: 'Invalid Claude API key or connection failed' }, { error: 'Invalid Claude API key or connection failed' },
{ status: 400 } { status: 400 }
@@ -333,32 +354,10 @@ async function unauthenticatedHandler(req: NextRequest) {
.sort((a: any, b: any) => a.name.localeCompare(b.name)); .sort((a: any, b: any) => a.name.localeCompare(b.name));
} else if (provider === 'claude') { } else if (provider === 'claude') {
// Claude: Hardcoded list (Anthropic doesn't have a public models API endpoint) // Claude: Fetch models dynamically from the Anthropic Models API
models = [ try {
{ id: 'claude-sonnet-4-5-20250929', name: 'Claude Sonnet 4.5 (Latest)' }, models = await fetchClaudeModels(apiKey);
{ id: 'claude-3-7-sonnet-20250219', name: 'Claude 3.7 Sonnet' }, } catch {
{ 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 });
return NextResponse.json( return NextResponse.json(
{ error: 'Invalid Claude API key or connection failed' }, { error: 'Invalid Claude API key or connection failed' },
{ status: 400 } { status: 400 }
@@ -8,6 +8,7 @@ import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db'; import { prisma } from '@/lib/db';
import { getProwlarrService } from '@/lib/integrations/prowlarr.service'; import { getProwlarrService } from '@/lib/integrations/prowlarr.service';
import { rankTorrents } from '@/lib/utils/ranking-algorithm'; import { rankTorrents } from '@/lib/utils/ranking-algorithm';
import { groupIndexersByCategories, getGroupDescription } from '@/lib/utils/indexer-grouping';
import { RMABLogger } from '@/lib/utils/logger'; import { RMABLogger } from '@/lib/utils/logger';
import { resolveInteractiveSearchAccess } from '@/lib/utils/permissions'; import { resolveInteractiveSearchAccess } from '@/lib/utils/permissions';
@@ -97,9 +98,8 @@ export async function POST(
} }
const indexersConfig = JSON.parse(indexersConfigStr); const indexersConfig = JSON.parse(indexersConfigStr);
const enabledIndexerIds = indexersConfig.map((indexer: any) => indexer.id);
if (enabledIndexerIds.length === 0) { if (indexersConfig.length === 0) {
return NextResponse.json( return NextResponse.json(
{ error: 'ConfigError', message: 'No indexers enabled. Please enable at least one indexer in settings.' }, { error: 'ConfigError', message: 'No indexers enabled. Please enable at least one indexer in settings.' },
{ status: 400 } { status: 400 }
@@ -115,22 +115,53 @@ export async function POST(
const flagConfigStr = await configService.get('indexer_flag_config'); const flagConfigStr = await configService.get('indexer_flag_config');
const flagConfigs = flagConfigStr ? JSON.parse(flagConfigStr) : []; const flagConfigs = flagConfigStr ? JSON.parse(flagConfigStr) : [];
// Search Prowlarr for torrents - ONLY enabled indexers // Group indexers by their category configuration
const prowlarr = await getProwlarrService(); const { groups, skippedIndexers } = groupIndexersByCategories(indexersConfig);
// Use custom title if provided, otherwise use audiobook's title
const searchQuery = customTitle || requestRecord.audiobook.title;
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) { if (customTitle) {
logger.debug('Using custom search title', { customTitle, originalTitle: requestRecord.audiobook.title }); logger.debug('Using custom search title', { customTitle, originalTitle: requestRecord.audiobook.title });
} }
const results = await prowlarr.search(searchQuery, { // Log each group for transparency
indexerIds: enabledIndexerIds, groups.forEach((group, index) => {
maxResults: 100, // Increased limit for broader search 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) { if (results.length === 0) {
return NextResponse.json({ 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 // 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) // Always use the audiobook's title/author for ranking (not custom search query)
// requireAuthor: false - interactive mode, show all results for user decision // requireAuthor: false - interactive mode, show all results for user decision
const rankedResults = rankTorrents(results, { const rankedResults = rankTorrents(results, {
title: requestRecord.audiobook.title, title: requestRecord.audiobook.title,
author: requestRecord.audiobook.author, author: requestRecord.audiobook.author,
durationMinutes,
}, { }, {
indexerPriorities, indexerPriorities,
flagConfigs, flagConfigs,
@@ -160,17 +210,23 @@ export async function POST(
const top3 = rankedResults.slice(0, 3); const top3 = rankedResults.slice(0, 3);
if (top3.length > 0) { if (top3.length > 0) {
logger.debug('==================== RANKING DEBUG ===================='); 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(`Top ${top3.length} results (out of ${rankedResults.length} total)`);
logger.debug('--------------------------------------------------------'); logger.debug('--------------------------------------------------------');
top3.forEach((result, index) => { 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}"`, { logger.debug(`${index + 1}. "${result.title}"`, {
indexer: result.indexer, indexer: result.indexer,
indexerId: result.indexerId, indexerId: result.indexerId,
baseScore: `${result.score.toFixed(1)}/100`, baseScore: `${result.score.toFixed(1)}/100`,
matchScore: `${result.breakdown.matchScore.toFixed(1)}/60`, matchScore: `${result.breakdown.matchScore.toFixed(1)}/60`,
formatScore: `${result.breakdown.formatScore.toFixed(1)}/25 (${result.format || 'unknown'})`, formatScore: `${result.breakdown.formatScore.toFixed(1)}/10 (${result.format || 'unknown'})`,
seederScore: `${result.breakdown.seederScore.toFixed(1)}/15 (${result.seeders} seeders)`, 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)}`, bonusPoints: `+${result.bonusPoints.toFixed(1)}`,
bonusModifiers: result.bonusModifiers.map(mod => `${mod.reason}: +${mod.points.toFixed(1)}`), bonusModifiers: result.bonusModifiers.map(mod => `${mod.reason}: +${mod.points.toFixed(1)}`),
finalScore: result.finalScore.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; hasLocalUsers: boolean;
oidcProviderName: string | null; oidcProviderName: string | null;
localLoginDisabled: boolean; localLoginDisabled: boolean;
allowWeakPassword: boolean;
automationEnabled: boolean; automationEnabled: boolean;
} | null>(null); } | null>(null);
const [showRegisterForm, setShowRegisterForm] = useState(false); const [showRegisterForm, setShowRegisterForm] = useState(false);
@@ -78,6 +79,7 @@ function LoginContent() {
hasLocalUsers: false, hasLocalUsers: false,
oidcProviderName: null, oidcProviderName: null,
localLoginDisabled: false, localLoginDisabled: false,
allowWeakPassword: false,
automationEnabled: false, automationEnabled: false,
}); });
} }
@@ -345,7 +347,7 @@ function LoginContent() {
return; return;
} }
if (registerPassword.length < 8) { if (!authProviders?.allowWeakPassword && registerPassword.length < 8) {
setError('Password must be at least 8 characters'); setError('Password must be at least 8 characters');
setIsLoggingIn(false); setIsLoggingIn(false);
return; 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" 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="••••••••" placeholder="••••••••"
required required
minLength={8} minLength={authProviders?.allowWeakPassword ? 1 : 8}
autoComplete="new-password" 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>
<div> <div>
<label htmlFor="register-confirm-password" className="block text-sm font-medium text-gray-300 mb-2"> <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" 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="••••••••" placeholder="••••••••"
required required
minLength={8} minLength={authProviders?.allowWeakPassword ? 1 : 8}
autoComplete="new-password" autoComplete="new-password"
/> />
</div> </div>
+28
View File
@@ -27,7 +27,13 @@ import { AudibleRegion } from '@/lib/types/audible';
interface SelectedIndexer { interface SelectedIndexer {
id: number; id: number;
name: string; name: string;
protocol: string;
priority: number; priority: number;
seedingTimeMinutes?: number;
removeAfterProcessing?: boolean;
rssEnabled: boolean;
audiobookCategories: number[];
ebookCategories: number[];
} }
interface SetupState { interface SetupState {
@@ -86,6 +92,14 @@ interface SetupState {
bookdateApiKey: string; bookdateApiKey: string;
bookdateModel: string; bookdateModel: string;
bookdateConfigured: boolean; 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: { validated: {
plex: boolean; plex: boolean;
prowlarr: boolean; prowlarr: boolean;
@@ -152,6 +166,14 @@ export default function SetupWizard() {
bookdateApiKey: '', bookdateApiKey: '',
bookdateModel: '', bookdateModel: '',
bookdateConfigured: false, bookdateConfigured: false,
// Cached UI state for back-navigation persistence
plexLibraries: [],
absLibraries: [],
oidcTested: false,
pathsTested: false,
bookdateModels: [],
validated: { validated: {
plex: false, plex: false,
prowlarr: false, prowlarr: false,
@@ -379,6 +401,7 @@ export default function SetupWizard() {
plexToken={state.plexToken} plexToken={state.plexToken}
plexLibraryId={state.plexLibraryId} plexLibraryId={state.plexLibraryId}
plexTriggerScanAfterImport={state.plexTriggerScanAfterImport} plexTriggerScanAfterImport={state.plexTriggerScanAfterImport}
plexLibraries={state.plexLibraries}
onUpdate={updateField} onUpdate={updateField}
onNext={() => goToStep(currentStepNumber + 1)} onNext={() => goToStep(currentStepNumber + 1)}
onBack={() => goToStep(currentStepNumber - 1)} onBack={() => goToStep(currentStepNumber - 1)}
@@ -397,6 +420,7 @@ export default function SetupWizard() {
absApiToken={state.absApiToken} absApiToken={state.absApiToken}
absLibraryId={state.absLibraryId} absLibraryId={state.absLibraryId}
absTriggerScanAfterImport={state.absTriggerScanAfterImport} absTriggerScanAfterImport={state.absTriggerScanAfterImport}
absLibraries={state.absLibraries}
onUpdate={updateField} onUpdate={updateField}
onNext={() => goToStep(currentStepNumber + 1)} onNext={() => goToStep(currentStepNumber + 1)}
onBack={() => goToStep(currentStepNumber - 1)} onBack={() => goToStep(currentStepNumber - 1)}
@@ -435,6 +459,7 @@ export default function SetupWizard() {
oidcAdminClaimEnabled={state.oidcAdminClaimEnabled} oidcAdminClaimEnabled={state.oidcAdminClaimEnabled}
oidcAdminClaimName={state.oidcAdminClaimName} oidcAdminClaimName={state.oidcAdminClaimName}
oidcAdminClaimValue={state.oidcAdminClaimValue} oidcAdminClaimValue={state.oidcAdminClaimValue}
oidcTested={state.oidcTested}
onUpdate={updateField} onUpdate={updateField}
onNext={() => goToStep(currentStepNumber + 1)} onNext={() => goToStep(currentStepNumber + 1)}
onBack={() => goToStep(currentStepNumber - 1)} onBack={() => goToStep(currentStepNumber - 1)}
@@ -482,6 +507,7 @@ export default function SetupWizard() {
<ProwlarrStep <ProwlarrStep
prowlarrUrl={state.prowlarrUrl} prowlarrUrl={state.prowlarrUrl}
prowlarrApiKey={state.prowlarrApiKey} prowlarrApiKey={state.prowlarrApiKey}
prowlarrIndexers={state.prowlarrIndexers}
onUpdate={updateField} onUpdate={updateField}
onNext={() => goToStep(currentStepNumber + 1)} onNext={() => goToStep(currentStepNumber + 1)}
onBack={() => goToStep(currentStepNumber - 1)} onBack={() => goToStep(currentStepNumber - 1)}
@@ -512,6 +538,7 @@ export default function SetupWizard() {
mediaDir={state.mediaDir} mediaDir={state.mediaDir}
metadataTaggingEnabled={state.metadataTaggingEnabled} metadataTaggingEnabled={state.metadataTaggingEnabled}
chapterMergingEnabled={state.chapterMergingEnabled} chapterMergingEnabled={state.chapterMergingEnabled}
pathsTested={state.pathsTested}
onUpdate={updateField} onUpdate={updateField}
onNext={() => goToStep(currentStepNumber + 1)} onNext={() => goToStep(currentStepNumber + 1)}
onBack={() => goToStep(currentStepNumber - 1)} onBack={() => goToStep(currentStepNumber - 1)}
@@ -528,6 +555,7 @@ export default function SetupWizard() {
bookdateApiKey={state.bookdateApiKey} bookdateApiKey={state.bookdateApiKey}
bookdateModel={state.bookdateModel} bookdateModel={state.bookdateModel}
bookdateConfigured={state.bookdateConfigured} bookdateConfigured={state.bookdateConfigured}
bookdateModels={state.bookdateModels}
onUpdate={updateField} onUpdate={updateField}
onNext={() => goToStep(currentStepNumber + 1)} onNext={() => goToStep(currentStepNumber + 1)}
onSkip={() => goToStep(currentStepNumber + 1)} onSkip={() => goToStep(currentStepNumber + 1)}
+22 -3
View File
@@ -5,7 +5,7 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
interface AdminAccountStepProps { interface AdminAccountStepProps {
@@ -25,6 +25,23 @@ export function AdminAccountStep({
}: AdminAccountStepProps) { }: AdminAccountStepProps) {
const [confirmPassword, setConfirmPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState('');
const [errors, setErrors] = useState<{ username?: string; password?: string; confirm?: string }>({}); 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 validate = () => {
const newErrors: { username?: string; password?: string; confirm?: string } = {}; const newErrors: { username?: string; password?: string; confirm?: string } = {};
@@ -35,7 +52,9 @@ export function AdminAccountStep({
} }
// Validate password // 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'; 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-sm text-red-400">{errors.password}</p>
)} )}
<p className="mt-1 text-xs text-gray-500"> <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> </p>
</div> </div>
+13 -5
View File
@@ -14,7 +14,8 @@ interface AudiobookshelfStepProps {
absApiToken: string; absApiToken: string;
absLibraryId: string; absLibraryId: string;
absTriggerScanAfterImport: boolean; absTriggerScanAfterImport: boolean;
onUpdate: (field: string, value: string | boolean) => void; absLibraries: Library[];
onUpdate: (field: string, value: any) => void;
onNext: () => void; onNext: () => void;
onBack: () => void; onBack: () => void;
} }
@@ -30,6 +31,7 @@ export function AudiobookshelfStep({
absApiToken, absApiToken,
absLibraryId, absLibraryId,
absTriggerScanAfterImport, absTriggerScanAfterImport,
absLibraries,
onUpdate, onUpdate,
onNext, onNext,
onBack, onBack,
@@ -39,8 +41,12 @@ export function AudiobookshelfStep({
success: boolean; success: boolean;
message?: string; message?: string;
libraries?: Library[]; libraries?: Library[];
} | null>(null); } | null>(
const [libraries, setLibraries] = useState<Library[]>([]); absLibraries.length > 0
? { success: true, message: 'Connection verified previously.' }
: null
);
const [libraries, setLibraries] = useState<Library[]>(absLibraries);
const testConnection = async () => { const testConnection = async () => {
setTesting(true); setTesting(true);
@@ -56,12 +62,14 @@ export function AudiobookshelfStep({
const data = await response.json(); const data = await response.json();
if (response.ok && data.success) { if (response.ok && data.success) {
const libs = data.libraries || [];
setTestResult({ setTestResult({
success: true, success: true,
message: 'Connection successful!', message: 'Connection successful!',
libraries: data.libraries || [], libraries: libs,
}); });
setLibraries(data.libraries || []); setLibraries(libs);
onUpdate('absLibraries', libs);
} else { } else {
setTestResult({ setTestResult({
success: false, success: false,
+11 -4
View File
@@ -12,6 +12,7 @@ interface BookDateStepProps {
bookdateApiKey: string; bookdateApiKey: string;
bookdateModel: string; bookdateModel: string;
bookdateConfigured: boolean; bookdateConfigured: boolean;
bookdateModels: ModelOption[];
onUpdate: (field: string, value: any) => void; onUpdate: (field: string, value: any) => void;
onNext: () => void; onNext: () => void;
onSkip: () => void; onSkip: () => void;
@@ -28,6 +29,7 @@ export function BookDateStep({
bookdateApiKey, bookdateApiKey,
bookdateModel, bookdateModel,
bookdateConfigured, bookdateConfigured,
bookdateModels,
onUpdate, onUpdate,
onNext, onNext,
onSkip, onSkip,
@@ -35,7 +37,7 @@ export function BookDateStep({
}: BookDateStepProps) { }: BookDateStepProps) {
const [testing, setTesting] = useState(false); const [testing, setTesting] = useState(false);
const [tested, setTested] = useState(bookdateConfigured); const [tested, setTested] = useState(bookdateConfigured);
const [models, setModels] = useState<ModelOption[]>([]); const [models, setModels] = useState<ModelOption[]>(bookdateModels);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const handleTestConnection = async () => { const handleTestConnection = async () => {
@@ -65,19 +67,22 @@ export function BookDateStep({
throw new Error(data.error || 'Connection test failed'); throw new Error(data.error || 'Connection test failed');
} }
setModels(data.models || []); const fetchedModels = data.models || [];
setModels(fetchedModels);
setTested(true); setTested(true);
onUpdate('bookdateConfigured', true); onUpdate('bookdateConfigured', true);
onUpdate('bookdateModels', fetchedModels);
// Auto-select first model if none selected // Auto-select first model if none selected
if (!bookdateModel && data.models?.length > 0) { if (!bookdateModel && fetchedModels.length > 0) {
onUpdate('bookdateModel', data.models[0].id); onUpdate('bookdateModel', fetchedModels[0].id);
} }
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Connection test failed'); setError(err instanceof Error ? err.message : 'Connection test failed');
setTested(false); setTested(false);
onUpdate('bookdateConfigured', false); onUpdate('bookdateConfigured', false);
onUpdate('bookdateModels', []);
} finally { } finally {
setTesting(false); setTesting(false);
} }
@@ -123,6 +128,7 @@ export function BookDateStep({
setTested(false); setTested(false);
setModels([]); setModels([]);
onUpdate('bookdateConfigured', false); 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" 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); setTested(false);
setModels([]); setModels([]);
onUpdate('bookdateConfigured', false); onUpdate('bookdateConfigured', false);
onUpdate('bookdateModels', []);
}} }}
placeholder={bookdateProvider === 'openai' ? 'sk-...' : 'sk-ant-...'} 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" 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'; 'use client';
import { useState, useEffect } from 'react'; import { useState } from 'react';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { DownloadClientManagement } from '@/components/admin/download-clients/DownloadClientManagement'; import { DownloadClientManagement } from '@/components/admin/download-clients/DownloadClientManagement';
import { DownloadClientType } from '@/lib/interfaces/download-client.interface'; import { DownloadClientType } from '@/lib/interfaces/download-client.interface';
@@ -24,6 +24,7 @@ interface DownloadClient {
localPath?: string; localPath?: string;
category?: string; category?: string;
customPath?: string; customPath?: string;
postImportCategory?: string;
} }
interface DownloadClientStepProps { interface DownloadClientStepProps {
+10 -1
View File
@@ -22,6 +22,7 @@ interface OIDCConfigStepProps {
oidcAdminClaimEnabled: boolean; oidcAdminClaimEnabled: boolean;
oidcAdminClaimName: string; oidcAdminClaimName: string;
oidcAdminClaimValue: string; oidcAdminClaimValue: string;
oidcTested: boolean;
onUpdate: (field: string, value: any) => void; onUpdate: (field: string, value: any) => void;
onNext: () => void; onNext: () => void;
onBack: () => void; onBack: () => void;
@@ -40,6 +41,7 @@ export function OIDCConfigStep({
oidcAdminClaimEnabled, oidcAdminClaimEnabled,
oidcAdminClaimName, oidcAdminClaimName,
oidcAdminClaimValue, oidcAdminClaimValue,
oidcTested,
onUpdate, onUpdate,
onNext, onNext,
onBack, onBack,
@@ -48,7 +50,11 @@ export function OIDCConfigStep({
const [testResult, setTestResult] = useState<{ const [testResult, setTestResult] = useState<{
success: boolean; success: boolean;
message: string; message: string;
} | null>(null); } | null>(
oidcTested
? { success: true, message: 'OIDC configuration verified previously.' }
: null
);
const testConnection = async () => { const testConnection = async () => {
setTesting(true); setTesting(true);
@@ -72,17 +78,20 @@ export function OIDCConfigStep({
success: true, success: true,
message: 'OIDC discovery successful! Provider configuration validated.', message: 'OIDC discovery successful! Provider configuration validated.',
}); });
onUpdate('oidcTested', true);
} else { } else {
setTestResult({ setTestResult({
success: false, success: false,
message: data.error || 'OIDC discovery failed', message: data.error || 'OIDC discovery failed',
}); });
onUpdate('oidcTested', false);
} }
} catch (error) { } catch (error) {
setTestResult({ setTestResult({
success: false, success: false,
message: error instanceof Error ? error.message : 'Connection test failed', message: error instanceof Error ? error.message : 'Connection test failed',
}); });
onUpdate('oidcTested', false);
} finally { } finally {
setTesting(false); setTesting(false);
} }
+11 -2
View File
@@ -14,7 +14,8 @@ interface PathsStepProps {
mediaDir: string; mediaDir: string;
metadataTaggingEnabled: boolean; metadataTaggingEnabled: boolean;
chapterMergingEnabled: boolean; chapterMergingEnabled: boolean;
onUpdate: (field: string, value: string | boolean) => void; pathsTested: boolean;
onUpdate: (field: string, value: any) => void;
onNext: () => void; onNext: () => void;
onBack: () => void; onBack: () => void;
} }
@@ -24,6 +25,7 @@ export function PathsStep({
mediaDir, mediaDir,
metadataTaggingEnabled, metadataTaggingEnabled,
chapterMergingEnabled, chapterMergingEnabled,
pathsTested,
onUpdate, onUpdate,
onNext, onNext,
onBack, onBack,
@@ -34,7 +36,11 @@ export function PathsStep({
message: string; message: string;
downloadDirValid?: boolean; downloadDirValid?: boolean;
mediaDirValid?: boolean; mediaDirValid?: boolean;
} | null>(null); } | null>(
pathsTested
? { success: true, message: 'Paths validated previously.', downloadDirValid: true, mediaDirValid: true }
: null
);
const testPaths = async () => { const testPaths = async () => {
setTesting(true); setTesting(true);
@@ -59,6 +65,7 @@ export function PathsStep({
downloadDirValid: data.downloadDirValid, downloadDirValid: data.downloadDirValid,
mediaDirValid: data.mediaDirValid, mediaDirValid: data.mediaDirValid,
}); });
onUpdate('pathsTested', true);
} else { } else {
setTestResult({ setTestResult({
success: false, success: false,
@@ -66,12 +73,14 @@ export function PathsStep({
downloadDirValid: data.downloadDirValid, downloadDirValid: data.downloadDirValid,
mediaDirValid: data.mediaDirValid, mediaDirValid: data.mediaDirValid,
}); });
onUpdate('pathsTested', false);
} }
} catch (error) { } catch (error) {
setTestResult({ setTestResult({
success: false, success: false,
message: error instanceof Error ? error.message : 'Path validation failed', message: error instanceof Error ? error.message : 'Path validation failed',
}); });
onUpdate('pathsTested', false);
} finally { } finally {
setTesting(false); setTesting(false);
} }
+13 -5
View File
@@ -14,7 +14,8 @@ interface PlexStepProps {
plexToken: string; plexToken: string;
plexLibraryId: string; plexLibraryId: string;
plexTriggerScanAfterImport: boolean; plexTriggerScanAfterImport: boolean;
onUpdate: (field: string, value: string | boolean) => void; plexLibraries: PlexLibrary[];
onUpdate: (field: string, value: any) => void;
onNext: () => void; onNext: () => void;
onBack: () => void; onBack: () => void;
} }
@@ -30,6 +31,7 @@ export function PlexStep({
plexToken, plexToken,
plexLibraryId, plexLibraryId,
plexTriggerScanAfterImport, plexTriggerScanAfterImport,
plexLibraries,
onUpdate, onUpdate,
onNext, onNext,
onBack, onBack,
@@ -39,8 +41,12 @@ export function PlexStep({
success: boolean; success: boolean;
message: string; message: string;
libraries?: PlexLibrary[]; libraries?: PlexLibrary[];
} | null>(null); } | null>(
const [libraries, setLibraries] = useState<PlexLibrary[]>([]); plexLibraries.length > 0
? { success: true, message: 'Connection verified previously.' }
: null
);
const [libraries, setLibraries] = useState<PlexLibrary[]>(plexLibraries);
const testConnection = async () => { const testConnection = async () => {
setTesting(true); setTesting(true);
@@ -56,12 +62,14 @@ export function PlexStep({
const data = await response.json(); const data = await response.json();
if (response.ok && data.success) { if (response.ok && data.success) {
const libs = data.libraries || [];
setTestResult({ setTestResult({
success: true, success: true,
message: `Connected to ${data.serverName || 'Plex server'} successfully!`, message: `Connected to ${data.serverName || 'Plex server'} successfully!`,
libraries: data.libraries || [], libraries: libs,
}); });
setLibraries(data.libraries || []); setLibraries(libs);
onUpdate('plexLibraries', libs);
} else { } else {
setTestResult({ setTestResult({
success: false, success: false,
+10 -7
View File
@@ -5,7 +5,7 @@
'use client'; 'use client';
import { useState, useEffect } from 'react'; import { useState } from 'react';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input'; import { Input } from '@/components/ui/Input';
import { IndexerManagement } from '@/components/admin/indexers/IndexerManagement'; import { IndexerManagement } from '@/components/admin/indexers/IndexerManagement';
@@ -13,6 +13,7 @@ import { IndexerManagement } from '@/components/admin/indexers/IndexerManagement
interface ProwlarrStepProps { interface ProwlarrStepProps {
prowlarrUrl: string; prowlarrUrl: string;
prowlarrApiKey: string; prowlarrApiKey: string;
prowlarrIndexers: SelectedIndexer[];
onUpdate: (field: string, value: any) => void; onUpdate: (field: string, value: any) => void;
onNext: () => void; onNext: () => void;
onBack: () => void; onBack: () => void;
@@ -33,17 +34,19 @@ interface SelectedIndexer {
export function ProwlarrStep({ export function ProwlarrStep({
prowlarrUrl, prowlarrUrl,
prowlarrApiKey, prowlarrApiKey,
prowlarrIndexers,
onUpdate, onUpdate,
onNext, onNext,
onBack, onBack,
}: ProwlarrStepProps) { }: ProwlarrStepProps) {
const [configuredIndexers, setConfiguredIndexers] = useState<SelectedIndexer[]>([]); const [configuredIndexers, setConfiguredIndexers] = useState<SelectedIndexer[]>(prowlarrIndexers);
const [errorMessage, setErrorMessage] = useState<string | null>(null); const [errorMessage, setErrorMessage] = useState<string | null>(null);
// Sync configured indexers with parent // Update both local and parent state when indexers change
useEffect(() => { const handleIndexersChange = (indexers: SelectedIndexer[]) => {
onUpdate('prowlarrIndexers', configuredIndexers); setConfiguredIndexers(indexers);
}, [configuredIndexers, onUpdate]); onUpdate('prowlarrIndexers', indexers);
};
const handleNext = () => { const handleNext = () => {
setErrorMessage(null); setErrorMessage(null);
@@ -136,7 +139,7 @@ export function ProwlarrStep({
prowlarrApiKey={prowlarrApiKey} prowlarrApiKey={prowlarrApiKey}
mode="wizard" mode="wizard"
initialIndexers={configuredIndexers} initialIndexers={configuredIndexers}
onIndexersChange={setConfiguredIndexers} onIndexersChange={handleIndexersChange}
/> />
</div> </div>
</div> </div>
@@ -16,6 +16,7 @@ interface DownloadClientCardProps {
url: string; url: string;
enabled: boolean; enabled: boolean;
customPath?: string; customPath?: string;
postImportCategory?: string;
}; };
onEdit: () => void; onEdit: () => void;
onDelete: () => void; onDelete: () => void;
@@ -62,6 +63,11 @@ export function DownloadClientCard({ client, onEdit, onDelete }: DownloadClientC
Path: {client.customPath} Path: {client.customPath}
</p> </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>
</div> </div>
@@ -26,6 +26,7 @@ interface DownloadClient {
localPath?: string; localPath?: string;
category?: string; category?: string;
customPath?: string; customPath?: string;
postImportCategory?: string;
} }
interface DownloadClientManagementProps { interface DownloadClientManagementProps {
@@ -72,20 +73,6 @@ export function DownloadClientManagement({
} }
}, [downloadDirProp]); }, [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 () => { const fetchClients = async () => {
setLoading(true); setLoading(true);
setError(null); setError(null);
@@ -172,7 +159,9 @@ export function DownloadClientManagement({
await fetchClients(); // Refresh list await fetchClients(); // Refresh list
} else { } else {
// Local removal for wizard mode // 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 }); setDeleteConfirm({ isOpen: false });
@@ -219,15 +208,18 @@ export function DownloadClientManagement({
} }
} else { } else {
// Local update for wizard mode // Local update for wizard mode
let updated: DownloadClient[];
if (modalState.mode === 'add') { if (modalState.mode === 'add') {
const newClient = { const newClient = {
...clientData, ...clientData,
id: `temp-${Date.now()}`, // Temporary ID for wizard mode id: `temp-${Date.now()}`, // Temporary ID for wizard mode
}; };
setClients([...clients, newClient]); updated = [...clients, newClient];
} else { } 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' }); setModalState({ isOpen: false, mode: 'add' });
@@ -10,7 +10,7 @@ import { Modal } from '@/components/ui/Modal';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input'; import { Input } from '@/components/ui/Input';
import { fetchWithAuth } from '@/lib/utils/api'; 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 { interface DownloadClientModalProps {
isOpen: boolean; isOpen: boolean;
@@ -31,6 +31,7 @@ interface DownloadClientModalProps {
localPath?: string; localPath?: string;
category?: string; category?: string;
customPath?: string; customPath?: string;
postImportCategory?: string;
}; };
onSave: (client: any) => Promise<void>; onSave: (client: any) => Promise<void>;
apiMode: 'wizard' | 'settings'; apiMode: 'wizard' | 'settings';
@@ -62,6 +63,9 @@ export function DownloadClientModal({
const [localPath, setLocalPath] = useState(''); const [localPath, setLocalPath] = useState('');
const [category, setCategory] = useState('readmeabook'); const [category, setCategory] = useState('readmeabook');
const [customPath, setCustomPath] = useState(''); const [customPath, setCustomPath] = useState('');
const [postImportCategory, setPostImportCategory] = useState('');
const [availableCategories, setAvailableCategories] = useState<string[]>([]);
const [fetchingCategories, setFetchingCategories] = useState(false);
const [testing, setTesting] = useState(false); const [testing, setTesting] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@@ -85,6 +89,7 @@ export function DownloadClientModal({
setLocalPath(initialClient.localPath || ''); setLocalPath(initialClient.localPath || '');
setCategory(initialClient.category || 'readmeabook'); setCategory(initialClient.category || 'readmeabook');
setCustomPath(initialClient.customPath || ''); setCustomPath(initialClient.customPath || '');
setPostImportCategory(initialClient.postImportCategory || '');
} else { } else {
// Add mode defaults // Add mode defaults
setName(typeName); setName(typeName);
@@ -98,9 +103,12 @@ export function DownloadClientModal({
setLocalPath(''); setLocalPath('');
setCategory('readmeabook'); setCategory('readmeabook');
setCustomPath(''); setCustomPath('');
setPostImportCategory('');
} }
setTestResult(null); setTestResult(null);
setErrors({}); setErrors({});
setAvailableCategories([]);
setFetchingCategories(false);
} }
}, [isOpen, mode, initialClient, type]); }, [isOpen, mode, initialClient, type]);
@@ -137,6 +145,50 @@ export function DownloadClientModal({
return Object.keys(newErrors).length === 0; 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 () => { const handleTestConnection = async () => {
if (!validate()) { if (!validate()) {
return; return;
@@ -187,6 +239,11 @@ export function DownloadClientModal({
// Handle both endpoint response formats (settings returns message, wizard returns version) // Handle both endpoint response formats (settings returns message, wizard returns version)
const message = data.message || (data.version ? `Connected successfully (v${data.version})` : 'Connection successful'); const message = data.message || (data.version ? `Connected successfully (v${data.version})` : 'Connection successful');
setTestResult({ success: true, message }); setTestResult({ success: true, message });
// Fetch categories for torrent clients after successful connection
if (type && CLIENT_PROTOCOL_MAP[type] === 'torrent') {
fetchCategories();
}
} else { } else {
setTestResult({ success: false, message: data.error || 'Connection test failed' }); setTestResult({ success: false, message: data.error || 'Connection test failed' });
} }
@@ -230,6 +287,7 @@ export function DownloadClientModal({
localPath: remotePathMappingEnabled ? localPath : undefined, localPath: remotePathMappingEnabled ? localPath : undefined,
category, category,
customPath: sanitizedCustomPath || undefined, customPath: sanitizedCustomPath || undefined,
postImportCategory,
}; };
if (mode === 'edit' && initialClient) { if (mode === 'edit' && initialClient) {
@@ -384,6 +442,37 @@ export function DownloadClientModal({
</p> </p>
</div> </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 */} {/* Remote Path Mapping */}
<div className="border-t border-gray-200 dark:border-gray-700 pt-4"> <div className="border-t border-gray-200 dark:border-gray-700 pt-4">
<div className="flex items-start mb-3"> <div className="flex items-start mb-3">
@@ -63,17 +63,14 @@ export function IndexerManagement({
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); 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(() => { useEffect(() => {
if (onIndexersChange) { if (mode === 'settings') {
onIndexersChange(configuredIndexers); setConfiguredIndexers(initialIndexers);
} }
}, [configuredIndexers, onIndexersChange]); }, [initialIndexers, mode]);
// Sync with initialIndexers prop changes
useEffect(() => {
setConfiguredIndexers(initialIndexers);
}, [initialIndexers]);
const fetchIndexers = async () => { const fetchIndexers = async () => {
setLoading(true); setLoading(true);
@@ -149,17 +146,16 @@ export function IndexerManagement({
}; };
const handleSave = (config: SavedIndexerConfig) => { const handleSave = (config: SavedIndexerConfig) => {
let updated: SavedIndexerConfig[];
if (modalState.mode === 'add') { if (modalState.mode === 'add') {
// Add new indexer updated = [...configuredIndexers, config];
setConfiguredIndexers([...configuredIndexers, config]);
} else { } else {
// Update existing indexer updated = configuredIndexers.map((idx) =>
setConfiguredIndexers( idx.id === config.id ? config : idx
configuredIndexers.map((idx) =>
idx.id === config.id ? config : idx
)
); );
} }
setConfiguredIndexers(updated);
onIndexersChange?.(updated);
}; };
const handleDelete = (id: number) => { const handleDelete = (id: number) => {
@@ -175,9 +171,9 @@ export function IndexerManagement({
const confirmDelete = () => { const confirmDelete = () => {
if (deleteModalState.indexerId) { if (deleteModalState.indexerId) {
setConfiguredIndexers( const updated = configuredIndexers.filter((idx) => idx.id !== deleteModalState.indexerId);
configuredIndexers.filter((idx) => idx.id !== deleteModalState.indexerId) setConfiguredIndexers(updated);
); onIndexersChange?.(updated);
} }
}; };
+21 -3
View File
@@ -5,7 +5,7 @@
'use client'; 'use client';
import React, { useState } from 'react'; import React, { useState, useEffect } from 'react';
import { Modal } from './Modal'; import { Modal } from './Modal';
import { Input } from './Input'; import { Input } from './Input';
import { Button } from './Button'; import { Button } from './Button';
@@ -22,6 +22,24 @@ export function ChangePasswordModal({ isOpen, onClose }: ChangePasswordModalProp
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false); 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 // Validation errors for individual fields
const [errors, setErrors] = useState({ const [errors, setErrors] = useState({
@@ -47,7 +65,7 @@ export function ChangePasswordModal({ isOpen, onClose }: ChangePasswordModalProp
if (!newPassword) { if (!newPassword) {
newErrors.newPassword = 'New password is required'; newErrors.newPassword = 'New password is required';
isValid = false; isValid = false;
} else if (newPassword.length < 8) { } else if (!allowWeakPassword && newPassword.length < 8) {
newErrors.newPassword = 'Password must be at least 8 characters'; newErrors.newPassword = 'Password must be at least 8 characters';
isValid = false; isValid = false;
} else if (newPassword === currentPassword) { } else if (newPassword === currentPassword) {
@@ -211,7 +229,7 @@ export function ChangePasswordModal({ isOpen, onClose }: ChangePasswordModalProp
}} }}
placeholder="Enter your new password" placeholder="Enter your new password"
autoComplete="new-password" autoComplete="new-password"
helperText="Must be at least 8 characters" helperText={allowWeakPassword ? undefined : 'Must be at least 8 characters'}
error={errors.newPassword} error={errors.newPassword}
disabled={loading || success} 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 // 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 * 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 * Set category for torrent
*/ */
+10
View File
@@ -825,6 +825,16 @@ export class SABnzbdService implements IDownloadClient {
await this.archiveCompletedNZB(id); 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. * 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 // 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 // Internal Helpers
// ========================================================================= // =========================================================================
@@ -177,4 +177,22 @@ export interface IDownloadClient {
* @param id - Download ID * @param id - Download ID
*/ */
postProcess(id: string): Promise<void>; 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`, { logger.info(`Request ${requestId} completed successfully - status: downloaded`, {
success: true, success: true,
message: 'Files organized successfully', 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)`); logger.info(`Ebook request ${requestId} completed - status: downloaded (terminal)`);
// Send "available" notification for ebooks at downloaded state // 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 // DOWNLOAD CLEANUP
// ========================================================================= // =========================================================================
@@ -75,10 +75,7 @@ export async function processSearchIndexers(payload: SearchIndexersPayload): Pro
// Get Prowlarr service // Get Prowlarr service
const prowlarr = await getProwlarrService(); const prowlarr = await getProwlarrService();
// Build search query (title only - cast wide net, let ranking filter) logger.info(`Searching for: "${audiobook.title}" by "${audiobook.author}"`);
const searchQuery = audiobook.title;
logger.info(`Searching for: "${searchQuery}"`);
// Search Prowlarr for each group and combine results // Search Prowlarr for each group and combine results
const allResults = []; const allResults = [];
@@ -88,7 +85,7 @@ export async function processSearchIndexers(payload: SearchIndexersPayload): Pro
logger.info(`Searching group ${i + 1}/${groups.length}: ${getGroupDescription(group)}`); logger.info(`Searching group ${i + 1}/${groups.length}: ${getGroupDescription(group)}`);
try { try {
const groupResults = await prowlarr.search(searchQuery, { const groupResults = await prowlarr.searchWithVariations(audiobook.title, audiobook.author, {
categories: group.categories, categories: group.categories,
indexerIds: group.indexerIds, indexerIds: group.indexerIds,
minSeeders: 1, // Only torrents with at least 1 seeder minSeeders: 1, // Only torrents with at least 1 seeder
@@ -6,7 +6,7 @@
* to all enabled backends subscribed to the event. * to all enabled backends subscribed to the event.
*/ */
import { getNotificationService } from '../services/notification.service'; import { getNotificationService } from '../services/notification';
import { RMABLogger } from '../utils/logger'; import { RMABLogger } from '../utils/logger';
export interface SendNotificationPayload { 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' }; 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' }; return { success: false, error: 'Password must be at least 8 characters' };
} }
@@ -35,6 +35,7 @@ export interface DownloadClientConfig {
localPath?: string; localPath?: string;
category?: string; // Default: 'readmeabook' category?: string; // Default: 'readmeabook'
customPath?: string; // Relative sub-path appended to download_dir 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 axios from 'axios';
import { tagMultipleFiles, checkFfmpegAvailable } from './metadata-tagger'; import { tagMultipleFiles, checkFfmpegAvailable } from './metadata-tagger';
import { RMABLogger } from './logger'; import { RMABLogger } from './logger';
import { copyFile } from './copy-file';
const moduleLogger = RMABLogger.create('FileOrganizer'); const moduleLogger = RMABLogger.create('FileOrganizer');
import { import {
@@ -340,8 +341,8 @@ export class FileOrganizer {
// Copy file (do NOT delete original - needed for seeding) // Copy file (do NOT delete original - needed for seeding)
try { try {
// Copy file using streaming (handles large files >2GB) // Copy file via streams (avoids copy_file_range EPERM on NFS/FUSE)
await fs.copyFile(sourcePath, targetFilePath); await copyFile(sourcePath, targetFilePath);
// Set explicit permissions after copy // Set explicit permissions after copy
await fs.chmod(targetFilePath, 0o644); await fs.chmod(targetFilePath, 0o644);
@@ -378,7 +379,7 @@ export class FileOrganizer {
await logger?.info(`Attempting fallback copy of original (untagged) file: ${filename}`); await logger?.info(`Attempting fallback copy of original (untagged) file: ${filename}`);
try { try {
await fs.access(originalSourcePath, fs.constants.R_OK); await fs.access(originalSourcePath, fs.constants.R_OK);
await fs.copyFile(originalSourcePath, targetFilePath); await copyFile(originalSourcePath, targetFilePath);
await fs.chmod(targetFilePath, 0o644); await fs.chmod(targetFilePath, 0o644);
result.audioFiles.push(targetFilePath); result.audioFiles.push(targetFilePath);
result.filesMovedCount++; result.filesMovedCount++;
@@ -413,7 +414,7 @@ export class FileOrganizer {
try { try {
// Copy cover art (do NOT delete original) // Copy cover art (do NOT delete original)
await fs.copyFile(sourcePath, targetCoverPath); await copyFile(sourcePath, targetCoverPath);
await fs.chmod(targetCoverPath, 0o644); await fs.chmod(targetCoverPath, 0o644);
result.coverArtFile = targetCoverPath; result.coverArtFile = targetCoverPath;
result.filesMovedCount++; result.filesMovedCount++;
@@ -608,7 +609,7 @@ export class FileOrganizer {
const cachedPath = path.join('/app/cache/thumbnails', filename); const cachedPath = path.join('/app/cache/thumbnails', filename);
// Copy from local cache instead of downloading // Copy from local cache instead of downloading
await fs.copyFile(cachedPath, targetPath); await copyFile(cachedPath, targetPath);
await fs.chmod(targetPath, 0o644); await fs.chmod(targetPath, 0o644);
moduleLogger.debug(`Copied cover art from cache: ${filename}`); moduleLogger.debug(`Copied cover art from cache: ${filename}`);
} else { } else {
@@ -755,7 +756,7 @@ export class FileOrganizer {
} }
// Copy ebook file (do NOT delete original - may need for seeding or retry) // 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 fs.chmod(targetPath, 0o644);
await logger?.info(`Copied ebook: ${targetFilename}`); 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 = /[<>:"|?*]/; 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 * Sanitize a path component by removing invalid characters
* Reuses logic from file-organizer.ts * Reuses logic from file-organizer.ts
@@ -87,6 +95,10 @@ export function substituteTemplate(
): string { ): string {
let result = template; 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 // Substitute each variable
for (const key of VALID_VARIABLES) { for (const key of VALID_VARIABLES) {
const value = variables[key as keyof TemplateVariables]; const value = variables[key as keyof TemplateVariables];
@@ -120,6 +132,11 @@ export function substituteTemplate(
.filter(part => part.length > 0) .filter(part => part.length > 0)
.join('/'); .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; return result;
} }
@@ -153,16 +170,20 @@ export function validateTemplate(template: string): ValidationResult {
}; };
} }
// Check for absolute paths // Check for absolute paths (backslash followed by { or } is a brace escape, not a path)
if (template.startsWith('/') || template.startsWith('\\') || /^[a-zA-Z]:/.test(template)) { if (template.startsWith('/') || /^\\(?![{}])/.test(template) || /^[a-zA-Z]:/.test(template)) {
return { return {
valid: false, valid: false,
error: 'Template must be a relative path (no absolute paths like "/" or "C:\\")' error: 'Template must be a relative path (no absolute paths like "/" or "C:\\")'
}; };
} }
// Extract all variables from template // Strip escaped braces (\{ and \}) before parsing so they don't interfere
const variableMatches = template.match(/\{[^}]+\}/g); // 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) { if (variableMatches) {
for (const match of 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 // Remove valid variables temporarily to check for invalid characters
let templateWithoutVars = template; let templateWithoutVars = templateWithoutEscapedBraces;
for (const varName of VALID_VARIABLES) { for (const varName of VALID_VARIABLES) {
templateWithoutVars = templateWithoutVars.replace(new RegExp(`\\{${varName}\\}`, 'g'), ''); templateWithoutVars = templateWithoutVars.replace(new RegExp(`\\{${varName}\\}`, 'g'), '');
} }
@@ -192,8 +213,9 @@ export function validateTemplate(template: string): ValidationResult {
}; };
} }
// Check for backslashes (Windows-style paths) // Check for backslashes that are not brace escapes (Windows-style paths)
if (templateWithoutVars.includes('\\')) { // We check the original template: any backslash NOT followed by { or } is invalid
if (/\\(?![{}])/.test(template)) {
return { return {
valid: false, valid: false,
error: 'Use forward slashes (/) for path separators, not backslashes (\\)' error: 'Use forward slashes (/) for path separators, not backslashes (\\)'
@@ -30,8 +30,9 @@ vi.mock('@/lib/middleware/auth', () => ({
requireAdmin: requireAdminMock, requireAdmin: requireAdminMock,
})); }));
vi.mock('@/lib/services/notification.service', () => ({ vi.mock('@/lib/services/notification', () => ({
getNotificationService: () => notificationServiceMock, getNotificationService: () => notificationServiceMock,
getRegisteredProviderTypes: () => ['discord', 'ntfy', 'pushover'],
})); }));
describe('Admin notifications test route', () => { describe('Admin notifications test route', () => {
+2 -1
View File
@@ -35,8 +35,9 @@ vi.mock('@/lib/middleware/auth', () => ({
requireAdmin: requireAdminMock, requireAdmin: requireAdminMock,
})); }));
vi.mock('@/lib/services/notification.service', () => ({ vi.mock('@/lib/services/notification', () => ({
getNotificationService: () => notificationServiceMock, getNotificationService: () => notificationServiceMock,
getRegisteredProviderTypes: () => ['discord', 'ntfy', 'pushover'],
})); }));
describe('Admin notifications routes', () => { describe('Admin notifications routes', () => {
+2 -1
View File
@@ -13,6 +13,7 @@ const configServiceMock = vi.hoisted(() => ({
})); }));
const prowlarrMock = vi.hoisted(() => ({ const prowlarrMock = vi.hoisted(() => ({
search: vi.fn(), search: vi.fn(),
searchWithVariations: vi.fn(),
})); }));
const rankTorrentsMock = vi.hoisted(() => vi.fn()); const rankTorrentsMock = vi.hoisted(() => vi.fn());
const groupIndexersMock = vi.hoisted(() => vi.fn()); const groupIndexersMock = vi.hoisted(() => vi.fn());
@@ -68,7 +69,7 @@ describe('Audiobooks search torrents route', () => {
.mockResolvedValueOnce(null); .mockResolvedValueOnce(null);
groupIndexersMock.mockReturnValue({ groups: [{ categories: [1], indexerIds: [1] }], skippedIndexers: [] }); 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([ rankTorrentsMock.mockReturnValue([
{ {
title: 'Result', title: 'Result',
@@ -68,6 +68,32 @@ describe('Change password route', () => {
expect(payload.error).toMatch(/at least 8 characters/i); 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 () => { it('blocks non-local users', async () => {
prismaMock.user.findUnique.mockResolvedValue({ prismaMock.user.findUnique.mockResolvedValue({
id: 'user-1', id: 'user-1',
@@ -149,6 +149,15 @@ describe('BookDate test connection route', () => {
it('returns Claude models for unauthenticated requests', async () => { it('returns Claude models for unauthenticated requests', async () => {
const fetchMock = vi.fn().mockResolvedValue({ const fetchMock = vi.fn().mockResolvedValue({
ok: true, 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'), text: vi.fn().mockResolvedValue('ok'),
}); });
vi.stubGlobal('fetch', fetchMock); vi.stubGlobal('fetch', fetchMock);
@@ -161,7 +170,142 @@ describe('BookDate test connection route', () => {
const payload = await response.json(); const payload = await response.json();
expect(payload.success).toBe(true); 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 () => { 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 prismaMock = createPrismaMock();
const requireAuthMock = vi.hoisted(() => vi.fn()); 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 rankTorrentsMock = vi.hoisted(() => vi.fn());
const configServiceMock = vi.hoisted(() => ({ get: 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(() => ({ const configState = vi.hoisted(() => ({
values: new Map<string, string>(), values: new Map<string, string>(),
})); }));
@@ -23,6 +25,9 @@ const jobQueueMock = vi.hoisted(() => ({
addSearchEbookJob: vi.fn(() => Promise.resolve()), addSearchEbookJob: vi.fn(() => Promise.resolve()),
})); }));
const downloadEbookMock = vi.hoisted(() => vi.fn()); const downloadEbookMock = vi.hoisted(() => vi.fn());
const audibleServiceMock = vi.hoisted(() => ({
getRuntime: vi.fn(),
}));
const fsMock = vi.hoisted(() => ({ const fsMock = vi.hoisted(() => ({
access: vi.fn(), access: vi.fn(),
})); }));
@@ -44,6 +49,11 @@ vi.mock('@/lib/utils/ranking-algorithm', () => ({
rankTorrents: rankTorrentsMock, rankTorrents: rankTorrentsMock,
})); }));
vi.mock('@/lib/utils/indexer-grouping', () => ({
groupIndexersByCategories: groupIndexersMock,
getGroupDescription: groupDescriptionMock,
}));
vi.mock('@/lib/services/config.service', () => ({ vi.mock('@/lib/services/config.service', () => ({
getConfigService: () => configServiceMock, getConfigService: () => configServiceMock,
})); }));
@@ -56,6 +66,10 @@ vi.mock('@/lib/services/ebook-scraper', () => ({
downloadEbook: downloadEbookMock, downloadEbook: downloadEbookMock,
})); }));
vi.mock('@/lib/integrations/audible.service', () => ({
getAudibleService: () => audibleServiceMock,
}));
vi.mock('fs/promises', () => ({ default: fsMock, ...fsMock, constants: { R_OK: 4 } })); vi.mock('fs/promises', () => ({ default: fsMock, ...fsMock, constants: { R_OK: 4 } }));
describe('Request action routes', () => { 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({}); authRequest.json.mockResolvedValue({});
prismaMock.request.findUnique.mockResolvedValueOnce({ prismaMock.request.findUnique.mockResolvedValueOnce({
id: 'req-1', id: 'req-1',
userId: 'user-1', userId: 'user-1',
audiobook: { title: 'Title', author: 'Author' }, audiobook: { title: 'Title', author: 'Author', audibleAsin: 'B00ASIN123' },
}); });
prismaMock.user.findUnique.mockResolvedValueOnce({ prismaMock.user.findUnique.mockResolvedValueOnce({
role: 'user', role: 'user',
interactiveSearchAccess: null, 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); 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([ 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'); 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.success).toBe(true);
expect(payload.results[0].rank).toBe(1); 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 () => { it('triggers manual search job', async () => {
@@ -24,6 +24,7 @@ const AudiobookshelfHarness = ({
absApiToken: 'token', absApiToken: 'token',
absLibraryId: '', absLibraryId: '',
absTriggerScanAfterImport: false, absTriggerScanAfterImport: false,
absLibraries: [] as { id: string; name: string; itemCount: number }[],
...initialState, ...initialState,
}); });
+1
View File
@@ -24,6 +24,7 @@ const PlexHarness = ({
plexToken: 'token', plexToken: 'token',
plexLibraryId: '', plexLibraryId: '',
plexTriggerScanAfterImport: false, plexTriggerScanAfterImport: false,
plexLibraries: [] as { id: string; title: string; type: string }[],
...initialState, ...initialState,
}); });
+120 -5
View File
@@ -100,8 +100,8 @@ describe('substituteTemplate', () => {
expect(result).toBe('Author/Title/Narrator'); expect(result).toBe('Author/Title/Narrator');
}); });
it('should handle mixed forward and backward slashes', () => { it('should resolve escaped braces to literal brace characters', () => {
const template = '{author}\\{title}/{narrator}'; const template = '{author}/\\{{narrator}\\}/{title}';
const variables: TemplateVariables = { const variables: TemplateVariables = {
author: 'Author', author: 'Author',
title: 'Title', title: 'Title',
@@ -109,7 +109,7 @@ describe('substituteTemplate', () => {
}; };
const result = substituteTemplate(template, variables); const result = substituteTemplate(template, variables);
expect(result).toBe('Author/Title/Narrator'); expect(result).toBe('Author/{Narrator}/Title');
}); });
it('should trim dots from path components', () => { it('should trim dots from path components', () => {
@@ -145,6 +145,74 @@ describe('substituteTemplate', () => {
const result = substituteTemplate(template, variables); const result = substituteTemplate(template, variables);
expect(result).toBe('Audiobooks/Author/Books/Title'); 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', () => { describe('validateTemplate', () => {
@@ -205,8 +273,8 @@ describe('validateTemplate', () => {
}); });
}); });
it('should reject backslashes in template', () => { it('should reject backslashes that are not brace escapes', () => {
const result = validateTemplate('{author}\\{title}'); const result = validateTemplate('{author}\\n{title}');
expect(result.valid).toBe(false); expect(result.valid).toBe(false);
expect(result.error).toContain('forward slashes'); expect(result.error).toContain('forward slashes');
}); });
@@ -230,6 +298,42 @@ describe('validateTemplate', () => {
expect(result.error).toContain('{narrator}'); expect(result.error).toContain('{narrator}');
expect(result.error).toContain('{asin}'); 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', () => { describe('generateMockPreviews', () => {
@@ -305,6 +409,17 @@ describe('generateMockPreviews', () => {
expect(preview).toContain(' - B'); 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', () => { describe('getValidVariables', () => {
@@ -10,7 +10,7 @@ import { createJobQueueMock } from '../helpers/job-queue';
const prismaMock = createPrismaMock(); const prismaMock = createPrismaMock();
const configMock = vi.hoisted(() => ({ get: vi.fn() })); const configMock = vi.hoisted(() => ({ get: vi.fn() }));
const jobQueueMock = createJobQueueMock(); const jobQueueMock = createJobQueueMock();
const prowlarrMock = vi.hoisted(() => ({ search: vi.fn() })); const prowlarrMock = vi.hoisted(() => ({ search: vi.fn(), searchWithVariations: vi.fn() }));
vi.mock('@/lib/db', () => ({ vi.mock('@/lib/db', () => ({
prisma: prismaMock, prisma: prismaMock,
@@ -44,7 +44,7 @@ describe('processSearchIndexers', () => {
} }
return null; return null;
}); });
prowlarrMock.search.mockResolvedValue([]); prowlarrMock.searchWithVariations.mockResolvedValue([]);
prismaMock.request.update.mockResolvedValue({}); prismaMock.request.update.mockResolvedValue({});
const { processSearchIndexers } = await import('@/lib/processors/search-indexers.processor'); const { processSearchIndexers } = await import('@/lib/processors/search-indexers.processor');
@@ -73,7 +73,7 @@ describe('processSearchIndexers', () => {
return null; return null;
}); });
prowlarrMock.search.mockResolvedValue([ prowlarrMock.searchWithVariations.mockResolvedValue([
{ {
indexer: 'Indexer', indexer: 'Indexer',
indexerId: 1, indexerId: 1,
@@ -9,7 +9,7 @@ const notificationServiceMock = vi.hoisted(() => ({
sendNotification: vi.fn(), sendNotification: vi.fn(),
})); }));
vi.mock('@/lib/services/notification.service', () => ({ vi.mock('@/lib/services/notification', () => ({
getNotificationService: () => notificationServiceMock, 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'); 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 () => { it('rejects registration when username is taken', async () => {
configMock.get.mockResolvedValueOnce('true'); configMock.get.mockResolvedValueOnce('true');
prismaMock.user.findFirst.mockResolvedValue({ id: 'user-10' }); prismaMock.user.findFirst.mockResolvedValue({ id: 'user-10' });
+178 -40
View File
@@ -66,7 +66,7 @@ describe('NotificationService', () => {
json: async () => ({ success: true }), json: async () => ({ success: true }),
}); });
const { NotificationService } = await import('@/lib/services/notification.service'); const { NotificationService } = await import('@/lib/services/notification');
const service = new NotificationService(); const service = new NotificationService();
await service.sendNotification({ await service.sendNotification({
@@ -92,7 +92,7 @@ describe('NotificationService', () => {
it('does not send if no backends are subscribed to the event', async () => { it('does not send if no backends are subscribed to the event', async () => {
prismaMock.notificationBackend.findMany.mockResolvedValue([]); prismaMock.notificationBackend.findMany.mockResolvedValue([]);
const { NotificationService } = await import('@/lib/services/notification.service'); const { NotificationService } = await import('@/lib/services/notification');
const service = new NotificationService(); const service = new NotificationService();
await service.sendNotification({ await service.sendNotification({
@@ -139,7 +139,7 @@ describe('NotificationService', () => {
json: async () => ({ success: true }), json: async () => ({ success: true }),
}); });
const { NotificationService } = await import('@/lib/services/notification.service'); const { NotificationService } = await import('@/lib/services/notification');
const service = new NotificationService(); const service = new NotificationService();
await service.sendNotification({ 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 () => { it('sends Discord webhook with rich embed', async () => {
fetchMock.mockResolvedValue({ fetchMock.mockResolvedValue({
ok: true, ok: true,
json: async () => ({ success: true }), json: async () => ({ success: true }),
}); });
const { NotificationService } = await import('@/lib/services/notification.service'); const { DiscordProvider } = await import('@/lib/services/notification');
const service = new NotificationService(); const provider = new DiscordProvider();
await service.sendDiscord( await provider.send(
{ {
webhookUrl: 'enc:https://discord.com/webhook', webhookUrl: 'https://discord.com/webhook',
username: 'ReadMeABook', username: 'ReadMeABook',
}, },
{ {
@@ -181,12 +261,12 @@ describe('NotificationService', () => {
} }
); );
// Should call the webhook (URL decryption happens internally)
expect(fetchMock).toHaveBeenCalled(); expect(fetchMock).toHaveBeenCalled();
const fetchCall = fetchMock.mock.calls[0]; const fetchCall = fetchMock.mock.calls[0];
const body = JSON.parse(fetchCall[1].body); const body = JSON.parse(fetchCall[1].body);
expect(fetchCall[0]).toBe('https://discord.com/webhook');
expect(fetchCall[1].method).toBe('POST'); expect(fetchCall[1].method).toBe('POST');
expect(fetchCall[1].headers['Content-Type']).toBe('application/json'); expect(fetchCall[1].headers['Content-Type']).toBe('application/json');
expect(body.username).toBe('ReadMeABook'); expect(body.username).toBe('ReadMeABook');
@@ -201,12 +281,12 @@ describe('NotificationService', () => {
json: async () => ({ success: true }), json: async () => ({ success: true }),
}); });
const { NotificationService } = await import('@/lib/services/notification.service'); const { DiscordProvider } = await import('@/lib/services/notification');
const service = new NotificationService(); const provider = new DiscordProvider();
await service.sendDiscord( await provider.send(
{ {
webhookUrl: 'enc:https://discord.com/webhook', webhookUrl: 'https://discord.com/webhook',
}, },
{ {
event: 'request_approved', event: 'request_approved',
@@ -230,12 +310,12 @@ describe('NotificationService', () => {
text: async () => 'Bad Request', text: async () => 'Bad Request',
}); });
const { NotificationService } = await import('@/lib/services/notification.service'); const { DiscordProvider } = await import('@/lib/services/notification');
const service = new NotificationService(); const provider = new DiscordProvider();
await expect( await expect(
service.sendDiscord( provider.send(
{ webhookUrl: 'enc:https://discord.com/webhook' }, { webhookUrl: 'https://discord.com/webhook' },
{ {
event: 'request_approved', event: 'request_approved',
requestId: 'req-1', requestId: 'req-1',
@@ -249,20 +329,20 @@ describe('NotificationService', () => {
}); });
}); });
describe('sendPushover', () => { describe('PushoverProvider', () => {
it('sends Pushover notification with correct payload', async () => { it('sends Pushover notification with correct payload', async () => {
fetchMock.mockResolvedValue({ fetchMock.mockResolvedValue({
ok: true, ok: true,
json: async () => ({ status: 1 }), json: async () => ({ status: 1 }),
}); });
const { NotificationService } = await import('@/lib/services/notification.service'); const { PushoverProvider } = await import('@/lib/services/notification');
const service = new NotificationService(); const provider = new PushoverProvider();
await service.sendPushover( await provider.send(
{ {
userKey: 'enc:user123', userKey: 'user123',
appToken: 'enc:app456', appToken: 'app456',
priority: 1, priority: 1,
}, },
{ {
@@ -275,7 +355,6 @@ describe('NotificationService', () => {
} }
); );
// Should call the Pushover API (credential decryption happens internally)
expect(fetchMock).toHaveBeenCalled(); expect(fetchMock).toHaveBeenCalled();
const fetchCall = fetchMock.mock.calls[0]; const fetchCall = fetchMock.mock.calls[0];
@@ -296,13 +375,13 @@ describe('NotificationService', () => {
json: async () => ({ status: 1 }), json: async () => ({ status: 1 }),
}); });
const { NotificationService } = await import('@/lib/services/notification.service'); const { PushoverProvider } = await import('@/lib/services/notification');
const service = new NotificationService(); const provider = new PushoverProvider();
await service.sendPushover( await provider.send(
{ {
userKey: 'enc:user123', userKey: 'user123',
appToken: 'enc:app456', appToken: 'app456',
}, },
{ {
event: 'request_approved', event: 'request_approved',
@@ -325,12 +404,12 @@ describe('NotificationService', () => {
text: async () => 'invalid user key', text: async () => 'invalid user key',
}); });
const { NotificationService } = await import('@/lib/services/notification.service'); const { PushoverProvider } = await import('@/lib/services/notification');
const service = new NotificationService(); const provider = new PushoverProvider();
await expect( await expect(
service.sendPushover( provider.send(
{ userKey: 'enc:user123', appToken: 'enc:app456' }, { userKey: 'user123', appToken: 'app456' },
{ {
event: 'request_approved', event: 'request_approved',
requestId: 'req-1', requestId: 'req-1',
@@ -344,11 +423,9 @@ describe('NotificationService', () => {
}); });
}); });
// Note: formatDiscordEmbed is a private method, tested indirectly through sendDiscord
describe('encryptConfig', () => { describe('encryptConfig', () => {
it('encrypts sensitive Discord config values', async () => { 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 service = new NotificationService();
const encrypted = service.encryptConfig('discord', { const encrypted = service.encryptConfig('discord', {
@@ -362,7 +439,7 @@ describe('NotificationService', () => {
}); });
it('encrypts sensitive Pushover config values', async () => { 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 service = new NotificationService();
const encrypted = service.encryptConfig('pushover', { 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', () => { describe('maskConfig', () => {
it('masks sensitive Discord config values', async () => { 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 service = new NotificationService();
const masked = service.maskConfig('discord', { const masked = service.maskConfig('discord', {
@@ -396,7 +534,7 @@ describe('NotificationService', () => {
}); });
it('masks sensitive Pushover config values', async () => { 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 service = new NotificationService();
const masked = service.maskConfig('pushover', { 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(), access: vi.fn(),
stat: vi.fn(), stat: vi.fn(),
mkdir: vi.fn(), mkdir: vi.fn(),
copyFile: vi.fn(),
chmod: vi.fn(), chmod: vi.fn(),
unlink: vi.fn(), unlink: vi.fn(),
writeFile: vi.fn(), writeFile: vi.fn(),
@@ -71,11 +70,17 @@ const ebookMock = vi.hoisted(() => ({
downloadEbook: vi.fn(), downloadEbook: vi.fn(),
})); }));
const copyFileMock = vi.hoisted(() => ({
copyFile: vi.fn(),
}));
vi.mock('fs/promises', () => ({ vi.mock('fs/promises', () => ({
default: fsMock, default: fsMock,
...fsMock, ...fsMock,
})); }));
vi.mock('@/lib/utils/copy-file', () => copyFileMock);
vi.mock('axios', () => ({ vi.mock('axios', () => ({
default: axiosMock, default: axiosMock,
...axiosMock, ...axiosMock,
@@ -109,7 +114,7 @@ describe('file organizer', () => {
throw new Error('missing'); throw new Error('missing');
}); });
fsMock.mkdir.mockResolvedValue(undefined); fsMock.mkdir.mockResolvedValue(undefined);
fsMock.copyFile.mockResolvedValue(undefined); copyFileMock.copyFile.mockResolvedValue(undefined);
fsMock.chmod.mockResolvedValue(undefined); fsMock.chmod.mockResolvedValue(undefined);
const organizer = new FileOrganizer('/media', '/tmp'); const organizer = new FileOrganizer('/media', '/tmp');
@@ -174,7 +179,7 @@ describe('file organizer', () => {
throw new Error('missing'); throw new Error('missing');
}); });
fsMock.mkdir.mockResolvedValue(undefined); fsMock.mkdir.mockResolvedValue(undefined);
fsMock.copyFile.mockResolvedValue(undefined); copyFileMock.copyFile.mockResolvedValue(undefined);
fsMock.chmod.mockResolvedValue(undefined); fsMock.chmod.mockResolvedValue(undefined);
const organizer = new FileOrganizer('/media', '/tmp'); const organizer = new FileOrganizer('/media', '/tmp');
@@ -216,7 +221,7 @@ describe('file organizer', () => {
throw new Error('missing'); throw new Error('missing');
}); });
fsMock.mkdir.mockResolvedValue(undefined); fsMock.mkdir.mockResolvedValue(undefined);
fsMock.copyFile.mockResolvedValue(undefined); copyFileMock.copyFile.mockResolvedValue(undefined);
fsMock.chmod.mockResolvedValue(undefined); fsMock.chmod.mockResolvedValue(undefined);
fsMock.unlink.mockResolvedValue(undefined); fsMock.unlink.mockResolvedValue(undefined);
@@ -235,7 +240,7 @@ describe('file organizer', () => {
const expectedDir = path.join('/media', 'Author', 'Book'); const expectedDir = path.join('/media', 'Author', 'Book');
expect(result.success).toBe(true); expect(result.success).toBe(true);
expect(result.targetPath).toBe(expectedDir); 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'); expect(fsMock.unlink).toHaveBeenCalledWith('/tmp/tagged.m4b');
}); });
@@ -261,7 +266,7 @@ describe('file organizer', () => {
throw new Error('missing'); throw new Error('missing');
}); });
fsMock.mkdir.mockResolvedValue(undefined); fsMock.mkdir.mockResolvedValue(undefined);
fsMock.copyFile.mockResolvedValue(undefined); copyFileMock.copyFile.mockResolvedValue(undefined);
fsMock.chmod.mockResolvedValue(undefined); fsMock.chmod.mockResolvedValue(undefined);
const result = await organizer.organize('/downloads/book', { const result = await organizer.organize('/downloads/book', {
@@ -272,7 +277,7 @@ describe('file organizer', () => {
expect(result.success).toBe(true); expect(result.success).toBe(true);
expect(result.errors).toContain('Metadata tagging skipped: ffmpeg not available'); expect(result.errors).toContain('Metadata tagging skipped: ffmpeg not available');
expect(metadataMock.tagMultipleFiles).not.toHaveBeenCalled(); 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 () => { it('downloads remote cover art when no local cover exists', async () => {
@@ -294,7 +299,7 @@ describe('file organizer', () => {
throw new Error('missing'); throw new Error('missing');
}); });
fsMock.mkdir.mockResolvedValue(undefined); fsMock.mkdir.mockResolvedValue(undefined);
fsMock.copyFile.mockResolvedValue(undefined); copyFileMock.copyFile.mockResolvedValue(undefined);
fsMock.chmod.mockResolvedValue(undefined); fsMock.chmod.mockResolvedValue(undefined);
fsMock.writeFile.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 // NOTE: Ebook downloads are now handled as first-class requests through the job queue
// The file organizer no longer downloads ebooks inline // The file organizer no longer downloads ebooks inline
expect(ebookMock.downloadEbook).not.toHaveBeenCalled(); expect(ebookMock.downloadEbook).not.toHaveBeenCalled();
expect(fsMock.copyFile).toHaveBeenCalledWith(sourcePath, targetFile); expect(copyFileMock.copyFile).toHaveBeenCalledWith(sourcePath, targetFile);
expect(result.filesMovedCount).toBe(1); expect(result.filesMovedCount).toBe(1);
}); });
@@ -340,7 +345,7 @@ describe('file organizer', () => {
throw new Error('missing'); throw new Error('missing');
}); });
fsMock.mkdir.mockResolvedValue(undefined); fsMock.mkdir.mockResolvedValue(undefined);
fsMock.copyFile.mockResolvedValue(undefined); copyFileMock.copyFile.mockResolvedValue(undefined);
fsMock.chmod.mockResolvedValue(undefined); fsMock.chmod.mockResolvedValue(undefined);
axiosMock.get.mockRejectedValue(new Error('cover failed')); axiosMock.get.mockRejectedValue(new Error('cover failed'));
@@ -352,7 +357,7 @@ describe('file organizer', () => {
expect(result.success).toBe(true); expect(result.success).toBe(true);
expect(result.errors.join(' ')).toContain('Failed to download cover art'); 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 () => { it('continues when chapter analysis returns no valid chapters', async () => {
@@ -378,7 +383,7 @@ describe('file organizer', () => {
throw new Error('missing'); throw new Error('missing');
}); });
fsMock.mkdir.mockResolvedValue(undefined); fsMock.mkdir.mockResolvedValue(undefined);
fsMock.copyFile.mockResolvedValue(undefined); copyFileMock.copyFile.mockResolvedValue(undefined);
fsMock.chmod.mockResolvedValue(undefined); fsMock.chmod.mockResolvedValue(undefined);
const result = await organizer.organize('/downloads/book', { const result = await organizer.organize('/downloads/book', {
@@ -415,7 +420,7 @@ describe('file organizer', () => {
throw new Error('missing'); throw new Error('missing');
}); });
fsMock.mkdir.mockResolvedValue(undefined); fsMock.mkdir.mockResolvedValue(undefined);
fsMock.copyFile.mockResolvedValue(undefined); copyFileMock.copyFile.mockResolvedValue(undefined);
fsMock.chmod.mockResolvedValue(undefined); fsMock.chmod.mockResolvedValue(undefined);
fsMock.unlink.mockResolvedValue(undefined); fsMock.unlink.mockResolvedValue(undefined);
@@ -502,7 +507,7 @@ describe('file organizer', () => {
expect(result.audioFiles).toEqual([]); expect(result.audioFiles).toEqual([]);
expect(result.errors.join(' ')).toContain('Source file not found'); expect(result.errors.join(' ')).toContain('Source file not found');
expect(result.errors.join(' ')).toContain('No audio files were successfully copied'); 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 () => { it('skips copying when target files already exist', async () => {
@@ -535,7 +540,7 @@ describe('file organizer', () => {
expect(result.success).toBe(true); expect(result.success).toBe(true);
expect(result.audioFiles).toEqual([targetPath]); expect(result.audioFiles).toEqual([targetPath]);
expect(result.filesMovedCount).toBe(0); expect(result.filesMovedCount).toBe(0);
expect(fsMock.copyFile).not.toHaveBeenCalled(); expect(copyFileMock.copyFile).not.toHaveBeenCalled();
}); });
it('continues when metadata tagging throws', async () => { it('continues when metadata tagging throws', async () => {
@@ -557,7 +562,7 @@ describe('file organizer', () => {
throw new Error('missing'); throw new Error('missing');
}); });
fsMock.mkdir.mockResolvedValue(undefined); fsMock.mkdir.mockResolvedValue(undefined);
fsMock.copyFile.mockResolvedValue(undefined); copyFileMock.copyFile.mockResolvedValue(undefined);
fsMock.chmod.mockResolvedValue(undefined); fsMock.chmod.mockResolvedValue(undefined);
const result = await organizer.organize('/downloads/book', { const result = await organizer.organize('/downloads/book', {
@@ -567,7 +572,7 @@ describe('file organizer', () => {
expect(result.success).toBe(true); expect(result.success).toBe(true);
expect(result.errors.join(' ')).toContain('Metadata tagging failed'); expect(result.errors.join(' ')).toContain('Metadata tagging failed');
expect(fsMock.copyFile).toHaveBeenCalled(); expect(copyFileMock.copyFile).toHaveBeenCalled();
}); });
it('validates paths and reports multiple issues', async () => { it('validates paths and reports multiple issues', async () => {
@@ -668,7 +673,7 @@ describe('file organizer', () => {
throw new Error('missing'); throw new Error('missing');
}); });
fsMock.mkdir.mockResolvedValue(undefined); fsMock.mkdir.mockResolvedValue(undefined);
fsMock.copyFile.mockRejectedValue( copyFileMock.copyFile.mockRejectedValue(
Object.assign(new Error('EPERM: operation not permitted, copyfile'), { code: 'EPERM' }) Object.assign(new Error('EPERM: operation not permitted, copyfile'), { code: 'EPERM' })
); );
@@ -705,7 +710,7 @@ describe('file organizer', () => {
throw new Error('missing'); throw new Error('missing');
}); });
fsMock.mkdir.mockResolvedValue(undefined); 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 // Tagged file copy fails with EPERM
if (path.normalize(src) === path.normalize(taggedPath)) { if (path.normalize(src) === path.normalize(taggedPath)) {
throw Object.assign(new Error('EPERM: operation not permitted'), { code: 'EPERM' }); 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 // Tagged temp file should be cleaned up
expect(fsMock.unlink).toHaveBeenCalledWith(taggedPath); expect(fsMock.unlink).toHaveBeenCalledWith(taggedPath);
// Fallback copy should use the original source // 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 // Should record that tagged copy failed
expect(result.errors.join(' ')).toContain('Tagged copy failed'); expect(result.errors.join(' ')).toContain('Tagged copy failed');
expect(result.errors.join(' ')).toContain('without metadata tags'); expect(result.errors.join(' ')).toContain('without metadata tags');
@@ -760,7 +765,7 @@ describe('file organizer', () => {
}); });
fsMock.mkdir.mockResolvedValue(undefined); fsMock.mkdir.mockResolvedValue(undefined);
// Both tagged and original copies fail // Both tagged and original copies fail
fsMock.copyFile.mockRejectedValue( copyFileMock.copyFile.mockRejectedValue(
Object.assign(new Error('EPERM: operation not permitted'), { code: 'EPERM' }) Object.assign(new Error('EPERM: operation not permitted'), { code: 'EPERM' })
); );
fsMock.unlink.mockResolvedValue(undefined); fsMock.unlink.mockResolvedValue(undefined);
@@ -808,7 +813,7 @@ describe('file organizer', () => {
throw new Error('missing'); throw new Error('missing');
}); });
fsMock.mkdir.mockResolvedValue(undefined); fsMock.mkdir.mockResolvedValue(undefined);
fsMock.copyFile.mockImplementation(async (src: string) => { copyFileMock.copyFile.mockImplementation(async (src: string) => {
// First file succeeds, second fails // First file succeeds, second fails
if (path.normalize(src) === path.normalize(source2)) { if (path.normalize(src) === path.normalize(source2)) {
throw Object.assign(new Error('EPERM: operation not permitted'), { code: 'EPERM' }); throw Object.assign(new Error('EPERM: operation not permitted'), { code: 'EPERM' });
@@ -847,7 +852,7 @@ describe('file organizer', () => {
throw new Error('missing'); throw new Error('missing');
}); });
fsMock.mkdir.mockResolvedValue(undefined); fsMock.mkdir.mockResolvedValue(undefined);
fsMock.copyFile.mockResolvedValue(undefined); copyFileMock.copyFile.mockResolvedValue(undefined);
fsMock.chmod.mockResolvedValue(undefined); fsMock.chmod.mockResolvedValue(undefined);
fsMock.writeFile.mockResolvedValue(undefined); fsMock.writeFile.mockResolvedValue(undefined);
axiosMock.get.mockResolvedValue({ data: Buffer.from('cover') }); axiosMock.get.mockResolvedValue({ data: Buffer.from('cover') });
@@ -881,7 +886,7 @@ describe('file organizer', () => {
throw new Error('missing'); throw new Error('missing');
}); });
fsMock.mkdir.mockResolvedValue(undefined); fsMock.mkdir.mockResolvedValue(undefined);
fsMock.copyFile.mockRejectedValue( copyFileMock.copyFile.mockRejectedValue(
Object.assign(new Error('EPERM: operation not permitted'), { code: 'EPERM' }) Object.assign(new Error('EPERM: operation not permitted'), { code: 'EPERM' })
); );
fsMock.writeFile.mockResolvedValue(undefined); fsMock.writeFile.mockResolvedValue(undefined);