From af0eaceb980c6d7e2f7e034494a9c7f4fbb0b9e0 Mon Sep 17 00:00:00 2001 From: kikootwo Date: Tue, 10 Feb 2026 15:06:20 -0500 Subject: [PATCH] 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. --- documentation/archive/BOOKDATE_COMPLETE.md | 2 +- .../backend/services/notifications.md | 74 ++- .../bookdate-implementation-prompt.md | 25 +- documentation/features/bookdate.md | 2 +- documentation/phase3/file-organization.md | 2 +- .../NotificationsTab/NotificationsTab.tsx | 280 ++++++----- src/app/api/admin/notifications/[id]/route.ts | 8 +- .../admin/notifications/providers/route.ts | 42 ++ src/app/api/admin/notifications/route.ts | 6 +- src/app/api/admin/notifications/test/route.ts | 95 +--- .../settings/download-clients/[id]/route.ts | 2 + .../download-clients/categories/route.ts | 104 ++++ .../admin/settings/download-clients/route.ts | 2 + .../api/audiobooks/search-torrents/route.ts | 3 +- src/app/api/auth/change-password/route.ts | 3 +- src/app/api/auth/providers/route.ts | 7 + src/app/api/bookdate/test-connection/route.ts | 103 ++-- .../requests/[id]/interactive-search/route.ts | 84 +++- .../setup/download-client-categories/route.ts | 63 +++ src/app/login/page.tsx | 12 +- src/app/setup/page.tsx | 28 ++ src/app/setup/steps/AdminAccountStep.tsx | 25 +- src/app/setup/steps/AudiobookshelfStep.tsx | 18 +- src/app/setup/steps/BookDateStep.tsx | 15 +- src/app/setup/steps/DownloadClientStep.tsx | 3 +- src/app/setup/steps/OIDCConfigStep.tsx | 11 +- src/app/setup/steps/PathsStep.tsx | 13 +- src/app/setup/steps/PlexStep.tsx | 18 +- src/app/setup/steps/ProwlarrStep.tsx | 17 +- .../download-clients/DownloadClientCard.tsx | 6 + .../DownloadClientManagement.tsx | 26 +- .../download-clients/DownloadClientModal.tsx | 91 +++- .../admin/indexers/IndexerManagement.tsx | 34 +- src/components/ui/ChangePasswordModal.tsx | 24 +- src/lib/integrations/nzbget.service.ts | 10 + src/lib/integrations/prowlarr.service.ts | 49 ++ src/lib/integrations/qbittorrent.service.ts | 20 + src/lib/integrations/sabnzbd.service.ts | 10 + src/lib/integrations/transmission.service.ts | 23 + .../interfaces/download-client.interface.ts | 18 + .../processors/organize-files.processor.ts | 59 +++ .../processors/search-indexers.processor.ts | 7 +- .../processors/send-notification.processor.ts | 2 +- src/lib/services/auth/LocalAuthProvider.ts | 6 +- .../download-client-manager.service.ts | 1 + src/lib/services/notification.service.ts | 380 -------------- .../notification/INotificationProvider.ts | 60 +++ src/lib/services/notification/index.ts | 36 ++ .../notification/notification.service.ts | 187 +++++++ .../providers/apprise.provider.ts | 133 +++++ .../providers/discord.provider.ts | 91 ++++ .../notification/providers/ntfy.provider.ts | 109 ++++ .../providers/pushover.provider.ts | 121 +++++ src/lib/utils/copy-file.ts | 22 + src/lib/utils/file-organizer.ts | 13 +- src/lib/utils/path-template.util.ts | 36 +- .../admin-notifications-test.routes.test.ts | 3 +- tests/api/admin-notifications.routes.test.ts | 3 +- tests/api/audiobooks-search.routes.test.ts | 3 +- tests/api/auth-change-password.routes.test.ts | 26 + .../bookdate-test-connection.routes.test.ts | 146 +++++- tests/api/requests-actions.routes.test.ts | 99 +++- .../setup/steps/AudiobookshelfStep.test.tsx | 1 + tests/app/setup/steps/PlexStep.test.tsx | 1 + tests/lib/utils/path-template.util.test.ts | 125 ++++- .../search-indexers.processor.test.ts | 6 +- .../send-notification.processor.test.ts | 2 +- tests/services/apprise.provider.test.ts | 473 ++++++++++++++++++ .../services/auth/local-auth-provider.test.ts | 34 ++ tests/services/notification.service.test.ts | 218 ++++++-- tests/services/ntfy.provider.test.ts | 368 ++++++++++++++ tests/utils/copy-file.test.ts | 85 ++++ tests/utils/file-organizer.test.ts | 53 +- 73 files changed, 3421 insertions(+), 866 deletions(-) create mode 100644 src/app/api/admin/notifications/providers/route.ts create mode 100644 src/app/api/admin/settings/download-clients/categories/route.ts create mode 100644 src/app/api/setup/download-client-categories/route.ts delete mode 100644 src/lib/services/notification.service.ts create mode 100644 src/lib/services/notification/INotificationProvider.ts create mode 100644 src/lib/services/notification/index.ts create mode 100644 src/lib/services/notification/notification.service.ts create mode 100644 src/lib/services/notification/providers/apprise.provider.ts create mode 100644 src/lib/services/notification/providers/discord.provider.ts create mode 100644 src/lib/services/notification/providers/ntfy.provider.ts create mode 100644 src/lib/services/notification/providers/pushover.provider.ts create mode 100644 src/lib/utils/copy-file.ts create mode 100644 tests/services/apprise.provider.test.ts create mode 100644 tests/services/ntfy.provider.test.ts create mode 100644 tests/utils/copy-file.test.ts diff --git a/documentation/archive/BOOKDATE_COMPLETE.md b/documentation/archive/BOOKDATE_COMPLETE.md index df9a5b7..877da4f 100644 --- a/documentation/archive/BOOKDATE_COMPLETE.md +++ b/documentation/archive/BOOKDATE_COMPLETE.md @@ -75,7 +75,7 @@ docker-compose logs -f app ## 📊 Feature Highlights ### AI-Powered Recommendations -- **Providers:** OpenAI (GPT-4o+) or Claude (Sonnet 4.5, Opus 4, Haiku) +- **Providers:** OpenAI (GPT-4+) or Claude (dynamically fetched from Anthropic Models API) - **Personalization:** Based on your Plex library + swipe history - **Context:** Max 50 books (40 library + 10 swipes) - **Filtering:** Excludes books already in library, already requested, or already swiped diff --git a/documentation/backend/services/notifications.md b/documentation/backend/services/notifications.md index c41e535..96a5fec 100644 --- a/documentation/backend/services/notifications.md +++ b/documentation/backend/services/notifications.md @@ -1,14 +1,14 @@ # Notification System -**Status:** ✅ Implemented | Extensible notification system with Discord and Pushover support +**Status:** ✅ Implemented | Extensible notification system with Discord, ntfy, and Pushover support ## Overview Sends notifications for audiobook request events (pending approval, approved, available, error) to configured backends. Non-blocking, atomic per-backend failure handling. Proper notification timing for all request flows including interactive search. ## Key Details -- **Backends:** Discord (webhooks), Pushover (API) +- **Backends:** Apprise (API), Discord (webhooks), ntfy (API), Pushover (API) - **Events:** request_pending_approval, request_approved, request_available, request_error -- **Encryption:** AES-256-GCM for sensitive config (webhook URLs, API keys) +- **Encryption:** AES-256-GCM for sensitive config (webhook URLs, API keys, notification URLs) - **Delivery:** Async via Bull job queue (priority 5) - **Failure Handling:** Non-blocking, Promise.allSettled (one backend fails, others succeed) @@ -17,7 +17,7 @@ Sends notifications for audiobook request events (pending approval, approved, av ```prisma model NotificationBackend { id String @id @default(uuid()) - type String // 'discord' | 'pushover' + type String // 'apprise' | 'discord' | 'ntfy' | 'pushover' name String // User-friendly label config Json // Encrypted sensitive values events Json // Array of subscribed events @@ -70,7 +70,9 @@ model NotificationBackend { ## Configuration Encryption **Encrypted Values:** +- Apprise: `urls`, `authToken` - Discord: `webhookUrl` +- ntfy: `accessToken` - Pushover: `userKey`, `appToken` **Pattern:** `iv:authTag:encryptedData` (base64) @@ -81,12 +83,26 @@ model NotificationBackend { ## Message Formatting +**Apprise (JSON via Apprise API):** +- Type: info (pending), success (approved/available), failure (error) +- Modes: Stateless (send URLs directly) or Stateful (use persistent configKey, optional tag filter) +- Endpoint: `{serverUrl}/notify/` (stateless) or `{serverUrl}/notify/{configKey}` (stateful) +- Auth: Optional Bearer token via `authToken` config field +- Format: Event title + book details + user + error (if applicable) + **Discord (Rich Embeds):** - Color-coded by event (yellow=pending, green=approved, blue=available, red=error) - Fields: Title, Author, Requested By, Error (if applicable) - Footer: Request ID - Timestamp: Event time +**ntfy (JSON with Tags):** +- Tags: mailbox_with_mail, white_check_mark, tada, x (rendered as emojis by ntfy) +- Priority: Default (3) for pending/approved, High (4) for available/error +- Format: Event title + book details + user + error (if applicable) +- Auth: Optional Bearer token via `accessToken` config field +- Server: Configurable `serverUrl` (default: https://ntfy.sh) + **Pushover (Plain Text with Emojis):** - Emojis: 📬 📬 🎉 ❌ - Priority: Normal (0) for pending/approved, High (1) for available/error @@ -154,15 +170,49 @@ model NotificationBackend { **Queue Method:** `addNotificationJob(event, requestId, title, author, userName, message?)` +## Architecture + +**Provider Pattern:** `INotificationProvider` interface + registry (matches `IAuthProvider` pattern) + +``` +src/lib/services/notification/ + INotificationProvider.ts # Interface + shared types + notification.service.ts # Core service with registry + index.ts # Re-exports + providers/ + apprise.provider.ts # Apprise API (100+ services) + discord.provider.ts # Discord webhook + ntfy.provider.ts # ntfy API + pushover.provider.ts # Pushover API +``` + +**Registry:** Module-level `Map` 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` — receives decrypted config + +**ProviderMetadata:** `{ type, displayName, description, iconLabel, iconColor, configFields[] }` +**ProviderConfigField:** `{ name, label, type, required, placeholder?, defaultValue?, options? }` + +**Helper functions:** +- `getRegisteredProviderTypes(): string[]` — all registered type keys +- `getAllProviderMetadata(): ProviderMetadata[]` — metadata for all providers + +**API Endpoint:** `GET /api/admin/notifications/providers` — returns all provider metadata (admin-only) + ## Extensibility -**Adding New Backend (e.g., Email):** -1. Add 'email' to NotificationBackendType enum -2. Create EmailConfig interface -3. Add encryption logic for smtpPassword -4. Implement sendEmail() method in NotificationService -5. Add email card to type selector (green "E" badge) -6. Add email form fields to modal +**Adding New Backend (2 steps):** +1. Create `providers/email.provider.ts` implementing `INotificationProvider`: + - Set `type = 'email'`, `sensitiveFields = ['smtpPassword']` + - Set `metadata` with displayName, description, iconLabel, iconColor, configFields + - Implement `send()` with email-specific logic +2. Register in `notification.service.ts`: `registerProvider(new EmailProvider())` + re-export from `index.ts` + +No UI changes, no API route changes, no Zod schema changes needed — the UI renders dynamically from provider metadata. **Adding New Event (e.g., download_complete):** 1. Add 'download_complete' to NotificationEvent enum @@ -173,7 +223,7 @@ model NotificationBackend { ## Tech Stack - Bull (job queue) - Node.js crypto (AES-256-GCM encryption) -- Discord webhooks, Pushover API +- Apprise API, Discord webhooks, ntfy API, Pushover API - React (UI), Tailwind CSS (styling) ## Related diff --git a/documentation/features/bookdate-implementation-prompt.md b/documentation/features/bookdate-implementation-prompt.md index e8a1f86..c17e7b5 100644 --- a/documentation/features/bookdate-implementation-prompt.md +++ b/documentation/features/bookdate-implementation-prompt.md @@ -200,32 +200,23 @@ export async function POST(req: NextRequest) { .map((m: any) => ({ id: m.id, name: m.id })); } else if (provider === 'claude') { - // Claude: Hardcoded list (Anthropic doesn't have a models API endpoint) - models = [ - { id: 'claude-sonnet-4-5-20250929', name: 'Claude Sonnet 4.5' }, - { id: 'claude-3-7-sonnet-20250219', name: 'Claude 3.7 Sonnet' }, - { id: 'claude-3-5-haiku-20241022', name: 'Claude 3.5 Haiku' }, - { id: 'claude-opus-4-20250514', name: 'Claude Opus 4' }, - ]; - - // Test connection with a simple API call - const response = await fetch('https://api.anthropic.com/v1/messages', { - method: 'POST', + // Claude: Fetch models dynamically from the Anthropic Models API + const response = await fetch('https://api.anthropic.com/v1/models?limit=1000', { headers: { 'x-api-key': apiKey, 'anthropic-version': '2023-06-01', - 'content-type': 'application/json' }, - body: JSON.stringify({ - model: 'claude-3-5-haiku-20241022', - max_tokens: 10, - messages: [{ role: 'user', content: 'Hi' }] - }) }); if (!response.ok) { return NextResponse.json({ error: 'Invalid Claude API key' }, { status: 400 }); } + + const data = await response.json(); + models = data.data.map((m: any) => ({ + id: m.id, + name: m.display_name || m.id, + })); } else { return NextResponse.json({ error: 'Invalid provider' }, { status: 400 }); } diff --git a/documentation/features/bookdate.md b/documentation/features/bookdate.md index 03fc762..372ed03 100644 --- a/documentation/features/bookdate.md +++ b/documentation/features/bookdate.md @@ -6,7 +6,7 @@ Personalized audiobook discovery using OpenAI/Claude APIs. Admin configures AI provider globally. Users swipe through recommendations based on their individual Plex library + swipe history. Right swipe creates request, left rejects, up dismisses. ## Key Details -- **AI Providers:** OpenAI (GPT-4o+), Claude (Sonnet 4.5, Opus 4, Haiku) +- **AI Providers:** OpenAI (GPT-4+), Claude (dynamically fetched from Anthropic Models API) - **Configuration:** Global admin-managed (provider, model, API key), per-user preferences (library scope, custom prompt) - **Personalization:** Each user receives recommendations based on their own library, ratings, swipe history, and custom preferences - **Library Scopes (per-user):** diff --git a/documentation/phase3/file-organization.md b/documentation/phase3/file-organization.md index 0f2750c..21686aa 100644 --- a/documentation/phase3/file-organization.md +++ b/documentation/phase3/file-organization.md @@ -208,7 +208,7 @@ async function organize( ## Fixed Issues ✅ -**1. EPERM errors** - Fixed with `fs.readFile/writeFile` instead of `copyFile` +**1. EPERM errors** - Fixed with stream-based copy (`pipeline` + `createReadStream`/`createWriteStream`) instead of `fs.copyFile()` which uses `copy_file_range()` — a syscall that returns EPERM on cross-export NFS4 and some FUSE mounts **2. Immediate deletion** - Changed to copy-only, scheduled cleanup after seeding **3. Files moved not copied** - Now copies to support seeding **4. Single file downloads** - Now supports files directly in downloads folder (not just directories) diff --git a/src/app/admin/settings/tabs/NotificationsTab/NotificationsTab.tsx b/src/app/admin/settings/tabs/NotificationsTab/NotificationsTab.tsx index d39a9af..0c97111 100644 --- a/src/app/admin/settings/tabs/NotificationsTab/NotificationsTab.tsx +++ b/src/app/admin/settings/tabs/NotificationsTab/NotificationsTab.tsx @@ -6,6 +6,25 @@ import { fetchWithAuth } from '@/lib/utils/api'; const logger = RMABLogger.create('NotificationsTab'); +interface ProviderConfigField { + name: string; + label: string; + type: 'text' | 'password' | 'select' | 'number'; + required: boolean; + placeholder?: string; + defaultValue?: string | number; + options?: { label: string; value: string | number }[]; +} + +interface ProviderMetadata { + type: string; + displayName: string; + description: string; + iconLabel: string; + iconColor: string; + configFields: ProviderConfigField[]; +} + interface NotificationBackend { id: string; type: string; @@ -24,15 +43,6 @@ interface ModalState { backend?: NotificationBackend; } -const typeColors: Record = { - 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 = { request_pending_approval: 'Request Pending Approval', request_approved: 'Request Approved', @@ -42,6 +52,7 @@ const eventLabels: Record = { export function NotificationsTab() { const [backends, setBackends] = useState([]); + const [providerMetadata, setProviderMetadata] = useState([]); const [loading, setLoading] = useState(true); const [modalState, setModalState] = useState({ isOpen: false, @@ -59,8 +70,23 @@ export function NotificationsTab() { useEffect(() => { fetchBackends(); + fetchProviderMetadata(); }, []); + const fetchProviderMetadata = async () => { + try { + const response = await fetchWithAuth('/api/admin/notifications/providers'); + if (response.ok) { + const data = await response.json(); + if (data.success) { + setProviderMetadata(data.providers); + } + } + } catch (error) { + logger.error('Failed to fetch provider metadata', { error: error instanceof Error ? error.message : String(error) }); + } + }; + const fetchBackends = async () => { try { setLoading(true); @@ -83,11 +109,23 @@ export function NotificationsTab() { } }; + const getMetadataForType = (type: string): ProviderMetadata | undefined => { + return providerMetadata.find((p) => p.type === type); + }; + const openAddModal = (type: string) => { + const meta = getMetadataForType(type); + const defaultConfig: Record = {}; + if (meta) { + for (const field of meta.configFields) { + defaultConfig[field.name] = field.defaultValue ?? ''; + } + } + setModalState({ isOpen: true, mode: 'add', selectedType: type }); setFormData({ - name: `${type.charAt(0).toUpperCase() + type.slice(1)} Notifications`, - config: type === 'discord' ? { webhookUrl: '', username: 'ReadMeABook', avatarUrl: '' } : { userKey: '', appToken: '', device: '', priority: 0 }, + name: `${meta?.displayName ?? type} Notifications`, + config: defaultConfig, events: ['request_available', 'request_error'], enabled: true, }); @@ -193,6 +231,49 @@ export function NotificationsTab() { } }; + const renderConfigField = (field: ProviderConfigField) => { + if (field.type === 'select' && field.options) { + return ( +
+ + +
+ ); + } + + return ( +
+ + 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} + /> +
+ ); + }; + + const currentMeta = modalState.selectedType ? getMetadataForType(modalState.selectedType) : undefined; + return (
{/* Header */} @@ -206,32 +287,22 @@ export function NotificationsTab() { {/* Type Selector */}

Add Notification Backend

-
- - - +
+ {providerMetadata.map((meta) => ( + + ))}
@@ -244,43 +315,46 @@ export function NotificationsTab() {

No notification backends configured.

) : (
- {backends.map((backend) => ( -
-
-
-
- {backend.type.charAt(0).toUpperCase()} -
-
-
{backend.name}
-
{backend.type}
+ {backends.map((backend) => { + const meta = getMetadataForType(backend.type); + return ( +
+
+
+
+ {meta?.iconLabel ?? backend.type.charAt(0).toUpperCase()} +
+
+
{backend.name}
+
{meta?.displayName ?? backend.type}
+
-
-
-
- {backend.enabled ? 'Enabled' : 'Disabled'} +
+
+ {backend.enabled ? 'Enabled' : 'Disabled'} +
+
+ {backend.events.length} {backend.events.length === 1 ? 'event' : 'events'} subscribed +
-
- {backend.events.length} {backend.events.length === 1 ? 'event' : 'events'} subscribed +
+ +
-
- - -
-
- ))} + ); + })}
)}
@@ -292,7 +366,7 @@ export function NotificationsTab() {

- {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

- {/* Config Fields */} - {modalState.selectedType === 'discord' && ( - <> -
- - 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/..." - /> -
-
- - 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" - /> -
- - )} - - {modalState.selectedType === 'pushover' && ( - <> -
- - 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" - /> -
-
- - 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" - /> -
-
- - -
- - )} + {/* Dynamic Config Fields */} + {currentMeta?.configFields.map((field) => renderConfigField(field))} {/* Events */}
diff --git a/src/app/api/admin/notifications/[id]/route.ts b/src/app/api/admin/notifications/[id]/route.ts index 4a47bc1..d2f854b 100644 --- a/src/app/api/admin/notifications/[id]/route.ts +++ b/src/app/api/admin/notifications/[id]/route.ts @@ -6,7 +6,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth'; import { prisma } from '@/lib/db'; -import { getNotificationService, NotificationBackendType } from '@/lib/services/notification.service'; +import { getNotificationService } from '@/lib/services/notification'; import { RMABLogger } from '@/lib/utils/logger'; import { z } from 'zod'; @@ -50,7 +50,7 @@ export async function GET( success: true, backend: { ...backend, - config: notificationService.maskConfig(backend.type as NotificationBackendType, backend.config), + config: notificationService.maskConfig(backend.type, backend.config), }, }); } catch (error) { @@ -114,7 +114,7 @@ export async function PUT( }); // Encrypt new/changed values - finalConfig = notificationService.encryptConfig(existing.type as NotificationBackendType, updatedConfig); + finalConfig = notificationService.encryptConfig(existing.type, updatedConfig); } // Update backend @@ -139,7 +139,7 @@ export async function PUT( success: true, backend: { ...updated, - config: notificationService.maskConfig(updated.type as NotificationBackendType, updated.config), + config: notificationService.maskConfig(updated.type, updated.config), }, }); } catch (error) { diff --git a/src/app/api/admin/notifications/providers/route.ts b/src/app/api/admin/notifications/providers/route.ts new file mode 100644 index 0000000..4f375c6 --- /dev/null +++ b/src/app/api/admin/notifications/providers/route.ts @@ -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 } + ); + } + }); + }); +} diff --git a/src/app/api/admin/notifications/route.ts b/src/app/api/admin/notifications/route.ts index d53a150..44dab48 100644 --- a/src/app/api/admin/notifications/route.ts +++ b/src/app/api/admin/notifications/route.ts @@ -6,14 +6,14 @@ import { NextRequest, NextResponse } from 'next/server'; import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth'; import { prisma } from '@/lib/db'; -import { getNotificationService, NotificationBackendType } from '@/lib/services/notification.service'; +import { getNotificationService, getRegisteredProviderTypes } from '@/lib/services/notification'; import { RMABLogger } from '@/lib/utils/logger'; import { z } from 'zod'; const logger = RMABLogger.create('API.Admin.Notifications'); const CreateBackendSchema = z.object({ - type: z.enum(['discord', 'pushover', 'email', 'slack', 'telegram', 'webhook']), + type: z.string().refine((val) => getRegisteredProviderTypes().includes(val), { message: 'Unsupported notification provider type' }), name: z.string().min(1), config: z.record(z.any()), events: z.array(z.enum(['request_pending_approval', 'request_approved', 'request_available', 'request_error'])).min(1), @@ -37,7 +37,7 @@ export async function GET(request: NextRequest) { // Mask sensitive config values const maskedBackends = backends.map((backend) => ({ ...backend, - config: notificationService.maskConfig(backend.type as NotificationBackendType, backend.config), + config: notificationService.maskConfig(backend.type, backend.config), })); return NextResponse.json({ diff --git a/src/app/api/admin/notifications/test/route.ts b/src/app/api/admin/notifications/test/route.ts index 81f2d74..f3524c0 100644 --- a/src/app/api/admin/notifications/test/route.ts +++ b/src/app/api/admin/notifications/test/route.ts @@ -5,31 +5,17 @@ import { NextRequest, NextResponse } from 'next/server'; import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth'; -import { getNotificationService, NotificationBackendType, NotificationPayload } from '@/lib/services/notification.service'; +import { getNotificationService, getRegisteredProviderTypes, NotificationPayload } from '@/lib/services/notification'; import { RMABLogger } from '@/lib/utils/logger'; import { z } from 'zod'; import { prisma } from '@/lib/db'; const logger = RMABLogger.create('API.Admin.Notifications.Test'); -const TestNotificationSchema = z.discriminatedUnion('mode', [ - // Test existing backend by ID (uses stored config) - z.object({ - mode: z.literal('backend'), - backendId: z.string(), - }), - // Test new config before saving - z.object({ - mode: z.literal('config'), - type: z.enum(['discord', 'pushover', 'email', 'slack', 'telegram', 'webhook']), - config: z.record(z.any()), - }), -]); - -// Support legacy format without mode -const LegacyTestNotificationSchema = z.object({ +// Flexible schema: supports both backendId and type+config formats +const TestNotificationSchema = z.object({ backendId: z.string().optional(), - type: z.enum(['discord', 'pushover', 'email', 'slack', 'telegram', 'webhook']).optional(), + type: z.string().refine((val) => getRegisteredProviderTypes().includes(val), { message: 'Unsupported notification provider type' }).optional(), config: z.record(z.any()).optional(), }); @@ -42,66 +28,37 @@ export async function POST(request: NextRequest) { return requireAdmin(req, async () => { try { const body = await request.json(); + const parsed = TestNotificationSchema.parse(body); - // Support legacy format for backward compatibility - const legacyParsed = LegacyTestNotificationSchema.safeParse(body); - - let type: NotificationBackendType; + let type: string; let encryptedConfig: any; const notificationService = getNotificationService(); - if (legacyParsed.success) { - // Legacy format - if (legacyParsed.data.backendId) { - // Test existing backend - const backend = await prisma.notificationBackend.findUnique({ - where: { id: legacyParsed.data.backendId }, - }); + if (parsed.backendId) { + // Test existing backend by ID (uses stored config) + const backend = await prisma.notificationBackend.findUnique({ + where: { id: parsed.backendId }, + }); - if (!backend) { - return NextResponse.json( - { error: 'NotFound', message: 'Backend not found' }, - { status: 404 } - ); - } - - type = backend.type as NotificationBackendType; - encryptedConfig = backend.config; // Already encrypted in DB - } else if (legacyParsed.data.type && legacyParsed.data.config) { - // Test new config - type = legacyParsed.data.type as NotificationBackendType; - encryptedConfig = notificationService.encryptConfig(type, legacyParsed.data.config); - } else { + if (!backend) { return NextResponse.json( - { error: 'ValidationError', message: 'Must provide either backendId or type+config' }, - { status: 400 } + { error: 'NotFound', message: 'Backend not found' }, + { status: 404 } ); } + + type = backend.type; + encryptedConfig = backend.config; // Already encrypted in DB + } else if (parsed.type && parsed.config) { + // Test new config before saving + type = parsed.type; + encryptedConfig = notificationService.encryptConfig(type, parsed.config); } else { - // New format with discriminated union - const parsed = TestNotificationSchema.parse(body); - - if (parsed.mode === 'backend') { - // Test existing backend - const backend = await prisma.notificationBackend.findUnique({ - where: { id: parsed.backendId }, - }); - - if (!backend) { - return NextResponse.json( - { error: 'NotFound', message: 'Backend not found' }, - { status: 404 } - ); - } - - type = backend.type as NotificationBackendType; - encryptedConfig = backend.config; // Already encrypted in DB - } else { - // Test new config - type = parsed.type; - encryptedConfig = notificationService.encryptConfig(type, parsed.config); - } + return NextResponse.json( + { error: 'ValidationError', message: 'Must provide either backendId or type+config' }, + { status: 400 } + ); } // Create test payload @@ -117,7 +74,7 @@ export async function POST(request: NextRequest) { // Send test notification synchronously (not via job queue) try { // Call sendToBackend directly - await (notificationService as any).sendToBackend(type, encryptedConfig, testPayload); + await notificationService.sendToBackend(type, encryptedConfig, testPayload); logger.info(`Test notification sent successfully for ${type}`, { adminId: req.user?.sub, diff --git a/src/app/api/admin/settings/download-clients/[id]/route.ts b/src/app/api/admin/settings/download-clients/[id]/route.ts index f5a092c..a229b30 100644 --- a/src/app/api/admin/settings/download-clients/[id]/route.ts +++ b/src/app/api/admin/settings/download-clients/[id]/route.ts @@ -38,6 +38,7 @@ export async function PUT( localPath, category, customPath, + postImportCategory, } = body; const config = await getConfigService(); @@ -76,6 +77,7 @@ export async function PUT( localPath: localPath !== undefined ? localPath : existingClient.localPath, category: category !== undefined ? category : existingClient.category, customPath: customPath !== undefined ? (customPath || undefined) : existingClient.customPath, + postImportCategory: postImportCategory !== undefined ? (postImportCategory || undefined) : existingClient.postImportCategory, }; // Validate path mapping if enabled diff --git a/src/app/api/admin/settings/download-clients/categories/route.ts b/src/app/api/admin/settings/download-clients/categories/route.ts new file mode 100644 index 0000000..64b2778 --- /dev/null +++ b/src/app/api/admin/settings/download-clients/categories/route.ts @@ -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 } + ); + } + }); + }); +} diff --git a/src/app/api/admin/settings/download-clients/route.ts b/src/app/api/admin/settings/download-clients/route.ts index 33e5105..e8921ad 100644 --- a/src/app/api/admin/settings/download-clients/route.ts +++ b/src/app/api/admin/settings/download-clients/route.ts @@ -63,6 +63,7 @@ export async function POST(request: NextRequest) { localPath, category, customPath, + postImportCategory, } = body; // Validate type @@ -138,6 +139,7 @@ export async function POST(request: NextRequest) { localPath: localPath || undefined, category: category || 'readmeabook', customPath: customPath || undefined, + postImportCategory: postImportCategory || undefined, }; // Test connection before saving diff --git a/src/app/api/audiobooks/search-torrents/route.ts b/src/app/api/audiobooks/search-torrents/route.ts index 23b67f3..0565f7e 100644 --- a/src/app/api/audiobooks/search-torrents/route.ts +++ b/src/app/api/audiobooks/search-torrents/route.ts @@ -86,7 +86,6 @@ export async function POST(request: NextRequest) { // Search Prowlarr for each group and combine results const prowlarr = await getProwlarrService(); - const searchQuery = title; // Title only - cast wide net const allResults = []; for (let i = 0; i < groups.length; i++) { @@ -94,7 +93,7 @@ export async function POST(request: NextRequest) { logger.debug(`Searching group ${i + 1}/${groups.length}: ${getGroupDescription(group)}`); try { - const groupResults = await prowlarr.search(searchQuery, { + const groupResults = await prowlarr.searchWithVariations(title, author, { categories: group.categories, indexerIds: group.indexerIds, maxResults: 100, // Limit per group diff --git a/src/app/api/auth/change-password/route.ts b/src/app/api/auth/change-password/route.ts index b121738..c48b9f4 100644 --- a/src/app/api/auth/change-password/route.ts +++ b/src/app/api/auth/change-password/route.ts @@ -39,7 +39,8 @@ export async function POST(request: NextRequest) { } // Validate new password length - if (newPassword.length < 8) { + const allowWeakPassword = process.env.ALLOW_WEAK_PASSWORD === 'true'; + if (!allowWeakPassword && newPassword.length < 8) { return NextResponse.json( { success: false, diff --git a/src/app/api/auth/providers/route.ts b/src/app/api/auth/providers/route.ts index 4a9ced7..7aef9de 100644 --- a/src/app/api/auth/providers/route.ts +++ b/src/app/api/auth/providers/route.ts @@ -18,6 +18,9 @@ export async function GET() { // Check if local login is disabled via environment variable const localLoginDisabled = process.env.DISABLE_LOCAL_LOGIN === 'true'; + // Check if weak passwords are allowed via environment variable + const allowWeakPassword = process.env.ALLOW_WEAK_PASSWORD === 'true'; + // Check if automation (Phase 3) is configured by checking for Prowlarr/indexer config const indexerType = await configService.get('indexer.type'); const prowlarrUrl = await configService.get('indexer.prowlarr_url'); @@ -47,6 +50,7 @@ export async function GET() { hasLocalUsers, oidcProviderName: oidcEnabled ? oidcProviderName : null, localLoginDisabled, + allowWeakPassword, automationEnabled, }); } else { @@ -65,6 +69,7 @@ export async function GET() { hasLocalUsers, oidcProviderName: null, localLoginDisabled, + allowWeakPassword, automationEnabled, }); } @@ -72,6 +77,7 @@ export async function GET() { logger.error('Failed to fetch auth providers', { error: error instanceof Error ? error.message : String(error) }); // Default to Plex mode if config can't be read const localLoginDisabled = process.env.DISABLE_LOCAL_LOGIN === 'true'; + const allowWeakPassword = process.env.ALLOW_WEAK_PASSWORD === 'true'; return NextResponse.json({ backendMode: 'plex', providers: ['plex'], @@ -79,6 +85,7 @@ export async function GET() { hasLocalUsers: false, oidcProviderName: null, localLoginDisabled, + allowWeakPassword, automationEnabled: false, }); } diff --git a/src/app/api/bookdate/test-connection/route.ts b/src/app/api/bookdate/test-connection/route.ts index 89b49ca..6cd491d 100644 --- a/src/app/api/bookdate/test-connection/route.ts +++ b/src/app/api/bookdate/test-connection/route.ts @@ -9,6 +9,49 @@ import { RMABLogger } from '@/lib/utils/logger'; const logger = RMABLogger.create('API.BookDate.TestConnection'); +// Fetch available Claude models from the Anthropic API +async function fetchClaudeModels(apiKey: string): Promise<{ id: string; name: string }[]> { + const allModels: { id: string; name: string }[] = []; + let afterId: string | undefined; + + // Paginate through all available models + do { + const params = new URLSearchParams({ limit: '1000' }); + if (afterId) { + params.set('after_id', afterId); + } + + const response = await fetch( + `https://api.anthropic.com/v1/models?${params.toString()}`, + { + headers: { + 'x-api-key': apiKey, + 'anthropic-version': '2023-06-01', + }, + } + ); + + if (!response.ok) { + const errorText = await response.text(); + logger.error('Claude API error', { error: errorText }); + throw new Error('Invalid Claude API key or connection failed'); + } + + const data = await response.json(); + + for (const model of data.data) { + allModels.push({ + id: model.id, + name: model.display_name || model.id, + }); + } + + afterId = data.has_more ? data.last_id : undefined; + } while (afterId); + + return allModels; +} + // Helper functions for custom provider function isValidBaseUrl(url: string): boolean { try { @@ -141,32 +184,10 @@ async function authenticatedHandler(req: AuthenticatedRequest) { .sort((a: any, b: any) => a.name.localeCompare(b.name)); } else if (provider === 'claude') { - // Claude: Hardcoded list (Anthropic doesn't have a public models API endpoint) - models = [ - { id: 'claude-sonnet-4-5-20250929', name: 'Claude Sonnet 4.5 (Latest)' }, - { id: 'claude-3-7-sonnet-20250219', name: 'Claude 3.7 Sonnet' }, - { id: 'claude-opus-4-20250514', name: 'Claude Opus 4' }, - { id: 'claude-3-5-haiku-20241022', name: 'Claude 3.5 Haiku' }, - ]; - - // Test connection with a simple API call - const response = await fetch('https://api.anthropic.com/v1/messages', { - method: 'POST', - headers: { - 'x-api-key': testApiKey, - 'anthropic-version': '2023-06-01', - 'content-type': 'application/json', - }, - body: JSON.stringify({ - model: 'claude-3-5-haiku-20241022', - max_tokens: 10, - messages: [{ role: 'user', content: 'Test' }], - }), - }); - - if (!response.ok) { - const errorText = await response.text(); - logger.error('Claude API error', { error: errorText }); + // Claude: Fetch models dynamically from the Anthropic Models API + try { + models = await fetchClaudeModels(testApiKey); + } catch { return NextResponse.json( { error: 'Invalid Claude API key or connection failed' }, { status: 400 } @@ -333,32 +354,10 @@ async function unauthenticatedHandler(req: NextRequest) { .sort((a: any, b: any) => a.name.localeCompare(b.name)); } else if (provider === 'claude') { - // Claude: Hardcoded list (Anthropic doesn't have a public models API endpoint) - models = [ - { id: 'claude-sonnet-4-5-20250929', name: 'Claude Sonnet 4.5 (Latest)' }, - { id: 'claude-3-7-sonnet-20250219', name: 'Claude 3.7 Sonnet' }, - { id: 'claude-opus-4-20250514', name: 'Claude Opus 4' }, - { id: 'claude-3-5-haiku-20241022', name: 'Claude 3.5 Haiku' }, - ]; - - // Test connection with a simple API call - const response = await fetch('https://api.anthropic.com/v1/messages', { - method: 'POST', - headers: { - 'x-api-key': apiKey, - 'anthropic-version': '2023-06-01', - 'content-type': 'application/json', - }, - body: JSON.stringify({ - model: 'claude-3-5-haiku-20241022', - max_tokens: 10, - messages: [{ role: 'user', content: 'Test' }], - }), - }); - - if (!response.ok) { - const errorText = await response.text(); - logger.error('Claude API error', { error: errorText }); + // Claude: Fetch models dynamically from the Anthropic Models API + try { + models = await fetchClaudeModels(apiKey); + } catch { return NextResponse.json( { error: 'Invalid Claude API key or connection failed' }, { status: 400 } diff --git a/src/app/api/requests/[id]/interactive-search/route.ts b/src/app/api/requests/[id]/interactive-search/route.ts index d75f918..016bbd8 100644 --- a/src/app/api/requests/[id]/interactive-search/route.ts +++ b/src/app/api/requests/[id]/interactive-search/route.ts @@ -8,6 +8,7 @@ import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth'; import { prisma } from '@/lib/db'; import { getProwlarrService } from '@/lib/integrations/prowlarr.service'; import { rankTorrents } from '@/lib/utils/ranking-algorithm'; +import { groupIndexersByCategories, getGroupDescription } from '@/lib/utils/indexer-grouping'; import { RMABLogger } from '@/lib/utils/logger'; import { resolveInteractiveSearchAccess } from '@/lib/utils/permissions'; @@ -97,9 +98,8 @@ export async function POST( } const indexersConfig = JSON.parse(indexersConfigStr); - const enabledIndexerIds = indexersConfig.map((indexer: any) => indexer.id); - if (enabledIndexerIds.length === 0) { + if (indexersConfig.length === 0) { return NextResponse.json( { error: 'ConfigError', message: 'No indexers enabled. Please enable at least one indexer in settings.' }, { status: 400 } @@ -115,22 +115,53 @@ export async function POST( const flagConfigStr = await configService.get('indexer_flag_config'); const flagConfigs = flagConfigStr ? JSON.parse(flagConfigStr) : []; - // Search Prowlarr for torrents - ONLY enabled indexers - const prowlarr = await getProwlarrService(); - // Use custom title if provided, otherwise use audiobook's title - const searchQuery = customTitle || requestRecord.audiobook.title; + // Group indexers by their category configuration + const { groups, skippedIndexers } = groupIndexersByCategories(indexersConfig); - logger.info(`Searching ${enabledIndexerIds.length} enabled indexers`, { searchQuery }); + if (skippedIndexers.length > 0) { + const skippedNames = skippedIndexers.map(idx => idx.name).join(', '); + logger.info(`Skipping ${skippedIndexers.length} indexer(s) with no audiobook categories: ${skippedNames}`); + } + + // Use custom title if provided, otherwise use audiobook's title + const searchTitle = customTitle || requestRecord.audiobook.title; + const searchAuthor = requestRecord.audiobook.author; + + logger.info(`Searching ${indexersConfig.length - skippedIndexers.length} enabled indexers in ${groups.length} group${groups.length > 1 ? 's' : ''}`, { searchTitle }); if (customTitle) { logger.debug('Using custom search title', { customTitle, originalTitle: requestRecord.audiobook.title }); } - const results = await prowlarr.search(searchQuery, { - indexerIds: enabledIndexerIds, - maxResults: 100, // Increased limit for broader search + // Log each group for transparency + groups.forEach((group, index) => { + logger.debug(`Group ${index + 1}: ${getGroupDescription(group)}`); }); - logger.debug(`Found ${results.length} raw results`, { requestId: id }); + // Search Prowlarr for each group and combine results + const prowlarr = await getProwlarrService(); + const allResults = []; + + for (let i = 0; i < groups.length; i++) { + const group = groups[i]; + logger.debug(`Searching group ${i + 1}/${groups.length}: ${getGroupDescription(group)}`); + + try { + const groupResults = await prowlarr.searchWithVariations(searchTitle, searchAuthor, { + categories: group.categories, + indexerIds: group.indexerIds, + maxResults: 100, + }); + + logger.debug(`Group ${i + 1} returned ${groupResults.length} results`); + allResults.push(...groupResults); + } catch (error) { + logger.error(`Group ${i + 1} search failed: ${error instanceof Error ? error.message : 'Unknown error'}`); + // Continue with other groups even if one fails + } + } + + const results = allResults; + logger.info(`Found ${results.length} total results from ${groups.length} group${groups.length > 1 ? 's' : ''}`, { requestId: id }); if (results.length === 0) { return NextResponse.json({ @@ -140,12 +171,31 @@ export async function POST( }); } + // Fetch runtime from Audnexus if ASIN available (for size-based scoring) + let durationMinutes: number | undefined; + if (requestRecord.audiobook.audibleAsin) { + try { + const { getAudibleService } = await import('@/lib/integrations/audible.service'); + const audibleService = getAudibleService(); + const runtime = await audibleService.getRuntime(requestRecord.audiobook.audibleAsin); + if (runtime) { + durationMinutes = runtime; + logger.info(`Fetched runtime: ${runtime} minutes for ASIN ${requestRecord.audiobook.audibleAsin}`); + } else { + logger.debug(`No runtime found for ASIN ${requestRecord.audiobook.audibleAsin}`); + } + } catch (error) { + logger.debug(`Failed to fetch runtime for ASIN ${requestRecord.audiobook.audibleAsin}: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + } + // Rank torrents using the ranking algorithm with indexer priorities and flag configs // Always use the audiobook's title/author for ranking (not custom search query) // requireAuthor: false - interactive mode, show all results for user decision const rankedResults = rankTorrents(results, { title: requestRecord.audiobook.title, author: requestRecord.audiobook.author, + durationMinutes, }, { indexerPriorities, flagConfigs, @@ -160,17 +210,23 @@ export async function POST( const top3 = rankedResults.slice(0, 3); if (top3.length > 0) { logger.debug('==================== RANKING DEBUG ===================='); - logger.debug('Search parameters', { searchQuery, requestedTitle: requestRecord.audiobook.title, requestedAuthor: requestRecord.audiobook.author }); + logger.debug('Search parameters', { searchTitle, requestedTitle: requestRecord.audiobook.title, requestedAuthor: requestRecord.audiobook.author }); logger.debug(`Top ${top3.length} results (out of ${rankedResults.length} total)`); logger.debug('--------------------------------------------------------'); top3.forEach((result, index) => { + const sizeMB = (result.size / (1024 * 1024)).toFixed(1); + const mbPerMin = durationMinutes ? ((result.size / (1024 * 1024)) / durationMinutes).toFixed(2) : 'N/A'; + logger.debug(`${index + 1}. "${result.title}"`, { indexer: result.indexer, indexerId: result.indexerId, baseScore: `${result.score.toFixed(1)}/100`, matchScore: `${result.breakdown.matchScore.toFixed(1)}/60`, - formatScore: `${result.breakdown.formatScore.toFixed(1)}/25 (${result.format || 'unknown'})`, - seederScore: `${result.breakdown.seederScore.toFixed(1)}/15 (${result.seeders} seeders)`, + formatScore: `${result.breakdown.formatScore.toFixed(1)}/10 (${result.format || 'unknown'})`, + sizeScore: durationMinutes + ? `${result.breakdown.sizeScore.toFixed(1)}/15 (${sizeMB} MB, ${mbPerMin} MB/min)` + : 'N/A (no runtime)', + seederScore: `${result.breakdown.seederScore.toFixed(1)}/15 (${result.seeders !== undefined ? result.seeders + ' seeders' : 'N/A for Usenet'})`, bonusPoints: `+${result.bonusPoints.toFixed(1)}`, bonusModifiers: result.bonusModifiers.map(mod => `${mod.reason}: +${mod.points.toFixed(1)}`), finalScore: result.finalScore.toFixed(1), diff --git a/src/app/api/setup/download-client-categories/route.ts b/src/app/api/setup/download-client-categories/route.ts new file mode 100644 index 0000000..03ea570 --- /dev/null +++ b/src/app/api/setup/download-client-categories/route.ts @@ -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 } + ); + } + }); +} diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 8543254..da1d286 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -38,6 +38,7 @@ function LoginContent() { hasLocalUsers: boolean; oidcProviderName: string | null; localLoginDisabled: boolean; + allowWeakPassword: boolean; automationEnabled: boolean; } | null>(null); const [showRegisterForm, setShowRegisterForm] = useState(false); @@ -78,6 +79,7 @@ function LoginContent() { hasLocalUsers: false, oidcProviderName: null, localLoginDisabled: false, + allowWeakPassword: false, automationEnabled: false, }); } @@ -345,7 +347,7 @@ function LoginContent() { return; } - if (registerPassword.length < 8) { + if (!authProviders?.allowWeakPassword && registerPassword.length < 8) { setError('Password must be at least 8 characters'); setIsLoggingIn(false); return; @@ -639,10 +641,12 @@ function LoginContent() { className="w-full px-4 py-3 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent" placeholder="••••••••" required - minLength={8} + minLength={authProviders?.allowWeakPassword ? 1 : 8} autoComplete="new-password" /> -

At least 8 characters

+ {!authProviders?.allowWeakPassword && ( +

At least 8 characters

+ )}
diff --git a/src/app/setup/page.tsx b/src/app/setup/page.tsx index 580b99f..067228c 100644 --- a/src/app/setup/page.tsx +++ b/src/app/setup/page.tsx @@ -27,7 +27,13 @@ import { AudibleRegion } from '@/lib/types/audible'; interface SelectedIndexer { id: number; name: string; + protocol: string; priority: number; + seedingTimeMinutes?: number; + removeAfterProcessing?: boolean; + rssEnabled: boolean; + audiobookCategories: number[]; + ebookCategories: number[]; } interface SetupState { @@ -86,6 +92,14 @@ interface SetupState { bookdateApiKey: string; bookdateModel: string; bookdateConfigured: boolean; + + // Cached UI state for back-navigation persistence + plexLibraries: { id: string; title: string; type: string }[]; + absLibraries: { id: string; name: string; itemCount: number }[]; + oidcTested: boolean; + pathsTested: boolean; + bookdateModels: { id: string; name: string }[]; + validated: { plex: boolean; prowlarr: boolean; @@ -152,6 +166,14 @@ export default function SetupWizard() { bookdateApiKey: '', bookdateModel: '', bookdateConfigured: false, + + // Cached UI state for back-navigation persistence + plexLibraries: [], + absLibraries: [], + oidcTested: false, + pathsTested: false, + bookdateModels: [], + validated: { plex: false, prowlarr: false, @@ -379,6 +401,7 @@ export default function SetupWizard() { plexToken={state.plexToken} plexLibraryId={state.plexLibraryId} plexTriggerScanAfterImport={state.plexTriggerScanAfterImport} + plexLibraries={state.plexLibraries} onUpdate={updateField} onNext={() => goToStep(currentStepNumber + 1)} onBack={() => goToStep(currentStepNumber - 1)} @@ -397,6 +420,7 @@ export default function SetupWizard() { absApiToken={state.absApiToken} absLibraryId={state.absLibraryId} absTriggerScanAfterImport={state.absTriggerScanAfterImport} + absLibraries={state.absLibraries} onUpdate={updateField} onNext={() => goToStep(currentStepNumber + 1)} onBack={() => goToStep(currentStepNumber - 1)} @@ -435,6 +459,7 @@ export default function SetupWizard() { oidcAdminClaimEnabled={state.oidcAdminClaimEnabled} oidcAdminClaimName={state.oidcAdminClaimName} oidcAdminClaimValue={state.oidcAdminClaimValue} + oidcTested={state.oidcTested} onUpdate={updateField} onNext={() => goToStep(currentStepNumber + 1)} onBack={() => goToStep(currentStepNumber - 1)} @@ -482,6 +507,7 @@ export default function SetupWizard() { goToStep(currentStepNumber + 1)} onBack={() => goToStep(currentStepNumber - 1)} @@ -512,6 +538,7 @@ export default function SetupWizard() { mediaDir={state.mediaDir} metadataTaggingEnabled={state.metadataTaggingEnabled} chapterMergingEnabled={state.chapterMergingEnabled} + pathsTested={state.pathsTested} onUpdate={updateField} onNext={() => goToStep(currentStepNumber + 1)} onBack={() => goToStep(currentStepNumber - 1)} @@ -528,6 +555,7 @@ export default function SetupWizard() { bookdateApiKey={state.bookdateApiKey} bookdateModel={state.bookdateModel} bookdateConfigured={state.bookdateConfigured} + bookdateModels={state.bookdateModels} onUpdate={updateField} onNext={() => goToStep(currentStepNumber + 1)} onSkip={() => goToStep(currentStepNumber + 1)} diff --git a/src/app/setup/steps/AdminAccountStep.tsx b/src/app/setup/steps/AdminAccountStep.tsx index a979b1b..c893fe1 100644 --- a/src/app/setup/steps/AdminAccountStep.tsx +++ b/src/app/setup/steps/AdminAccountStep.tsx @@ -5,7 +5,7 @@ 'use client'; -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { Button } from '@/components/ui/Button'; interface AdminAccountStepProps { @@ -25,6 +25,23 @@ export function AdminAccountStep({ }: AdminAccountStepProps) { const [confirmPassword, setConfirmPassword] = useState(''); const [errors, setErrors] = useState<{ username?: string; password?: string; confirm?: string }>({}); + const [allowWeakPassword, setAllowWeakPassword] = useState(false); + + // Fetch password policy + useEffect(() => { + const fetchPolicy = async () => { + try { + const response = await fetch('/api/auth/providers'); + if (response.ok) { + const data = await response.json(); + setAllowWeakPassword(data.allowWeakPassword === true); + } + } catch { + // Default to strict validation on error + } + }; + fetchPolicy(); + }, []); const validate = () => { const newErrors: { username?: string; password?: string; confirm?: string } = {}; @@ -35,7 +52,9 @@ export function AdminAccountStep({ } // Validate password - if (!adminPassword || adminPassword.length < 8) { + if (!adminPassword) { + newErrors.password = 'Password is required'; + } else if (!allowWeakPassword && adminPassword.length < 8) { newErrors.password = 'Password must be at least 8 characters'; } @@ -104,7 +123,7 @@ export function AdminAccountStep({

{errors.password}

)}

- Choose a strong password (minimum 8 characters) + {allowWeakPassword ? 'Choose a password' : 'Choose a strong password (minimum 8 characters)'}

diff --git a/src/app/setup/steps/AudiobookshelfStep.tsx b/src/app/setup/steps/AudiobookshelfStep.tsx index 4573517..f3496e5 100644 --- a/src/app/setup/steps/AudiobookshelfStep.tsx +++ b/src/app/setup/steps/AudiobookshelfStep.tsx @@ -14,7 +14,8 @@ interface AudiobookshelfStepProps { absApiToken: string; absLibraryId: string; absTriggerScanAfterImport: boolean; - onUpdate: (field: string, value: string | boolean) => void; + absLibraries: Library[]; + onUpdate: (field: string, value: any) => void; onNext: () => void; onBack: () => void; } @@ -30,6 +31,7 @@ export function AudiobookshelfStep({ absApiToken, absLibraryId, absTriggerScanAfterImport, + absLibraries, onUpdate, onNext, onBack, @@ -39,8 +41,12 @@ export function AudiobookshelfStep({ success: boolean; message?: string; libraries?: Library[]; - } | null>(null); - const [libraries, setLibraries] = useState([]); + } | null>( + absLibraries.length > 0 + ? { success: true, message: 'Connection verified previously.' } + : null + ); + const [libraries, setLibraries] = useState(absLibraries); const testConnection = async () => { setTesting(true); @@ -56,12 +62,14 @@ export function AudiobookshelfStep({ const data = await response.json(); if (response.ok && data.success) { + const libs = data.libraries || []; setTestResult({ success: true, message: 'Connection successful!', - libraries: data.libraries || [], + libraries: libs, }); - setLibraries(data.libraries || []); + setLibraries(libs); + onUpdate('absLibraries', libs); } else { setTestResult({ success: false, diff --git a/src/app/setup/steps/BookDateStep.tsx b/src/app/setup/steps/BookDateStep.tsx index 3cfdae3..ea1a6ac 100644 --- a/src/app/setup/steps/BookDateStep.tsx +++ b/src/app/setup/steps/BookDateStep.tsx @@ -12,6 +12,7 @@ interface BookDateStepProps { bookdateApiKey: string; bookdateModel: string; bookdateConfigured: boolean; + bookdateModels: ModelOption[]; onUpdate: (field: string, value: any) => void; onNext: () => void; onSkip: () => void; @@ -28,6 +29,7 @@ export function BookDateStep({ bookdateApiKey, bookdateModel, bookdateConfigured, + bookdateModels, onUpdate, onNext, onSkip, @@ -35,7 +37,7 @@ export function BookDateStep({ }: BookDateStepProps) { const [testing, setTesting] = useState(false); const [tested, setTested] = useState(bookdateConfigured); - const [models, setModels] = useState([]); + const [models, setModels] = useState(bookdateModels); const [error, setError] = useState(null); const handleTestConnection = async () => { @@ -65,19 +67,22 @@ export function BookDateStep({ throw new Error(data.error || 'Connection test failed'); } - setModels(data.models || []); + const fetchedModels = data.models || []; + setModels(fetchedModels); setTested(true); onUpdate('bookdateConfigured', true); + onUpdate('bookdateModels', fetchedModels); // Auto-select first model if none selected - if (!bookdateModel && data.models?.length > 0) { - onUpdate('bookdateModel', data.models[0].id); + if (!bookdateModel && fetchedModels.length > 0) { + onUpdate('bookdateModel', fetchedModels[0].id); } } catch (err) { setError(err instanceof Error ? err.message : 'Connection test failed'); setTested(false); onUpdate('bookdateConfigured', false); + onUpdate('bookdateModels', []); } finally { setTesting(false); } @@ -123,6 +128,7 @@ export function BookDateStep({ setTested(false); setModels([]); onUpdate('bookdateConfigured', false); + onUpdate('bookdateModels', []); }} className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500" > @@ -144,6 +150,7 @@ export function BookDateStep({ setTested(false); setModels([]); onUpdate('bookdateConfigured', false); + onUpdate('bookdateModels', []); }} placeholder={bookdateProvider === 'openai' ? 'sk-...' : 'sk-ant-...'} className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500" diff --git a/src/app/setup/steps/DownloadClientStep.tsx b/src/app/setup/steps/DownloadClientStep.tsx index a18c403..3790614 100644 --- a/src/app/setup/steps/DownloadClientStep.tsx +++ b/src/app/setup/steps/DownloadClientStep.tsx @@ -5,7 +5,7 @@ 'use client'; -import { useState, useEffect } from 'react'; +import { useState } from 'react'; import { Button } from '@/components/ui/Button'; import { DownloadClientManagement } from '@/components/admin/download-clients/DownloadClientManagement'; import { DownloadClientType } from '@/lib/interfaces/download-client.interface'; @@ -24,6 +24,7 @@ interface DownloadClient { localPath?: string; category?: string; customPath?: string; + postImportCategory?: string; } interface DownloadClientStepProps { diff --git a/src/app/setup/steps/OIDCConfigStep.tsx b/src/app/setup/steps/OIDCConfigStep.tsx index 84f568f..d11b597 100644 --- a/src/app/setup/steps/OIDCConfigStep.tsx +++ b/src/app/setup/steps/OIDCConfigStep.tsx @@ -22,6 +22,7 @@ interface OIDCConfigStepProps { oidcAdminClaimEnabled: boolean; oidcAdminClaimName: string; oidcAdminClaimValue: string; + oidcTested: boolean; onUpdate: (field: string, value: any) => void; onNext: () => void; onBack: () => void; @@ -40,6 +41,7 @@ export function OIDCConfigStep({ oidcAdminClaimEnabled, oidcAdminClaimName, oidcAdminClaimValue, + oidcTested, onUpdate, onNext, onBack, @@ -48,7 +50,11 @@ export function OIDCConfigStep({ const [testResult, setTestResult] = useState<{ success: boolean; message: string; - } | null>(null); + } | null>( + oidcTested + ? { success: true, message: 'OIDC configuration verified previously.' } + : null + ); const testConnection = async () => { setTesting(true); @@ -72,17 +78,20 @@ export function OIDCConfigStep({ success: true, message: 'OIDC discovery successful! Provider configuration validated.', }); + onUpdate('oidcTested', true); } else { setTestResult({ success: false, message: data.error || 'OIDC discovery failed', }); + onUpdate('oidcTested', false); } } catch (error) { setTestResult({ success: false, message: error instanceof Error ? error.message : 'Connection test failed', }); + onUpdate('oidcTested', false); } finally { setTesting(false); } diff --git a/src/app/setup/steps/PathsStep.tsx b/src/app/setup/steps/PathsStep.tsx index 46a8285..fb3b4cf 100644 --- a/src/app/setup/steps/PathsStep.tsx +++ b/src/app/setup/steps/PathsStep.tsx @@ -14,7 +14,8 @@ interface PathsStepProps { mediaDir: string; metadataTaggingEnabled: boolean; chapterMergingEnabled: boolean; - onUpdate: (field: string, value: string | boolean) => void; + pathsTested: boolean; + onUpdate: (field: string, value: any) => void; onNext: () => void; onBack: () => void; } @@ -24,6 +25,7 @@ export function PathsStep({ mediaDir, metadataTaggingEnabled, chapterMergingEnabled, + pathsTested, onUpdate, onNext, onBack, @@ -34,7 +36,11 @@ export function PathsStep({ message: string; downloadDirValid?: boolean; mediaDirValid?: boolean; - } | null>(null); + } | null>( + pathsTested + ? { success: true, message: 'Paths validated previously.', downloadDirValid: true, mediaDirValid: true } + : null + ); const testPaths = async () => { setTesting(true); @@ -59,6 +65,7 @@ export function PathsStep({ downloadDirValid: data.downloadDirValid, mediaDirValid: data.mediaDirValid, }); + onUpdate('pathsTested', true); } else { setTestResult({ success: false, @@ -66,12 +73,14 @@ export function PathsStep({ downloadDirValid: data.downloadDirValid, mediaDirValid: data.mediaDirValid, }); + onUpdate('pathsTested', false); } } catch (error) { setTestResult({ success: false, message: error instanceof Error ? error.message : 'Path validation failed', }); + onUpdate('pathsTested', false); } finally { setTesting(false); } diff --git a/src/app/setup/steps/PlexStep.tsx b/src/app/setup/steps/PlexStep.tsx index 670c44e..aeea473 100644 --- a/src/app/setup/steps/PlexStep.tsx +++ b/src/app/setup/steps/PlexStep.tsx @@ -14,7 +14,8 @@ interface PlexStepProps { plexToken: string; plexLibraryId: string; plexTriggerScanAfterImport: boolean; - onUpdate: (field: string, value: string | boolean) => void; + plexLibraries: PlexLibrary[]; + onUpdate: (field: string, value: any) => void; onNext: () => void; onBack: () => void; } @@ -30,6 +31,7 @@ export function PlexStep({ plexToken, plexLibraryId, plexTriggerScanAfterImport, + plexLibraries, onUpdate, onNext, onBack, @@ -39,8 +41,12 @@ export function PlexStep({ success: boolean; message: string; libraries?: PlexLibrary[]; - } | null>(null); - const [libraries, setLibraries] = useState([]); + } | null>( + plexLibraries.length > 0 + ? { success: true, message: 'Connection verified previously.' } + : null + ); + const [libraries, setLibraries] = useState(plexLibraries); const testConnection = async () => { setTesting(true); @@ -56,12 +62,14 @@ export function PlexStep({ const data = await response.json(); if (response.ok && data.success) { + const libs = data.libraries || []; setTestResult({ success: true, message: `Connected to ${data.serverName || 'Plex server'} successfully!`, - libraries: data.libraries || [], + libraries: libs, }); - setLibraries(data.libraries || []); + setLibraries(libs); + onUpdate('plexLibraries', libs); } else { setTestResult({ success: false, diff --git a/src/app/setup/steps/ProwlarrStep.tsx b/src/app/setup/steps/ProwlarrStep.tsx index 4f22d5c..b0ffd21 100644 --- a/src/app/setup/steps/ProwlarrStep.tsx +++ b/src/app/setup/steps/ProwlarrStep.tsx @@ -5,7 +5,7 @@ 'use client'; -import { useState, useEffect } from 'react'; +import { useState } from 'react'; import { Button } from '@/components/ui/Button'; import { Input } from '@/components/ui/Input'; import { IndexerManagement } from '@/components/admin/indexers/IndexerManagement'; @@ -13,6 +13,7 @@ import { IndexerManagement } from '@/components/admin/indexers/IndexerManagement interface ProwlarrStepProps { prowlarrUrl: string; prowlarrApiKey: string; + prowlarrIndexers: SelectedIndexer[]; onUpdate: (field: string, value: any) => void; onNext: () => void; onBack: () => void; @@ -33,17 +34,19 @@ interface SelectedIndexer { export function ProwlarrStep({ prowlarrUrl, prowlarrApiKey, + prowlarrIndexers, onUpdate, onNext, onBack, }: ProwlarrStepProps) { - const [configuredIndexers, setConfiguredIndexers] = useState([]); + const [configuredIndexers, setConfiguredIndexers] = useState(prowlarrIndexers); const [errorMessage, setErrorMessage] = useState(null); - // Sync configured indexers with parent - useEffect(() => { - onUpdate('prowlarrIndexers', configuredIndexers); - }, [configuredIndexers, onUpdate]); + // Update both local and parent state when indexers change + const handleIndexersChange = (indexers: SelectedIndexer[]) => { + setConfiguredIndexers(indexers); + onUpdate('prowlarrIndexers', indexers); + }; const handleNext = () => { setErrorMessage(null); @@ -136,7 +139,7 @@ export function ProwlarrStep({ prowlarrApiKey={prowlarrApiKey} mode="wizard" initialIndexers={configuredIndexers} - onIndexersChange={setConfiguredIndexers} + onIndexersChange={handleIndexersChange} />
diff --git a/src/components/admin/download-clients/DownloadClientCard.tsx b/src/components/admin/download-clients/DownloadClientCard.tsx index 309fbcd..cf0c539 100644 --- a/src/components/admin/download-clients/DownloadClientCard.tsx +++ b/src/components/admin/download-clients/DownloadClientCard.tsx @@ -16,6 +16,7 @@ interface DownloadClientCardProps { url: string; enabled: boolean; customPath?: string; + postImportCategory?: string; }; onEdit: () => void; onDelete: () => void; @@ -62,6 +63,11 @@ export function DownloadClientCard({ client, onEdit, onDelete }: DownloadClientC Path: {client.customPath}

)} + {client.postImportCategory && ( +

+ Post-import: {client.postImportCategory} +

+ )}
diff --git a/src/components/admin/download-clients/DownloadClientManagement.tsx b/src/components/admin/download-clients/DownloadClientManagement.tsx index 22e422a..c38f5c4 100644 --- a/src/components/admin/download-clients/DownloadClientManagement.tsx +++ b/src/components/admin/download-clients/DownloadClientManagement.tsx @@ -26,6 +26,7 @@ interface DownloadClient { localPath?: string; category?: string; customPath?: string; + postImportCategory?: string; } interface DownloadClientManagementProps { @@ -72,20 +73,6 @@ export function DownloadClientManagement({ } }, [downloadDirProp]); - // Sync with parent when clients change - useEffect(() => { - if (onClientsChange) { - onClientsChange(clients); - } - }, [clients, onClientsChange]); - - // Sync with initialClients prop changes (wizard mode) - useEffect(() => { - if (mode === 'wizard') { - setClients(initialClients); - } - }, [initialClients, mode]); - const fetchClients = async () => { setLoading(true); setError(null); @@ -172,7 +159,9 @@ export function DownloadClientManagement({ await fetchClients(); // Refresh list } else { // Local removal for wizard mode - setClients(clients.filter(c => c.id !== deleteConfirm.clientId)); + const updated = clients.filter(c => c.id !== deleteConfirm.clientId); + setClients(updated); + onClientsChange?.(updated); } setDeleteConfirm({ isOpen: false }); @@ -219,15 +208,18 @@ export function DownloadClientManagement({ } } else { // Local update for wizard mode + let updated: DownloadClient[]; if (modalState.mode === 'add') { const newClient = { ...clientData, id: `temp-${Date.now()}`, // Temporary ID for wizard mode }; - setClients([...clients, newClient]); + updated = [...clients, newClient]; } else { - setClients(clients.map(c => (c.id === clientData.id ? { ...c, ...clientData } : c))); + updated = clients.map(c => (c.id === clientData.id ? { ...c, ...clientData } : c)); } + setClients(updated); + onClientsChange?.(updated); } setModalState({ isOpen: false, mode: 'add' }); diff --git a/src/components/admin/download-clients/DownloadClientModal.tsx b/src/components/admin/download-clients/DownloadClientModal.tsx index 6e25a35..6f05c51 100644 --- a/src/components/admin/download-clients/DownloadClientModal.tsx +++ b/src/components/admin/download-clients/DownloadClientModal.tsx @@ -10,7 +10,7 @@ import { Modal } from '@/components/ui/Modal'; import { Button } from '@/components/ui/Button'; import { Input } from '@/components/ui/Input'; import { fetchWithAuth } from '@/lib/utils/api'; -import { DownloadClientType, getClientDisplayName } from '@/lib/interfaces/download-client.interface'; +import { DownloadClientType, getClientDisplayName, CLIENT_PROTOCOL_MAP } from '@/lib/interfaces/download-client.interface'; interface DownloadClientModalProps { isOpen: boolean; @@ -31,6 +31,7 @@ interface DownloadClientModalProps { localPath?: string; category?: string; customPath?: string; + postImportCategory?: string; }; onSave: (client: any) => Promise; apiMode: 'wizard' | 'settings'; @@ -62,6 +63,9 @@ export function DownloadClientModal({ const [localPath, setLocalPath] = useState(''); const [category, setCategory] = useState('readmeabook'); const [customPath, setCustomPath] = useState(''); + const [postImportCategory, setPostImportCategory] = useState(''); + const [availableCategories, setAvailableCategories] = useState([]); + const [fetchingCategories, setFetchingCategories] = useState(false); const [testing, setTesting] = useState(false); const [saving, setSaving] = useState(false); @@ -85,6 +89,7 @@ export function DownloadClientModal({ setLocalPath(initialClient.localPath || ''); setCategory(initialClient.category || 'readmeabook'); setCustomPath(initialClient.customPath || ''); + setPostImportCategory(initialClient.postImportCategory || ''); } else { // Add mode defaults setName(typeName); @@ -98,9 +103,12 @@ export function DownloadClientModal({ setLocalPath(''); setCategory('readmeabook'); setCustomPath(''); + setPostImportCategory(''); } setTestResult(null); setErrors({}); + setAvailableCategories([]); + setFetchingCategories(false); } }, [isOpen, mode, initialClient, type]); @@ -137,6 +145,50 @@ export function DownloadClientModal({ return Object.keys(newErrors).length === 0; }; + const fetchCategories = async () => { + setFetchingCategories(true); + try { + const isPasswordMasked = password === '********'; + const categoryData = { + type, + name, + url, + username: username || undefined, + password: isPasswordMasked ? undefined : password, + ...(mode === 'edit' && initialClient && isPasswordMasked ? { clientId: initialClient.id } : {}), + disableSSLVerify, + remotePathMappingEnabled, + remotePath: remotePathMappingEnabled ? remotePath : undefined, + localPath: remotePathMappingEnabled ? localPath : undefined, + }; + + const endpoint = apiMode === 'wizard' + ? '/api/setup/download-client-categories' + : '/api/admin/settings/download-clients/categories'; + + const response = apiMode === 'wizard' + ? await fetch(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(categoryData), + }) + : await fetchWithAuth(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(categoryData), + }); + + const data = await response.json(); + if (response.ok && data.success) { + setAvailableCategories(data.categories || []); + } + } catch { + // Non-critical — categories are optional + } finally { + setFetchingCategories(false); + } + }; + const handleTestConnection = async () => { if (!validate()) { return; @@ -187,6 +239,11 @@ export function DownloadClientModal({ // Handle both endpoint response formats (settings returns message, wizard returns version) const message = data.message || (data.version ? `Connected successfully (v${data.version})` : 'Connection successful'); setTestResult({ success: true, message }); + + // Fetch categories for torrent clients after successful connection + if (type && CLIENT_PROTOCOL_MAP[type] === 'torrent') { + fetchCategories(); + } } else { setTestResult({ success: false, message: data.error || 'Connection test failed' }); } @@ -230,6 +287,7 @@ export function DownloadClientModal({ localPath: remotePathMappingEnabled ? localPath : undefined, category, customPath: sanitizedCustomPath || undefined, + postImportCategory, }; if (mode === 'edit' && initialClient) { @@ -384,6 +442,37 @@ export function DownloadClientModal({

+ {/* Post-Import Category (torrent clients only) */} + {type && CLIENT_PROTOCOL_MAP[type] === 'torrent' && ( +
+ + {type === 'qbittorrent' && availableCategories.length > 0 ? ( + + ) : ( + setPostImportCategory(e.target.value)} + placeholder="e.g. completed" + disabled={fetchingCategories} + /> + )} +

+ After import, change the download's category/label in the client. Leave empty to skip. +

+
+ )} + {/* Remote Path Mapping */}
diff --git a/src/components/admin/indexers/IndexerManagement.tsx b/src/components/admin/indexers/IndexerManagement.tsx index da95b3f..7bc0962 100644 --- a/src/components/admin/indexers/IndexerManagement.tsx +++ b/src/components/admin/indexers/IndexerManagement.tsx @@ -63,17 +63,14 @@ export function IndexerManagement({ const [loading, setLoading] = useState(false); const [error, setError] = useState(null); - // Sync with parent when configuredIndexers changes + // In settings mode, the parent fetches indexers asynchronously and passes them + // as initialIndexers after mount. This effect picks up that late-arriving data. + // Wizard mode doesn't need this — it initializes correctly via useState above. useEffect(() => { - if (onIndexersChange) { - onIndexersChange(configuredIndexers); + if (mode === 'settings') { + setConfiguredIndexers(initialIndexers); } - }, [configuredIndexers, onIndexersChange]); - - // Sync with initialIndexers prop changes - useEffect(() => { - setConfiguredIndexers(initialIndexers); - }, [initialIndexers]); + }, [initialIndexers, mode]); const fetchIndexers = async () => { setLoading(true); @@ -149,17 +146,16 @@ export function IndexerManagement({ }; const handleSave = (config: SavedIndexerConfig) => { + let updated: SavedIndexerConfig[]; if (modalState.mode === 'add') { - // Add new indexer - setConfiguredIndexers([...configuredIndexers, config]); + updated = [...configuredIndexers, config]; } else { - // Update existing indexer - setConfiguredIndexers( - configuredIndexers.map((idx) => - idx.id === config.id ? config : idx - ) + updated = configuredIndexers.map((idx) => + idx.id === config.id ? config : idx ); } + setConfiguredIndexers(updated); + onIndexersChange?.(updated); }; const handleDelete = (id: number) => { @@ -175,9 +171,9 @@ export function IndexerManagement({ const confirmDelete = () => { if (deleteModalState.indexerId) { - setConfiguredIndexers( - configuredIndexers.filter((idx) => idx.id !== deleteModalState.indexerId) - ); + const updated = configuredIndexers.filter((idx) => idx.id !== deleteModalState.indexerId); + setConfiguredIndexers(updated); + onIndexersChange?.(updated); } }; diff --git a/src/components/ui/ChangePasswordModal.tsx b/src/components/ui/ChangePasswordModal.tsx index d382535..3abbadf 100644 --- a/src/components/ui/ChangePasswordModal.tsx +++ b/src/components/ui/ChangePasswordModal.tsx @@ -5,7 +5,7 @@ 'use client'; -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import { Modal } from './Modal'; import { Input } from './Input'; import { Button } from './Button'; @@ -22,6 +22,24 @@ export function ChangePasswordModal({ isOpen, onClose }: ChangePasswordModalProp const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [success, setSuccess] = useState(false); + const [allowWeakPassword, setAllowWeakPassword] = useState(false); + + // Fetch password policy when modal opens + useEffect(() => { + if (!isOpen) return; + const fetchPolicy = async () => { + try { + const response = await fetch('/api/auth/providers'); + if (response.ok) { + const data = await response.json(); + setAllowWeakPassword(data.allowWeakPassword === true); + } + } catch { + // Default to strict validation on error + } + }; + fetchPolicy(); + }, [isOpen]); // Validation errors for individual fields const [errors, setErrors] = useState({ @@ -47,7 +65,7 @@ export function ChangePasswordModal({ isOpen, onClose }: ChangePasswordModalProp if (!newPassword) { newErrors.newPassword = 'New password is required'; isValid = false; - } else if (newPassword.length < 8) { + } else if (!allowWeakPassword && newPassword.length < 8) { newErrors.newPassword = 'Password must be at least 8 characters'; isValid = false; } else if (newPassword === currentPassword) { @@ -211,7 +229,7 @@ export function ChangePasswordModal({ isOpen, onClose }: ChangePasswordModalProp }} placeholder="Enter your new password" autoComplete="new-password" - helperText="Must be at least 8 characters" + helperText={allowWeakPassword ? undefined : 'Must be at least 8 characters'} error={errors.newPassword} disabled={loading || success} /> diff --git a/src/lib/integrations/nzbget.service.ts b/src/lib/integrations/nzbget.service.ts index 8cd3678..8a2161e 100644 --- a/src/lib/integrations/nzbget.service.ts +++ b/src/lib/integrations/nzbget.service.ts @@ -406,6 +406,16 @@ export class NZBGetService implements IDownloadClient { } } + /** Not applicable for usenet clients */ + async getCategories(): Promise { + return []; + } + + /** Not applicable for usenet clients */ + async setCategory(_id: string, _category: string): Promise { + // No-op: post-import category is scoped to torrent clients + } + // ========================================================================= // Category Management // ========================================================================= diff --git a/src/lib/integrations/prowlarr.service.ts b/src/lib/integrations/prowlarr.service.ts index 44f9044..71dcd96 100644 --- a/src/lib/integrations/prowlarr.service.ts +++ b/src/lib/integrations/prowlarr.service.ts @@ -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 { + 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(); + return results.filter(result => { + if (seen.has(result.guid)) { + return false; + } + seen.add(result.guid); + return true; + }); + } + /** * Get list of configured indexers */ diff --git a/src/lib/integrations/qbittorrent.service.ts b/src/lib/integrations/qbittorrent.service.ts index 0bf3ab9..7ac3190 100644 --- a/src/lib/integrations/qbittorrent.service.ts +++ b/src/lib/integrations/qbittorrent.service.ts @@ -729,6 +729,26 @@ export class QBittorrentService implements IDownloadClient { } } + /** + * Get all configured categories from qBittorrent + */ + async getCategories(): Promise { + 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 */ diff --git a/src/lib/integrations/sabnzbd.service.ts b/src/lib/integrations/sabnzbd.service.ts index 8d4b2ea..784069c 100644 --- a/src/lib/integrations/sabnzbd.service.ts +++ b/src/lib/integrations/sabnzbd.service.ts @@ -825,6 +825,16 @@ export class SABnzbdService implements IDownloadClient { await this.archiveCompletedNZB(id); } + /** Not applicable for usenet clients */ + async getCategories(): Promise { + return []; + } + + /** Not applicable for usenet clients */ + async setCategory(_id: string, _category: string): Promise { + // No-op: post-import category is scoped to torrent clients + } + /** * Map NZBInfo to the unified DownloadInfo format. */ diff --git a/src/lib/integrations/transmission.service.ts b/src/lib/integrations/transmission.service.ts index 21d609a..4864d2c 100644 --- a/src/lib/integrations/transmission.service.ts +++ b/src/lib/integrations/transmission.service.ts @@ -441,6 +441,29 @@ export class TransmissionService implements IDownloadClient { // No-op: torrents are managed by the seeding cleanup scheduler } + /** + * Get available categories/labels. + * Transmission uses free-form labels — no predefined list to fetch. + */ + async getCategories(): Promise { + 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 { + 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 // ========================================================================= diff --git a/src/lib/interfaces/download-client.interface.ts b/src/lib/interfaces/download-client.interface.ts index cc67b37..8ac47a6 100644 --- a/src/lib/interfaces/download-client.interface.ts +++ b/src/lib/interfaces/download-client.interface.ts @@ -177,4 +177,22 @@ export interface IDownloadClient { * @param id - Download ID */ postProcess(id: string): Promise; + + /** + * 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; + + /** + * 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; } diff --git a/src/lib/processors/organize-files.processor.ts b/src/lib/processors/organize-files.processor.ts index ea8e8f4..ed9091f 100644 --- a/src/lib/processors/organize-files.processor.ts +++ b/src/lib/processors/organize-files.processor.ts @@ -180,6 +180,9 @@ export async function processOrganizeFiles(payload: OrganizeFilesPayload): Promi }, }); + // Apply post-import category to torrent client if configured + await applyPostImportCategory(requestId, logger); + logger.info(`Request ${requestId} completed successfully - status: downloaded`, { success: true, message: 'Files organized successfully', @@ -606,6 +609,9 @@ async function processEbookOrganization( }, }); + // Apply post-import category to torrent client if configured + await applyPostImportCategory(requestId, logger); + logger.info(`Ebook request ${requestId} completed - status: downloaded (terminal)`); // Send "available" notification for ebooks at downloaded state @@ -753,6 +759,59 @@ async function createEbookRequestIfEnabled( } } +// ========================================================================= +// POST-IMPORT CATEGORY +// ========================================================================= + +/** + * Apply post-import category to the download client after successful import. + * Only applies to torrent clients (qBittorrent/Transmission) when configured. + * Non-fatal: logs a warning on failure but does not fail the job. + */ +async function applyPostImportCategory( + requestId: string, + logger: RMABLogger +): Promise { + 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 // ========================================================================= diff --git a/src/lib/processors/search-indexers.processor.ts b/src/lib/processors/search-indexers.processor.ts index 2b479bb..46fd2c0 100644 --- a/src/lib/processors/search-indexers.processor.ts +++ b/src/lib/processors/search-indexers.processor.ts @@ -75,10 +75,7 @@ export async function processSearchIndexers(payload: SearchIndexersPayload): Pro // Get Prowlarr service const prowlarr = await getProwlarrService(); - // Build search query (title only - cast wide net, let ranking filter) - const searchQuery = audiobook.title; - - logger.info(`Searching for: "${searchQuery}"`); + logger.info(`Searching for: "${audiobook.title}" by "${audiobook.author}"`); // Search Prowlarr for each group and combine results const allResults = []; @@ -88,7 +85,7 @@ export async function processSearchIndexers(payload: SearchIndexersPayload): Pro logger.info(`Searching group ${i + 1}/${groups.length}: ${getGroupDescription(group)}`); try { - const groupResults = await prowlarr.search(searchQuery, { + const groupResults = await prowlarr.searchWithVariations(audiobook.title, audiobook.author, { categories: group.categories, indexerIds: group.indexerIds, minSeeders: 1, // Only torrents with at least 1 seeder diff --git a/src/lib/processors/send-notification.processor.ts b/src/lib/processors/send-notification.processor.ts index 1912114..061db9a 100644 --- a/src/lib/processors/send-notification.processor.ts +++ b/src/lib/processors/send-notification.processor.ts @@ -6,7 +6,7 @@ * to all enabled backends subscribed to the event. */ -import { getNotificationService } from '../services/notification.service'; +import { getNotificationService } from '../services/notification'; import { RMABLogger } from '../utils/logger'; export interface SendNotificationPayload { diff --git a/src/lib/services/auth/LocalAuthProvider.ts b/src/lib/services/auth/LocalAuthProvider.ts index a00dfd5..1fbc929 100644 --- a/src/lib/services/auth/LocalAuthProvider.ts +++ b/src/lib/services/auth/LocalAuthProvider.ts @@ -150,7 +150,11 @@ export class LocalAuthProvider implements IAuthProvider { return { success: false, error: 'Username must be at least 3 characters' }; } - if (!password || password.length < 8) { + const allowWeakPassword = process.env.ALLOW_WEAK_PASSWORD === 'true'; + if (!password) { + return { success: false, error: 'Password is required' }; + } + if (!allowWeakPassword && password.length < 8) { return { success: false, error: 'Password must be at least 8 characters' }; } diff --git a/src/lib/services/download-client-manager.service.ts b/src/lib/services/download-client-manager.service.ts index cca84e2..11693ba 100644 --- a/src/lib/services/download-client-manager.service.ts +++ b/src/lib/services/download-client-manager.service.ts @@ -35,6 +35,7 @@ export interface DownloadClientConfig { localPath?: string; category?: string; // Default: 'readmeabook' customPath?: string; // Relative sub-path appended to download_dir + postImportCategory?: string; // Category to assign after import (torrent clients only) } diff --git a/src/lib/services/notification.service.ts b/src/lib/services/notification.service.ts deleted file mode 100644 index da6a848..0000000 --- a/src/lib/services/notification.service.ts +++ /dev/null @@ -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 { - 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 { - // 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 { - 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 { - 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; -} diff --git a/src/lib/services/notification/INotificationProvider.ts b/src/lib/services/notification/INotificationProvider.ts new file mode 100644 index 0000000..a0907be --- /dev/null +++ b/src/lib/services/notification/INotificationProvider.ts @@ -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, payload: NotificationPayload): Promise; +} diff --git a/src/lib/services/notification/index.ts b/src/lib/services/notification/index.ts new file mode 100644 index 0000000..566758f --- /dev/null +++ b/src/lib/services/notification/index.ts @@ -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'; diff --git a/src/lib/services/notification/notification.service.ts b/src/lib/services/notification/notification.service.ts new file mode 100644 index 0000000..c17e910 --- /dev/null +++ b/src/lib/services/notification/notification.service.ts @@ -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(); + +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 { + 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 { + 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; +} diff --git a/src/lib/services/notification/providers/apprise.provider.ts b/src/lib/services/notification/providers/apprise.provider.ts new file mode 100644 index 0000000..85f6d86 --- /dev/null +++ b/src/lib/services/notification/providers/apprise.provider.ts @@ -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 = { + 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, payload: NotificationPayload): Promise { + 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 = { + '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 = { + 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 = { + 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'), + }; + } +} diff --git a/src/lib/services/notification/providers/discord.provider.ts b/src/lib/services/notification/providers/discord.provider.ts new file mode 100644 index 0000000..c07ed43 --- /dev/null +++ b/src/lib/services/notification/providers/discord.provider.ts @@ -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, payload: NotificationPayload): Promise { + 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(), + }; + } +} diff --git a/src/lib/services/notification/providers/ntfy.provider.ts b/src/lib/services/notification/providers/ntfy.provider.ts new file mode 100644 index 0000000..110e692 --- /dev/null +++ b/src/lib/services/notification/providers/ntfy.provider.ts @@ -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, payload: NotificationPayload): Promise { + 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 = { + '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'), + }; + } +} diff --git a/src/lib/services/notification/providers/pushover.provider.ts b/src/lib/services/notification/providers/pushover.provider.ts new file mode 100644 index 0000000..3635b1d --- /dev/null +++ b/src/lib/services/notification/providers/pushover.provider.ts @@ -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, payload: NotificationPayload): Promise { + 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'), + }; + } +} diff --git a/src/lib/utils/copy-file.ts b/src/lib/utils/copy-file.ts new file mode 100644 index 0000000..50660fb --- /dev/null +++ b/src/lib/utils/copy-file.ts @@ -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 { + await pipeline(createReadStream(source), createWriteStream(destination)); +} diff --git a/src/lib/utils/file-organizer.ts b/src/lib/utils/file-organizer.ts index 8c13123..6af1115 100644 --- a/src/lib/utils/file-organizer.ts +++ b/src/lib/utils/file-organizer.ts @@ -8,6 +8,7 @@ import path from 'path'; import axios from 'axios'; import { tagMultipleFiles, checkFfmpegAvailable } from './metadata-tagger'; import { RMABLogger } from './logger'; +import { copyFile } from './copy-file'; const moduleLogger = RMABLogger.create('FileOrganizer'); import { @@ -340,8 +341,8 @@ export class FileOrganizer { // Copy file (do NOT delete original - needed for seeding) try { - // Copy file using streaming (handles large files >2GB) - await fs.copyFile(sourcePath, targetFilePath); + // Copy file via streams (avoids copy_file_range EPERM on NFS/FUSE) + await copyFile(sourcePath, targetFilePath); // Set explicit permissions after copy await fs.chmod(targetFilePath, 0o644); @@ -378,7 +379,7 @@ export class FileOrganizer { await logger?.info(`Attempting fallback copy of original (untagged) file: ${filename}`); try { await fs.access(originalSourcePath, fs.constants.R_OK); - await fs.copyFile(originalSourcePath, targetFilePath); + await copyFile(originalSourcePath, targetFilePath); await fs.chmod(targetFilePath, 0o644); result.audioFiles.push(targetFilePath); result.filesMovedCount++; @@ -413,7 +414,7 @@ export class FileOrganizer { try { // Copy cover art (do NOT delete original) - await fs.copyFile(sourcePath, targetCoverPath); + await copyFile(sourcePath, targetCoverPath); await fs.chmod(targetCoverPath, 0o644); result.coverArtFile = targetCoverPath; result.filesMovedCount++; @@ -608,7 +609,7 @@ export class FileOrganizer { const cachedPath = path.join('/app/cache/thumbnails', filename); // Copy from local cache instead of downloading - await fs.copyFile(cachedPath, targetPath); + await copyFile(cachedPath, targetPath); await fs.chmod(targetPath, 0o644); moduleLogger.debug(`Copied cover art from cache: ${filename}`); } else { @@ -755,7 +756,7 @@ export class FileOrganizer { } // Copy ebook file (do NOT delete original - may need for seeding or retry) - await fs.copyFile(sourceFilePath, targetPath); + await copyFile(sourceFilePath, targetPath); await fs.chmod(targetPath, 0o644); await logger?.info(`Copied ebook: ${targetFilename}`); diff --git a/src/lib/utils/path-template.util.ts b/src/lib/utils/path-template.util.ts index 320051c..ce3dda3 100644 --- a/src/lib/utils/path-template.util.ts +++ b/src/lib/utils/path-template.util.ts @@ -37,6 +37,14 @@ const VALID_VARIABLES = ['author', 'title', 'narrator', 'asin', 'year', 'series' */ const INVALID_PATH_CHARS = /[<>:"|?*]/; +/** + * Placeholder characters for escaped braces during substitution. + * Uses Unicode Private Use Area characters that won't appear in metadata + * and won't be affected by path cleanup operations. + */ +const LBRACE_PLACEHOLDER = '\uE000'; +const RBRACE_PLACEHOLDER = '\uE001'; + /** * Sanitize a path component by removing invalid characters * Reuses logic from file-organizer.ts @@ -87,6 +95,10 @@ export function substituteTemplate( ): string { let result = template; + // Replace escaped braces with placeholders before any processing, + // so they survive the variable substitution and path cleanup steps + result = result.replace(/\\\{/g, LBRACE_PLACEHOLDER).replace(/\\\}/g, RBRACE_PLACEHOLDER); + // Substitute each variable for (const key of VALID_VARIABLES) { const value = variables[key as keyof TemplateVariables]; @@ -120,6 +132,11 @@ export function substituteTemplate( .filter(part => part.length > 0) .join('/'); + // Resolve escaped brace placeholders as the final step, + // after all variable substitution and path cleanup is complete + result = result.replace(new RegExp(LBRACE_PLACEHOLDER, 'g'), '{'); + result = result.replace(new RegExp(RBRACE_PLACEHOLDER, 'g'), '}'); + return result; } @@ -153,16 +170,20 @@ export function validateTemplate(template: string): ValidationResult { }; } - // Check for absolute paths - if (template.startsWith('/') || template.startsWith('\\') || /^[a-zA-Z]:/.test(template)) { + // Check for absolute paths (backslash followed by { or } is a brace escape, not a path) + if (template.startsWith('/') || /^\\(?![{}])/.test(template) || /^[a-zA-Z]:/.test(template)) { return { valid: false, error: 'Template must be a relative path (no absolute paths like "/" or "C:\\")' }; } - // Extract all variables from template - const variableMatches = template.match(/\{[^}]+\}/g); + // Strip escaped braces (\{ and \}) before parsing so they don't interfere + // with variable extraction or character validation + const templateWithoutEscapedBraces = template.replace(/\\[{}]/g, ''); + + // Extract all variables from the stripped template + const variableMatches = templateWithoutEscapedBraces.match(/\{[^}]+\}/g); if (variableMatches) { for (const match of variableMatches) { @@ -178,7 +199,7 @@ export function validateTemplate(template: string): ValidationResult { } // Remove valid variables temporarily to check for invalid characters - let templateWithoutVars = template; + let templateWithoutVars = templateWithoutEscapedBraces; for (const varName of VALID_VARIABLES) { templateWithoutVars = templateWithoutVars.replace(new RegExp(`\\{${varName}\\}`, 'g'), ''); } @@ -192,8 +213,9 @@ export function validateTemplate(template: string): ValidationResult { }; } - // Check for backslashes (Windows-style paths) - if (templateWithoutVars.includes('\\')) { + // Check for backslashes that are not brace escapes (Windows-style paths) + // We check the original template: any backslash NOT followed by { or } is invalid + if (/\\(?![{}])/.test(template)) { return { valid: false, error: 'Use forward slashes (/) for path separators, not backslashes (\\)' diff --git a/tests/api/admin-notifications-test.routes.test.ts b/tests/api/admin-notifications-test.routes.test.ts index 3dc5f3b..284cd9f 100644 --- a/tests/api/admin-notifications-test.routes.test.ts +++ b/tests/api/admin-notifications-test.routes.test.ts @@ -30,8 +30,9 @@ vi.mock('@/lib/middleware/auth', () => ({ requireAdmin: requireAdminMock, })); -vi.mock('@/lib/services/notification.service', () => ({ +vi.mock('@/lib/services/notification', () => ({ getNotificationService: () => notificationServiceMock, + getRegisteredProviderTypes: () => ['discord', 'ntfy', 'pushover'], })); describe('Admin notifications test route', () => { diff --git a/tests/api/admin-notifications.routes.test.ts b/tests/api/admin-notifications.routes.test.ts index 9ce5a03..dd7dc5d 100644 --- a/tests/api/admin-notifications.routes.test.ts +++ b/tests/api/admin-notifications.routes.test.ts @@ -35,8 +35,9 @@ vi.mock('@/lib/middleware/auth', () => ({ requireAdmin: requireAdminMock, })); -vi.mock('@/lib/services/notification.service', () => ({ +vi.mock('@/lib/services/notification', () => ({ getNotificationService: () => notificationServiceMock, + getRegisteredProviderTypes: () => ['discord', 'ntfy', 'pushover'], })); describe('Admin notifications routes', () => { diff --git a/tests/api/audiobooks-search.routes.test.ts b/tests/api/audiobooks-search.routes.test.ts index 3a47453..8522d42 100644 --- a/tests/api/audiobooks-search.routes.test.ts +++ b/tests/api/audiobooks-search.routes.test.ts @@ -13,6 +13,7 @@ const configServiceMock = vi.hoisted(() => ({ })); const prowlarrMock = vi.hoisted(() => ({ search: vi.fn(), + searchWithVariations: vi.fn(), })); const rankTorrentsMock = vi.hoisted(() => vi.fn()); const groupIndexersMock = vi.hoisted(() => vi.fn()); @@ -68,7 +69,7 @@ describe('Audiobooks search torrents route', () => { .mockResolvedValueOnce(null); groupIndexersMock.mockReturnValue({ groups: [{ categories: [1], indexerIds: [1] }], skippedIndexers: [] }); - prowlarrMock.search.mockResolvedValue([{ title: 'Result', size: 100, indexer: 'Indexer', indexerId: 1 }]); + prowlarrMock.searchWithVariations.mockResolvedValue([{ title: 'Result', size: 100, indexer: 'Indexer', indexerId: 1 }]); rankTorrentsMock.mockReturnValue([ { title: 'Result', diff --git a/tests/api/auth-change-password.routes.test.ts b/tests/api/auth-change-password.routes.test.ts index a0954fc..14d3686 100644 --- a/tests/api/auth-change-password.routes.test.ts +++ b/tests/api/auth-change-password.routes.test.ts @@ -68,6 +68,32 @@ describe('Change password route', () => { expect(payload.error).toMatch(/at least 8 characters/i); }); + it('allows short passwords when ALLOW_WEAK_PASSWORD is enabled', async () => { + process.env.ALLOW_WEAK_PASSWORD = 'true'; + prismaMock.user.findUnique.mockResolvedValue({ + id: 'user-1', + authProvider: 'local', + authToken: 'enc-hash', + plexId: 'local-user', + plexUsername: 'user', + }); + encryptionMock.decrypt.mockReturnValue('hash'); + bcryptMock.compare.mockResolvedValue(true); + bcryptMock.hash.mockResolvedValue('new-hash'); + encryptionMock.encrypt.mockReturnValue('enc-new-hash'); + prismaMock.user.update.mockResolvedValue({}); + const { POST } = await import('@/app/api/auth/change-password/route'); + + const response = await POST( + makeRequest({ currentPassword: 'oldpass', newPassword: 'ab', confirmPassword: 'ab' }) as any + ); + const payload = await response.json(); + + expect(response.status).toBe(200); + expect(payload.success).toBe(true); + delete process.env.ALLOW_WEAK_PASSWORD; + }); + it('blocks non-local users', async () => { prismaMock.user.findUnique.mockResolvedValue({ id: 'user-1', diff --git a/tests/api/bookdate-test-connection.routes.test.ts b/tests/api/bookdate-test-connection.routes.test.ts index a3dc202..8bc5ce2 100644 --- a/tests/api/bookdate-test-connection.routes.test.ts +++ b/tests/api/bookdate-test-connection.routes.test.ts @@ -149,6 +149,15 @@ describe('BookDate test connection route', () => { it('returns Claude models for unauthenticated requests', async () => { const fetchMock = vi.fn().mockResolvedValue({ ok: true, + json: vi.fn().mockResolvedValue({ + data: [ + { id: 'claude-sonnet-4-5-20250929', display_name: 'Claude Sonnet 4.5', type: 'model', created_at: '2025-09-29T00:00:00Z' }, + { id: 'claude-haiku-4-5-20251001', display_name: 'Claude Haiku 4.5', type: 'model', created_at: '2025-10-01T00:00:00Z' }, + ], + has_more: false, + first_id: 'claude-sonnet-4-5-20250929', + last_id: 'claude-haiku-4-5-20251001', + }), text: vi.fn().mockResolvedValue('ok'), }); vi.stubGlobal('fetch', fetchMock); @@ -161,7 +170,142 @@ describe('BookDate test connection route', () => { const payload = await response.json(); expect(payload.success).toBe(true); - expect(payload.models.length).toBe(4); + expect(payload.models).toEqual([ + { id: 'claude-sonnet-4-5-20250929', name: 'Claude Sonnet 4.5' }, + { id: 'claude-haiku-4-5-20251001', name: 'Claude Haiku 4.5' }, + ]); + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining('https://api.anthropic.com/v1/models'), + expect.objectContaining({ + headers: expect.objectContaining({ 'x-api-key': 'key' }), + }) + ); + }); + + it('returns Claude models for authenticated requests', async () => { + requireAuthMock.mockImplementation((_req: any, handler: any) => handler(_req)); + + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ + data: [ + { id: 'claude-opus-4-20250514', display_name: 'Claude Opus 4', type: 'model', created_at: '2025-05-14T00:00:00Z' }, + ], + has_more: false, + first_id: 'claude-opus-4-20250514', + last_id: 'claude-opus-4-20250514', + }), + text: vi.fn().mockResolvedValue('ok'), + }); + vi.stubGlobal('fetch', fetchMock); + + const { POST } = await import('@/app/api/bookdate/test-connection/route'); + const response = await POST({ + headers: { get: () => 'Bearer token' }, + json: vi.fn().mockResolvedValue({ provider: 'claude', apiKey: 'key' }), + } as any); + + const payload = await response.json(); + expect(payload.success).toBe(true); + expect(payload.models).toEqual([ + { id: 'claude-opus-4-20250514', name: 'Claude Opus 4' }, + ]); + }); + + it('returns error for invalid Claude API key', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: false, + text: vi.fn().mockResolvedValue('{"type":"error","error":{"type":"authentication_error","message":"invalid x-api-key"}}'), + }); + vi.stubGlobal('fetch', fetchMock); + + const { POST } = await import('@/app/api/bookdate/test-connection/route'); + const response = await POST({ + headers: { get: () => null }, + json: vi.fn().mockResolvedValue({ provider: 'claude', apiKey: 'bad-key' }), + } as any); + + const payload = await response.json(); + expect(response.status).toBe(400); + expect(payload.error).toMatch(/Invalid Claude API key/i); + }); + + it('paginates through Claude models when has_more is true', async () => { + let callCount = 0; + const fetchMock = vi.fn().mockImplementation(() => { + callCount++; + if (callCount === 1) { + return Promise.resolve({ + ok: true, + json: vi.fn().mockResolvedValue({ + data: [ + { id: 'claude-sonnet-4-5-20250929', display_name: 'Claude Sonnet 4.5', type: 'model', created_at: '2025-09-29T00:00:00Z' }, + ], + has_more: true, + first_id: 'claude-sonnet-4-5-20250929', + last_id: 'claude-sonnet-4-5-20250929', + }), + text: vi.fn().mockResolvedValue('ok'), + }); + } + return Promise.resolve({ + ok: true, + json: vi.fn().mockResolvedValue({ + data: [ + { id: 'claude-haiku-4-5-20251001', display_name: 'Claude Haiku 4.5', type: 'model', created_at: '2025-10-01T00:00:00Z' }, + ], + has_more: false, + first_id: 'claude-haiku-4-5-20251001', + last_id: 'claude-haiku-4-5-20251001', + }), + text: vi.fn().mockResolvedValue('ok'), + }); + }); + vi.stubGlobal('fetch', fetchMock); + + const { POST } = await import('@/app/api/bookdate/test-connection/route'); + const response = await POST({ + headers: { get: () => null }, + json: vi.fn().mockResolvedValue({ provider: 'claude', apiKey: 'key' }), + } as any); + + const payload = await response.json(); + expect(payload.success).toBe(true); + expect(payload.models).toEqual([ + { id: 'claude-sonnet-4-5-20250929', name: 'Claude Sonnet 4.5' }, + { id: 'claude-haiku-4-5-20251001', name: 'Claude Haiku 4.5' }, + ]); + expect(fetchMock).toHaveBeenCalledTimes(2); + // Second call should include after_id for pagination + expect(fetchMock.mock.calls[1][0]).toContain('after_id=claude-sonnet-4-5-20250929'); + }); + + it('falls back to model id when display_name is missing', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ + data: [ + { id: 'claude-test-model', type: 'model', created_at: '2025-01-01T00:00:00Z' }, + ], + has_more: false, + first_id: 'claude-test-model', + last_id: 'claude-test-model', + }), + text: vi.fn().mockResolvedValue('ok'), + }); + vi.stubGlobal('fetch', fetchMock); + + const { POST } = await import('@/app/api/bookdate/test-connection/route'); + const response = await POST({ + headers: { get: () => null }, + json: vi.fn().mockResolvedValue({ provider: 'claude', apiKey: 'key' }), + } as any); + + const payload = await response.json(); + expect(payload.success).toBe(true); + expect(payload.models).toEqual([ + { id: 'claude-test-model', name: 'claude-test-model' }, + ]); }); it('returns OpenAI error for unauthenticated requests with invalid key', async () => { diff --git a/tests/api/requests-actions.routes.test.ts b/tests/api/requests-actions.routes.test.ts index b45fd1b..3c00433 100644 --- a/tests/api/requests-actions.routes.test.ts +++ b/tests/api/requests-actions.routes.test.ts @@ -10,9 +10,11 @@ let authRequest: any; const prismaMock = createPrismaMock(); const requireAuthMock = vi.hoisted(() => vi.fn()); -const prowlarrMock = vi.hoisted(() => ({ search: vi.fn() })); +const prowlarrMock = vi.hoisted(() => ({ search: vi.fn(), searchWithVariations: vi.fn() })); const rankTorrentsMock = vi.hoisted(() => vi.fn()); const configServiceMock = vi.hoisted(() => ({ get: vi.fn() })); +const groupIndexersMock = vi.hoisted(() => vi.fn()); +const groupDescriptionMock = vi.hoisted(() => vi.fn(() => 'Group')); const configState = vi.hoisted(() => ({ values: new Map(), })); @@ -23,6 +25,9 @@ const jobQueueMock = vi.hoisted(() => ({ addSearchEbookJob: vi.fn(() => Promise.resolve()), })); const downloadEbookMock = vi.hoisted(() => vi.fn()); +const audibleServiceMock = vi.hoisted(() => ({ + getRuntime: vi.fn(), +})); const fsMock = vi.hoisted(() => ({ access: vi.fn(), })); @@ -44,6 +49,11 @@ vi.mock('@/lib/utils/ranking-algorithm', () => ({ rankTorrents: rankTorrentsMock, })); +vi.mock('@/lib/utils/indexer-grouping', () => ({ + groupIndexersByCategories: groupIndexersMock, + getGroupDescription: groupDescriptionMock, +})); + vi.mock('@/lib/services/config.service', () => ({ getConfigService: () => configServiceMock, })); @@ -56,6 +66,10 @@ vi.mock('@/lib/services/ebook-scraper', () => ({ downloadEbook: downloadEbookMock, })); +vi.mock('@/lib/integrations/audible.service', () => ({ + getAudibleService: () => audibleServiceMock, +})); + vi.mock('fs/promises', () => ({ default: fsMock, ...fsMock, constants: { R_OK: 4 } })); describe('Request action routes', () => { @@ -72,22 +86,24 @@ describe('Request action routes', () => { ); }); - it('performs interactive search and ranks results', async () => { + it('performs interactive search and ranks results with runtime from ASIN', async () => { authRequest.json.mockResolvedValue({}); prismaMock.request.findUnique.mockResolvedValueOnce({ id: 'req-1', userId: 'user-1', - audiobook: { title: 'Title', author: 'Author' }, + audiobook: { title: 'Title', author: 'Author', audibleAsin: 'B00ASIN123' }, }); prismaMock.user.findUnique.mockResolvedValueOnce({ role: 'user', interactiveSearchAccess: null, }); - configServiceMock.get.mockResolvedValueOnce(JSON.stringify([{ id: 1, priority: 10 }])); + configServiceMock.get.mockResolvedValueOnce(JSON.stringify([{ id: 1, priority: 10, categories: [3030] }])); configServiceMock.get.mockResolvedValueOnce(null); - prowlarrMock.search.mockResolvedValueOnce([{ title: 'Result', size: 100 }]); + groupIndexersMock.mockReturnValue({ groups: [{ categories: [3030], indexerIds: [1] }], skippedIndexers: [] }); + prowlarrMock.searchWithVariations.mockResolvedValueOnce([{ title: 'Result', size: 500 * 1024 * 1024 }]); + audibleServiceMock.getRuntime.mockResolvedValueOnce(600); rankTorrentsMock.mockReturnValueOnce([ - { title: 'Result', score: 50, breakdown: { matchScore: 50, formatScore: 0, seederScore: 0, notes: [] }, bonusPoints: 0, bonusModifiers: [], finalScore: 50 }, + { title: 'Result', size: 500 * 1024 * 1024, score: 50, breakdown: { matchScore: 50, formatScore: 0, sizeScore: 12, seederScore: 0, notes: [] }, bonusPoints: 0, bonusModifiers: [], finalScore: 62 }, ]); const { POST } = await import('@/app/api/requests/[id]/interactive-search/route'); @@ -96,6 +112,77 @@ describe('Request action routes', () => { expect(payload.success).toBe(true); expect(payload.results[0].rank).toBe(1); + expect(audibleServiceMock.getRuntime).toHaveBeenCalledWith('B00ASIN123'); + expect(rankTorrentsMock).toHaveBeenCalledWith( + expect.any(Array), + expect.objectContaining({ title: 'Title', author: 'Author', durationMinutes: 600 }), + expect.any(Object) + ); + }); + + it('performs interactive search without runtime when no ASIN', async () => { + authRequest.json.mockResolvedValue({}); + prismaMock.request.findUnique.mockResolvedValueOnce({ + id: 'req-1b', + userId: 'user-1', + audiobook: { title: 'Title', author: 'Author', audibleAsin: null }, + }); + prismaMock.user.findUnique.mockResolvedValueOnce({ + role: 'user', + interactiveSearchAccess: null, + }); + configServiceMock.get.mockResolvedValueOnce(JSON.stringify([{ id: 1, priority: 10, categories: [3030] }])); + configServiceMock.get.mockResolvedValueOnce(null); + groupIndexersMock.mockReturnValue({ groups: [{ categories: [3030], indexerIds: [1] }], skippedIndexers: [] }); + prowlarrMock.searchWithVariations.mockResolvedValueOnce([{ title: 'Result', size: 100 }]); + rankTorrentsMock.mockReturnValueOnce([ + { title: 'Result', size: 100, score: 50, breakdown: { matchScore: 50, formatScore: 0, sizeScore: 0, seederScore: 0, notes: [] }, bonusPoints: 0, bonusModifiers: [], finalScore: 50 }, + ]); + + const { POST } = await import('@/app/api/requests/[id]/interactive-search/route'); + const response = await POST({} as any, { params: Promise.resolve({ id: 'req-1b' }) }); + const payload = await response.json(); + + expect(payload.success).toBe(true); + expect(audibleServiceMock.getRuntime).not.toHaveBeenCalled(); + expect(rankTorrentsMock).toHaveBeenCalledWith( + expect.any(Array), + expect.objectContaining({ title: 'Title', author: 'Author', durationMinutes: undefined }), + expect.any(Object) + ); + }); + + it('performs interactive search gracefully when runtime fetch fails', async () => { + authRequest.json.mockResolvedValue({}); + prismaMock.request.findUnique.mockResolvedValueOnce({ + id: 'req-1c', + userId: 'user-1', + audiobook: { title: 'Title', author: 'Author', audibleAsin: 'B00FAIL' }, + }); + prismaMock.user.findUnique.mockResolvedValueOnce({ + role: 'user', + interactiveSearchAccess: null, + }); + configServiceMock.get.mockResolvedValueOnce(JSON.stringify([{ id: 1, priority: 10, categories: [3030] }])); + configServiceMock.get.mockResolvedValueOnce(null); + groupIndexersMock.mockReturnValue({ groups: [{ categories: [3030], indexerIds: [1] }], skippedIndexers: [] }); + prowlarrMock.searchWithVariations.mockResolvedValueOnce([{ title: 'Result', size: 100 }]); + audibleServiceMock.getRuntime.mockRejectedValueOnce(new Error('Network error')); + rankTorrentsMock.mockReturnValueOnce([ + { title: 'Result', size: 100, score: 50, breakdown: { matchScore: 50, formatScore: 0, sizeScore: 0, seederScore: 0, notes: [] }, bonusPoints: 0, bonusModifiers: [], finalScore: 50 }, + ]); + + const { POST } = await import('@/app/api/requests/[id]/interactive-search/route'); + const response = await POST({} as any, { params: Promise.resolve({ id: 'req-1c' }) }); + const payload = await response.json(); + + expect(payload.success).toBe(true); + expect(payload.results).toHaveLength(1); + expect(rankTorrentsMock).toHaveBeenCalledWith( + expect.any(Array), + expect.objectContaining({ durationMinutes: undefined }), + expect.any(Object) + ); }); it('triggers manual search job', async () => { diff --git a/tests/app/setup/steps/AudiobookshelfStep.test.tsx b/tests/app/setup/steps/AudiobookshelfStep.test.tsx index 265cfd2..b38941c 100644 --- a/tests/app/setup/steps/AudiobookshelfStep.test.tsx +++ b/tests/app/setup/steps/AudiobookshelfStep.test.tsx @@ -24,6 +24,7 @@ const AudiobookshelfHarness = ({ absApiToken: 'token', absLibraryId: '', absTriggerScanAfterImport: false, + absLibraries: [] as { id: string; name: string; itemCount: number }[], ...initialState, }); diff --git a/tests/app/setup/steps/PlexStep.test.tsx b/tests/app/setup/steps/PlexStep.test.tsx index 9eb6d48..ac56cce 100644 --- a/tests/app/setup/steps/PlexStep.test.tsx +++ b/tests/app/setup/steps/PlexStep.test.tsx @@ -24,6 +24,7 @@ const PlexHarness = ({ plexToken: 'token', plexLibraryId: '', plexTriggerScanAfterImport: false, + plexLibraries: [] as { id: string; title: string; type: string }[], ...initialState, }); diff --git a/tests/lib/utils/path-template.util.test.ts b/tests/lib/utils/path-template.util.test.ts index 4b13978..d5d3f46 100644 --- a/tests/lib/utils/path-template.util.test.ts +++ b/tests/lib/utils/path-template.util.test.ts @@ -100,8 +100,8 @@ describe('substituteTemplate', () => { expect(result).toBe('Author/Title/Narrator'); }); - it('should handle mixed forward and backward slashes', () => { - const template = '{author}\\{title}/{narrator}'; + it('should resolve escaped braces to literal brace characters', () => { + const template = '{author}/\\{{narrator}\\}/{title}'; const variables: TemplateVariables = { author: 'Author', title: 'Title', @@ -109,7 +109,7 @@ describe('substituteTemplate', () => { }; const result = substituteTemplate(template, variables); - expect(result).toBe('Author/Title/Narrator'); + expect(result).toBe('Author/{Narrator}/Title'); }); it('should trim dots from path components', () => { @@ -145,6 +145,74 @@ describe('substituteTemplate', () => { const result = substituteTemplate(template, variables); expect(result).toBe('Audiobooks/Author/Books/Title'); }); + + it('should resolve escaped left brace only', () => { + const template = '{author}/\\{prefix {title}'; + const variables: TemplateVariables = { + author: 'Author', + title: 'Title' + }; + + const result = substituteTemplate(template, variables); + expect(result).toBe('Author/{prefix Title'); + }); + + it('should resolve escaped right brace only', () => { + const template = '{author}/{title} suffix\\}'; + const variables: TemplateVariables = { + author: 'Author', + title: 'Title' + }; + + const result = substituteTemplate(template, variables); + expect(result).toBe('Author/Title suffix}'); + }); + + it('should resolve multiple escaped brace pairs', () => { + const template = '\\{{author}\\}/\\{{title}\\}'; + const variables: TemplateVariables = { + author: 'Author', + title: 'Title' + }; + + const result = substituteTemplate(template, variables); + expect(result).toBe('{Author}/{Title}'); + }); + + it('should handle escaped braces with missing optional variable', () => { + const template = '{author}/\\{{narrator}\\}/{title}'; + const variables: TemplateVariables = { + author: 'Author', + title: 'Title' + // narrator is missing + }; + + const result = substituteTemplate(template, variables); + expect(result).toBe('Author/{}/Title'); + }); + + it('should handle escaped braces adjacent to path separators', () => { + const template = '{author}/\\{{narrator}\\}/{title}'; + const variables: TemplateVariables = { + author: 'Author', + title: 'Title', + narrator: 'Michael Kramer' + }; + + const result = substituteTemplate(template, variables); + expect(result).toBe('Author/{Michael Kramer}/Title'); + }); + + it('should handle escaped braces around static text', () => { + const template = '{author}/\\{narrated\\}/{title}'; + const variables: TemplateVariables = { + author: 'Author', + title: 'Title' + }; + + const result = substituteTemplate(template, variables); + expect(result).toBe('Author/{narrated}/Title'); + }); }); describe('validateTemplate', () => { @@ -205,8 +273,8 @@ describe('validateTemplate', () => { }); }); - it('should reject backslashes in template', () => { - const result = validateTemplate('{author}\\{title}'); + it('should reject backslashes that are not brace escapes', () => { + const result = validateTemplate('{author}\\n{title}'); expect(result.valid).toBe(false); expect(result.error).toContain('forward slashes'); }); @@ -230,6 +298,42 @@ describe('validateTemplate', () => { expect(result.error).toContain('{narrator}'); expect(result.error).toContain('{asin}'); }); + + it('should accept escaped braces around a variable', () => { + const result = validateTemplate('{author}/\\{{narrator}\\}/{title}'); + expect(result.valid).toBe(true); + }); + + it('should accept escaped braces around static text', () => { + const result = validateTemplate('{author}/\\{custom\\}/{title}'); + expect(result.valid).toBe(true); + }); + + it('should accept escaped left brace only', () => { + const result = validateTemplate('{author}/\\{prefix {title}'); + expect(result.valid).toBe(true); + }); + + it('should accept escaped right brace only', () => { + const result = validateTemplate('{author}/{title} suffix\\}'); + expect(result.valid).toBe(true); + }); + + it('should accept multiple escaped brace pairs', () => { + const result = validateTemplate('\\{{author}\\}/\\{{title}\\}'); + expect(result.valid).toBe(true); + }); + + it('should accept backslash before brace but reject backslash before other characters', () => { + const result = validateTemplate('{author}\\n/\\{{title}\\}'); + expect(result.valid).toBe(false); + expect(result.error).toContain('forward slashes'); + }); + + it('should accept a template that is only escaped braces', () => { + const result = validateTemplate('\\{\\}'); + expect(result.valid).toBe(true); + }); }); describe('generateMockPreviews', () => { @@ -305,6 +409,17 @@ describe('generateMockPreviews', () => { expect(preview).toContain(' - B'); }); }); + + it('should resolve escaped braces in previews', () => { + const template = '{author}/\\{{narrator}\\}/{title}'; + const previews = generateMockPreviews(template); + + // First two mock entries have narrators + expect(previews[0]).toContain('{Michael Kramer}'); + expect(previews[1]).toContain('{Stephen Fry}'); + // Third mock entry has no narrator - escaped braces remain empty + expect(previews[2]).toContain('{}'); + }); }); describe('getValidVariables', () => { diff --git a/tests/processors/search-indexers.processor.test.ts b/tests/processors/search-indexers.processor.test.ts index 5fe8397..15cf334 100644 --- a/tests/processors/search-indexers.processor.test.ts +++ b/tests/processors/search-indexers.processor.test.ts @@ -10,7 +10,7 @@ import { createJobQueueMock } from '../helpers/job-queue'; const prismaMock = createPrismaMock(); const configMock = vi.hoisted(() => ({ get: vi.fn() })); const jobQueueMock = createJobQueueMock(); -const prowlarrMock = vi.hoisted(() => ({ search: vi.fn() })); +const prowlarrMock = vi.hoisted(() => ({ search: vi.fn(), searchWithVariations: vi.fn() })); vi.mock('@/lib/db', () => ({ prisma: prismaMock, @@ -44,7 +44,7 @@ describe('processSearchIndexers', () => { } return null; }); - prowlarrMock.search.mockResolvedValue([]); + prowlarrMock.searchWithVariations.mockResolvedValue([]); prismaMock.request.update.mockResolvedValue({}); const { processSearchIndexers } = await import('@/lib/processors/search-indexers.processor'); @@ -73,7 +73,7 @@ describe('processSearchIndexers', () => { return null; }); - prowlarrMock.search.mockResolvedValue([ + prowlarrMock.searchWithVariations.mockResolvedValue([ { indexer: 'Indexer', indexerId: 1, diff --git a/tests/processors/send-notification.processor.test.ts b/tests/processors/send-notification.processor.test.ts index 44b414a..c19bb82 100644 --- a/tests/processors/send-notification.processor.test.ts +++ b/tests/processors/send-notification.processor.test.ts @@ -9,7 +9,7 @@ const notificationServiceMock = vi.hoisted(() => ({ sendNotification: vi.fn(), })); -vi.mock('@/lib/services/notification.service', () => ({ +vi.mock('@/lib/services/notification', () => ({ getNotificationService: () => notificationServiceMock, })); diff --git a/tests/services/apprise.provider.test.ts b/tests/services/apprise.provider.test.ts new file mode 100644 index 0000000..898bf3e --- /dev/null +++ b/tests/services/apprise.provider.test.ts @@ -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 + }); + }); +}); diff --git a/tests/services/auth/local-auth-provider.test.ts b/tests/services/auth/local-auth-provider.test.ts index 6f479b3..ff7a16e 100644 --- a/tests/services/auth/local-auth-provider.test.ts +++ b/tests/services/auth/local-auth-provider.test.ts @@ -191,6 +191,40 @@ describe('LocalAuthProvider', () => { expect(result.error).toContain('Password'); }); + it('allows short passwords when ALLOW_WEAK_PASSWORD is enabled', async () => { + process.env.ALLOW_WEAK_PASSWORD = 'true'; + configMock.get.mockResolvedValueOnce('true'); // registration enabled + configMock.get.mockResolvedValueOnce('false'); // no admin approval + prismaMock.user.findFirst.mockResolvedValue(null); + prismaMock.user.count.mockResolvedValue(0); + prismaMock.user.create.mockResolvedValue({ + id: 'user-1', + plexId: 'local-user', + plexUsername: 'user', + role: 'admin', + }); + bcryptHash.mockResolvedValue('hash'); + + const { LocalAuthProvider } = await import('@/lib/services/auth/LocalAuthProvider'); + const provider = new LocalAuthProvider(); + const result = await provider.register({ username: 'user', password: 'ab' }); + + expect(result.success).toBe(true); + delete process.env.ALLOW_WEAK_PASSWORD; + }); + + it('still rejects empty passwords when ALLOW_WEAK_PASSWORD is enabled', async () => { + process.env.ALLOW_WEAK_PASSWORD = 'true'; + + const { LocalAuthProvider } = await import('@/lib/services/auth/LocalAuthProvider'); + const provider = new LocalAuthProvider(); + const result = await provider.register({ username: 'user', password: '' }); + + expect(result.success).toBe(false); + expect(result.error).toContain('required'); + delete process.env.ALLOW_WEAK_PASSWORD; + }); + it('rejects registration when username is taken', async () => { configMock.get.mockResolvedValueOnce('true'); prismaMock.user.findFirst.mockResolvedValue({ id: 'user-10' }); diff --git a/tests/services/notification.service.test.ts b/tests/services/notification.service.test.ts index 30d9aa8..678bffc 100644 --- a/tests/services/notification.service.test.ts +++ b/tests/services/notification.service.test.ts @@ -66,7 +66,7 @@ describe('NotificationService', () => { json: async () => ({ success: true }), }); - const { NotificationService } = await import('@/lib/services/notification.service'); + const { NotificationService } = await import('@/lib/services/notification'); const service = new NotificationService(); await service.sendNotification({ @@ -92,7 +92,7 @@ describe('NotificationService', () => { it('does not send if no backends are subscribed to the event', async () => { prismaMock.notificationBackend.findMany.mockResolvedValue([]); - const { NotificationService } = await import('@/lib/services/notification.service'); + const { NotificationService } = await import('@/lib/services/notification'); const service = new NotificationService(); await service.sendNotification({ @@ -139,7 +139,7 @@ describe('NotificationService', () => { json: async () => ({ success: true }), }); - const { NotificationService } = await import('@/lib/services/notification.service'); + const { NotificationService } = await import('@/lib/services/notification'); const service = new NotificationService(); await service.sendNotification({ @@ -156,19 +156,99 @@ describe('NotificationService', () => { }); }); - describe('sendDiscord', () => { + describe('sendToBackend', () => { + it('routes to Discord provider and decrypts config', async () => { + fetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ success: true }), + }); + + const { NotificationService } = await import('@/lib/services/notification'); + const service = new NotificationService(); + + await service.sendToBackend( + 'discord', + { webhookUrl: 'enc:https://discord.com/webhook', username: 'ReadMeABook' }, + { + event: 'request_approved', + requestId: 'req-1', + title: 'Test Book', + author: 'Test Author', + userName: 'Test User', + timestamp: new Date('2024-01-01T00:00:00Z'), + } + ); + + expect(encryptionMock.decrypt).toHaveBeenCalledWith('enc:https://discord.com/webhook'); + expect(fetchMock).toHaveBeenCalled(); + + const fetchCall = fetchMock.mock.calls[0]; + // Decrypted URL should be used + expect(fetchCall[0]).toBe('https://discord.com/webhook'); + }); + + it('routes to Pushover provider and decrypts config', async () => { + fetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ status: 1 }), + }); + + const { NotificationService } = await import('@/lib/services/notification'); + const service = new NotificationService(); + + // Use iv:authTag:data format to pass isEncrypted() check + await service.sendToBackend( + 'pushover', + { userKey: 'iv:tag:user123', appToken: 'iv:tag:app456', priority: 1 }, + { + event: 'request_approved', + requestId: 'req-1', + title: 'Test Book', + author: 'Test Author', + userName: 'Test User', + timestamp: new Date(), + } + ); + + expect(encryptionMock.decrypt).toHaveBeenCalledWith('iv:tag:user123'); + expect(encryptionMock.decrypt).toHaveBeenCalledWith('iv:tag:app456'); + expect(fetchMock).toHaveBeenCalled(); + }); + + it('throws error for unsupported backend type', async () => { + const { NotificationService } = await import('@/lib/services/notification'); + const service = new NotificationService(); + + await expect( + service.sendToBackend( + 'email', + {}, + { + event: 'request_approved', + requestId: 'req-1', + title: 'Test Book', + author: 'Test Author', + userName: 'Test User', + timestamp: new Date(), + } + ) + ).rejects.toThrow('Unsupported backend type: email'); + }); + }); + + describe('DiscordProvider', () => { it('sends Discord webhook with rich embed', async () => { fetchMock.mockResolvedValue({ ok: true, json: async () => ({ success: true }), }); - const { NotificationService } = await import('@/lib/services/notification.service'); - const service = new NotificationService(); + const { DiscordProvider } = await import('@/lib/services/notification'); + const provider = new DiscordProvider(); - await service.sendDiscord( + await provider.send( { - webhookUrl: 'enc:https://discord.com/webhook', + webhookUrl: 'https://discord.com/webhook', username: 'ReadMeABook', }, { @@ -181,12 +261,12 @@ describe('NotificationService', () => { } ); - // Should call the webhook (URL decryption happens internally) expect(fetchMock).toHaveBeenCalled(); const fetchCall = fetchMock.mock.calls[0]; const body = JSON.parse(fetchCall[1].body); + expect(fetchCall[0]).toBe('https://discord.com/webhook'); expect(fetchCall[1].method).toBe('POST'); expect(fetchCall[1].headers['Content-Type']).toBe('application/json'); expect(body.username).toBe('ReadMeABook'); @@ -201,12 +281,12 @@ describe('NotificationService', () => { json: async () => ({ success: true }), }); - const { NotificationService } = await import('@/lib/services/notification.service'); - const service = new NotificationService(); + const { DiscordProvider } = await import('@/lib/services/notification'); + const provider = new DiscordProvider(); - await service.sendDiscord( + await provider.send( { - webhookUrl: 'enc:https://discord.com/webhook', + webhookUrl: 'https://discord.com/webhook', }, { event: 'request_approved', @@ -230,12 +310,12 @@ describe('NotificationService', () => { text: async () => 'Bad Request', }); - const { NotificationService } = await import('@/lib/services/notification.service'); - const service = new NotificationService(); + const { DiscordProvider } = await import('@/lib/services/notification'); + const provider = new DiscordProvider(); await expect( - service.sendDiscord( - { webhookUrl: 'enc:https://discord.com/webhook' }, + provider.send( + { webhookUrl: 'https://discord.com/webhook' }, { event: 'request_approved', requestId: 'req-1', @@ -249,20 +329,20 @@ describe('NotificationService', () => { }); }); - describe('sendPushover', () => { + describe('PushoverProvider', () => { it('sends Pushover notification with correct payload', async () => { fetchMock.mockResolvedValue({ ok: true, json: async () => ({ status: 1 }), }); - const { NotificationService } = await import('@/lib/services/notification.service'); - const service = new NotificationService(); + const { PushoverProvider } = await import('@/lib/services/notification'); + const provider = new PushoverProvider(); - await service.sendPushover( + await provider.send( { - userKey: 'enc:user123', - appToken: 'enc:app456', + userKey: 'user123', + appToken: 'app456', priority: 1, }, { @@ -275,7 +355,6 @@ describe('NotificationService', () => { } ); - // Should call the Pushover API (credential decryption happens internally) expect(fetchMock).toHaveBeenCalled(); const fetchCall = fetchMock.mock.calls[0]; @@ -296,13 +375,13 @@ describe('NotificationService', () => { json: async () => ({ status: 1 }), }); - const { NotificationService } = await import('@/lib/services/notification.service'); - const service = new NotificationService(); + const { PushoverProvider } = await import('@/lib/services/notification'); + const provider = new PushoverProvider(); - await service.sendPushover( + await provider.send( { - userKey: 'enc:user123', - appToken: 'enc:app456', + userKey: 'user123', + appToken: 'app456', }, { event: 'request_approved', @@ -325,12 +404,12 @@ describe('NotificationService', () => { text: async () => 'invalid user key', }); - const { NotificationService } = await import('@/lib/services/notification.service'); - const service = new NotificationService(); + const { PushoverProvider } = await import('@/lib/services/notification'); + const provider = new PushoverProvider(); await expect( - service.sendPushover( - { userKey: 'enc:user123', appToken: 'enc:app456' }, + provider.send( + { userKey: 'user123', appToken: 'app456' }, { event: 'request_approved', requestId: 'req-1', @@ -344,11 +423,9 @@ describe('NotificationService', () => { }); }); - // Note: formatDiscordEmbed is a private method, tested indirectly through sendDiscord - describe('encryptConfig', () => { it('encrypts sensitive Discord config values', async () => { - const { NotificationService } = await import('@/lib/services/notification.service'); + const { NotificationService } = await import('@/lib/services/notification'); const service = new NotificationService(); const encrypted = service.encryptConfig('discord', { @@ -362,7 +439,7 @@ describe('NotificationService', () => { }); it('encrypts sensitive Pushover config values', async () => { - const { NotificationService } = await import('@/lib/services/notification.service'); + const { NotificationService } = await import('@/lib/services/notification'); const service = new NotificationService(); const encrypted = service.encryptConfig('pushover', { @@ -379,11 +456,72 @@ describe('NotificationService', () => { }); }); - // Note: decryptConfig is a private method, tested indirectly through sendDiscord/sendPushover + describe('getRegisteredProviderTypes', () => { + it('returns all registered provider type keys', async () => { + const { getRegisteredProviderTypes } = await import('@/lib/services/notification'); + const types = getRegisteredProviderTypes(); + + expect(types).toContain('apprise'); + expect(types).toContain('discord'); + expect(types).toContain('ntfy'); + expect(types).toContain('pushover'); + expect(types).toHaveLength(4); + }); + }); + + describe('getAllProviderMetadata', () => { + it('returns metadata for all registered providers', async () => { + const { getAllProviderMetadata } = await import('@/lib/services/notification'); + const metadata = getAllProviderMetadata(); + + expect(metadata).toHaveLength(4); + + const apprise = metadata.find((m) => m.type === 'apprise'); + expect(apprise).toBeDefined(); + expect(apprise!.displayName).toBe('Apprise'); + expect(apprise!.iconLabel).toBe('A'); + expect(apprise!.iconColor).toBe('bg-purple-500'); + + const discord = metadata.find((m) => m.type === 'discord'); + expect(discord).toBeDefined(); + expect(discord!.displayName).toBe('Discord'); + expect(discord!.iconLabel).toBe('D'); + expect(discord!.iconColor).toBe('bg-indigo-500'); + expect(discord!.configFields.length).toBeGreaterThan(0); + + const ntfy = metadata.find((m) => m.type === 'ntfy'); + expect(ntfy).toBeDefined(); + expect(ntfy!.displayName).toBe('ntfy'); + expect(ntfy!.iconLabel).toBe('N'); + + const pushover = metadata.find((m) => m.type === 'pushover'); + expect(pushover).toBeDefined(); + expect(pushover!.displayName).toBe('Pushover'); + expect(pushover!.iconLabel).toBe('P'); + }); + + it('includes config field definitions with correct properties', async () => { + const { getAllProviderMetadata } = await import('@/lib/services/notification'); + const metadata = getAllProviderMetadata(); + + const discord = metadata.find((m) => m.type === 'discord')!; + const webhookField = discord.configFields.find((f) => f.name === 'webhookUrl'); + expect(webhookField).toBeDefined(); + expect(webhookField!.required).toBe(true); + expect(webhookField!.type).toBe('text'); + + const pushover = metadata.find((m) => m.type === 'pushover')!; + const priorityField = pushover.configFields.find((f) => f.name === 'priority'); + expect(priorityField).toBeDefined(); + expect(priorityField!.type).toBe('select'); + expect(priorityField!.options).toBeDefined(); + expect(priorityField!.options!.length).toBe(5); + }); + }); describe('maskConfig', () => { it('masks sensitive Discord config values', async () => { - const { NotificationService } = await import('@/lib/services/notification.service'); + const { NotificationService } = await import('@/lib/services/notification'); const service = new NotificationService(); const masked = service.maskConfig('discord', { @@ -396,7 +534,7 @@ describe('NotificationService', () => { }); it('masks sensitive Pushover config values', async () => { - const { NotificationService } = await import('@/lib/services/notification.service'); + const { NotificationService } = await import('@/lib/services/notification'); const service = new NotificationService(); const masked = service.maskConfig('pushover', { diff --git a/tests/services/ntfy.provider.test.ts b/tests/services/ntfy.provider.test.ts new file mode 100644 index 0000000..a47f349 --- /dev/null +++ b/tests/services/ntfy.provider.test.ts @@ -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 + }); + }); +}); diff --git a/tests/utils/copy-file.test.ts b/tests/utils/copy-file.test.ts new file mode 100644 index 0000000..fe5d031 --- /dev/null +++ b/tests/utils/copy-file.test.ts @@ -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'); + }); +}); diff --git a/tests/utils/file-organizer.test.ts b/tests/utils/file-organizer.test.ts index 13b83e2..93178c3 100644 --- a/tests/utils/file-organizer.test.ts +++ b/tests/utils/file-organizer.test.ts @@ -11,7 +11,6 @@ const fsMock = vi.hoisted(() => ({ access: vi.fn(), stat: vi.fn(), mkdir: vi.fn(), - copyFile: vi.fn(), chmod: vi.fn(), unlink: vi.fn(), writeFile: vi.fn(), @@ -71,11 +70,17 @@ const ebookMock = vi.hoisted(() => ({ downloadEbook: vi.fn(), })); +const copyFileMock = vi.hoisted(() => ({ + copyFile: vi.fn(), +})); + vi.mock('fs/promises', () => ({ default: fsMock, ...fsMock, })); +vi.mock('@/lib/utils/copy-file', () => copyFileMock); + vi.mock('axios', () => ({ default: axiosMock, ...axiosMock, @@ -109,7 +114,7 @@ describe('file organizer', () => { throw new Error('missing'); }); fsMock.mkdir.mockResolvedValue(undefined); - fsMock.copyFile.mockResolvedValue(undefined); + copyFileMock.copyFile.mockResolvedValue(undefined); fsMock.chmod.mockResolvedValue(undefined); const organizer = new FileOrganizer('/media', '/tmp'); @@ -174,7 +179,7 @@ describe('file organizer', () => { throw new Error('missing'); }); fsMock.mkdir.mockResolvedValue(undefined); - fsMock.copyFile.mockResolvedValue(undefined); + copyFileMock.copyFile.mockResolvedValue(undefined); fsMock.chmod.mockResolvedValue(undefined); const organizer = new FileOrganizer('/media', '/tmp'); @@ -216,7 +221,7 @@ describe('file organizer', () => { throw new Error('missing'); }); fsMock.mkdir.mockResolvedValue(undefined); - fsMock.copyFile.mockResolvedValue(undefined); + copyFileMock.copyFile.mockResolvedValue(undefined); fsMock.chmod.mockResolvedValue(undefined); fsMock.unlink.mockResolvedValue(undefined); @@ -235,7 +240,7 @@ describe('file organizer', () => { const expectedDir = path.join('/media', 'Author', 'Book'); expect(result.success).toBe(true); expect(result.targetPath).toBe(expectedDir); - expect(fsMock.copyFile).toHaveBeenCalledWith('/tmp/tagged.m4b', path.join(expectedDir, 'book.m4b')); + expect(copyFileMock.copyFile).toHaveBeenCalledWith('/tmp/tagged.m4b', path.join(expectedDir, 'book.m4b')); expect(fsMock.unlink).toHaveBeenCalledWith('/tmp/tagged.m4b'); }); @@ -261,7 +266,7 @@ describe('file organizer', () => { throw new Error('missing'); }); fsMock.mkdir.mockResolvedValue(undefined); - fsMock.copyFile.mockResolvedValue(undefined); + copyFileMock.copyFile.mockResolvedValue(undefined); fsMock.chmod.mockResolvedValue(undefined); const result = await organizer.organize('/downloads/book', { @@ -272,7 +277,7 @@ describe('file organizer', () => { expect(result.success).toBe(true); expect(result.errors).toContain('Metadata tagging skipped: ffmpeg not available'); expect(metadataMock.tagMultipleFiles).not.toHaveBeenCalled(); - expect(fsMock.copyFile).toHaveBeenCalledWith(sourcePath, targetFile); + expect(copyFileMock.copyFile).toHaveBeenCalledWith(sourcePath, targetFile); }); it('downloads remote cover art when no local cover exists', async () => { @@ -294,7 +299,7 @@ describe('file organizer', () => { throw new Error('missing'); }); fsMock.mkdir.mockResolvedValue(undefined); - fsMock.copyFile.mockResolvedValue(undefined); + copyFileMock.copyFile.mockResolvedValue(undefined); fsMock.chmod.mockResolvedValue(undefined); fsMock.writeFile.mockResolvedValue(undefined); @@ -316,7 +321,7 @@ describe('file organizer', () => { // NOTE: Ebook downloads are now handled as first-class requests through the job queue // The file organizer no longer downloads ebooks inline expect(ebookMock.downloadEbook).not.toHaveBeenCalled(); - expect(fsMock.copyFile).toHaveBeenCalledWith(sourcePath, targetFile); + expect(copyFileMock.copyFile).toHaveBeenCalledWith(sourcePath, targetFile); expect(result.filesMovedCount).toBe(1); }); @@ -340,7 +345,7 @@ describe('file organizer', () => { throw new Error('missing'); }); fsMock.mkdir.mockResolvedValue(undefined); - fsMock.copyFile.mockResolvedValue(undefined); + copyFileMock.copyFile.mockResolvedValue(undefined); fsMock.chmod.mockResolvedValue(undefined); axiosMock.get.mockRejectedValue(new Error('cover failed')); @@ -352,7 +357,7 @@ describe('file organizer', () => { expect(result.success).toBe(true); expect(result.errors.join(' ')).toContain('Failed to download cover art'); - expect(fsMock.copyFile).toHaveBeenCalledWith(sourcePath, targetFile); + expect(copyFileMock.copyFile).toHaveBeenCalledWith(sourcePath, targetFile); }); it('continues when chapter analysis returns no valid chapters', async () => { @@ -378,7 +383,7 @@ describe('file organizer', () => { throw new Error('missing'); }); fsMock.mkdir.mockResolvedValue(undefined); - fsMock.copyFile.mockResolvedValue(undefined); + copyFileMock.copyFile.mockResolvedValue(undefined); fsMock.chmod.mockResolvedValue(undefined); const result = await organizer.organize('/downloads/book', { @@ -415,7 +420,7 @@ describe('file organizer', () => { throw new Error('missing'); }); fsMock.mkdir.mockResolvedValue(undefined); - fsMock.copyFile.mockResolvedValue(undefined); + copyFileMock.copyFile.mockResolvedValue(undefined); fsMock.chmod.mockResolvedValue(undefined); fsMock.unlink.mockResolvedValue(undefined); @@ -502,7 +507,7 @@ describe('file organizer', () => { expect(result.audioFiles).toEqual([]); expect(result.errors.join(' ')).toContain('Source file not found'); expect(result.errors.join(' ')).toContain('No audio files were successfully copied'); - expect(fsMock.copyFile).not.toHaveBeenCalled(); + expect(copyFileMock.copyFile).not.toHaveBeenCalled(); }); it('skips copying when target files already exist', async () => { @@ -535,7 +540,7 @@ describe('file organizer', () => { expect(result.success).toBe(true); expect(result.audioFiles).toEqual([targetPath]); expect(result.filesMovedCount).toBe(0); - expect(fsMock.copyFile).not.toHaveBeenCalled(); + expect(copyFileMock.copyFile).not.toHaveBeenCalled(); }); it('continues when metadata tagging throws', async () => { @@ -557,7 +562,7 @@ describe('file organizer', () => { throw new Error('missing'); }); fsMock.mkdir.mockResolvedValue(undefined); - fsMock.copyFile.mockResolvedValue(undefined); + copyFileMock.copyFile.mockResolvedValue(undefined); fsMock.chmod.mockResolvedValue(undefined); const result = await organizer.organize('/downloads/book', { @@ -567,7 +572,7 @@ describe('file organizer', () => { expect(result.success).toBe(true); expect(result.errors.join(' ')).toContain('Metadata tagging failed'); - expect(fsMock.copyFile).toHaveBeenCalled(); + expect(copyFileMock.copyFile).toHaveBeenCalled(); }); it('validates paths and reports multiple issues', async () => { @@ -668,7 +673,7 @@ describe('file organizer', () => { throw new Error('missing'); }); fsMock.mkdir.mockResolvedValue(undefined); - fsMock.copyFile.mockRejectedValue( + copyFileMock.copyFile.mockRejectedValue( Object.assign(new Error('EPERM: operation not permitted, copyfile'), { code: 'EPERM' }) ); @@ -705,7 +710,7 @@ describe('file organizer', () => { throw new Error('missing'); }); fsMock.mkdir.mockResolvedValue(undefined); - fsMock.copyFile.mockImplementation(async (src: string, dest: string) => { + copyFileMock.copyFile.mockImplementation(async (src: string, dest: string) => { // Tagged file copy fails with EPERM if (path.normalize(src) === path.normalize(taggedPath)) { throw Object.assign(new Error('EPERM: operation not permitted'), { code: 'EPERM' }); @@ -736,7 +741,7 @@ describe('file organizer', () => { // Tagged temp file should be cleaned up expect(fsMock.unlink).toHaveBeenCalledWith(taggedPath); // Fallback copy should use the original source - expect(fsMock.copyFile).toHaveBeenCalledWith(sourcePath, targetFile); + expect(copyFileMock.copyFile).toHaveBeenCalledWith(sourcePath, targetFile); // Should record that tagged copy failed expect(result.errors.join(' ')).toContain('Tagged copy failed'); expect(result.errors.join(' ')).toContain('without metadata tags'); @@ -760,7 +765,7 @@ describe('file organizer', () => { }); fsMock.mkdir.mockResolvedValue(undefined); // Both tagged and original copies fail - fsMock.copyFile.mockRejectedValue( + copyFileMock.copyFile.mockRejectedValue( Object.assign(new Error('EPERM: operation not permitted'), { code: 'EPERM' }) ); fsMock.unlink.mockResolvedValue(undefined); @@ -808,7 +813,7 @@ describe('file organizer', () => { throw new Error('missing'); }); fsMock.mkdir.mockResolvedValue(undefined); - fsMock.copyFile.mockImplementation(async (src: string) => { + copyFileMock.copyFile.mockImplementation(async (src: string) => { // First file succeeds, second fails if (path.normalize(src) === path.normalize(source2)) { throw Object.assign(new Error('EPERM: operation not permitted'), { code: 'EPERM' }); @@ -847,7 +852,7 @@ describe('file organizer', () => { throw new Error('missing'); }); fsMock.mkdir.mockResolvedValue(undefined); - fsMock.copyFile.mockResolvedValue(undefined); + copyFileMock.copyFile.mockResolvedValue(undefined); fsMock.chmod.mockResolvedValue(undefined); fsMock.writeFile.mockResolvedValue(undefined); axiosMock.get.mockResolvedValue({ data: Buffer.from('cover') }); @@ -881,7 +886,7 @@ describe('file organizer', () => { throw new Error('missing'); }); fsMock.mkdir.mockResolvedValue(undefined); - fsMock.copyFile.mockRejectedValue( + copyFileMock.copyFile.mockRejectedValue( Object.assign(new Error('EPERM: operation not permitted'), { code: 'EPERM' }) ); fsMock.writeFile.mockResolvedValue(undefined);