Compare commits

..

11 Commits

Author SHA1 Message Date
kikootwo eca24e46a8 Add test mocks and update delete API assertion
Add missing mocks used by updated code paths: mock PreferencesContext in profile page tests and add useReplaceWithTorrent/replaceWithTorrent mock for InteractiveTorrentSearchModal tests. Update Audiobookshelf API test to expect DELETE to include ?hard=1 and Authorization header. Extend the prisma test helper in audiobook-matcher tests with a reportedIssue.findMany mock and ensure it resolves to an empty array for the test.
2026-02-11 17:02:21 -05:00
kikootwo b1561a8311 Bump package version to 1.0.7
Update package.json version from 1.0.6 to 1.0.7 to reflect a new patch release.
2026-02-11 16:50:45 -05:00
kikootwo 20c8fb0898 Add reported-issues, Goodreads sync & notifs
Introduce user-reported-issues and Goodreads shelf sync features and wire them into notifications. Adds Prisma migrations and schema changes (ReportedIssue, GoodreadsShelf, GoodreadsBookMapping), API endpoints for reporting (POST /audiobooks/[asin]/report-issue) and admin management (list, resolve/dismiss, replace), and an admin UI section to view/dismiss/replace reported issues. Adds a new notification event (issue_reported) with updates to notification schemas, docs and provider handling, plus a notification-events constants file. Refactors request creation to use createRequestForUser service, adds a Goodreads sync processor/service/hooks/UI modals, a scrape-resilience util, and related tests and minor integration updates.
2026-02-11 16:49:55 -05:00
kikootwo b013538b63 Tighten fetch assertion in change password test
Replace a broad fetch mock expectation with a more specific one to ensure the test only asserts that the change-password endpoint was not called. This avoids false failures when other fetch calls occur by checking not.toHaveBeenCalledWith('/api/auth/change-password', expect.anything()).
2026-02-10 22:12:06 -05:00
kikootwo bceb13f4dd Bump version to 1.0.6 and document auth envs
Bump package version from 1.0.5 to 1.0.6. Add two commented environment variable examples to docker-compose.yml for authentication configuration: DISABLE_LOCAL_LOGIN (force OAuth) and ALLOW_WEAK_PASSWORD (remove minimum password length). These entries are documented examples only and do not change runtime behavior.
2026-02-10 22:01:01 -05:00
kikootwo 6b83e5dac1 Merge branch 'main' of https://github.com/kikootwo/ReadMeABook 2026-02-10 21:43:13 -05:00
kikootwo af0eaceb98 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.
2026-02-10 15:06:20 -05:00
kikootwo 1d25f7f7b2 Merge pull request #65 from alceasan/spanish-audible-region-and-regional-title
Add Spain as Audible region
2026-02-10 09:46:12 -05:00
alceasan 4e84887d33 2026-02-10 09:43:52 +01:00
kikootwo 4a38dd3da8 Bump package version to 1.0.5
Update package.json version from 1.0.4 to 1.0.5 to prepare for a patch release.
2026-02-09 21:46:02 -05:00
kikootwo f9947b745e Add requireSetupIncompleteOrAdmin and adjust routes
Introduce a new middleware requireSetupIncompleteOrAdmin that allows unauthenticated access while the setup wizard is in progress but enforces admin authentication once setup is complete. Replace requireSetupIncomplete with the new guard in test-paths, test-abs and test-oidc API routes. Update the front-end hook to use fetchWithAuth for authenticated requests. Revise setup-guard tests to cover the new semantics: shared endpoints now return 401 when setup is complete and no auth is provided, return 403 for authenticated non-admin users, and allow admin access or unauthenticated access during setup/DB-unready conditions; also add jwt verification and user lookup mocks to the tests.
2026-02-09 21:45:37 -05:00
138 changed files with 7610 additions and 1526 deletions
+3
View File
@@ -71,6 +71,9 @@ services:
# PLEX_CLIENT_IDENTIFIER: "readmeabook-custom-id" # PLEX_CLIENT_IDENTIFIER: "readmeabook-custom-id"
# PLEX_PRODUCT_NAME: "ReadMeABook" # PLEX_PRODUCT_NAME: "ReadMeABook"
# LOG_LEVEL: "info" # LOG_LEVEL: "info"
# DISABLE_LOCAL_LOGIN: "true" # Set to "true" to disable local login (force OAuth)
# ALLOW_WEAK_PASSWORD: "true" # Set to "true" to remove minimum password length requirement
# ======================================================================== # ========================================================================
# IMPORTANT: Public URL Configuration (Required for OAuth) # IMPORTANT: Public URL Configuration (Required for OAuth)
+1 -1
View File
@@ -75,7 +75,7 @@ docker-compose logs -f app
## 📊 Feature Highlights ## 📊 Feature Highlights
### AI-Powered Recommendations ### AI-Powered Recommendations
- **Providers:** OpenAI (GPT-4o+) or Claude (Sonnet 4.5, Opus 4, Haiku) - **Providers:** OpenAI (GPT-4+) or Claude (dynamically fetched from Anthropic Models API)
- **Personalization:** Based on your Plex library + swipe history - **Personalization:** Based on your Plex library + swipe history
- **Context:** Max 50 books (40 library + 10 swipes) - **Context:** Max 50 books (40 library + 10 swipes)
- **Filtering:** Excludes books already in library, already requested, or already swiped - **Filtering:** Excludes books already in library, already requested, or already swiped
+73 -17
View File
@@ -1,14 +1,14 @@
# Notification System # Notification System
**Status:** ✅ Implemented | Extensible notification system with Discord and Pushover support **Status:** ✅ Implemented | Extensible notification system with Discord, ntfy, and Pushover support
## Overview ## Overview
Sends notifications for audiobook request events (pending approval, approved, available, error) to configured backends. Non-blocking, atomic per-backend failure handling. Proper notification timing for all request flows including interactive search. Sends notifications for audiobook request events (pending approval, approved, available, error) to configured backends. Non-blocking, atomic per-backend failure handling. Proper notification timing for all request flows including interactive search.
## Key Details ## Key Details
- **Backends:** Discord (webhooks), Pushover (API) - **Backends:** Apprise (API), Discord (webhooks), ntfy (API), Pushover (API)
- **Events:** request_pending_approval, request_approved, request_available, request_error - **Events:** request_pending_approval, request_approved, request_available, request_error, issue_reported
- **Encryption:** AES-256-GCM for sensitive config (webhook URLs, API keys) - **Encryption:** AES-256-GCM for sensitive config (webhook URLs, API keys, notification URLs)
- **Delivery:** Async via Bull job queue (priority 5) - **Delivery:** Async via Bull job queue (priority 5)
- **Failure Handling:** Non-blocking, Promise.allSettled (one backend fails, others succeed) - **Failure Handling:** Non-blocking, Promise.allSettled (one backend fails, others succeed)
@@ -17,7 +17,7 @@ Sends notifications for audiobook request events (pending approval, approved, av
```prisma ```prisma
model NotificationBackend { model NotificationBackend {
id String @id @default(uuid()) id String @id @default(uuid())
type String // 'discord' | 'pushover' type String // 'apprise' | 'discord' | 'ntfy' | 'pushover'
name String // User-friendly label name String // User-friendly label
config Json // Encrypted sensitive values config Json // Encrypted sensitive values
events Json // Array of subscribed events events Json // Array of subscribed events
@@ -35,6 +35,7 @@ model NotificationBackend {
| request_approved | Admin approves OR auto-approval | Request approved (manual or auto) | | request_approved | Admin approves OR auto-approval | Request approved (manual or auto) |
| request_available | Plex/ABS scan completes | Audiobook available in library | | request_available | Plex/ABS scan completes | Audiobook available in library |
| request_error | Download/import fails | Request failed at any stage | | request_error | Download/import fails | Request failed at any stage |
| issue_reported | User reports issue | User reports problem with available audiobook |
## Notification Triggers ## Notification Triggers
@@ -67,10 +68,16 @@ model NotificationBackend {
- After `status: 'failed'` or `status: 'warn'` update → request_error - After `status: 'failed'` or `status: 'warn'` update → request_error
- Includes error message in payload - Includes error message in payload
**Issue Reported (reported-issue.service.ts)**
- After user reports issue with available audiobook → issue_reported
- Payload: issue ID (as requestId), book title/author, reporter username, reason (as message)
## Configuration Encryption ## Configuration Encryption
**Encrypted Values:** **Encrypted Values:**
- Apprise: `urls`, `authToken`
- Discord: `webhookUrl` - Discord: `webhookUrl`
- ntfy: `accessToken`
- Pushover: `userKey`, `appToken` - Pushover: `userKey`, `appToken`
**Pattern:** `iv:authTag:encryptedData` (base64) **Pattern:** `iv:authTag:encryptedData` (base64)
@@ -81,12 +88,27 @@ model NotificationBackend {
## Message Formatting ## Message Formatting
**Apprise (JSON via Apprise API):**
- Type: info (pending), success (approved/available), failure (error)
- Modes: Stateless (send URLs directly) or Stateful (use persistent configKey, optional tag filter)
- Endpoint: `{serverUrl}/notify/` (stateless) or `{serverUrl}/notify/{configKey}` (stateful)
- Auth: Optional Bearer token via `authToken` config field
- Format: Event title + book details + user + error (if applicable)
**Discord (Rich Embeds):** **Discord (Rich Embeds):**
- Color-coded by event (yellow=pending, green=approved, blue=available, red=error) - Color-coded by event (yellow=pending, green=approved, blue=available, red=error, orange=issue)
- Fields: Title, Author, Requested By, Error (if applicable) - Fields: Title, Author, Requested/Reported By, Error/Reason (if applicable)
- Footer: Request ID - Footer: Request/Issue ID
- Timestamp: Event time - Timestamp: Event time
**ntfy (JSON Publishing to Base URL):**
- Endpoint: POST to base `serverUrl` (default: https://ntfy.sh), topic in JSON body
- Tags: mailbox_with_mail, white_check_mark, tada, x, triangular_flag_on_post (rendered as emojis by ntfy)
- Priority: Default (3) for pending/approved, High (4) for available/error
- Format: Event title + book details + user + error (if applicable)
- Auth: Optional Bearer token via `accessToken` config field
- Server: Configurable `serverUrl` (default: https://ntfy.sh)
**Pushover (Plain Text with Emojis):** **Pushover (Plain Text with Emojis):**
- Emojis: 📬 📬 🎉 ❌ - Emojis: 📬 📬 🎉 ❌
- Priority: Normal (0) for pending/approved, High (1) for available/error - Priority: Normal (0) for pending/approved, High (1) for available/error
@@ -126,7 +148,7 @@ model NotificationBackend {
**Modal Features:** **Modal Features:**
- Type-first selection (user clicks "Add Discord" or "Add Pushover") - Type-first selection (user clicks "Add Discord" or "Add Pushover")
- Password inputs for sensitive values - Password inputs for sensitive values
- Event subscription checkboxes (4 events, default: available + error) - Event subscription checkboxes (5 events, default: available + error)
- Test button (sends synchronous test notification) - Test button (sends synchronous test notification)
- Save button (validates and creates/updates backend) - Save button (validates and creates/updates backend)
@@ -154,15 +176,49 @@ model NotificationBackend {
**Queue Method:** `addNotificationJob(event, requestId, title, author, userName, message?)` **Queue Method:** `addNotificationJob(event, requestId, title, author, userName, message?)`
## Architecture
**Provider Pattern:** `INotificationProvider` interface + registry (matches `IAuthProvider` pattern)
```
src/lib/services/notification/
INotificationProvider.ts # Interface + shared types
notification.service.ts # Core service with registry
index.ts # Re-exports
providers/
apprise.provider.ts # Apprise API (100+ services)
discord.provider.ts # Discord webhook
ntfy.provider.ts # ntfy API
pushover.provider.ts # Pushover API
```
**Registry:** Module-level `Map<string, INotificationProvider>` with `registerProvider()` / `getProvider()`
**INotificationProvider interface:**
- `type: string` — provider identifier (registry key)
- `sensitiveFields: string[]` — fields needing encryption/masking
- `metadata: ProviderMetadata` — self-describing UI/validation metadata
- `send(config, payload): Promise<void>` — receives decrypted config
**ProviderMetadata:** `{ type, displayName, description, iconLabel, iconColor, configFields[] }`
**ProviderConfigField:** `{ name, label, type, required, placeholder?, defaultValue?, options? }`
**Helper functions:**
- `getRegisteredProviderTypes(): string[]` — all registered type keys
- `getAllProviderMetadata(): ProviderMetadata[]` — metadata for all providers
**API Endpoint:** `GET /api/admin/notifications/providers` — returns all provider metadata (admin-only)
## Extensibility ## Extensibility
**Adding New Backend (e.g., Email):** **Adding New Backend (2 steps):**
1. Add 'email' to NotificationBackendType enum 1. Create `providers/email.provider.ts` implementing `INotificationProvider`:
2. Create EmailConfig interface - Set `type = 'email'`, `sensitiveFields = ['smtpPassword']`
3. Add encryption logic for smtpPassword - Set `metadata` with displayName, description, iconLabel, iconColor, configFields
4. Implement sendEmail() method in NotificationService - Implement `send()` with email-specific logic
5. Add email card to type selector (green "E" badge) 2. Register in `notification.service.ts`: `registerProvider(new EmailProvider())` + re-export from `index.ts`
6. Add email form fields to modal
No UI changes, no API route changes, no Zod schema changes needed — the UI renders dynamically from provider metadata.
**Adding New Event (e.g., download_complete):** **Adding New Event (e.g., download_complete):**
1. Add 'download_complete' to NotificationEvent enum 1. Add 'download_complete' to NotificationEvent enum
@@ -173,7 +229,7 @@ model NotificationBackend {
## Tech Stack ## Tech Stack
- Bull (job queue) - Bull (job queue)
- Node.js crypto (AES-256-GCM encryption) - Node.js crypto (AES-256-GCM encryption)
- Discord webhooks, Pushover API - Apprise API, Discord webhooks, ntfy API, Pushover API
- React (UI), Tailwind CSS (styling) - React (UI), Tailwind CSS (styling)
## Related ## Related
@@ -200,32 +200,23 @@ export async function POST(req: NextRequest) {
.map((m: any) => ({ id: m.id, name: m.id })); .map((m: any) => ({ id: m.id, name: m.id }));
} else if (provider === 'claude') { } else if (provider === 'claude') {
// Claude: Hardcoded list (Anthropic doesn't have a models API endpoint) // Claude: Fetch models dynamically from the Anthropic Models API
models = [ const response = await fetch('https://api.anthropic.com/v1/models?limit=1000', {
{ id: 'claude-sonnet-4-5-20250929', name: 'Claude Sonnet 4.5' },
{ id: 'claude-3-7-sonnet-20250219', name: 'Claude 3.7 Sonnet' },
{ id: 'claude-3-5-haiku-20241022', name: 'Claude 3.5 Haiku' },
{ id: 'claude-opus-4-20250514', name: 'Claude Opus 4' },
];
// Test connection with a simple API call
const response = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: { headers: {
'x-api-key': apiKey, 'x-api-key': apiKey,
'anthropic-version': '2023-06-01', 'anthropic-version': '2023-06-01',
'content-type': 'application/json'
}, },
body: JSON.stringify({
model: 'claude-3-5-haiku-20241022',
max_tokens: 10,
messages: [{ role: 'user', content: 'Hi' }]
})
}); });
if (!response.ok) { if (!response.ok) {
return NextResponse.json({ error: 'Invalid Claude API key' }, { status: 400 }); return NextResponse.json({ error: 'Invalid Claude API key' }, { status: 400 });
} }
const data = await response.json();
models = data.data.map((m: any) => ({
id: m.id,
name: m.display_name || m.id,
}));
} else { } else {
return NextResponse.json({ error: 'Invalid provider' }, { status: 400 }); return NextResponse.json({ error: 'Invalid provider' }, { status: 400 });
} }
+1 -1
View File
@@ -6,7 +6,7 @@
Personalized audiobook discovery using OpenAI/Claude APIs. Admin configures AI provider globally. Users swipe through recommendations based on their individual Plex library + swipe history. Right swipe creates request, left rejects, up dismisses. Personalized audiobook discovery using OpenAI/Claude APIs. Admin configures AI provider globally. Users swipe through recommendations based on their individual Plex library + swipe history. Right swipe creates request, left rejects, up dismisses.
## Key Details ## Key Details
- **AI Providers:** OpenAI (GPT-4o+), Claude (Sonnet 4.5, Opus 4, Haiku) - **AI Providers:** OpenAI (GPT-4+), Claude (dynamically fetched from Anthropic Models API)
- **Configuration:** Global admin-managed (provider, model, API key), per-user preferences (library scope, custom prompt) - **Configuration:** Global admin-managed (provider, model, API key), per-user preferences (library scope, custom prompt)
- **Personalization:** Each user receives recommendations based on their own library, ratings, swipe history, and custom preferences - **Personalization:** Each user receives recommendations based on their own library, ratings, swipe history, and custom preferences
- **Library Scopes (per-user):** - **Library Scopes (per-user):**
+1
View File
@@ -32,6 +32,7 @@ Configurable Audible region for accurate metadata matching across different inte
- Australia (`au`) - `audible.com.au` (English) - Australia (`au`) - `audible.com.au` (English)
- India (`in`) - `audible.in` (English) - India (`in`) - `audible.in` (English)
- Germany (`de`) - `audible.de` (non-English) - Germany (`de`) - `audible.de` (non-English)
- Spain (`es`) - `audible.es` (non-English)
**`isEnglish` Flag:** **`isEnglish` Flag:**
- Each region has `isEnglish: boolean` in `AudibleRegionConfig` - Each region has `isEnglish: boolean` in `AudibleRegionConfig`
+1 -1
View File
@@ -208,7 +208,7 @@ async function organize(
## Fixed Issues ✅ ## Fixed Issues ✅
**1. EPERM errors** - Fixed with `fs.readFile/writeFile` instead of `copyFile` **1. EPERM errors** - Fixed with stream-based copy (`pipeline` + `createReadStream`/`createWriteStream`) instead of `fs.copyFile()` which uses `copy_file_range()` — a syscall that returns EPERM on cross-export NFS4 and some FUSE mounts
**2. Immediate deletion** - Changed to copy-only, scheduled cleanup after seeding **2. Immediate deletion** - Changed to copy-only, scheduled cleanup after seeding
**3. Files moved not copied** - Now copies to support seeding **3. Files moved not copied** - Now copies to support seeding
**4. Single file downloads** - Now supports files directly in downloads folder (not just directories) **4. Single file downloads** - Now supports files directly in downloads folder (not just directories)
+14 -8
View File
@@ -175,19 +175,19 @@ interface TorrentInfo {
} }
type TorrentState = type TorrentState =
// Core states // Core states (*DL = download phase, *UP = upload/post-download phase)
| 'downloading' | 'uploading' | 'downloading' | 'uploading'
| 'stalledDL' | 'stalledUP' | 'stalledDL' | 'stalledUP' // stalledUP → completed (download done)
| 'pausedDL' | 'pausedUP' | 'pausedDL' | 'pausedUP' // pausedUP → completed (download done, paused seeding)
| 'queuedDL' | 'queuedUP' | 'queuedDL' | 'queuedUP' // queuedUP → completed (download done)
| 'checkingDL' | 'checkingUP' | 'checkingDL' | 'checkingUP' // checkingUP → completed (download done, rechecking)
| 'error' | 'missingFiles' | 'allocating' | 'error' | 'missingFiles' | 'allocating'
// Forced states (user clicked "Force Resume") // Forced states (user clicked "Force Resume")
| 'forcedDL' | 'forcedUP' | 'forcedDL' | 'forcedUP' // forcedUP → completed (download done)
// Metadata fetching // Metadata fetching
| 'metaDL' | 'forcedMetaDL' | 'metaDL' | 'forcedMetaDL'
// qBittorrent v5.0+ (renamed paused → stopped) // qBittorrent v5.0+ (renamed paused → stopped)
| 'stoppedDL' | 'stoppedUP' | 'stoppedDL' | 'stoppedUP' // stoppedUP → completed (download done)
// Other // Other
| 'checkingResumeData' | 'moving'; | 'checkingResumeData' | 'moving';
``` ```
@@ -241,7 +241,13 @@ type TorrentState =
- Adding all 8 missing states to `TorrentState` type union - Adding all 8 missing states to `TorrentState` type union
- Adding mappings to both `mapState()` (legacy) and `mapStateToDownloadStatus()` (unified interface) - Adding mappings to both `mapState()` (legacy) and `mapStateToDownloadStatus()` (unified interface)
- `forcedUP``seeding`/`completed` enables monitor to trigger import - `forcedUP``seeding`/`completed` enables monitor to trigger import
- `stoppedDL`/`stoppedUP``paused` ensures qBittorrent v5.x compatibility - `stoppedDL``paused` ensures qBittorrent v5.x compatibility
**16. pausedUP/stoppedUP mapped as paused instead of completed** - RDT-Client (and qBittorrent after ratio limits) transitions directly to `pausedUP`/`stoppedUP` without passing through `uploading`/`stalledUP`. The `*UP` suffix means the download phase is complete and the torrent is on the upload side. Both states were incorrectly mapped to `'paused'`, causing the monitor to re-schedule checks indefinitely instead of triggering file organization. Fixed by:
- `pausedUP``seeding` (unified) / `completed` (legacy) — triggers completion in monitor
- `stoppedUP``seeding` (unified) / `completed` (legacy) — same fix for qBittorrent v5.x
- `pausedDL`/`stoppedDL` remain `paused` — download phase genuinely paused
- Key insight: any `*UP` state is post-download; any `*DL` state is pre-completion
## Tech Stack ## Tech Stack
+1 -1
View File
@@ -271,7 +271,7 @@ src/app/admin/settings/
**PUT /api/admin/settings/audible** **PUT /api/admin/settings/audible**
- Updates Audible region - Updates Audible region
- Body: `{ region: string }` (one of: us, ca, uk, au, in) - Body: `{ region: string }` (one of: us, ca, uk, au, in, es)
- No validation required - No validation required
**PUT /api/admin/settings/prowlarr/indexers** **PUT /api/admin/settings/prowlarr/indexers**
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "readmeabook", "name": "readmeabook",
"version": "1.0.4", "version": "1.0.7",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
@@ -0,0 +1,46 @@
-- CreateTable
CREATE TABLE "goodreads_shelves" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"rss_url" TEXT NOT NULL,
"last_sync_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "goodreads_shelves_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "goodreads_book_mappings" (
"id" TEXT NOT NULL,
"goodreads_book_id" TEXT NOT NULL,
"title" TEXT NOT NULL,
"author" TEXT NOT NULL,
"audible_asin" TEXT,
"cover_url" TEXT,
"no_match" BOOLEAN NOT NULL DEFAULT false,
"last_search_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "goodreads_book_mappings_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "goodreads_shelves_user_id_idx" ON "goodreads_shelves"("user_id");
-- CreateIndex
CREATE UNIQUE INDEX "goodreads_shelves_user_id_rss_url_key" ON "goodreads_shelves"("user_id", "rss_url");
-- CreateIndex
CREATE UNIQUE INDEX "goodreads_book_mappings_goodreads_book_id_key" ON "goodreads_book_mappings"("goodreads_book_id");
-- CreateIndex
CREATE INDEX "goodreads_book_mappings_goodreads_book_id_idx" ON "goodreads_book_mappings"("goodreads_book_id");
-- CreateIndex
CREATE INDEX "goodreads_book_mappings_audible_asin_idx" ON "goodreads_book_mappings"("audible_asin");
-- AddForeignKey
ALTER TABLE "goodreads_shelves" ADD CONSTRAINT "goodreads_shelves_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,3 @@
-- Add cached book count and cover URLs to goodreads_shelves for rich UI display
ALTER TABLE "goodreads_shelves" ADD COLUMN "book_count" INTEGER;
ALTER TABLE "goodreads_shelves" ADD COLUMN "cover_urls" TEXT;
@@ -0,0 +1,32 @@
-- CreateTable
CREATE TABLE "reported_issues" (
"id" TEXT NOT NULL,
"audiobook_id" TEXT NOT NULL,
"reporter_id" TEXT NOT NULL,
"reason" VARCHAR(250) NOT NULL,
"status" TEXT NOT NULL DEFAULT 'open',
"resolved_at" TIMESTAMP(3),
"resolved_by_id" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "reported_issues_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "reported_issues_audiobook_id_idx" ON "reported_issues"("audiobook_id");
-- CreateIndex
CREATE INDEX "reported_issues_reporter_id_idx" ON "reported_issues"("reporter_id");
-- CreateIndex
CREATE INDEX "reported_issues_status_idx" ON "reported_issues"("status");
-- AddForeignKey
ALTER TABLE "reported_issues" ADD CONSTRAINT "reported_issues_audiobook_id_fkey" FOREIGN KEY ("audiobook_id") REFERENCES "audiobooks"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "reported_issues" ADD CONSTRAINT "reported_issues_reporter_id_fkey" FOREIGN KEY ("reporter_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "reported_issues" ADD CONSTRAINT "reported_issues_resolved_by_id_fkey" FOREIGN KEY ("resolved_by_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+73 -1
View File
@@ -64,6 +64,9 @@ model User {
requests Request[] requests Request[]
bookDateRecommendations BookDateRecommendation[] bookDateRecommendations BookDateRecommendation[]
bookDateSwipes BookDateSwipe[] bookDateSwipes BookDateSwipe[]
goodreadsShelves GoodreadsShelf[]
reportedIssues ReportedIssue[] @relation("Reporter")
resolvedIssues ReportedIssue[] @relation("Resolver")
@@index([plexId]) @@index([plexId])
@@index([role]) @@index([role])
@@ -197,7 +200,8 @@ model Audiobook {
completedAt DateTime? @map("completed_at") completedAt DateTime? @map("completed_at")
// Relations // Relations
requests Request[] requests Request[]
reportedIssues ReportedIssue[]
@@index([audibleAsin]) @@index([audibleAsin])
@@index([plexGuid]) @@index([plexGuid])
@@ -456,3 +460,71 @@ model NotificationBackend {
@@index([enabled]) @@index([enabled])
@@map("notification_backends") @@map("notification_backends")
} }
// ============================================================================
// REPORTED ISSUES TABLE
// User-reported problems with available audiobooks (corrupted, wrong book, etc.)
// ============================================================================
model ReportedIssue {
id String @id @default(uuid())
audiobookId String @map("audiobook_id")
reporterId String @map("reporter_id")
reason String @db.VarChar(250)
status String @default("open") // open, dismissed, replaced
resolvedAt DateTime? @map("resolved_at")
resolvedById String? @map("resolved_by_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relations
audiobook Audiobook @relation(fields: [audiobookId], references: [id], onDelete: Cascade)
reporter User @relation("Reporter", fields: [reporterId], references: [id], onDelete: Cascade)
resolvedBy User? @relation("Resolver", fields: [resolvedById], references: [id], onDelete: SetNull)
@@index([audiobookId])
@@index([reporterId])
@@index([status])
@@map("reported_issues")
}
// ============================================================================
// GOODREADS SYNC TABLES
// Per-user Goodreads shelf subscriptions + global book-to-ASIN mapping cache
// ============================================================================
model GoodreadsShelf {
id String @id @default(uuid())
userId String @map("user_id")
name String // Extracted from RSS <title>
rssUrl String @map("rss_url") @db.Text
lastSyncAt DateTime? @map("last_sync_at")
bookCount Int? @map("book_count")
coverUrls String? @map("cover_urls") @db.Text // JSON array of cover image URLs
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relations
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, rssUrl])
@@index([userId])
@@map("goodreads_shelves")
}
model GoodreadsBookMapping {
id String @id @default(uuid())
goodreadsBookId String @unique @map("goodreads_book_id")
title String
author String
audibleAsin String? @map("audible_asin")
coverUrl String? @map("cover_url") @db.Text
noMatch Boolean @default(false) @map("no_match")
lastSearchAt DateTime? @map("last_search_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@index([goodreadsBookId])
@@index([audibleAsin])
@@map("goodreads_book_mappings")
}
@@ -0,0 +1,242 @@
/**
* Component: Admin Reported Issues Section
* Documentation: documentation/backend/services/reported-issues.md
*
* Displays open reported issues on the admin dashboard.
* Allows dismiss or search-for-replacement actions.
*/
'use client';
import React, { useState } from 'react';
import { createPortal } from 'react-dom';
import { useToast } from '@/components/ui/Toast';
import { formatDistanceToNow } from 'date-fns';
import { InteractiveTorrentSearchModal } from '@/components/requests/InteractiveTorrentSearchModal';
import { fetchJSON } from '@/lib/utils/api';
import { mutate } from 'swr';
interface ReportedIssue {
id: string;
reason: string;
status: string;
createdAt: string;
audiobook: {
id: string;
title: string;
author: string;
coverArtUrl: string | null;
audibleAsin: string | null;
};
reporter: {
id: string;
plexUsername: string;
avatarUrl: string | null;
};
}
interface ReportedIssuesSectionProps {
issues: ReportedIssue[];
}
export function ReportedIssuesSection({ issues }: ReportedIssuesSectionProps) {
const toast = useToast();
const [loadingStates, setLoadingStates] = useState<Record<string, boolean>>({});
const [replaceIssue, setReplaceIssue] = useState<ReportedIssue | null>(null);
const handleDismiss = async (issueId: string) => {
setLoadingStates((prev) => ({ ...prev, [issueId]: true }));
try {
await fetchJSON(`/api/admin/reported-issues/${issueId}/resolve`, {
method: 'POST',
body: JSON.stringify({ action: 'dismiss' }),
});
toast.success('Issue dismissed');
await mutate((key: unknown) => typeof key === 'string' && key.includes('/api/admin/reported-issues'));
} catch (error) {
toast.error(
`Failed to dismiss issue: ${error instanceof Error ? error.message : 'Unknown error'}`
);
} finally {
setLoadingStates((prev) => ({ ...prev, [issueId]: false }));
}
};
const handleReplaceSuccess = async () => {
toast.success('Replacement download started');
setReplaceIssue(null);
await mutate((key: unknown) => typeof key === 'string' && key.includes('/api/admin/reported-issues'));
await mutate((key: unknown) => typeof key === 'string' && key.includes('/api/admin/metrics'));
};
return (
<>
<div className="mb-8">
{/* Section Header */}
<div className="flex items-center gap-3 mb-4">
<div className="flex items-center gap-2">
<svg
className="w-6 h-6 text-orange-600 dark:text-orange-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 21v-4m0 0V5a2 2 0 012-2h6.5l1 1H21l-3 6 3 6h-8.5l-1-1H5a2 2 0 00-2 2zm9-13.5V9"
/>
</svg>
<h2 className="text-xl font-bold text-gray-900 dark:text-gray-100">
Reported Issues
</h2>
</div>
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200">
{issues.length}
</span>
</div>
{/* Issues Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{issues.map((issue) => {
const isLoading = loadingStates[issue.id] || false;
return (
<div
key={issue.id}
className="bg-white dark:bg-gray-800 border-2 border-orange-200 dark:border-orange-800 rounded-lg shadow-sm hover:shadow-md transition-shadow overflow-hidden"
>
{/* Card Content */}
<div className="p-4">
<div className="flex gap-3">
{/* Cover Image */}
<div className="flex-shrink-0">
{issue.audiobook.coverArtUrl ? (
<img
src={issue.audiobook.coverArtUrl}
alt={issue.audiobook.title}
className="w-16 h-16 rounded object-cover"
/>
) : (
<div className="w-16 h-16 rounded bg-gray-200 dark:bg-gray-700 flex items-center justify-center">
<svg
className="w-8 h-8 text-gray-400 dark:text-gray-600"
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M9 4.804A7.968 7.968 0 005.5 4c-1.255 0-2.443.29-3.5.804v10A7.969 7.969 0 015.5 14c1.669 0 3.218.51 4.5 1.385A7.962 7.962 0 0114.5 14c1.255 0 2.443.29 3.5.804v-10A7.968 7.968 0 0014.5 4c-1.255 0-2.443.29-3.5.804V12a1 1 0 11-2 0V4.804z" />
</svg>
</div>
)}
</div>
{/* Info */}
<div className="flex-1 min-w-0">
<h3 className="text-sm font-bold text-gray-900 dark:text-gray-100 truncate">
{issue.audiobook.title}
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 truncate">
{issue.audiobook.author}
</p>
{/* Reporter */}
<div className="flex items-center gap-2 mt-2">
{issue.reporter.avatarUrl ? (
<img
src={issue.reporter.avatarUrl}
alt={issue.reporter.plexUsername}
className="w-5 h-5 rounded-full"
/>
) : (
<div className="w-5 h-5 rounded-full bg-gray-300 dark:bg-gray-600 flex items-center justify-center">
<svg
className="w-3 h-3 text-gray-600 dark:text-gray-400"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z"
clipRule="evenodd"
/>
</svg>
</div>
)}
<span className="text-xs text-gray-600 dark:text-gray-400">
{issue.reporter.plexUsername}
</span>
</div>
{/* Timestamp */}
<p className="text-xs text-gray-500 dark:text-gray-500 mt-1">
{formatDistanceToNow(new Date(issue.createdAt), { addSuffix: true })}
</p>
</div>
</div>
{/* Reason */}
<p className="mt-3 text-sm text-gray-700 dark:text-gray-300 line-clamp-2 break-words bg-orange-50 dark:bg-orange-900/20 rounded-lg px-3 py-2 border border-orange-100 dark:border-orange-800/50">
{issue.reason}
</p>
</div>
{/* Action Buttons */}
<div className="border-t border-orange-200 dark:border-orange-800 bg-gray-50 dark:bg-gray-900/50 px-4 py-3 flex gap-2">
<button
onClick={() => handleDismiss(issue.id)}
disabled={isLoading}
className="flex-1 inline-flex items-center justify-center gap-2 px-3 py-2 bg-gray-500 hover:bg-gray-600 disabled:bg-gray-400 disabled:cursor-not-allowed text-white text-sm font-medium rounded-lg transition-colors"
>
{isLoading ? (
<svg className="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
) : (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
)}
<span>Dismiss</span>
</button>
<button
onClick={() => setReplaceIssue(issue)}
disabled={isLoading}
className="flex-1 inline-flex items-center justify-center gap-2 px-3 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 disabled:cursor-not-allowed text-white text-sm font-medium rounded-lg transition-colors"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<span>Replace</span>
</button>
</div>
</div>
);
})}
</div>
</div>
{/* Interactive Search Modal for Replacement */}
{replaceIssue && createPortal(
<div className="fixed inset-0 z-[60]">
<InteractiveTorrentSearchModal
isOpen={!!replaceIssue}
onClose={() => setReplaceIssue(null)}
onSuccess={handleReplaceSuccess}
audiobook={{
title: replaceIssue.audiobook.title,
author: replaceIssue.audiobook.author,
}}
asin={replaceIssue.audiobook.audibleAsin || undefined}
replaceIssueId={replaceIssue.id}
/>
</div>,
document.body
)}
</>
);
}
+14
View File
@@ -12,6 +12,7 @@ import { MetricCard } from './components/MetricCard';
import { ActiveDownloadsTable } from './components/ActiveDownloadsTable'; import { ActiveDownloadsTable } from './components/ActiveDownloadsTable';
import { RecentRequestsTable } from './components/RecentRequestsTable'; import { RecentRequestsTable } from './components/RecentRequestsTable';
import { ToastProvider, useToast } from '@/components/ui/Toast'; import { ToastProvider, useToast } from '@/components/ui/Toast';
import { ReportedIssuesSection } from './components/ReportedIssuesSection';
import { formatDistanceToNow } from 'date-fns'; import { formatDistanceToNow } from 'date-fns';
import { useState } from 'react'; import { useState } from 'react';
@@ -328,6 +329,14 @@ function AdminDashboardContent() {
} }
); );
const { data: reportedIssuesData } = useSWR(
'/api/admin/reported-issues',
authenticatedFetcher,
{
refreshInterval: 10000,
}
);
const { data: settingsData } = useSWR( const { data: settingsData } = useSWR(
'/api/admin/settings', '/api/admin/settings',
authenticatedFetcher, authenticatedFetcher,
@@ -578,6 +587,11 @@ function AdminDashboardContent() {
<PendingApprovalSection requests={pendingApprovalData.requests} /> <PendingApprovalSection requests={pendingApprovalData.requests} />
)} )}
{/* Reported Issues */}
{reportedIssuesData?.issues && reportedIssuesData.issues.length > 0 && (
<ReportedIssuesSection issues={reportedIssuesData.issues} />
)}
{/* Active Downloads */} {/* Active Downloads */}
<div className="mb-8"> <div className="mb-8">
<h2 className="text-xl font-bold text-gray-900 dark:text-gray-100 mb-4"> <h2 className="text-xl font-bold text-gray-900 dark:text-gray-100 mb-4">
@@ -3,9 +3,29 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { RMABLogger } from '@/lib/utils/logger'; import { RMABLogger } from '@/lib/utils/logger';
import { fetchWithAuth } from '@/lib/utils/api'; import { fetchWithAuth } from '@/lib/utils/api';
import { EVENT_LABELS } from '@/lib/constants/notification-events';
const logger = RMABLogger.create('NotificationsTab'); const logger = RMABLogger.create('NotificationsTab');
interface ProviderConfigField {
name: string;
label: string;
type: 'text' | 'password' | 'select' | 'number';
required: boolean;
placeholder?: string;
defaultValue?: string | number;
options?: { label: string; value: string | number }[];
}
interface ProviderMetadata {
type: string;
displayName: string;
description: string;
iconLabel: string;
iconColor: string;
configFields: ProviderConfigField[];
}
interface NotificationBackend { interface NotificationBackend {
id: string; id: string;
type: string; type: string;
@@ -24,24 +44,11 @@ interface ModalState {
backend?: NotificationBackend; backend?: NotificationBackend;
} }
const typeColors: Record<string, string> = { const eventLabels: Record<string, string> = EVENT_LABELS;
discord: 'bg-indigo-500',
pushover: 'bg-blue-500',
email: 'bg-green-500',
slack: 'bg-purple-500',
telegram: 'bg-sky-500',
webhook: 'bg-gray-500',
};
const eventLabels: Record<string, string> = {
request_pending_approval: 'Request Pending Approval',
request_approved: 'Request Approved',
request_available: 'Audiobook Available',
request_error: 'Request Error',
};
export function NotificationsTab() { export function NotificationsTab() {
const [backends, setBackends] = useState<NotificationBackend[]>([]); const [backends, setBackends] = useState<NotificationBackend[]>([]);
const [providerMetadata, setProviderMetadata] = useState<ProviderMetadata[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [modalState, setModalState] = useState<ModalState>({ const [modalState, setModalState] = useState<ModalState>({
isOpen: false, isOpen: false,
@@ -59,8 +66,23 @@ export function NotificationsTab() {
useEffect(() => { useEffect(() => {
fetchBackends(); fetchBackends();
fetchProviderMetadata();
}, []); }, []);
const fetchProviderMetadata = async () => {
try {
const response = await fetchWithAuth('/api/admin/notifications/providers');
if (response.ok) {
const data = await response.json();
if (data.success) {
setProviderMetadata(data.providers);
}
}
} catch (error) {
logger.error('Failed to fetch provider metadata', { error: error instanceof Error ? error.message : String(error) });
}
};
const fetchBackends = async () => { const fetchBackends = async () => {
try { try {
setLoading(true); setLoading(true);
@@ -83,11 +105,23 @@ export function NotificationsTab() {
} }
}; };
const getMetadataForType = (type: string): ProviderMetadata | undefined => {
return providerMetadata.find((p) => p.type === type);
};
const openAddModal = (type: string) => { const openAddModal = (type: string) => {
const meta = getMetadataForType(type);
const defaultConfig: Record<string, any> = {};
if (meta) {
for (const field of meta.configFields) {
defaultConfig[field.name] = field.defaultValue ?? '';
}
}
setModalState({ isOpen: true, mode: 'add', selectedType: type }); setModalState({ isOpen: true, mode: 'add', selectedType: type });
setFormData({ setFormData({
name: `${type.charAt(0).toUpperCase() + type.slice(1)} Notifications`, name: `${meta?.displayName ?? type} Notifications`,
config: type === 'discord' ? { webhookUrl: '', username: 'ReadMeABook', avatarUrl: '' } : { userKey: '', appToken: '', device: '', priority: 0 }, config: defaultConfig,
events: ['request_available', 'request_error'], events: ['request_available', 'request_error'],
enabled: true, enabled: true,
}); });
@@ -193,6 +227,49 @@ export function NotificationsTab() {
} }
}; };
const renderConfigField = (field: ProviderConfigField) => {
if (field.type === 'select' && field.options) {
return (
<div key={field.name}>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{field.label}{field.required ? ' *' : ''}
</label>
<select
value={formData.config[field.name] ?? field.defaultValue ?? ''}
onChange={(e) => {
const value = field.options?.some((o) => typeof o.value === 'number')
? Number(e.target.value)
: e.target.value;
setFormData({ ...formData, config: { ...formData.config, [field.name]: value } });
}}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
>
{field.options.map((opt) => (
<option key={String(opt.value)} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
);
}
return (
<div key={field.name}>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{field.label}{field.required ? ' *' : field.label.includes('optional') ? '' : ' (optional)'}
</label>
<input
type={field.type === 'password' ? 'password' : 'text'}
value={formData.config[field.name] ?? ''}
onChange={(e) => setFormData({ ...formData, config: { ...formData.config, [field.name]: e.target.value } })}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
placeholder={field.placeholder}
/>
</div>
);
};
const currentMeta = modalState.selectedType ? getMetadataForType(modalState.selectedType) : undefined;
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Header */} {/* Header */}
@@ -206,32 +283,22 @@ export function NotificationsTab() {
{/* Type Selector */} {/* Type Selector */}
<div> <div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">Add Notification Backend</h3> <h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">Add Notification Backend</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<button {providerMetadata.map((meta) => (
onClick={() => openAddModal('discord')} <button
className="flex items-center p-4 bg-white dark:bg-gray-800 rounded-lg border-2 border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600 transition-colors" key={meta.type}
> onClick={() => openAddModal(meta.type)}
<div className="flex-shrink-0 w-12 h-12 bg-indigo-500 rounded-lg flex items-center justify-center text-white font-bold text-2xl"> className="flex items-center p-4 bg-white dark:bg-gray-800 rounded-lg border-2 border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600 transition-colors"
D >
</div> <div className={`flex-shrink-0 w-12 h-12 ${meta.iconColor} rounded-lg flex items-center justify-center text-white font-bold text-2xl`}>
<div className="ml-4 text-left"> {meta.iconLabel}
<div className="font-semibold text-gray-900 dark:text-white">Discord</div> </div>
<div className="text-sm text-gray-600 dark:text-gray-400">Send notifications via Discord webhook</div> <div className="ml-4 text-left">
</div> <div className="font-semibold text-gray-900 dark:text-white">{meta.displayName}</div>
</button> <div className="text-sm text-gray-600 dark:text-gray-400">{meta.description}</div>
</div>
<button </button>
onClick={() => openAddModal('pushover')} ))}
className="flex items-center p-4 bg-white dark:bg-gray-800 rounded-lg border-2 border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600 transition-colors"
>
<div className="flex-shrink-0 w-12 h-12 bg-blue-500 rounded-lg flex items-center justify-center text-white font-bold text-2xl">
P
</div>
<div className="ml-4 text-left">
<div className="font-semibold text-gray-900 dark:text-white">Pushover</div>
<div className="text-sm text-gray-600 dark:text-gray-400">Send notifications via Pushover API</div>
</div>
</button>
</div> </div>
</div> </div>
@@ -244,43 +311,46 @@ export function NotificationsTab() {
<p className="text-gray-600 dark:text-gray-400">No notification backends configured.</p> <p className="text-gray-600 dark:text-gray-400">No notification backends configured.</p>
) : ( ) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{backends.map((backend) => ( {backends.map((backend) => {
<div key={backend.id} className="bg-white dark:bg-gray-800 rounded-lg shadow-md border border-gray-200 dark:border-gray-700 p-4 hover:shadow-lg transition-shadow"> const meta = getMetadataForType(backend.type);
<div className="flex items-start justify-between mb-3"> return (
<div className="flex items-center space-x-3"> <div key={backend.id} className="bg-white dark:bg-gray-800 rounded-lg shadow-md border border-gray-200 dark:border-gray-700 p-4 hover:shadow-lg transition-shadow">
<div className={`w-10 h-10 ${typeColors[backend.type]} rounded-lg flex items-center justify-center text-white font-bold`}> <div className="flex items-start justify-between mb-3">
{backend.type.charAt(0).toUpperCase()} <div className="flex items-center space-x-3">
</div> <div className={`w-10 h-10 ${meta?.iconColor ?? 'bg-gray-500'} rounded-lg flex items-center justify-center text-white font-bold`}>
<div> {meta?.iconLabel ?? backend.type.charAt(0).toUpperCase()}
<div className="font-semibold text-gray-900 dark:text-white truncate">{backend.name}</div> </div>
<div className="text-xs text-gray-500 dark:text-gray-400 capitalize">{backend.type}</div> <div>
<div className="font-semibold text-gray-900 dark:text-white truncate">{backend.name}</div>
<div className="text-xs text-gray-500 dark:text-gray-400">{meta?.displayName ?? backend.type}</div>
</div>
</div> </div>
</div> </div>
</div> <div className="space-y-2 mb-3">
<div className="space-y-2 mb-3"> <div className={`inline-block px-2 py-1 rounded text-xs ${backend.enabled ? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200' : 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300'}`}>
<div className={`inline-block px-2 py-1 rounded text-xs ${backend.enabled ? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200' : 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300'}`}> {backend.enabled ? 'Enabled' : 'Disabled'}
{backend.enabled ? 'Enabled' : 'Disabled'} </div>
<div className="text-sm text-gray-600 dark:text-gray-400">
{backend.events.length} {backend.events.length === 1 ? 'event' : 'events'} subscribed
</div>
</div> </div>
<div className="text-sm text-gray-600 dark:text-gray-400"> <div className="flex space-x-2">
{backend.events.length} {backend.events.length === 1 ? 'event' : 'events'} subscribed <button
onClick={() => openEditModal(backend)}
className="flex-1 px-3 py-1.5 text-sm bg-blue-600 hover:bg-blue-700 text-white rounded transition-colors"
>
Edit
</button>
<button
onClick={() => handleDelete(backend.id)}
className="flex-1 px-3 py-1.5 text-sm bg-red-600 hover:bg-red-700 text-white rounded transition-colors"
>
Delete
</button>
</div> </div>
</div> </div>
<div className="flex space-x-2"> );
<button })}
onClick={() => openEditModal(backend)}
className="flex-1 px-3 py-1.5 text-sm bg-blue-600 hover:bg-blue-700 text-white rounded transition-colors"
>
Edit
</button>
<button
onClick={() => handleDelete(backend.id)}
className="flex-1 px-3 py-1.5 text-sm bg-red-600 hover:bg-red-700 text-white rounded transition-colors"
>
Delete
</button>
</div>
</div>
))}
</div> </div>
)} )}
</div> </div>
@@ -292,7 +362,7 @@ export function NotificationsTab() {
<div className="p-6"> <div className="p-6">
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<h3 className="text-xl font-bold text-gray-900 dark:text-white"> <h3 className="text-xl font-bold text-gray-900 dark:text-white">
{modalState.mode === 'add' ? 'Add' : 'Edit'} {modalState.selectedType.charAt(0).toUpperCase() + modalState.selectedType.slice(1)} Notification {modalState.mode === 'add' ? 'Add' : 'Edit'} {currentMeta?.displayName ?? modalState.selectedType} Notification
</h3> </h3>
<button onClick={closeModal} className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"> <button onClick={closeModal} className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200">
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -314,70 +384,8 @@ export function NotificationsTab() {
/> />
</div> </div>
{/* Config Fields */} {/* Dynamic Config Fields */}
{modalState.selectedType === 'discord' && ( {currentMeta?.configFields.map((field) => renderConfigField(field))}
<>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Webhook URL *</label>
<input
type="text"
value={formData.config.webhookUrl}
onChange={(e) => setFormData({ ...formData, config: { ...formData.config, webhookUrl: e.target.value } })}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
placeholder="https://discord.com/api/webhooks/..."
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Username (optional)</label>
<input
type="text"
value={formData.config.username}
onChange={(e) => setFormData({ ...formData, config: { ...formData.config, username: e.target.value } })}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
placeholder="ReadMeABook"
/>
</div>
</>
)}
{modalState.selectedType === 'pushover' && (
<>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">User Key *</label>
<input
type="text"
value={formData.config.userKey}
onChange={(e) => setFormData({ ...formData, config: { ...formData.config, userKey: e.target.value } })}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
placeholder="Your Pushover user key"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">App Token *</label>
<input
type="text"
value={formData.config.appToken}
onChange={(e) => setFormData({ ...formData, config: { ...formData.config, appToken: e.target.value } })}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
placeholder="Your Pushover app token"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Priority</label>
<select
value={formData.config.priority}
onChange={(e) => setFormData({ ...formData, config: { ...formData.config, priority: Number(e.target.value) } })}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
>
<option value="-2">Lowest</option>
<option value="-1">Low</option>
<option value="0">Normal</option>
<option value="1">High</option>
<option value="2">Emergency</option>
</select>
</div>
</>
)}
{/* Events */} {/* Events */}
<div> <div>
@@ -6,6 +6,7 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState } from 'react';
import { fetchWithAuth } from '@/lib/utils/api';
import type { PathsSettings, TestResult } from '../../lib/types'; import type { PathsSettings, TestResult } from '../../lib/types';
interface UsePathsSettingsProps { interface UsePathsSettingsProps {
@@ -34,7 +35,7 @@ export function usePathsSettings({ paths, onChange, onValidationChange }: UsePat
setTestResult(null); setTestResult(null);
try { try {
const response = await fetch('/api/setup/test-paths', { const response = await fetchWithAuth('/api/setup/test-paths', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
@@ -6,7 +6,8 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth'; import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db'; import { prisma } from '@/lib/db';
import { getNotificationService, NotificationBackendType } from '@/lib/services/notification.service'; import { getNotificationService } from '@/lib/services/notification';
import { NOTIFICATION_EVENT_KEYS } from '@/lib/constants/notification-events';
import { RMABLogger } from '@/lib/utils/logger'; import { RMABLogger } from '@/lib/utils/logger';
import { z } from 'zod'; import { z } from 'zod';
@@ -15,7 +16,7 @@ const logger = RMABLogger.create('API.Admin.Notifications.Id');
const UpdateBackendSchema = z.object({ const UpdateBackendSchema = z.object({
name: z.string().min(1).optional(), name: z.string().min(1).optional(),
config: z.record(z.any()).optional(), config: z.record(z.any()).optional(),
events: z.array(z.enum(['request_pending_approval', 'request_approved', 'request_available', 'request_error'])).min(1).optional(), events: z.array(z.enum(NOTIFICATION_EVENT_KEYS)).min(1).optional(),
enabled: z.boolean().optional(), enabled: z.boolean().optional(),
}); });
@@ -50,7 +51,7 @@ export async function GET(
success: true, success: true,
backend: { backend: {
...backend, ...backend,
config: notificationService.maskConfig(backend.type as NotificationBackendType, backend.config), config: notificationService.maskConfig(backend.type, backend.config),
}, },
}); });
} catch (error) { } catch (error) {
@@ -114,7 +115,7 @@ export async function PUT(
}); });
// Encrypt new/changed values // Encrypt new/changed values
finalConfig = notificationService.encryptConfig(existing.type as NotificationBackendType, updatedConfig); finalConfig = notificationService.encryptConfig(existing.type, updatedConfig);
} }
// Update backend // Update backend
@@ -139,7 +140,7 @@ export async function PUT(
success: true, success: true,
backend: { backend: {
...updated, ...updated,
config: notificationService.maskConfig(updated.type as NotificationBackendType, updated.config), config: notificationService.maskConfig(updated.type, updated.config),
}, },
}); });
} catch (error) { } catch (error) {
@@ -0,0 +1,42 @@
/**
* Component: Notification Providers Metadata API
* Documentation: documentation/backend/services/notifications.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { getAllProviderMetadata } from '@/lib/services/notification';
import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('API.Admin.Notifications.Providers');
/**
* GET /api/admin/notifications/providers
* Returns metadata for all registered notification providers
*/
export async function GET(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
const providers = getAllProviderMetadata();
return NextResponse.json({
success: true,
providers,
});
} catch (error) {
logger.error('Failed to fetch provider metadata', {
error: error instanceof Error ? error.message : String(error),
});
return NextResponse.json(
{
error: 'FetchError',
message: 'Failed to fetch provider metadata',
},
{ status: 500 }
);
}
});
});
}
+5 -4
View File
@@ -6,17 +6,18 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth'; import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db'; import { prisma } from '@/lib/db';
import { getNotificationService, NotificationBackendType } from '@/lib/services/notification.service'; import { getNotificationService, getRegisteredProviderTypes } from '@/lib/services/notification';
import { NOTIFICATION_EVENT_KEYS } from '@/lib/constants/notification-events';
import { RMABLogger } from '@/lib/utils/logger'; import { RMABLogger } from '@/lib/utils/logger';
import { z } from 'zod'; import { z } from 'zod';
const logger = RMABLogger.create('API.Admin.Notifications'); const logger = RMABLogger.create('API.Admin.Notifications');
const CreateBackendSchema = z.object({ const CreateBackendSchema = z.object({
type: z.enum(['discord', 'pushover', 'email', 'slack', 'telegram', 'webhook']), type: z.string().refine((val) => getRegisteredProviderTypes().includes(val), { message: 'Unsupported notification provider type' }),
name: z.string().min(1), name: z.string().min(1),
config: z.record(z.any()), config: z.record(z.any()),
events: z.array(z.enum(['request_pending_approval', 'request_approved', 'request_available', 'request_error'])).min(1), events: z.array(z.enum(NOTIFICATION_EVENT_KEYS)).min(1),
enabled: z.boolean().default(true), enabled: z.boolean().default(true),
}); });
@@ -37,7 +38,7 @@ export async function GET(request: NextRequest) {
// Mask sensitive config values // Mask sensitive config values
const maskedBackends = backends.map((backend) => ({ const maskedBackends = backends.map((backend) => ({
...backend, ...backend,
config: notificationService.maskConfig(backend.type as NotificationBackendType, backend.config), config: notificationService.maskConfig(backend.type, backend.config),
})); }));
return NextResponse.json({ return NextResponse.json({
+26 -69
View File
@@ -5,31 +5,17 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth'; import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { getNotificationService, NotificationBackendType, NotificationPayload } from '@/lib/services/notification.service'; import { getNotificationService, getRegisteredProviderTypes, NotificationPayload } from '@/lib/services/notification';
import { RMABLogger } from '@/lib/utils/logger'; import { RMABLogger } from '@/lib/utils/logger';
import { z } from 'zod'; import { z } from 'zod';
import { prisma } from '@/lib/db'; import { prisma } from '@/lib/db';
const logger = RMABLogger.create('API.Admin.Notifications.Test'); const logger = RMABLogger.create('API.Admin.Notifications.Test');
const TestNotificationSchema = z.discriminatedUnion('mode', [ // Flexible schema: supports both backendId and type+config formats
// Test existing backend by ID (uses stored config) const TestNotificationSchema = z.object({
z.object({
mode: z.literal('backend'),
backendId: z.string(),
}),
// Test new config before saving
z.object({
mode: z.literal('config'),
type: z.enum(['discord', 'pushover', 'email', 'slack', 'telegram', 'webhook']),
config: z.record(z.any()),
}),
]);
// Support legacy format without mode
const LegacyTestNotificationSchema = z.object({
backendId: z.string().optional(), backendId: z.string().optional(),
type: z.enum(['discord', 'pushover', 'email', 'slack', 'telegram', 'webhook']).optional(), type: z.string().refine((val) => getRegisteredProviderTypes().includes(val), { message: 'Unsupported notification provider type' }).optional(),
config: z.record(z.any()).optional(), config: z.record(z.any()).optional(),
}); });
@@ -42,66 +28,37 @@ export async function POST(request: NextRequest) {
return requireAdmin(req, async () => { return requireAdmin(req, async () => {
try { try {
const body = await request.json(); const body = await request.json();
const parsed = TestNotificationSchema.parse(body);
// Support legacy format for backward compatibility let type: string;
const legacyParsed = LegacyTestNotificationSchema.safeParse(body);
let type: NotificationBackendType;
let encryptedConfig: any; let encryptedConfig: any;
const notificationService = getNotificationService(); const notificationService = getNotificationService();
if (legacyParsed.success) { if (parsed.backendId) {
// Legacy format // Test existing backend by ID (uses stored config)
if (legacyParsed.data.backendId) { const backend = await prisma.notificationBackend.findUnique({
// Test existing backend where: { id: parsed.backendId },
const backend = await prisma.notificationBackend.findUnique({ });
where: { id: legacyParsed.data.backendId },
});
if (!backend) { if (!backend) {
return NextResponse.json(
{ error: 'NotFound', message: 'Backend not found' },
{ status: 404 }
);
}
type = backend.type as NotificationBackendType;
encryptedConfig = backend.config; // Already encrypted in DB
} else if (legacyParsed.data.type && legacyParsed.data.config) {
// Test new config
type = legacyParsed.data.type as NotificationBackendType;
encryptedConfig = notificationService.encryptConfig(type, legacyParsed.data.config);
} else {
return NextResponse.json( return NextResponse.json(
{ error: 'ValidationError', message: 'Must provide either backendId or type+config' }, { error: 'NotFound', message: 'Backend not found' },
{ status: 400 } { status: 404 }
); );
} }
type = backend.type;
encryptedConfig = backend.config; // Already encrypted in DB
} else if (parsed.type && parsed.config) {
// Test new config before saving
type = parsed.type;
encryptedConfig = notificationService.encryptConfig(type, parsed.config);
} else { } else {
// New format with discriminated union return NextResponse.json(
const parsed = TestNotificationSchema.parse(body); { error: 'ValidationError', message: 'Must provide either backendId or type+config' },
{ status: 400 }
if (parsed.mode === 'backend') { );
// Test existing backend
const backend = await prisma.notificationBackend.findUnique({
where: { id: parsed.backendId },
});
if (!backend) {
return NextResponse.json(
{ error: 'NotFound', message: 'Backend not found' },
{ status: 404 }
);
}
type = backend.type as NotificationBackendType;
encryptedConfig = backend.config; // Already encrypted in DB
} else {
// Test new config
type = parsed.type;
encryptedConfig = notificationService.encryptConfig(type, parsed.config);
}
} }
// Create test payload // Create test payload
@@ -117,7 +74,7 @@ export async function POST(request: NextRequest) {
// Send test notification synchronously (not via job queue) // Send test notification synchronously (not via job queue)
try { try {
// Call sendToBackend directly // Call sendToBackend directly
await (notificationService as any).sendToBackend(type, encryptedConfig, testPayload); await notificationService.sendToBackend(type, encryptedConfig, testPayload);
logger.info(`Test notification sent successfully for ${type}`, { logger.info(`Test notification sent successfully for ${type}`, {
adminId: req.user?.sub, adminId: req.user?.sub,
@@ -0,0 +1,87 @@
/**
* Component: Admin Replace Audiobook API
* Documentation: documentation/backend/services/reported-issues.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { replaceAudiobook, ReportedIssueError } from '@/lib/services/reported-issue.service';
import { z } from 'zod';
import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('API.Admin.ReportedIssues.Replace');
const ReplaceSchema = z.object({
torrent: z.object({
guid: z.string(),
title: z.string(),
size: z.number(),
seeders: z.number().optional(),
leechers: z.number().optional(),
indexer: z.string(),
indexerId: z.number().optional(),
downloadUrl: z.string(),
infoUrl: z.string().optional(),
publishDate: z.string().transform((str) => new Date(str)),
infoHash: z.string().optional(),
format: z.enum(['M4B', 'M4A', 'MP3', 'OTHER']).optional(),
bitrate: z.string().optional(),
hasChapters: z.boolean().optional(),
protocol: z.enum(['torrent', 'usenet']).optional(),
}),
});
/**
* POST /api/admin/reported-issues/[id]/replace
* Atomically replace audiobook content: delete old → create new request → start download → resolve issue
*/
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
if (!req.user) {
return NextResponse.json(
{ error: 'Unauthorized', message: 'User not authenticated' },
{ status: 401 }
);
}
const { id } = await params;
const body = await req.json();
const { torrent } = ReplaceSchema.parse(body);
const result = await replaceAudiobook(id, req.user.id, torrent);
return NextResponse.json({
success: true,
request: result.request,
});
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: 'ValidationError', details: error.errors },
{ status: 400 }
);
}
if (error instanceof ReportedIssueError) {
return NextResponse.json(
{ error: 'ReplaceError', message: error.message },
{ status: error.statusCode }
);
}
logger.error('Failed to replace audiobook', {
error: error instanceof Error ? error.message : String(error),
});
return NextResponse.json(
{ error: 'ServerError', message: 'Failed to replace audiobook' },
{ status: 500 }
);
}
});
});
}
@@ -0,0 +1,74 @@
/**
* Component: Admin Resolve Reported Issue API
* Documentation: documentation/backend/services/reported-issues.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { dismissIssue, ReportedIssueError } from '@/lib/services/reported-issue.service';
import { z } from 'zod';
import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('API.Admin.ReportedIssues.Resolve');
const ResolveSchema = z.object({
action: z.enum(['dismiss']),
});
/**
* POST /api/admin/reported-issues/[id]/resolve
* Dismiss a reported issue
*/
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
if (!req.user) {
return NextResponse.json(
{ error: 'Unauthorized', message: 'User not authenticated' },
{ status: 401 }
);
}
const { id } = await params;
const body = await req.json();
const { action } = ResolveSchema.parse(body);
if (action === 'dismiss') {
const issue = await dismissIssue(id, req.user.id);
return NextResponse.json({ success: true, issue });
}
return NextResponse.json(
{ error: 'InvalidAction', message: 'Unknown action' },
{ status: 400 }
);
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: 'ValidationError', details: error.errors },
{ status: 400 }
);
}
if (error instanceof ReportedIssueError) {
return NextResponse.json(
{ error: 'ResolveError', message: error.message },
{ status: error.statusCode }
);
}
logger.error('Failed to resolve issue', {
error: error instanceof Error ? error.message : String(error),
});
return NextResponse.json(
{ error: 'ServerError', message: 'Failed to resolve issue' },
{ status: 500 }
);
}
});
});
}
@@ -0,0 +1,39 @@
/**
* Component: Admin Reported Issues List API
* Documentation: documentation/backend/services/reported-issues.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { getOpenIssues } from '@/lib/services/reported-issue.service';
import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('API.Admin.ReportedIssues');
/**
* GET /api/admin/reported-issues
* Get all open reported issues with audiobook metadata and reporter info
*/
export async function GET(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
const issues = await getOpenIssues();
return NextResponse.json({
success: true,
issues,
count: issues.length,
});
} catch (error) {
logger.error('Failed to fetch reported issues', {
error: error instanceof Error ? error.message : String(error),
});
return NextResponse.json(
{ error: 'ServerError', message: 'Failed to fetch reported issues' },
{ status: 500 }
);
}
});
});
}
@@ -38,6 +38,7 @@ export async function PUT(
localPath, localPath,
category, category,
customPath, customPath,
postImportCategory,
} = body; } = body;
const config = await getConfigService(); const config = await getConfigService();
@@ -76,6 +77,7 @@ export async function PUT(
localPath: localPath !== undefined ? localPath : existingClient.localPath, localPath: localPath !== undefined ? localPath : existingClient.localPath,
category: category !== undefined ? category : existingClient.category, category: category !== undefined ? category : existingClient.category,
customPath: customPath !== undefined ? (customPath || undefined) : existingClient.customPath, customPath: customPath !== undefined ? (customPath || undefined) : existingClient.customPath,
postImportCategory: postImportCategory !== undefined ? (postImportCategory || undefined) : existingClient.postImportCategory,
}; };
// Validate path mapping if enabled // Validate path mapping if enabled
@@ -0,0 +1,104 @@
/**
* Component: Fetch Download Client Categories API
* Documentation: documentation/phase3/download-clients.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { getConfigService } from '@/lib/services/config.service';
import { getDownloadClientManager, DownloadClientConfig } from '@/lib/services/download-client-manager.service';
import { SUPPORTED_CLIENT_TYPES } from '@/lib/interfaces/download-client.interface';
import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('API.Admin.Settings.DownloadClients.Categories');
/**
* POST - Fetch categories from a download client
* Accepts same connection config as the test endpoint
*/
export async function POST(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
const body = await request.json();
const {
clientId,
type,
name: clientName,
url,
username,
password,
disableSSLVerify,
remotePathMappingEnabled,
remotePath,
localPath,
} = body;
if (!SUPPORTED_CLIENT_TYPES.includes(type)) {
return NextResponse.json(
{ error: `Invalid client type. Must be one of: ${SUPPORTED_CLIENT_TYPES.join(', ')}` },
{ status: 400 }
);
}
if (!url) {
return NextResponse.json(
{ error: 'URL is required' },
{ status: 400 }
);
}
const config = await getConfigService();
const manager = getDownloadClientManager(config);
// If editing and password not provided, use stored password
let effectivePassword = password;
let effectiveUsername = username;
if (clientId && !password) {
const existingClients = await manager.getAllClients();
const existingClient = existingClients.find(c => c.id === clientId);
if (!existingClient) {
return NextResponse.json(
{ error: 'Client not found' },
{ status: 404 }
);
}
effectivePassword = existingClient.password;
if (!username && existingClient.username) {
effectiveUsername = existingClient.username;
}
}
const testConfig: DownloadClientConfig = {
id: 'categories-fetch',
type,
name: clientName || type,
enabled: true,
url,
username: effectiveUsername || '',
password: effectivePassword || '',
disableSSLVerify: disableSSLVerify || false,
remotePathMappingEnabled: remotePathMappingEnabled || false,
remotePath: remotePath || undefined,
localPath: localPath || undefined,
category: 'readmeabook',
};
const service = await manager.createClientFromConfig(testConfig);
const categories = await service.getCategories();
return NextResponse.json({ success: true, categories });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
logger.error('Failed to fetch categories', { error: message });
return NextResponse.json(
{ success: false, error: message },
{ status: 400 }
);
}
});
});
}
@@ -63,6 +63,7 @@ export async function POST(request: NextRequest) {
localPath, localPath,
category, category,
customPath, customPath,
postImportCategory,
} = body; } = body;
// Validate type // Validate type
@@ -138,6 +139,7 @@ export async function POST(request: NextRequest) {
localPath: localPath || undefined, localPath: localPath || undefined,
category: category || 'readmeabook', category: category || 'readmeabook',
customPath: customPath || undefined, customPath: customPath || undefined,
postImportCategory: postImportCategory || undefined,
}; };
// Test connection before saving // Test connection before saving
@@ -0,0 +1,69 @@
/**
* Component: Report Issue API
* Documentation: documentation/backend/services/reported-issues.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
import { reportIssue, ReportedIssueError } from '@/lib/services/reported-issue.service';
import { z } from 'zod';
import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('API.ReportIssue');
const ReportIssueSchema = z.object({
reason: z.string().min(1, 'Reason is required').max(250, 'Reason must be 250 characters or less'),
title: z.string().optional(),
author: z.string().optional(),
coverArtUrl: z.string().optional(),
});
/**
* POST /api/audiobooks/[asin]/report-issue
* Report an issue with an available audiobook
*/
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ asin: string }> }
) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
try {
if (!req.user) {
return NextResponse.json(
{ error: 'Unauthorized', message: 'User not authenticated' },
{ status: 401 }
);
}
const { asin } = await params;
const body = await req.json();
const { reason, title, author, coverArtUrl } = ReportIssueSchema.parse(body);
const issue = await reportIssue(asin, req.user.id, reason, { title, author, coverArtUrl });
return NextResponse.json({ success: true, issue }, { status: 201 });
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: 'ValidationError', details: error.errors },
{ status: 400 }
);
}
if (error instanceof ReportedIssueError) {
return NextResponse.json(
{ error: 'ReportIssueError', message: error.message },
{ status: error.statusCode }
);
}
logger.error('Failed to report issue', {
error: error instanceof Error ? error.message : String(error),
});
return NextResponse.json(
{ error: 'ServerError', message: 'Failed to report issue' },
{ status: 500 }
);
}
});
}
@@ -86,7 +86,6 @@ export async function POST(request: NextRequest) {
// Search Prowlarr for each group and combine results // Search Prowlarr for each group and combine results
const prowlarr = await getProwlarrService(); const prowlarr = await getProwlarrService();
const searchQuery = title; // Title only - cast wide net
const allResults = []; const allResults = [];
for (let i = 0; i < groups.length; i++) { for (let i = 0; i < groups.length; i++) {
@@ -94,7 +93,7 @@ export async function POST(request: NextRequest) {
logger.debug(`Searching group ${i + 1}/${groups.length}: ${getGroupDescription(group)}`); logger.debug(`Searching group ${i + 1}/${groups.length}: ${getGroupDescription(group)}`);
try { try {
const groupResults = await prowlarr.search(searchQuery, { const groupResults = await prowlarr.searchWithVariations(title, author, {
categories: group.categories, categories: group.categories,
indexerIds: group.indexerIds, indexerIds: group.indexerIds,
maxResults: 100, // Limit per group maxResults: 100, // Limit per group
+2 -1
View File
@@ -39,7 +39,8 @@ export async function POST(request: NextRequest) {
} }
// Validate new password length // Validate new password length
if (newPassword.length < 8) { const allowWeakPassword = process.env.ALLOW_WEAK_PASSWORD === 'true';
if (!allowWeakPassword && newPassword.length < 8) {
return NextResponse.json( return NextResponse.json(
{ {
success: false, success: false,
+7
View File
@@ -18,6 +18,9 @@ export async function GET() {
// Check if local login is disabled via environment variable // Check if local login is disabled via environment variable
const localLoginDisabled = process.env.DISABLE_LOCAL_LOGIN === 'true'; const localLoginDisabled = process.env.DISABLE_LOCAL_LOGIN === 'true';
// Check if weak passwords are allowed via environment variable
const allowWeakPassword = process.env.ALLOW_WEAK_PASSWORD === 'true';
// Check if automation (Phase 3) is configured by checking for Prowlarr/indexer config // Check if automation (Phase 3) is configured by checking for Prowlarr/indexer config
const indexerType = await configService.get('indexer.type'); const indexerType = await configService.get('indexer.type');
const prowlarrUrl = await configService.get('indexer.prowlarr_url'); const prowlarrUrl = await configService.get('indexer.prowlarr_url');
@@ -47,6 +50,7 @@ export async function GET() {
hasLocalUsers, hasLocalUsers,
oidcProviderName: oidcEnabled ? oidcProviderName : null, oidcProviderName: oidcEnabled ? oidcProviderName : null,
localLoginDisabled, localLoginDisabled,
allowWeakPassword,
automationEnabled, automationEnabled,
}); });
} else { } else {
@@ -65,6 +69,7 @@ export async function GET() {
hasLocalUsers, hasLocalUsers,
oidcProviderName: null, oidcProviderName: null,
localLoginDisabled, localLoginDisabled,
allowWeakPassword,
automationEnabled, automationEnabled,
}); });
} }
@@ -72,6 +77,7 @@ export async function GET() {
logger.error('Failed to fetch auth providers', { error: error instanceof Error ? error.message : String(error) }); logger.error('Failed to fetch auth providers', { error: error instanceof Error ? error.message : String(error) });
// Default to Plex mode if config can't be read // Default to Plex mode if config can't be read
const localLoginDisabled = process.env.DISABLE_LOCAL_LOGIN === 'true'; const localLoginDisabled = process.env.DISABLE_LOCAL_LOGIN === 'true';
const allowWeakPassword = process.env.ALLOW_WEAK_PASSWORD === 'true';
return NextResponse.json({ return NextResponse.json({
backendMode: 'plex', backendMode: 'plex',
providers: ['plex'], providers: ['plex'],
@@ -79,6 +85,7 @@ export async function GET() {
hasLocalUsers: false, hasLocalUsers: false,
oidcProviderName: null, oidcProviderName: null,
localLoginDisabled, localLoginDisabled,
allowWeakPassword,
automationEnabled: false, automationEnabled: false,
}); });
} }
+51 -52
View File
@@ -9,6 +9,49 @@ import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('API.BookDate.TestConnection'); const logger = RMABLogger.create('API.BookDate.TestConnection');
// Fetch available Claude models from the Anthropic API
async function fetchClaudeModels(apiKey: string): Promise<{ id: string; name: string }[]> {
const allModels: { id: string; name: string }[] = [];
let afterId: string | undefined;
// Paginate through all available models
do {
const params = new URLSearchParams({ limit: '1000' });
if (afterId) {
params.set('after_id', afterId);
}
const response = await fetch(
`https://api.anthropic.com/v1/models?${params.toString()}`,
{
headers: {
'x-api-key': apiKey,
'anthropic-version': '2023-06-01',
},
}
);
if (!response.ok) {
const errorText = await response.text();
logger.error('Claude API error', { error: errorText });
throw new Error('Invalid Claude API key or connection failed');
}
const data = await response.json();
for (const model of data.data) {
allModels.push({
id: model.id,
name: model.display_name || model.id,
});
}
afterId = data.has_more ? data.last_id : undefined;
} while (afterId);
return allModels;
}
// Helper functions for custom provider // Helper functions for custom provider
function isValidBaseUrl(url: string): boolean { function isValidBaseUrl(url: string): boolean {
try { try {
@@ -141,32 +184,10 @@ async function authenticatedHandler(req: AuthenticatedRequest) {
.sort((a: any, b: any) => a.name.localeCompare(b.name)); .sort((a: any, b: any) => a.name.localeCompare(b.name));
} else if (provider === 'claude') { } else if (provider === 'claude') {
// Claude: Hardcoded list (Anthropic doesn't have a public models API endpoint) // Claude: Fetch models dynamically from the Anthropic Models API
models = [ try {
{ id: 'claude-sonnet-4-5-20250929', name: 'Claude Sonnet 4.5 (Latest)' }, models = await fetchClaudeModels(testApiKey);
{ id: 'claude-3-7-sonnet-20250219', name: 'Claude 3.7 Sonnet' }, } catch {
{ id: 'claude-opus-4-20250514', name: 'Claude Opus 4' },
{ id: 'claude-3-5-haiku-20241022', name: 'Claude 3.5 Haiku' },
];
// Test connection with a simple API call
const response = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: {
'x-api-key': testApiKey,
'anthropic-version': '2023-06-01',
'content-type': 'application/json',
},
body: JSON.stringify({
model: 'claude-3-5-haiku-20241022',
max_tokens: 10,
messages: [{ role: 'user', content: 'Test' }],
}),
});
if (!response.ok) {
const errorText = await response.text();
logger.error('Claude API error', { error: errorText });
return NextResponse.json( return NextResponse.json(
{ error: 'Invalid Claude API key or connection failed' }, { error: 'Invalid Claude API key or connection failed' },
{ status: 400 } { status: 400 }
@@ -333,32 +354,10 @@ async function unauthenticatedHandler(req: NextRequest) {
.sort((a: any, b: any) => a.name.localeCompare(b.name)); .sort((a: any, b: any) => a.name.localeCompare(b.name));
} else if (provider === 'claude') { } else if (provider === 'claude') {
// Claude: Hardcoded list (Anthropic doesn't have a public models API endpoint) // Claude: Fetch models dynamically from the Anthropic Models API
models = [ try {
{ id: 'claude-sonnet-4-5-20250929', name: 'Claude Sonnet 4.5 (Latest)' }, models = await fetchClaudeModels(apiKey);
{ id: 'claude-3-7-sonnet-20250219', name: 'Claude 3.7 Sonnet' }, } catch {
{ id: 'claude-opus-4-20250514', name: 'Claude Opus 4' },
{ id: 'claude-3-5-haiku-20241022', name: 'Claude 3.5 Haiku' },
];
// Test connection with a simple API call
const response = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: {
'x-api-key': apiKey,
'anthropic-version': '2023-06-01',
'content-type': 'application/json',
},
body: JSON.stringify({
model: 'claude-3-5-haiku-20241022',
max_tokens: 10,
messages: [{ role: 'user', content: 'Test' }],
}),
});
if (!response.ok) {
const errorText = await response.text();
logger.error('Claude API error', { error: errorText });
return NextResponse.json( return NextResponse.json(
{ error: 'Invalid Claude API key or connection failed' }, { error: 'Invalid Claude API key or connection failed' },
{ status: 400 } { status: 400 }
@@ -8,6 +8,7 @@ import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db'; import { prisma } from '@/lib/db';
import { getProwlarrService } from '@/lib/integrations/prowlarr.service'; import { getProwlarrService } from '@/lib/integrations/prowlarr.service';
import { rankTorrents } from '@/lib/utils/ranking-algorithm'; import { rankTorrents } from '@/lib/utils/ranking-algorithm';
import { groupIndexersByCategories, getGroupDescription } from '@/lib/utils/indexer-grouping';
import { RMABLogger } from '@/lib/utils/logger'; import { RMABLogger } from '@/lib/utils/logger';
import { resolveInteractiveSearchAccess } from '@/lib/utils/permissions'; import { resolveInteractiveSearchAccess } from '@/lib/utils/permissions';
@@ -97,9 +98,8 @@ export async function POST(
} }
const indexersConfig = JSON.parse(indexersConfigStr); const indexersConfig = JSON.parse(indexersConfigStr);
const enabledIndexerIds = indexersConfig.map((indexer: any) => indexer.id);
if (enabledIndexerIds.length === 0) { if (indexersConfig.length === 0) {
return NextResponse.json( return NextResponse.json(
{ error: 'ConfigError', message: 'No indexers enabled. Please enable at least one indexer in settings.' }, { error: 'ConfigError', message: 'No indexers enabled. Please enable at least one indexer in settings.' },
{ status: 400 } { status: 400 }
@@ -115,22 +115,53 @@ export async function POST(
const flagConfigStr = await configService.get('indexer_flag_config'); const flagConfigStr = await configService.get('indexer_flag_config');
const flagConfigs = flagConfigStr ? JSON.parse(flagConfigStr) : []; const flagConfigs = flagConfigStr ? JSON.parse(flagConfigStr) : [];
// Search Prowlarr for torrents - ONLY enabled indexers // Group indexers by their category configuration
const prowlarr = await getProwlarrService(); const { groups, skippedIndexers } = groupIndexersByCategories(indexersConfig);
// Use custom title if provided, otherwise use audiobook's title
const searchQuery = customTitle || requestRecord.audiobook.title;
logger.info(`Searching ${enabledIndexerIds.length} enabled indexers`, { searchQuery }); if (skippedIndexers.length > 0) {
const skippedNames = skippedIndexers.map(idx => idx.name).join(', ');
logger.info(`Skipping ${skippedIndexers.length} indexer(s) with no audiobook categories: ${skippedNames}`);
}
// Use custom title if provided, otherwise use audiobook's title
const searchTitle = customTitle || requestRecord.audiobook.title;
const searchAuthor = requestRecord.audiobook.author;
logger.info(`Searching ${indexersConfig.length - skippedIndexers.length} enabled indexers in ${groups.length} group${groups.length > 1 ? 's' : ''}`, { searchTitle });
if (customTitle) { if (customTitle) {
logger.debug('Using custom search title', { customTitle, originalTitle: requestRecord.audiobook.title }); logger.debug('Using custom search title', { customTitle, originalTitle: requestRecord.audiobook.title });
} }
const results = await prowlarr.search(searchQuery, { // Log each group for transparency
indexerIds: enabledIndexerIds, groups.forEach((group, index) => {
maxResults: 100, // Increased limit for broader search logger.debug(`Group ${index + 1}: ${getGroupDescription(group)}`);
}); });
logger.debug(`Found ${results.length} raw results`, { requestId: id }); // Search Prowlarr for each group and combine results
const prowlarr = await getProwlarrService();
const allResults = [];
for (let i = 0; i < groups.length; i++) {
const group = groups[i];
logger.debug(`Searching group ${i + 1}/${groups.length}: ${getGroupDescription(group)}`);
try {
const groupResults = await prowlarr.searchWithVariations(searchTitle, searchAuthor, {
categories: group.categories,
indexerIds: group.indexerIds,
maxResults: 100,
});
logger.debug(`Group ${i + 1} returned ${groupResults.length} results`);
allResults.push(...groupResults);
} catch (error) {
logger.error(`Group ${i + 1} search failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
// Continue with other groups even if one fails
}
}
const results = allResults;
logger.info(`Found ${results.length} total results from ${groups.length} group${groups.length > 1 ? 's' : ''}`, { requestId: id });
if (results.length === 0) { if (results.length === 0) {
return NextResponse.json({ return NextResponse.json({
@@ -140,12 +171,31 @@ export async function POST(
}); });
} }
// Fetch runtime from Audnexus if ASIN available (for size-based scoring)
let durationMinutes: number | undefined;
if (requestRecord.audiobook.audibleAsin) {
try {
const { getAudibleService } = await import('@/lib/integrations/audible.service');
const audibleService = getAudibleService();
const runtime = await audibleService.getRuntime(requestRecord.audiobook.audibleAsin);
if (runtime) {
durationMinutes = runtime;
logger.info(`Fetched runtime: ${runtime} minutes for ASIN ${requestRecord.audiobook.audibleAsin}`);
} else {
logger.debug(`No runtime found for ASIN ${requestRecord.audiobook.audibleAsin}`);
}
} catch (error) {
logger.debug(`Failed to fetch runtime for ASIN ${requestRecord.audiobook.audibleAsin}: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
// Rank torrents using the ranking algorithm with indexer priorities and flag configs // Rank torrents using the ranking algorithm with indexer priorities and flag configs
// Always use the audiobook's title/author for ranking (not custom search query) // Always use the audiobook's title/author for ranking (not custom search query)
// requireAuthor: false - interactive mode, show all results for user decision // requireAuthor: false - interactive mode, show all results for user decision
const rankedResults = rankTorrents(results, { const rankedResults = rankTorrents(results, {
title: requestRecord.audiobook.title, title: requestRecord.audiobook.title,
author: requestRecord.audiobook.author, author: requestRecord.audiobook.author,
durationMinutes,
}, { }, {
indexerPriorities, indexerPriorities,
flagConfigs, flagConfigs,
@@ -160,17 +210,23 @@ export async function POST(
const top3 = rankedResults.slice(0, 3); const top3 = rankedResults.slice(0, 3);
if (top3.length > 0) { if (top3.length > 0) {
logger.debug('==================== RANKING DEBUG ===================='); logger.debug('==================== RANKING DEBUG ====================');
logger.debug('Search parameters', { searchQuery, requestedTitle: requestRecord.audiobook.title, requestedAuthor: requestRecord.audiobook.author }); logger.debug('Search parameters', { searchTitle, requestedTitle: requestRecord.audiobook.title, requestedAuthor: requestRecord.audiobook.author });
logger.debug(`Top ${top3.length} results (out of ${rankedResults.length} total)`); logger.debug(`Top ${top3.length} results (out of ${rankedResults.length} total)`);
logger.debug('--------------------------------------------------------'); logger.debug('--------------------------------------------------------');
top3.forEach((result, index) => { top3.forEach((result, index) => {
const sizeMB = (result.size / (1024 * 1024)).toFixed(1);
const mbPerMin = durationMinutes ? ((result.size / (1024 * 1024)) / durationMinutes).toFixed(2) : 'N/A';
logger.debug(`${index + 1}. "${result.title}"`, { logger.debug(`${index + 1}. "${result.title}"`, {
indexer: result.indexer, indexer: result.indexer,
indexerId: result.indexerId, indexerId: result.indexerId,
baseScore: `${result.score.toFixed(1)}/100`, baseScore: `${result.score.toFixed(1)}/100`,
matchScore: `${result.breakdown.matchScore.toFixed(1)}/60`, matchScore: `${result.breakdown.matchScore.toFixed(1)}/60`,
formatScore: `${result.breakdown.formatScore.toFixed(1)}/25 (${result.format || 'unknown'})`, formatScore: `${result.breakdown.formatScore.toFixed(1)}/10 (${result.format || 'unknown'})`,
seederScore: `${result.breakdown.seederScore.toFixed(1)}/15 (${result.seeders} seeders)`, sizeScore: durationMinutes
? `${result.breakdown.sizeScore.toFixed(1)}/15 (${sizeMB} MB, ${mbPerMin} MB/min)`
: 'N/A (no runtime)',
seederScore: `${result.breakdown.seederScore.toFixed(1)}/15 (${result.seeders !== undefined ? result.seeders + ' seeders' : 'N/A for Usenet'})`,
bonusPoints: `+${result.bonusPoints.toFixed(1)}`, bonusPoints: `+${result.bonusPoints.toFixed(1)}`,
bonusModifiers: result.bonusModifiers.map(mod => `${mod.reason}: +${mod.points.toFixed(1)}`), bonusModifiers: result.bonusModifiers.map(mod => `${mod.reason}: +${mod.points.toFixed(1)}`),
finalScore: result.finalScore.toFixed(1), finalScore: result.finalScore.toFixed(1),
+17 -259
View File
@@ -6,11 +6,9 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth'; import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db'; import { prisma } from '@/lib/db';
import { getJobQueueService } from '@/lib/services/job-queue.service';
import { findPlexMatch } from '@/lib/utils/audiobook-matcher';
import { getAudibleService } from '@/lib/integrations/audible.service';
import { z } from 'zod'; import { z } from 'zod';
import { RMABLogger } from '@/lib/utils/logger'; import { RMABLogger } from '@/lib/utils/logger';
import { createRequestForUser } from '@/lib/services/request-creator.service';
const logger = RMABLogger.create('API.Requests'); const logger = RMABLogger.create('API.Requests');
@@ -45,274 +43,34 @@ export async function POST(request: NextRequest) {
const body = await req.json(); const body = await req.json();
const { audiobook } = CreateRequestSchema.parse(body); const { audiobook } = CreateRequestSchema.parse(body);
// First check: Is there an existing audiobook request in 'downloaded' or 'available' status? const skipAutoSearch = req.nextUrl.searchParams.get('skipAutoSearch') === 'true';
// This catches the gap where files are organized but Plex hasn't scanned yet
const existingActiveRequest = await prisma.request.findFirst({
where: {
audiobook: {
audibleAsin: audiobook.asin,
},
type: 'audiobook', // Only check audiobook requests (ebook requests are separate)
status: { in: ['downloaded', 'available'] },
deletedAt: null,
},
include: {
user: { select: { plexUsername: true } },
},
});
if (existingActiveRequest) { const result = await createRequestForUser(req.user.id, {
const status = existingActiveRequest.status;
const isOwnRequest = existingActiveRequest.userId === req.user.id;
return NextResponse.json(
{
error: status === 'available' ? 'AlreadyAvailable' : 'BeingProcessed',
message: status === 'available'
? 'This audiobook is already available in your Plex library'
: 'This audiobook is being processed and will be available soon',
requestStatus: status,
isOwnRequest,
requestedBy: existingActiveRequest.user?.plexUsername,
},
{ status: 409 }
);
}
// Second check: Is audiobook already in Plex library? (fallback for non-requested books)
const plexMatch = await findPlexMatch({
asin: audiobook.asin, asin: audiobook.asin,
title: audiobook.title, title: audiobook.title,
author: audiobook.author, author: audiobook.author,
narrator: audiobook.narrator, narrator: audiobook.narrator,
}); description: audiobook.description,
coverArtUrl: audiobook.coverArtUrl,
}, { skipAutoSearch });
if (plexMatch) { if (!result.success) {
const statusMap: Record<string, { error: string; status: number }> = {
already_available: { error: 'AlreadyAvailable', status: 409 },
being_processed: { error: 'BeingProcessed', status: 409 },
duplicate: { error: 'DuplicateRequest', status: 409 },
user_not_found: { error: 'UserNotFound', status: 404 },
};
const mapped = statusMap[result.reason] || { error: 'RequestError', status: 500 };
return NextResponse.json( return NextResponse.json(
{ { error: mapped.error, message: result.message },
error: 'AlreadyAvailable', { status: mapped.status }
message: 'This audiobook is already available in your Plex library',
plexGuid: plexMatch.plexGuid,
},
{ status: 409 }
); );
} }
// Fetch full details from Audnexus to get releaseDate, year, and series
let year: number | undefined;
let series: string | undefined;
let seriesPart: string | undefined;
try {
const audibleService = getAudibleService();
const audnexusData = await audibleService.getAudiobookDetails(audiobook.asin);
if (audnexusData?.releaseDate) {
try {
const releaseYear = new Date(audnexusData.releaseDate).getFullYear();
if (!isNaN(releaseYear)) {
year = releaseYear;
logger.debug(`Extracted year ${year} from Audnexus releaseDate: ${audnexusData.releaseDate}`);
}
} catch (error) {
logger.warn(`Failed to parse Audnexus releaseDate "${audnexusData.releaseDate}": ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
// Extract series data
if (audnexusData?.series) {
series = audnexusData.series;
logger.debug(`Extracted series: ${series}`);
}
if (audnexusData?.seriesPart) {
seriesPart = audnexusData.seriesPart;
logger.debug(`Extracted seriesPart: ${seriesPart}`);
}
} catch (error) {
logger.warn(`Failed to fetch Audnexus data for ASIN ${audiobook.asin}: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
// Try to find existing audiobook record by ASIN
let audiobookRecord = await prisma.audiobook.findFirst({
where: { audibleAsin: audiobook.asin },
});
// If not found, create new audiobook record
if (!audiobookRecord) {
audiobookRecord = await prisma.audiobook.create({
data: {
audibleAsin: audiobook.asin,
title: audiobook.title,
author: audiobook.author,
narrator: audiobook.narrator,
description: audiobook.description,
coverArtUrl: audiobook.coverArtUrl,
year,
series,
seriesPart,
status: 'requested',
},
});
logger.debug(`Created audiobook ${audiobookRecord.id} with year: ${year || 'none'}, series: ${series || 'none'}`);
} else if (year || series || seriesPart) {
// Always update year/series if we have them from Audnexus (even if audiobook already has them)
audiobookRecord = await prisma.audiobook.update({
where: { id: audiobookRecord.id },
data: {
...(year && { year }),
...(series && { series }),
...(seriesPart && { seriesPart }),
},
});
logger.debug(`Updated audiobook ${audiobookRecord.id} with year: ${year || 'unchanged'}, series: ${series || 'unchanged'}`);
}
// Check if user already has an active (non-deleted) audiobook request for this audiobook
const existingRequest = await prisma.request.findFirst({
where: {
userId: req.user.id,
audiobookId: audiobookRecord.id,
type: 'audiobook', // Only check audiobook requests (ebook requests are separate)
deletedAt: null, // Only check active requests
},
});
if (existingRequest) {
// Allow re-requesting if the status is failed, warn, or cancelled
const canReRequest = ['failed', 'warn', 'cancelled'].includes(existingRequest.status);
if (!canReRequest) {
return NextResponse.json(
{
error: 'DuplicateRequest',
message: 'You have already requested this audiobook',
request: existingRequest,
},
{ status: 409 }
);
}
// Delete the existing failed/warn/cancelled request
logger.debug(`Deleting existing ${existingRequest.status} request ${existingRequest.id} to allow re-request`);
await prisma.request.delete({
where: { id: existingRequest.id },
});
}
// Check if we should skip auto-search (for interactive search)
const skipAutoSearch = req.nextUrl.searchParams.get('skipAutoSearch') === 'true';
// Check if request needs approval
let needsApproval = false;
let shouldTriggerSearch = !skipAutoSearch;
// Fetch user with autoApproveRequests setting
const user = await prisma.user.findUnique({
where: { id: req.user.id },
select: {
role: true,
autoApproveRequests: true,
},
});
if (!user) {
return NextResponse.json(
{ error: 'UserNotFound', message: 'User not found' },
{ status: 404 }
);
}
// Determine if approval is needed
if (user.role === 'admin') {
// Admins always auto-approve
needsApproval = false;
} else {
// Check user's personal setting first
if (user.autoApproveRequests === true) {
needsApproval = false;
} else if (user.autoApproveRequests === false) {
needsApproval = true;
} else {
// User setting is null, check global setting
const globalConfig = await prisma.configuration.findUnique({
where: { key: 'auto_approve_requests' },
});
// Default to true if not configured (backward compatibility)
const globalAutoApprove = globalConfig === null ? true : globalConfig.value === 'true';
needsApproval = !globalAutoApprove;
}
}
// Determine initial status
let initialStatus: string;
if (needsApproval) {
initialStatus = 'awaiting_approval';
shouldTriggerSearch = false; // Don't trigger search if awaiting approval
} else if (skipAutoSearch) {
initialStatus = 'awaiting_search';
} else {
initialStatus = 'pending';
}
// Create request with appropriate status
const newRequest = await prisma.request.create({
data: {
userId: req.user.id,
audiobookId: audiobookRecord.id,
status: initialStatus,
type: 'audiobook', // Explicit type for user-created requests
progress: 0,
},
include: {
audiobook: true,
user: {
select: {
id: true,
plexUsername: true,
},
},
},
});
const jobQueue = getJobQueueService();
// Send notification based on approval status
if (initialStatus === 'awaiting_approval') {
// Request needs approval - send pending notification
await jobQueue.addNotificationJob(
'request_pending_approval',
newRequest.id,
audiobookRecord.title,
audiobookRecord.author,
newRequest.user.plexUsername || 'Unknown User'
).catch((error) => {
logger.error('Failed to queue notification', { error: error instanceof Error ? error.message : String(error) });
});
} else {
// Request was auto-approved (either automatic or interactive search) - send approved notification
await jobQueue.addNotificationJob(
'request_approved',
newRequest.id,
audiobookRecord.title,
audiobookRecord.author,
newRequest.user.plexUsername || 'Unknown User'
).catch((error) => {
logger.error('Failed to queue notification', { error: error instanceof Error ? error.message : String(error) });
});
}
// Trigger search job only if not skipped and not awaiting approval
if (shouldTriggerSearch) {
await jobQueue.addSearchJob(newRequest.id, {
id: audiobookRecord.id,
title: audiobookRecord.title,
author: audiobookRecord.author,
asin: audiobookRecord.audibleAsin || undefined,
});
}
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
request: newRequest, request: result.request,
}, { status: 201 }); }, { status: 201 });
} catch (error) { } catch (error) {
logger.error('Failed to create request', { error: error instanceof Error ? error.message : String(error) }); logger.error('Failed to create request', { error: error instanceof Error ? error.message : String(error) });
@@ -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 }
);
}
});
}
+2 -2
View File
@@ -4,10 +4,10 @@
*/ */
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { requireSetupIncomplete } from '@/lib/middleware/auth'; import { requireSetupIncompleteOrAdmin } from '@/lib/middleware/auth';
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
return requireSetupIncomplete(request, async (req) => { return requireSetupIncompleteOrAdmin(request, async (req) => {
try { try {
const { serverUrl, apiToken } = await req.json(); const { serverUrl, apiToken } = await req.json();
+2 -2
View File
@@ -5,13 +5,13 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { Issuer } from 'openid-client'; import { Issuer } from 'openid-client';
import { requireSetupIncomplete } from '@/lib/middleware/auth'; import { requireSetupIncompleteOrAdmin } from '@/lib/middleware/auth';
import { RMABLogger } from '@/lib/utils/logger'; import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('API.Setup.TestOIDC'); const logger = RMABLogger.create('API.Setup.TestOIDC');
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
return requireSetupIncomplete(request, async (req) => { return requireSetupIncompleteOrAdmin(request, async (req) => {
try { try {
const body = await req.json(); const body = await req.json();
const { issuerUrl, clientId, clientSecret } = body; const { issuerUrl, clientId, clientSecret } = body;
+2 -2
View File
@@ -6,7 +6,7 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import fs from 'fs/promises'; import fs from 'fs/promises';
import path from 'path'; import path from 'path';
import { requireSetupIncomplete } from '@/lib/middleware/auth'; import { requireSetupIncompleteOrAdmin } from '@/lib/middleware/auth';
import { RMABLogger } from '@/lib/utils/logger'; import { RMABLogger } from '@/lib/utils/logger';
import { validateTemplate, generateMockPreviews } from '@/lib/utils/path-template.util'; import { validateTemplate, generateMockPreviews } from '@/lib/utils/path-template.util';
@@ -46,7 +46,7 @@ async function testPath(dirPath: string): Promise<boolean> {
} }
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
return requireSetupIncomplete(request, async (req) => { return requireSetupIncompleteOrAdmin(request, async (req) => {
try { try {
const { downloadDir, mediaDir, audiobookPathTemplate } = await req.json(); const { downloadDir, mediaDir, audiobookPathTemplate } = await req.json();
@@ -0,0 +1,50 @@
/**
* Component: Goodreads Shelf Delete Route
* Documentation: documentation/backend/services/goodreads-sync.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('API.GoodreadsShelves');
/**
* DELETE /api/user/goodreads-shelves/[id]
* Remove a Goodreads shelf subscription (ownership check)
*/
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
try {
if (!req.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { id } = await params;
const shelf = await prisma.goodreadsShelf.findUnique({
where: { id },
});
if (!shelf) {
return NextResponse.json({ error: 'Shelf not found' }, { status: 404 });
}
// Ownership check
if (shelf.userId !== req.user.id) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
await prisma.goodreadsShelf.delete({ where: { id } });
return NextResponse.json({ success: true });
} catch (error) {
logger.error('Failed to delete shelf', { error: error instanceof Error ? error.message : String(error) });
return NextResponse.json({ error: 'Failed to delete shelf' }, { status: 500 });
}
});
}
+174
View File
@@ -0,0 +1,174 @@
/**
* Component: Goodreads Shelves API Routes
* Documentation: documentation/backend/services/goodreads-sync.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
import { fetchAndValidateRss } from '@/lib/services/goodreads-sync.service';
import { getJobQueueService } from '@/lib/services/job-queue.service';
import { z } from 'zod';
import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('API.GoodreadsShelves');
const GOODREADS_RSS_PATTERN = /goodreads\.com\/review\/list_rss\//;
const AddShelfSchema = z.object({
rssUrl: z.string().url().refine(
(url) => GOODREADS_RSS_PATTERN.test(url),
{ message: 'URL must be a Goodreads shelf RSS URL (goodreads.com/review/list_rss/...)' }
),
});
/**
* GET /api/user/goodreads-shelves
* List the current user's Goodreads shelves with book counts and covers
*/
export async function GET(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
try {
if (!req.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const shelves = await prisma.goodreadsShelf.findMany({
where: { userId: req.user.id },
orderBy: { createdAt: 'desc' },
});
const shelvesWithMeta = shelves.map((shelf) => {
// Normalize coverUrls: old format (string[]) → new format ({coverUrl,asin,title,author}[])
let books: { coverUrl: string; asin: string | null; title: string; author: string }[] = [];
if (shelf.coverUrls) {
const parsed = JSON.parse(shelf.coverUrls);
if (Array.isArray(parsed)) {
books = parsed.map((item: unknown) => {
if (typeof item === 'string') {
return { coverUrl: item, asin: null, title: '', author: '' };
}
const obj = item as Record<string, unknown>;
return {
coverUrl: (obj.coverUrl as string) || '',
asin: (obj.asin as string) || null,
title: (obj.title as string) || '',
author: (obj.author as string) || '',
};
});
}
}
return {
id: shelf.id,
name: shelf.name,
rssUrl: shelf.rssUrl,
lastSyncAt: shelf.lastSyncAt,
createdAt: shelf.createdAt,
bookCount: shelf.bookCount ?? null,
books,
};
});
return NextResponse.json({ success: true, shelves: shelvesWithMeta });
} catch (error) {
logger.error('Failed to list shelves', { error: error instanceof Error ? error.message : String(error) });
return NextResponse.json({ error: 'Failed to list shelves' }, { status: 500 });
}
});
}
/**
* POST /api/user/goodreads-shelves
* Add a new Goodreads shelf subscription
*/
export async function POST(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
try {
if (!req.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await req.json();
const { rssUrl } = AddShelfSchema.parse(body);
// Check for duplicate
const existing = await prisma.goodreadsShelf.findUnique({
where: { userId_rssUrl: { userId: req.user.id, rssUrl } },
});
if (existing) {
return NextResponse.json(
{ error: 'DuplicateShelf', message: 'You have already added this shelf' },
{ status: 409 }
);
}
// Validate by fetching the RSS feed
let shelfName: string;
let bookCount: number;
let initialBooks: { coverUrl: string; asin: null; title: string; author: string }[] = [];
try {
const rssData = await fetchAndValidateRss(rssUrl);
shelfName = rssData.shelfName;
bookCount = rssData.books.length;
initialBooks = rssData.books
.filter(b => b.coverUrl)
.slice(0, 8)
.map(b => ({ coverUrl: b.coverUrl!, asin: null, title: b.title, author: b.author }));
} catch (error) {
return NextResponse.json(
{
error: 'InvalidRSS',
message: `Could not fetch or parse the RSS feed: ${error instanceof Error ? error.message : 'Unknown error'}`,
},
{ status: 400 }
);
}
const shelf = await prisma.goodreadsShelf.create({
data: {
userId: req.user.id,
name: shelfName,
rssUrl,
bookCount,
coverUrls: initialBooks.length > 0 ? JSON.stringify(initialBooks) : null,
},
});
// Trigger immediate sync for this shelf (unlimited lookups, process all books)
try {
const jobQueue = getJobQueueService();
await jobQueue.addSyncGoodreadsShelvesJob(undefined, shelf.id, 0);
logger.info(`Triggered immediate sync for shelf "${shelfName}" (${shelf.id})`);
} catch (error) {
logger.error('Failed to trigger immediate shelf sync', { error: error instanceof Error ? error.message : String(error) });
}
return NextResponse.json({
success: true,
shelf: {
id: shelf.id,
name: shelf.name,
rssUrl: shelf.rssUrl,
lastSyncAt: shelf.lastSyncAt,
createdAt: shelf.createdAt,
bookCount: shelf.bookCount,
books: initialBooks,
},
bookCount,
}, { status: 201 });
} catch (error) {
logger.error('Failed to add shelf', { error: error instanceof Error ? error.message : String(error) });
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: 'ValidationError', details: error.errors },
{ status: 400 }
);
}
return NextResponse.json({ error: 'Failed to add shelf' }, { status: 500 });
}
});
}
+9
View File
@@ -196,3 +196,12 @@ body {
.animate-toast-in { .animate-toast-in {
animation: toast-slide-in 0.3s ease-out; animation: toast-slide-in 0.3s ease-out;
} }
/* Hide scrollbar while keeping scroll functional */
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
+8 -4
View File
@@ -38,6 +38,7 @@ function LoginContent() {
hasLocalUsers: boolean; hasLocalUsers: boolean;
oidcProviderName: string | null; oidcProviderName: string | null;
localLoginDisabled: boolean; localLoginDisabled: boolean;
allowWeakPassword: boolean;
automationEnabled: boolean; automationEnabled: boolean;
} | null>(null); } | null>(null);
const [showRegisterForm, setShowRegisterForm] = useState(false); const [showRegisterForm, setShowRegisterForm] = useState(false);
@@ -78,6 +79,7 @@ function LoginContent() {
hasLocalUsers: false, hasLocalUsers: false,
oidcProviderName: null, oidcProviderName: null,
localLoginDisabled: false, localLoginDisabled: false,
allowWeakPassword: false,
automationEnabled: false, automationEnabled: false,
}); });
} }
@@ -345,7 +347,7 @@ function LoginContent() {
return; return;
} }
if (registerPassword.length < 8) { if (!authProviders?.allowWeakPassword && registerPassword.length < 8) {
setError('Password must be at least 8 characters'); setError('Password must be at least 8 characters');
setIsLoggingIn(false); setIsLoggingIn(false);
return; return;
@@ -639,10 +641,12 @@ function LoginContent() {
className="w-full px-4 py-3 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent" className="w-full px-4 py-3 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent"
placeholder="••••••••" placeholder="••••••••"
required required
minLength={8} minLength={authProviders?.allowWeakPassword ? 1 : 8}
autoComplete="new-password" autoComplete="new-password"
/> />
<p className="text-xs text-gray-500 mt-1">At least 8 characters</p> {!authProviders?.allowWeakPassword && (
<p className="text-xs text-gray-500 mt-1">At least 8 characters</p>
)}
</div> </div>
<div> <div>
<label htmlFor="register-confirm-password" className="block text-sm font-medium text-gray-300 mb-2"> <label htmlFor="register-confirm-password" className="block text-sm font-medium text-gray-300 mb-2">
@@ -656,7 +660,7 @@ function LoginContent() {
className="w-full px-4 py-3 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent" className="w-full px-4 py-3 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent"
placeholder="••••••••" placeholder="••••••••"
required required
minLength={8} minLength={authProviders?.allowWeakPassword ? 1 : 8}
autoComplete="new-password" autoComplete="new-password"
/> />
</div> </div>
+124 -252
View File
@@ -11,80 +11,63 @@ import { RequestCard } from '@/components/requests/RequestCard';
import { useAuth } from '@/contexts/AuthContext'; import { useAuth } from '@/contexts/AuthContext';
import { useRequests } from '@/lib/hooks/useRequests'; import { useRequests } from '@/lib/hooks/useRequests';
import { cn } from '@/lib/utils/cn'; import { cn } from '@/lib/utils/cn';
import { GoodreadsShelvesSection } from '@/components/profile/GoodreadsShelvesSection';
const statConfig = [
{ key: 'total', label: 'Total', color: 'text-gray-900 dark:text-white' },
{ key: 'active', label: 'Active', color: 'text-blue-500' },
{ key: 'waiting', label: 'Waiting', color: 'text-amber-500' },
{ key: 'completed', label: 'Complete', color: 'text-emerald-500' },
{ key: 'failed', label: 'Failed', color: 'text-red-500' },
{ key: 'cancelled', label: 'Cancelled', color: 'text-gray-400 dark:text-gray-500' },
] as const;
type StatKey = (typeof statConfig)[number]['key'];
export default function ProfilePage() { export default function ProfilePage() {
const { user } = useAuth(); const { user } = useAuth();
// Always show only the current user's own requests (even for admins)
const { requests, isLoading } = useRequests(undefined, 50, true); const { requests, isLoading } = useRequests(undefined, 50, true);
// Calculate statistics
const stats = useMemo(() => { const stats = useMemo(() => {
if (!requests.length) { if (!requests.length) {
return { return { total: 0, completed: 0, active: 0, waiting: 0, failed: 0, cancelled: 0 };
total: 0,
completed: 0,
active: 0,
waiting: 0,
failed: 0,
cancelled: 0,
};
} }
return { return {
total: requests.length, total: requests.length,
completed: requests.filter((r: any) => ['available', 'downloaded'].includes(r.status)).length, completed: requests.filter((r: any) => ['available', 'downloaded'].includes(r.status)).length,
active: requests.filter((r: any) => active: requests.filter((r: any) => ['pending', 'searching', 'downloading', 'processing'].includes(r.status)).length,
['pending', 'searching', 'downloading', 'processing'].includes(r.status) waiting: requests.filter((r: any) => ['awaiting_search', 'awaiting_import'].includes(r.status)).length,
).length,
waiting: requests.filter((r: any) =>
['awaiting_search', 'awaiting_import'].includes(r.status)
).length,
failed: requests.filter((r: any) => r.status === 'failed').length, failed: requests.filter((r: any) => r.status === 'failed').length,
cancelled: requests.filter((r: any) => r.status === 'cancelled').length, cancelled: requests.filter((r: any) => r.status === 'cancelled').length,
}; };
}, [requests]); }, [requests]);
// Get active downloads (downloading or processing)
const activeDownloads = useMemo(() => { const activeDownloads = useMemo(() => {
return requests.filter((r: any) => return requests.filter((r: any) => ['downloading', 'processing'].includes(r.status));
['downloading', 'processing'].includes(r.status)
);
}, [requests]); }, [requests]);
// Get recent requests (last 5)
const recentRequests = useMemo(() => { const recentRequests = useMemo(() => {
return [...requests] return [...requests]
.sort((a: any, b: any) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) .sort((a: any, b: any) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
.slice(0, 5); .slice(0, 5);
}, [requests]); }, [requests]);
// Redirect to login if not authenticated
if (!user) { if (!user) {
return ( return (
<div className="min-h-screen"> <div className="min-h-screen">
<Header /> <Header />
<main className="container mx-auto px-4 py-8 max-w-7xl"> <main className="container mx-auto px-4 py-20 max-w-5xl text-center">
<div className="text-center py-16 space-y-4"> <div className="w-16 h-16 rounded-full bg-gray-100 dark:bg-gray-800 flex items-center justify-center mx-auto mb-5">
<svg <svg className="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
className="mx-auto h-16 w-16 text-gray-400" <path strokeLinecap="round" strokeLinejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z" />
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
/>
</svg> </svg>
<h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
Authentication Required
</h2>
<p className="text-gray-600 dark:text-gray-400">
Please log in to view your profile
</p>
</div> </div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">
Sign in required
</h2>
<p className="text-gray-500 dark:text-gray-400">
Please log in to view your profile
</p>
</main> </main>
</div> </div>
); );
@@ -94,183 +77,83 @@ export default function ProfilePage() {
<div className="min-h-screen"> <div className="min-h-screen">
<Header /> <Header />
<main className="container mx-auto px-4 py-8 max-w-7xl space-y-8"> <main className="container mx-auto px-4 py-8 max-w-5xl space-y-10">
{/* User Info Card */} {/* Profile Card — gradient banner + avatar + info + stats */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-4 sm:p-6"> <section className="rounded-2xl overflow-hidden bg-white dark:bg-gray-800 border border-gray-100 dark:border-gray-700/50 shadow-sm">
<div className="flex flex-col sm:flex-row items-center gap-4 sm:gap-6"> {/* Gradient Banner */}
<div className="h-32 sm:h-40 bg-gradient-to-br from-blue-600 via-indigo-500 to-violet-600" />
{/* Profile Content — overlapping the banner */}
<div className="px-6 sm:px-8 pb-8 -mt-14 sm:-mt-16">
{/* Avatar */} {/* Avatar */}
<div className="flex-shrink-0"> {user.avatarUrl ? (
{user.avatarUrl ? ( <img
<img src={user.avatarUrl}
src={user.avatarUrl} alt={user.username}
alt={user.username} className="w-28 h-28 rounded-full ring-4 ring-white dark:ring-gray-800 shadow-lg object-cover mb-5"
className="w-24 h-24 rounded-full" />
/> ) : (
) : ( <div className="w-28 h-28 rounded-full bg-gradient-to-br from-blue-400 to-blue-600 flex items-center justify-center text-white text-4xl font-bold ring-4 ring-white dark:ring-gray-800 shadow-lg mb-5">
<div className="w-24 h-24 rounded-full bg-blue-600 flex items-center justify-center text-white text-3xl font-bold"> {user.username.charAt(0).toUpperCase()}
{user.username.charAt(0).toUpperCase()} </div>
</div> )}
)}
</div>
{/* User Details */} {/* Name + Email + Badge */}
<div className="flex-1 space-y-2 text-center sm:text-left"> <h1 className="text-3xl font-bold text-gray-900 dark:text-white">
<h1 className="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-gray-100"> {user.username}
{user.username} </h1>
</h1> {user.email && (
{user.email && ( <p className="text-base text-gray-500 dark:text-gray-400 mt-1">
<p className="text-gray-600 dark:text-gray-400"> {user.email}
{user.email} </p>
</p> )}
)} <div className="mt-3">
<div className="flex items-center gap-2"> <span
<span className={cn(
className={cn( 'inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold uppercase tracking-wide',
'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium', user.role === 'admin'
user.role === 'admin' ? 'bg-purple-50 text-purple-600 dark:bg-purple-500/15 dark:text-purple-400'
? 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200' : 'bg-gray-100 text-gray-500 dark:bg-gray-700/50 dark:text-gray-400'
: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300' )}
)} >
> {user.role === 'admin' ? 'Administrator' : 'User'}
{user.role === 'admin' ? 'Administrator' : 'User'} </span>
</span>
<span className="text-sm text-gray-500 dark:text-gray-500">
Plex ID: {user.plexId}
</span>
</div>
</div>
</div>
</div>
{/* Statistics Grid */}
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3 sm:gap-4">
{/* Total Requests */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-4 sm:p-6">
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-3 sm:gap-4">
<div className="flex-shrink-0">
<div className="w-10 h-10 sm:w-12 sm:h-12 bg-blue-100 dark:bg-blue-900 rounded-lg flex items-center justify-center">
<svg className="w-6 h-6 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
</div>
<div>
<p className="text-xs sm:text-sm text-gray-600 dark:text-gray-400">Total</p>
<p className="text-xl sm:text-2xl font-bold text-gray-900 dark:text-gray-100">
{isLoading ? '...' : stats.total}
</p>
</div>
</div> </div>
</div> </div>
{/* Active Requests */} {/* Stats Strip */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-4 sm:p-6"> <div className="grid grid-cols-3 sm:grid-cols-6 gap-px bg-gray-100 dark:bg-gray-700/30">
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-3 sm:gap-4"> {statConfig.map((stat) => (
<div className="flex-shrink-0"> <div
<div className="w-10 h-10 sm:w-12 sm:h-12 bg-blue-100 dark:bg-blue-900 rounded-lg flex items-center justify-center"> key={stat.key}
<svg className="w-6 h-6 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> className="py-5 sm:py-6 px-3 text-center bg-white dark:bg-gray-800"
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" /> >
</svg> <div className={cn('text-2xl sm:text-3xl font-bold tabular-nums', stat.color)}>
{isLoading ? '\u2013' : stats[stat.key as StatKey]}
</div>
<div className="text-xs font-medium text-gray-400 dark:text-gray-500 uppercase tracking-wider mt-1.5">
{stat.label}
</div> </div>
</div> </div>
<div> ))}
<p className="text-xs sm:text-sm text-gray-600 dark:text-gray-400">Active</p>
<p className="text-xl sm:text-2xl font-bold text-blue-600 dark:text-blue-400">
{isLoading ? '...' : stats.active}
</p>
</div>
</div>
</div> </div>
</section>
{/* Waiting Requests */} {/* Goodreads Shelves */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-4 sm:p-6"> <GoodreadsShelvesSection />
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-3 sm:gap-4">
<div className="flex-shrink-0">
<div className="w-12 h-12 bg-yellow-100 dark:bg-yellow-900 rounded-lg flex items-center justify-center">
<svg className="w-6 h-6 text-yellow-600 dark:text-yellow-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
</div>
<div>
<p className="text-xs sm:text-sm text-gray-600 dark:text-gray-400">Waiting</p>
<p className="text-xl sm:text-2xl font-bold text-yellow-600 dark:text-yellow-400">
{isLoading ? '...' : stats.waiting}
</p>
</div>
</div>
</div>
{/* Completed Requests */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-4 sm:p-6">
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-3 sm:gap-4">
<div className="flex-shrink-0">
<div className="w-12 h-12 bg-green-100 dark:bg-green-900 rounded-lg flex items-center justify-center">
<svg className="w-6 h-6 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
</div>
<div>
<p className="text-xs sm:text-sm text-gray-600 dark:text-gray-400">Completed</p>
<p className="text-xl sm:text-2xl font-bold text-green-600 dark:text-green-400">
{isLoading ? '...' : stats.completed}
</p>
</div>
</div>
</div>
{/* Failed Requests */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-4 sm:p-6">
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-3 sm:gap-4">
<div className="flex-shrink-0">
<div className="w-12 h-12 bg-red-100 dark:bg-red-900 rounded-lg flex items-center justify-center">
<svg className="w-6 h-6 text-red-600 dark:text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
</div>
<div>
<p className="text-xs sm:text-sm text-gray-600 dark:text-gray-400">Failed</p>
<p className="text-xl sm:text-2xl font-bold text-red-600 dark:text-red-400">
{isLoading ? '...' : stats.failed}
</p>
</div>
</div>
</div>
{/* Cancelled Requests */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-4 sm:p-6">
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-3 sm:gap-4">
<div className="flex-shrink-0">
<div className="w-12 h-12 bg-gray-100 dark:bg-gray-700 rounded-lg flex items-center justify-center">
<svg className="w-6 h-6 text-gray-600 dark:text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</div>
</div>
<div>
<p className="text-xs sm:text-sm text-gray-600 dark:text-gray-400">Cancelled</p>
<p className="text-xl sm:text-2xl font-bold text-gray-600 dark:text-gray-400">
{isLoading ? '...' : stats.cancelled}
</p>
</div>
</div>
</div>
</div>
{/* Active Downloads */} {/* Active Downloads */}
{activeDownloads.length > 0 && ( {activeDownloads.length > 0 && (
<div className="space-y-4"> <section>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between mb-5">
<h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100"> <h2 className="text-xl font-bold text-gray-900 dark:text-white">
Active Downloads Active Downloads
</h2> </h2>
<a <a
href="/requests" href="/requests"
className="text-sm text-blue-600 dark:text-blue-400 hover:underline" className="text-sm font-medium text-gray-500 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
> >
View All Requests View All
</a> </a>
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
@@ -278,21 +161,23 @@ export default function ProfilePage() {
<RequestCard key={request.id} request={request} showActions={false} /> <RequestCard key={request.id} request={request} showActions={false} />
))} ))}
</div> </div>
</div> </section>
)} )}
{/* Recent Requests */} {/* Recent Requests */}
<div className="space-y-4"> <section>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between mb-5">
<h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100"> <h2 className="text-xl font-bold text-gray-900 dark:text-white">
Recent Requests Recent Requests
</h2> </h2>
<a {requests.length > 0 && (
href="/requests" <a
className="text-sm text-blue-600 dark:text-blue-400 hover:underline" href="/requests"
> className="text-sm font-medium text-gray-500 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
View All Requests >
</a> View All
</a>
)}
</div> </div>
{isLoading ? ( {isLoading ? (
@@ -300,14 +185,14 @@ export default function ProfilePage() {
{[1, 2, 3].map((i) => ( {[1, 2, 3].map((i) => (
<div <div
key={i} key={i}
className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-4 animate-pulse" className="rounded-2xl bg-white dark:bg-gray-800 border border-gray-100 dark:border-gray-700/50 p-5 animate-pulse"
> >
<div className="flex gap-4"> <div className="flex gap-4">
<div className="w-24 h-36 bg-gray-300 dark:bg-gray-700 rounded"></div> <div className="w-20 h-28 bg-gray-100 dark:bg-gray-700/50 rounded-lg flex-shrink-0" />
<div className="flex-1 space-y-3"> <div className="flex-1 space-y-3 py-1">
<div className="h-6 bg-gray-300 dark:bg-gray-700 rounded w-3/4"></div> <div className="h-6 bg-gray-100 dark:bg-gray-700/50 rounded w-3/4" />
<div className="h-4 bg-gray-300 dark:bg-gray-700 rounded w-1/2"></div> <div className="h-4 bg-gray-100 dark:bg-gray-700/50 rounded w-1/2" />
<div className="h-6 bg-gray-300 dark:bg-gray-700 rounded w-24"></div> <div className="h-6 bg-gray-100 dark:bg-gray-700/50 rounded w-24" />
</div> </div>
</div> </div>
</div> </div>
@@ -320,47 +205,34 @@ export default function ProfilePage() {
))} ))}
</div> </div>
) : ( ) : (
<div className="text-center py-16 bg-white dark:bg-gray-800 rounded-lg shadow-md space-y-4"> <div className="rounded-2xl border-2 border-dashed border-gray-200 dark:border-gray-700/50 py-16 text-center">
<svg <svg
className="mx-auto h-16 w-16 text-gray-400" className="mx-auto w-10 h-10 text-gray-300 dark:text-gray-600 mb-4"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
viewBox="0 0 24 24" viewBox="0 0 24 24"
strokeWidth={1.5}
> >
<path <path strokeLinecap="round" strokeLinejoin="round" d="M9 9l10.5-3m0 6.553v3.75a2.25 2.25 0 01-1.632 2.163l-1.32.377a1.803 1.803 0 11-.99-3.467l2.31-.66a2.25 2.25 0 001.632-2.163zm0 0V2.25L9 5.25v10.303m0 0v3.75a2.25 2.25 0 01-1.632 2.163l-1.32.377a1.803 1.803 0 01-.99-3.467l2.31-.66A2.25 2.25 0 009 15.553z" />
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg> </svg>
<div className="space-y-2"> <p className="text-base font-medium text-gray-500 dark:text-gray-400">
<h3 className="text-xl font-semibold text-gray-900 dark:text-gray-100"> No requests yet
No requests yet </p>
</h3> <p className="text-sm text-gray-400 dark:text-gray-600 mt-1">
<p className="text-gray-600 dark:text-gray-400"> Search for audiobooks to get started
Start by searching for audiobooks and requesting them </p>
</p> <a
</div> href="/search"
<div className="pt-4"> className="inline-flex items-center gap-2 mt-5 px-5 py-2.5 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors"
<a >
href="/search" <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
className="inline-flex items-center gap-2 px-6 py-3 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 transition-colors" <path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
> </svg>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> Search Audiobooks
<path </a>
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
Search Audiobooks
</a>
</div>
</div> </div>
)} )}
</div> </section>
</main> </main>
</div> </div>
); );
+28
View File
@@ -27,7 +27,13 @@ import { AudibleRegion } from '@/lib/types/audible';
interface SelectedIndexer { interface SelectedIndexer {
id: number; id: number;
name: string; name: string;
protocol: string;
priority: number; priority: number;
seedingTimeMinutes?: number;
removeAfterProcessing?: boolean;
rssEnabled: boolean;
audiobookCategories: number[];
ebookCategories: number[];
} }
interface SetupState { interface SetupState {
@@ -86,6 +92,14 @@ interface SetupState {
bookdateApiKey: string; bookdateApiKey: string;
bookdateModel: string; bookdateModel: string;
bookdateConfigured: boolean; bookdateConfigured: boolean;
// Cached UI state for back-navigation persistence
plexLibraries: { id: string; title: string; type: string }[];
absLibraries: { id: string; name: string; itemCount: number }[];
oidcTested: boolean;
pathsTested: boolean;
bookdateModels: { id: string; name: string }[];
validated: { validated: {
plex: boolean; plex: boolean;
prowlarr: boolean; prowlarr: boolean;
@@ -152,6 +166,14 @@ export default function SetupWizard() {
bookdateApiKey: '', bookdateApiKey: '',
bookdateModel: '', bookdateModel: '',
bookdateConfigured: false, bookdateConfigured: false,
// Cached UI state for back-navigation persistence
plexLibraries: [],
absLibraries: [],
oidcTested: false,
pathsTested: false,
bookdateModels: [],
validated: { validated: {
plex: false, plex: false,
prowlarr: false, prowlarr: false,
@@ -379,6 +401,7 @@ export default function SetupWizard() {
plexToken={state.plexToken} plexToken={state.plexToken}
plexLibraryId={state.plexLibraryId} plexLibraryId={state.plexLibraryId}
plexTriggerScanAfterImport={state.plexTriggerScanAfterImport} plexTriggerScanAfterImport={state.plexTriggerScanAfterImport}
plexLibraries={state.plexLibraries}
onUpdate={updateField} onUpdate={updateField}
onNext={() => goToStep(currentStepNumber + 1)} onNext={() => goToStep(currentStepNumber + 1)}
onBack={() => goToStep(currentStepNumber - 1)} onBack={() => goToStep(currentStepNumber - 1)}
@@ -397,6 +420,7 @@ export default function SetupWizard() {
absApiToken={state.absApiToken} absApiToken={state.absApiToken}
absLibraryId={state.absLibraryId} absLibraryId={state.absLibraryId}
absTriggerScanAfterImport={state.absTriggerScanAfterImport} absTriggerScanAfterImport={state.absTriggerScanAfterImport}
absLibraries={state.absLibraries}
onUpdate={updateField} onUpdate={updateField}
onNext={() => goToStep(currentStepNumber + 1)} onNext={() => goToStep(currentStepNumber + 1)}
onBack={() => goToStep(currentStepNumber - 1)} onBack={() => goToStep(currentStepNumber - 1)}
@@ -435,6 +459,7 @@ export default function SetupWizard() {
oidcAdminClaimEnabled={state.oidcAdminClaimEnabled} oidcAdminClaimEnabled={state.oidcAdminClaimEnabled}
oidcAdminClaimName={state.oidcAdminClaimName} oidcAdminClaimName={state.oidcAdminClaimName}
oidcAdminClaimValue={state.oidcAdminClaimValue} oidcAdminClaimValue={state.oidcAdminClaimValue}
oidcTested={state.oidcTested}
onUpdate={updateField} onUpdate={updateField}
onNext={() => goToStep(currentStepNumber + 1)} onNext={() => goToStep(currentStepNumber + 1)}
onBack={() => goToStep(currentStepNumber - 1)} onBack={() => goToStep(currentStepNumber - 1)}
@@ -482,6 +507,7 @@ export default function SetupWizard() {
<ProwlarrStep <ProwlarrStep
prowlarrUrl={state.prowlarrUrl} prowlarrUrl={state.prowlarrUrl}
prowlarrApiKey={state.prowlarrApiKey} prowlarrApiKey={state.prowlarrApiKey}
prowlarrIndexers={state.prowlarrIndexers}
onUpdate={updateField} onUpdate={updateField}
onNext={() => goToStep(currentStepNumber + 1)} onNext={() => goToStep(currentStepNumber + 1)}
onBack={() => goToStep(currentStepNumber - 1)} onBack={() => goToStep(currentStepNumber - 1)}
@@ -512,6 +538,7 @@ export default function SetupWizard() {
mediaDir={state.mediaDir} mediaDir={state.mediaDir}
metadataTaggingEnabled={state.metadataTaggingEnabled} metadataTaggingEnabled={state.metadataTaggingEnabled}
chapterMergingEnabled={state.chapterMergingEnabled} chapterMergingEnabled={state.chapterMergingEnabled}
pathsTested={state.pathsTested}
onUpdate={updateField} onUpdate={updateField}
onNext={() => goToStep(currentStepNumber + 1)} onNext={() => goToStep(currentStepNumber + 1)}
onBack={() => goToStep(currentStepNumber - 1)} onBack={() => goToStep(currentStepNumber - 1)}
@@ -528,6 +555,7 @@ export default function SetupWizard() {
bookdateApiKey={state.bookdateApiKey} bookdateApiKey={state.bookdateApiKey}
bookdateModel={state.bookdateModel} bookdateModel={state.bookdateModel}
bookdateConfigured={state.bookdateConfigured} bookdateConfigured={state.bookdateConfigured}
bookdateModels={state.bookdateModels}
onUpdate={updateField} onUpdate={updateField}
onNext={() => goToStep(currentStepNumber + 1)} onNext={() => goToStep(currentStepNumber + 1)}
onSkip={() => goToStep(currentStepNumber + 1)} onSkip={() => goToStep(currentStepNumber + 1)}
+22 -3
View File
@@ -5,7 +5,7 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
interface AdminAccountStepProps { interface AdminAccountStepProps {
@@ -25,6 +25,23 @@ export function AdminAccountStep({
}: AdminAccountStepProps) { }: AdminAccountStepProps) {
const [confirmPassword, setConfirmPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState('');
const [errors, setErrors] = useState<{ username?: string; password?: string; confirm?: string }>({}); const [errors, setErrors] = useState<{ username?: string; password?: string; confirm?: string }>({});
const [allowWeakPassword, setAllowWeakPassword] = useState(false);
// Fetch password policy
useEffect(() => {
const fetchPolicy = async () => {
try {
const response = await fetch('/api/auth/providers');
if (response.ok) {
const data = await response.json();
setAllowWeakPassword(data.allowWeakPassword === true);
}
} catch {
// Default to strict validation on error
}
};
fetchPolicy();
}, []);
const validate = () => { const validate = () => {
const newErrors: { username?: string; password?: string; confirm?: string } = {}; const newErrors: { username?: string; password?: string; confirm?: string } = {};
@@ -35,7 +52,9 @@ export function AdminAccountStep({
} }
// Validate password // Validate password
if (!adminPassword || adminPassword.length < 8) { if (!adminPassword) {
newErrors.password = 'Password is required';
} else if (!allowWeakPassword && adminPassword.length < 8) {
newErrors.password = 'Password must be at least 8 characters'; newErrors.password = 'Password must be at least 8 characters';
} }
@@ -104,7 +123,7 @@ export function AdminAccountStep({
<p className="mt-1 text-sm text-red-400">{errors.password}</p> <p className="mt-1 text-sm text-red-400">{errors.password}</p>
)} )}
<p className="mt-1 text-xs text-gray-500"> <p className="mt-1 text-xs text-gray-500">
Choose a strong password (minimum 8 characters) {allowWeakPassword ? 'Choose a password' : 'Choose a strong password (minimum 8 characters)'}
</p> </p>
</div> </div>
+13 -5
View File
@@ -14,7 +14,8 @@ interface AudiobookshelfStepProps {
absApiToken: string; absApiToken: string;
absLibraryId: string; absLibraryId: string;
absTriggerScanAfterImport: boolean; absTriggerScanAfterImport: boolean;
onUpdate: (field: string, value: string | boolean) => void; absLibraries: Library[];
onUpdate: (field: string, value: any) => void;
onNext: () => void; onNext: () => void;
onBack: () => void; onBack: () => void;
} }
@@ -30,6 +31,7 @@ export function AudiobookshelfStep({
absApiToken, absApiToken,
absLibraryId, absLibraryId,
absTriggerScanAfterImport, absTriggerScanAfterImport,
absLibraries,
onUpdate, onUpdate,
onNext, onNext,
onBack, onBack,
@@ -39,8 +41,12 @@ export function AudiobookshelfStep({
success: boolean; success: boolean;
message?: string; message?: string;
libraries?: Library[]; libraries?: Library[];
} | null>(null); } | null>(
const [libraries, setLibraries] = useState<Library[]>([]); absLibraries.length > 0
? { success: true, message: 'Connection verified previously.' }
: null
);
const [libraries, setLibraries] = useState<Library[]>(absLibraries);
const testConnection = async () => { const testConnection = async () => {
setTesting(true); setTesting(true);
@@ -56,12 +62,14 @@ export function AudiobookshelfStep({
const data = await response.json(); const data = await response.json();
if (response.ok && data.success) { if (response.ok && data.success) {
const libs = data.libraries || [];
setTestResult({ setTestResult({
success: true, success: true,
message: 'Connection successful!', message: 'Connection successful!',
libraries: data.libraries || [], libraries: libs,
}); });
setLibraries(data.libraries || []); setLibraries(libs);
onUpdate('absLibraries', libs);
} else { } else {
setTestResult({ setTestResult({
success: false, success: false,
+11 -4
View File
@@ -12,6 +12,7 @@ interface BookDateStepProps {
bookdateApiKey: string; bookdateApiKey: string;
bookdateModel: string; bookdateModel: string;
bookdateConfigured: boolean; bookdateConfigured: boolean;
bookdateModels: ModelOption[];
onUpdate: (field: string, value: any) => void; onUpdate: (field: string, value: any) => void;
onNext: () => void; onNext: () => void;
onSkip: () => void; onSkip: () => void;
@@ -28,6 +29,7 @@ export function BookDateStep({
bookdateApiKey, bookdateApiKey,
bookdateModel, bookdateModel,
bookdateConfigured, bookdateConfigured,
bookdateModels,
onUpdate, onUpdate,
onNext, onNext,
onSkip, onSkip,
@@ -35,7 +37,7 @@ export function BookDateStep({
}: BookDateStepProps) { }: BookDateStepProps) {
const [testing, setTesting] = useState(false); const [testing, setTesting] = useState(false);
const [tested, setTested] = useState(bookdateConfigured); const [tested, setTested] = useState(bookdateConfigured);
const [models, setModels] = useState<ModelOption[]>([]); const [models, setModels] = useState<ModelOption[]>(bookdateModels);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const handleTestConnection = async () => { const handleTestConnection = async () => {
@@ -65,19 +67,22 @@ export function BookDateStep({
throw new Error(data.error || 'Connection test failed'); throw new Error(data.error || 'Connection test failed');
} }
setModels(data.models || []); const fetchedModels = data.models || [];
setModels(fetchedModels);
setTested(true); setTested(true);
onUpdate('bookdateConfigured', true); onUpdate('bookdateConfigured', true);
onUpdate('bookdateModels', fetchedModels);
// Auto-select first model if none selected // Auto-select first model if none selected
if (!bookdateModel && data.models?.length > 0) { if (!bookdateModel && fetchedModels.length > 0) {
onUpdate('bookdateModel', data.models[0].id); onUpdate('bookdateModel', fetchedModels[0].id);
} }
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Connection test failed'); setError(err instanceof Error ? err.message : 'Connection test failed');
setTested(false); setTested(false);
onUpdate('bookdateConfigured', false); onUpdate('bookdateConfigured', false);
onUpdate('bookdateModels', []);
} finally { } finally {
setTesting(false); setTesting(false);
} }
@@ -123,6 +128,7 @@ export function BookDateStep({
setTested(false); setTested(false);
setModels([]); setModels([]);
onUpdate('bookdateConfigured', false); onUpdate('bookdateConfigured', false);
onUpdate('bookdateModels', []);
}} }}
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500" className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500"
> >
@@ -144,6 +150,7 @@ export function BookDateStep({
setTested(false); setTested(false);
setModels([]); setModels([]);
onUpdate('bookdateConfigured', false); onUpdate('bookdateConfigured', false);
onUpdate('bookdateModels', []);
}} }}
placeholder={bookdateProvider === 'openai' ? 'sk-...' : 'sk-ant-...'} placeholder={bookdateProvider === 'openai' ? 'sk-...' : 'sk-ant-...'}
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500" className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500"
+2 -1
View File
@@ -5,7 +5,7 @@
'use client'; 'use client';
import { useState, useEffect } from 'react'; import { useState } from 'react';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { DownloadClientManagement } from '@/components/admin/download-clients/DownloadClientManagement'; import { DownloadClientManagement } from '@/components/admin/download-clients/DownloadClientManagement';
import { DownloadClientType } from '@/lib/interfaces/download-client.interface'; import { DownloadClientType } from '@/lib/interfaces/download-client.interface';
@@ -24,6 +24,7 @@ interface DownloadClient {
localPath?: string; localPath?: string;
category?: string; category?: string;
customPath?: string; customPath?: string;
postImportCategory?: string;
} }
interface DownloadClientStepProps { interface DownloadClientStepProps {
+10 -1
View File
@@ -22,6 +22,7 @@ interface OIDCConfigStepProps {
oidcAdminClaimEnabled: boolean; oidcAdminClaimEnabled: boolean;
oidcAdminClaimName: string; oidcAdminClaimName: string;
oidcAdminClaimValue: string; oidcAdminClaimValue: string;
oidcTested: boolean;
onUpdate: (field: string, value: any) => void; onUpdate: (field: string, value: any) => void;
onNext: () => void; onNext: () => void;
onBack: () => void; onBack: () => void;
@@ -40,6 +41,7 @@ export function OIDCConfigStep({
oidcAdminClaimEnabled, oidcAdminClaimEnabled,
oidcAdminClaimName, oidcAdminClaimName,
oidcAdminClaimValue, oidcAdminClaimValue,
oidcTested,
onUpdate, onUpdate,
onNext, onNext,
onBack, onBack,
@@ -48,7 +50,11 @@ export function OIDCConfigStep({
const [testResult, setTestResult] = useState<{ const [testResult, setTestResult] = useState<{
success: boolean; success: boolean;
message: string; message: string;
} | null>(null); } | null>(
oidcTested
? { success: true, message: 'OIDC configuration verified previously.' }
: null
);
const testConnection = async () => { const testConnection = async () => {
setTesting(true); setTesting(true);
@@ -72,17 +78,20 @@ export function OIDCConfigStep({
success: true, success: true,
message: 'OIDC discovery successful! Provider configuration validated.', message: 'OIDC discovery successful! Provider configuration validated.',
}); });
onUpdate('oidcTested', true);
} else { } else {
setTestResult({ setTestResult({
success: false, success: false,
message: data.error || 'OIDC discovery failed', message: data.error || 'OIDC discovery failed',
}); });
onUpdate('oidcTested', false);
} }
} catch (error) { } catch (error) {
setTestResult({ setTestResult({
success: false, success: false,
message: error instanceof Error ? error.message : 'Connection test failed', message: error instanceof Error ? error.message : 'Connection test failed',
}); });
onUpdate('oidcTested', false);
} finally { } finally {
setTesting(false); setTesting(false);
} }
+11 -2
View File
@@ -14,7 +14,8 @@ interface PathsStepProps {
mediaDir: string; mediaDir: string;
metadataTaggingEnabled: boolean; metadataTaggingEnabled: boolean;
chapterMergingEnabled: boolean; chapterMergingEnabled: boolean;
onUpdate: (field: string, value: string | boolean) => void; pathsTested: boolean;
onUpdate: (field: string, value: any) => void;
onNext: () => void; onNext: () => void;
onBack: () => void; onBack: () => void;
} }
@@ -24,6 +25,7 @@ export function PathsStep({
mediaDir, mediaDir,
metadataTaggingEnabled, metadataTaggingEnabled,
chapterMergingEnabled, chapterMergingEnabled,
pathsTested,
onUpdate, onUpdate,
onNext, onNext,
onBack, onBack,
@@ -34,7 +36,11 @@ export function PathsStep({
message: string; message: string;
downloadDirValid?: boolean; downloadDirValid?: boolean;
mediaDirValid?: boolean; mediaDirValid?: boolean;
} | null>(null); } | null>(
pathsTested
? { success: true, message: 'Paths validated previously.', downloadDirValid: true, mediaDirValid: true }
: null
);
const testPaths = async () => { const testPaths = async () => {
setTesting(true); setTesting(true);
@@ -59,6 +65,7 @@ export function PathsStep({
downloadDirValid: data.downloadDirValid, downloadDirValid: data.downloadDirValid,
mediaDirValid: data.mediaDirValid, mediaDirValid: data.mediaDirValid,
}); });
onUpdate('pathsTested', true);
} else { } else {
setTestResult({ setTestResult({
success: false, success: false,
@@ -66,12 +73,14 @@ export function PathsStep({
downloadDirValid: data.downloadDirValid, downloadDirValid: data.downloadDirValid,
mediaDirValid: data.mediaDirValid, mediaDirValid: data.mediaDirValid,
}); });
onUpdate('pathsTested', false);
} }
} catch (error) { } catch (error) {
setTestResult({ setTestResult({
success: false, success: false,
message: error instanceof Error ? error.message : 'Path validation failed', message: error instanceof Error ? error.message : 'Path validation failed',
}); });
onUpdate('pathsTested', false);
} finally { } finally {
setTesting(false); setTesting(false);
} }
+13 -5
View File
@@ -14,7 +14,8 @@ interface PlexStepProps {
plexToken: string; plexToken: string;
plexLibraryId: string; plexLibraryId: string;
plexTriggerScanAfterImport: boolean; plexTriggerScanAfterImport: boolean;
onUpdate: (field: string, value: string | boolean) => void; plexLibraries: PlexLibrary[];
onUpdate: (field: string, value: any) => void;
onNext: () => void; onNext: () => void;
onBack: () => void; onBack: () => void;
} }
@@ -30,6 +31,7 @@ export function PlexStep({
plexToken, plexToken,
plexLibraryId, plexLibraryId,
plexTriggerScanAfterImport, plexTriggerScanAfterImport,
plexLibraries,
onUpdate, onUpdate,
onNext, onNext,
onBack, onBack,
@@ -39,8 +41,12 @@ export function PlexStep({
success: boolean; success: boolean;
message: string; message: string;
libraries?: PlexLibrary[]; libraries?: PlexLibrary[];
} | null>(null); } | null>(
const [libraries, setLibraries] = useState<PlexLibrary[]>([]); plexLibraries.length > 0
? { success: true, message: 'Connection verified previously.' }
: null
);
const [libraries, setLibraries] = useState<PlexLibrary[]>(plexLibraries);
const testConnection = async () => { const testConnection = async () => {
setTesting(true); setTesting(true);
@@ -56,12 +62,14 @@ export function PlexStep({
const data = await response.json(); const data = await response.json();
if (response.ok && data.success) { if (response.ok && data.success) {
const libs = data.libraries || [];
setTestResult({ setTestResult({
success: true, success: true,
message: `Connected to ${data.serverName || 'Plex server'} successfully!`, message: `Connected to ${data.serverName || 'Plex server'} successfully!`,
libraries: data.libraries || [], libraries: libs,
}); });
setLibraries(data.libraries || []); setLibraries(libs);
onUpdate('plexLibraries', libs);
} else { } else {
setTestResult({ setTestResult({
success: false, success: false,
+10 -7
View File
@@ -5,7 +5,7 @@
'use client'; 'use client';
import { useState, useEffect } from 'react'; import { useState } from 'react';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input'; import { Input } from '@/components/ui/Input';
import { IndexerManagement } from '@/components/admin/indexers/IndexerManagement'; import { IndexerManagement } from '@/components/admin/indexers/IndexerManagement';
@@ -13,6 +13,7 @@ import { IndexerManagement } from '@/components/admin/indexers/IndexerManagement
interface ProwlarrStepProps { interface ProwlarrStepProps {
prowlarrUrl: string; prowlarrUrl: string;
prowlarrApiKey: string; prowlarrApiKey: string;
prowlarrIndexers: SelectedIndexer[];
onUpdate: (field: string, value: any) => void; onUpdate: (field: string, value: any) => void;
onNext: () => void; onNext: () => void;
onBack: () => void; onBack: () => void;
@@ -33,17 +34,19 @@ interface SelectedIndexer {
export function ProwlarrStep({ export function ProwlarrStep({
prowlarrUrl, prowlarrUrl,
prowlarrApiKey, prowlarrApiKey,
prowlarrIndexers,
onUpdate, onUpdate,
onNext, onNext,
onBack, onBack,
}: ProwlarrStepProps) { }: ProwlarrStepProps) {
const [configuredIndexers, setConfiguredIndexers] = useState<SelectedIndexer[]>([]); const [configuredIndexers, setConfiguredIndexers] = useState<SelectedIndexer[]>(prowlarrIndexers);
const [errorMessage, setErrorMessage] = useState<string | null>(null); const [errorMessage, setErrorMessage] = useState<string | null>(null);
// Sync configured indexers with parent // Update both local and parent state when indexers change
useEffect(() => { const handleIndexersChange = (indexers: SelectedIndexer[]) => {
onUpdate('prowlarrIndexers', configuredIndexers); setConfiguredIndexers(indexers);
}, [configuredIndexers, onUpdate]); onUpdate('prowlarrIndexers', indexers);
};
const handleNext = () => { const handleNext = () => {
setErrorMessage(null); setErrorMessage(null);
@@ -136,7 +139,7 @@ export function ProwlarrStep({
prowlarrApiKey={prowlarrApiKey} prowlarrApiKey={prowlarrApiKey}
mode="wizard" mode="wizard"
initialIndexers={configuredIndexers} initialIndexers={configuredIndexers}
onIndexersChange={setConfiguredIndexers} onIndexersChange={handleIndexersChange}
/> />
</div> </div>
</div> </div>
@@ -16,6 +16,7 @@ interface DownloadClientCardProps {
url: string; url: string;
enabled: boolean; enabled: boolean;
customPath?: string; customPath?: string;
postImportCategory?: string;
}; };
onEdit: () => void; onEdit: () => void;
onDelete: () => void; onDelete: () => void;
@@ -62,6 +63,11 @@ export function DownloadClientCard({ client, onEdit, onDelete }: DownloadClientC
Path: {client.customPath} Path: {client.customPath}
</p> </p>
)} )}
{client.postImportCategory && (
<p className="text-xs text-purple-600 dark:text-purple-400 truncate" title={`Post-import category: ${client.postImportCategory}`}>
Post-import: {client.postImportCategory}
</p>
)}
</div> </div>
</div> </div>
@@ -26,6 +26,7 @@ interface DownloadClient {
localPath?: string; localPath?: string;
category?: string; category?: string;
customPath?: string; customPath?: string;
postImportCategory?: string;
} }
interface DownloadClientManagementProps { interface DownloadClientManagementProps {
@@ -72,20 +73,6 @@ export function DownloadClientManagement({
} }
}, [downloadDirProp]); }, [downloadDirProp]);
// Sync with parent when clients change
useEffect(() => {
if (onClientsChange) {
onClientsChange(clients);
}
}, [clients, onClientsChange]);
// Sync with initialClients prop changes (wizard mode)
useEffect(() => {
if (mode === 'wizard') {
setClients(initialClients);
}
}, [initialClients, mode]);
const fetchClients = async () => { const fetchClients = async () => {
setLoading(true); setLoading(true);
setError(null); setError(null);
@@ -172,7 +159,9 @@ export function DownloadClientManagement({
await fetchClients(); // Refresh list await fetchClients(); // Refresh list
} else { } else {
// Local removal for wizard mode // Local removal for wizard mode
setClients(clients.filter(c => c.id !== deleteConfirm.clientId)); const updated = clients.filter(c => c.id !== deleteConfirm.clientId);
setClients(updated);
onClientsChange?.(updated);
} }
setDeleteConfirm({ isOpen: false }); setDeleteConfirm({ isOpen: false });
@@ -219,15 +208,18 @@ export function DownloadClientManagement({
} }
} else { } else {
// Local update for wizard mode // Local update for wizard mode
let updated: DownloadClient[];
if (modalState.mode === 'add') { if (modalState.mode === 'add') {
const newClient = { const newClient = {
...clientData, ...clientData,
id: `temp-${Date.now()}`, // Temporary ID for wizard mode id: `temp-${Date.now()}`, // Temporary ID for wizard mode
}; };
setClients([...clients, newClient]); updated = [...clients, newClient];
} else { } else {
setClients(clients.map(c => (c.id === clientData.id ? { ...c, ...clientData } : c))); updated = clients.map(c => (c.id === clientData.id ? { ...c, ...clientData } : c));
} }
setClients(updated);
onClientsChange?.(updated);
} }
setModalState({ isOpen: false, mode: 'add' }); setModalState({ isOpen: false, mode: 'add' });
@@ -10,7 +10,7 @@ import { Modal } from '@/components/ui/Modal';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input'; import { Input } from '@/components/ui/Input';
import { fetchWithAuth } from '@/lib/utils/api'; import { fetchWithAuth } from '@/lib/utils/api';
import { DownloadClientType, getClientDisplayName } from '@/lib/interfaces/download-client.interface'; import { DownloadClientType, getClientDisplayName, CLIENT_PROTOCOL_MAP } from '@/lib/interfaces/download-client.interface';
interface DownloadClientModalProps { interface DownloadClientModalProps {
isOpen: boolean; isOpen: boolean;
@@ -31,6 +31,7 @@ interface DownloadClientModalProps {
localPath?: string; localPath?: string;
category?: string; category?: string;
customPath?: string; customPath?: string;
postImportCategory?: string;
}; };
onSave: (client: any) => Promise<void>; onSave: (client: any) => Promise<void>;
apiMode: 'wizard' | 'settings'; apiMode: 'wizard' | 'settings';
@@ -62,6 +63,9 @@ export function DownloadClientModal({
const [localPath, setLocalPath] = useState(''); const [localPath, setLocalPath] = useState('');
const [category, setCategory] = useState('readmeabook'); const [category, setCategory] = useState('readmeabook');
const [customPath, setCustomPath] = useState(''); const [customPath, setCustomPath] = useState('');
const [postImportCategory, setPostImportCategory] = useState('');
const [availableCategories, setAvailableCategories] = useState<string[]>([]);
const [fetchingCategories, setFetchingCategories] = useState(false);
const [testing, setTesting] = useState(false); const [testing, setTesting] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@@ -85,6 +89,7 @@ export function DownloadClientModal({
setLocalPath(initialClient.localPath || ''); setLocalPath(initialClient.localPath || '');
setCategory(initialClient.category || 'readmeabook'); setCategory(initialClient.category || 'readmeabook');
setCustomPath(initialClient.customPath || ''); setCustomPath(initialClient.customPath || '');
setPostImportCategory(initialClient.postImportCategory || '');
} else { } else {
// Add mode defaults // Add mode defaults
setName(typeName); setName(typeName);
@@ -98,9 +103,12 @@ export function DownloadClientModal({
setLocalPath(''); setLocalPath('');
setCategory('readmeabook'); setCategory('readmeabook');
setCustomPath(''); setCustomPath('');
setPostImportCategory('');
} }
setTestResult(null); setTestResult(null);
setErrors({}); setErrors({});
setAvailableCategories([]);
setFetchingCategories(false);
} }
}, [isOpen, mode, initialClient, type]); }, [isOpen, mode, initialClient, type]);
@@ -137,6 +145,50 @@ export function DownloadClientModal({
return Object.keys(newErrors).length === 0; return Object.keys(newErrors).length === 0;
}; };
const fetchCategories = async () => {
setFetchingCategories(true);
try {
const isPasswordMasked = password === '********';
const categoryData = {
type,
name,
url,
username: username || undefined,
password: isPasswordMasked ? undefined : password,
...(mode === 'edit' && initialClient && isPasswordMasked ? { clientId: initialClient.id } : {}),
disableSSLVerify,
remotePathMappingEnabled,
remotePath: remotePathMappingEnabled ? remotePath : undefined,
localPath: remotePathMappingEnabled ? localPath : undefined,
};
const endpoint = apiMode === 'wizard'
? '/api/setup/download-client-categories'
: '/api/admin/settings/download-clients/categories';
const response = apiMode === 'wizard'
? await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(categoryData),
})
: await fetchWithAuth(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(categoryData),
});
const data = await response.json();
if (response.ok && data.success) {
setAvailableCategories(data.categories || []);
}
} catch {
// Non-critical — categories are optional
} finally {
setFetchingCategories(false);
}
};
const handleTestConnection = async () => { const handleTestConnection = async () => {
if (!validate()) { if (!validate()) {
return; return;
@@ -187,6 +239,11 @@ export function DownloadClientModal({
// Handle both endpoint response formats (settings returns message, wizard returns version) // Handle both endpoint response formats (settings returns message, wizard returns version)
const message = data.message || (data.version ? `Connected successfully (v${data.version})` : 'Connection successful'); const message = data.message || (data.version ? `Connected successfully (v${data.version})` : 'Connection successful');
setTestResult({ success: true, message }); setTestResult({ success: true, message });
// Fetch categories for torrent clients after successful connection
if (type && CLIENT_PROTOCOL_MAP[type] === 'torrent') {
fetchCategories();
}
} else { } else {
setTestResult({ success: false, message: data.error || 'Connection test failed' }); setTestResult({ success: false, message: data.error || 'Connection test failed' });
} }
@@ -230,6 +287,7 @@ export function DownloadClientModal({
localPath: remotePathMappingEnabled ? localPath : undefined, localPath: remotePathMappingEnabled ? localPath : undefined,
category, category,
customPath: sanitizedCustomPath || undefined, customPath: sanitizedCustomPath || undefined,
postImportCategory,
}; };
if (mode === 'edit' && initialClient) { if (mode === 'edit' && initialClient) {
@@ -384,6 +442,37 @@ export function DownloadClientModal({
</p> </p>
</div> </div>
{/* Post-Import Category (torrent clients only) */}
{type && CLIENT_PROTOCOL_MAP[type] === 'torrent' && (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Post-Import Category
</label>
{type === 'qbittorrent' && availableCategories.length > 0 ? (
<select
value={postImportCategory}
onChange={(e) => setPostImportCategory(e.target.value)}
className="w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
<option value="">None (keep original)</option>
{availableCategories.map((cat) => (
<option key={cat} value={cat}>{cat}</option>
))}
</select>
) : (
<Input
value={postImportCategory}
onChange={(e) => setPostImportCategory(e.target.value)}
placeholder="e.g. completed"
disabled={fetchingCategories}
/>
)}
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
After import, change the download&apos;s category/label in the client. Leave empty to skip.
</p>
</div>
)}
{/* Remote Path Mapping */} {/* Remote Path Mapping */}
<div className="border-t border-gray-200 dark:border-gray-700 pt-4"> <div className="border-t border-gray-200 dark:border-gray-700 pt-4">
<div className="flex items-start mb-3"> <div className="flex items-start mb-3">
@@ -63,17 +63,14 @@ export function IndexerManagement({
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// Sync with parent when configuredIndexers changes // In settings mode, the parent fetches indexers asynchronously and passes them
// as initialIndexers after mount. This effect picks up that late-arriving data.
// Wizard mode doesn't need this — it initializes correctly via useState above.
useEffect(() => { useEffect(() => {
if (onIndexersChange) { if (mode === 'settings') {
onIndexersChange(configuredIndexers); setConfiguredIndexers(initialIndexers);
} }
}, [configuredIndexers, onIndexersChange]); }, [initialIndexers, mode]);
// Sync with initialIndexers prop changes
useEffect(() => {
setConfiguredIndexers(initialIndexers);
}, [initialIndexers]);
const fetchIndexers = async () => { const fetchIndexers = async () => {
setLoading(true); setLoading(true);
@@ -149,17 +146,16 @@ export function IndexerManagement({
}; };
const handleSave = (config: SavedIndexerConfig) => { const handleSave = (config: SavedIndexerConfig) => {
let updated: SavedIndexerConfig[];
if (modalState.mode === 'add') { if (modalState.mode === 'add') {
// Add new indexer updated = [...configuredIndexers, config];
setConfiguredIndexers([...configuredIndexers, config]);
} else { } else {
// Update existing indexer updated = configuredIndexers.map((idx) =>
setConfiguredIndexers( idx.id === config.id ? config : idx
configuredIndexers.map((idx) =>
idx.id === config.id ? config : idx
)
); );
} }
setConfiguredIndexers(updated);
onIndexersChange?.(updated);
}; };
const handleDelete = (id: number) => { const handleDelete = (id: number) => {
@@ -175,9 +171,9 @@ export function IndexerManagement({
const confirmDelete = () => { const confirmDelete = () => {
if (deleteModalState.indexerId) { if (deleteModalState.indexerId) {
setConfiguredIndexers( const updated = configuredIndexers.filter((idx) => idx.id !== deleteModalState.indexerId);
configuredIndexers.filter((idx) => idx.id !== deleteModalState.indexerId) setConfiguredIndexers(updated);
); onIndexersChange?.(updated);
} }
}; };
@@ -244,6 +244,7 @@ export function AudiobookCard({
requestStatus={audiobook.requestStatus} requestStatus={audiobook.requestStatus}
isAvailable={audiobook.isAvailable} isAvailable={audiobook.isAvailable}
requestedByUsername={audiobook.requestedByUsername} requestedByUsername={audiobook.requestedByUsername}
hasReportedIssue={audiobook.hasReportedIssue}
/> />
</> </>
); );
@@ -16,6 +16,7 @@ import { useCreateRequest, useEbookStatus, useFetchEbookByAsin } from '@/lib/hoo
import { useAuth } from '@/contexts/AuthContext'; import { useAuth } from '@/contexts/AuthContext';
import { usePreferences } from '@/contexts/PreferencesContext'; import { usePreferences } from '@/contexts/PreferencesContext';
import { InteractiveTorrentSearchModal } from '@/components/requests/InteractiveTorrentSearchModal'; import { InteractiveTorrentSearchModal } from '@/components/requests/InteractiveTorrentSearchModal';
import { ReportIssueModal } from '@/components/audiobooks/ReportIssueModal';
interface AudiobookDetailsModalProps { interface AudiobookDetailsModalProps {
asin: string; asin: string;
@@ -27,6 +28,7 @@ interface AudiobookDetailsModalProps {
isAvailable?: boolean; isAvailable?: boolean;
requestedByUsername?: string | null; requestedByUsername?: string | null;
hideRequestActions?: boolean; hideRequestActions?: boolean;
hasReportedIssue?: boolean;
} }
// Status helper // Status helper
@@ -65,6 +67,7 @@ export function AudiobookDetailsModal({
isAvailable = false, isAvailable = false,
requestedByUsername = null, requestedByUsername = null,
hideRequestActions = false, hideRequestActions = false,
hasReportedIssue = false,
}: AudiobookDetailsModalProps) { }: AudiobookDetailsModalProps) {
const { user } = useAuth(); const { user } = useAuth();
const { squareCovers } = usePreferences(); const { squareCovers } = usePreferences();
@@ -79,6 +82,7 @@ export function AudiobookDetailsModal({
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
const [showInteractiveSearch, setShowInteractiveSearch] = useState(false); const [showInteractiveSearch, setShowInteractiveSearch] = useState(false);
const [showInteractiveSearchEbook, setShowInteractiveSearchEbook] = useState(false); const [showInteractiveSearchEbook, setShowInteractiveSearchEbook] = useState(false);
const [showReportIssue, setShowReportIssue] = useState(false);
const [asinCopied, setAsinCopied] = useState(false); const [asinCopied, setAsinCopied] = useState(false);
const status = getStatusInfo(isAvailable, requestStatus, requestedByUsername); const status = getStatusInfo(isAvailable, requestStatus, requestedByUsername);
@@ -316,6 +320,33 @@ export function AudiobookDetailsModal({
</div> </div>
)} )}
{/* Issue Reported Badge */}
{isAvailable && hasReportedIssue && (
<div className="mt-2 inline-flex">
<span className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm font-medium bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 21v-4m0 0V5a2 2 0 012-2h6.5l1 1H21l-3 6 3 6h-8.5l-1-1H5a2 2 0 00-2 2zm9-13.5V9" />
</svg>
Issue Reported
</span>
</div>
)}
{/* Report Issue Button - inline with metadata, not in action bar */}
{isAvailable && !hasReportedIssue && user && (
<div className="mt-2 inline-flex">
<button
onClick={() => setShowReportIssue(true)}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm font-medium text-red-600 dark:text-red-400 hover:bg-red-100 dark:hover:bg-red-900/30 transition-colors"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 21v-4m0 0V5a2 2 0 012-2h6.5l1 1H21l-3 6 3 6h-8.5l-1-1H5a2 2 0 00-2 2zm9-13.5V9" />
</svg>
Report Issue
</button>
</div>
)}
{/* Quick Metadata */} {/* Quick Metadata */}
<div className="mt-4 flex flex-wrap items-center justify-center sm:justify-start gap-3 text-sm text-gray-500 dark:text-gray-400"> <div className="mt-4 flex flex-wrap items-center justify-center sm:justify-start gap-3 text-sm text-gray-500 dark:text-gray-400">
{audiobook.durationMinutes && ( {audiobook.durationMinutes && (
@@ -526,6 +557,7 @@ export function AudiobookDetailsModal({
)} )}
</> </>
)} )}
</div> </div>
</div> </div>
)} )}
@@ -594,6 +626,22 @@ export function AudiobookDetailsModal({
</div>, </div>,
document.body document.body
)} )}
{/* Report Issue Modal */}
{showReportIssue && audiobook && (
<ReportIssueModal
isOpen={showReportIssue}
onClose={() => setShowReportIssue(false)}
onSuccess={() => {
setShowReportIssue(false);
showNotification('Issue reported!');
}}
asin={asin}
bookTitle={audiobook.title}
bookAuthor={audiobook.author}
coverArtUrl={audiobook.coverArtUrl}
/>
)}
</> </>
); );
} }
@@ -0,0 +1,143 @@
/**
* Component: Report Issue Modal
* Documentation: documentation/frontend/components.md
*
* Sub-modal for reporting problems with available audiobooks.
* Rendered via portal at z-[60] to layer above AudiobookDetailsModal.
*/
'use client';
import React, { useState } from 'react';
import { createPortal } from 'react-dom';
import { useReportIssue } from '@/lib/hooks/useReportedIssues';
interface ReportIssueModalProps {
isOpen: boolean;
onClose: () => void;
onSuccess: () => void;
asin: string;
bookTitle: string;
bookAuthor: string;
coverArtUrl?: string;
}
export function ReportIssueModal({
isOpen,
onClose,
onSuccess,
asin,
bookTitle,
bookAuthor,
coverArtUrl,
}: ReportIssueModalProps) {
const { reportIssue, isLoading } = useReportIssue();
const [reason, setReason] = useState('');
const [error, setError] = useState<string | null>(null);
const maxChars = 250;
const canSubmit = reason.trim().length > 0 && reason.length <= maxChars && !isLoading;
const handleSubmit = async () => {
if (!canSubmit) return;
setError(null);
try {
await reportIssue(asin, reason.trim(), {
title: bookTitle,
author: bookAuthor,
coverArtUrl,
});
setReason('');
onSuccess();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to report issue');
}
};
if (!isOpen) return null;
const modalContent = (
<div
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/40 dark:bg-black/60 backdrop-blur-sm animate-in fade-in duration-150"
onClick={() => !isLoading && onClose()}
>
<div
className="mx-5 w-full max-w-sm bg-white dark:bg-gray-800 rounded-2xl shadow-2xl shadow-black/20 overflow-hidden animate-in zoom-in-95 duration-200"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="px-5 pt-5 pb-4">
<div className="flex items-center gap-3 mb-3">
<div className="w-10 h-10 rounded-xl bg-orange-500/10 dark:bg-orange-400/15 flex items-center justify-center flex-shrink-0">
<svg className="w-5 h-5 text-orange-600 dark:text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 21v-4m0 0V5a2 2 0 012-2h6.5l1 1H21l-3 6 3 6h-8.5l-1-1H5a2 2 0 00-2 2zm9-13.5V9" />
</svg>
</div>
<div className="min-w-0">
<h3 className="text-[15px] font-semibold text-gray-900 dark:text-white">
Report Issue
</h3>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-0.5 truncate">
{bookTitle}
</p>
</div>
</div>
{/* Reason Textarea */}
<div className="space-y-2">
<textarea
value={reason}
onChange={(e) => {
setReason(e.target.value);
if (error) setError(null);
}}
placeholder="Describe the problem (e.g., corrupted audio, wrong book, missing chapters...)"
rows={3}
maxLength={maxChars}
disabled={isLoading}
className="w-full px-3.5 py-2.5 bg-gray-50 dark:bg-white/[0.06] rounded-xl border border-gray-200 dark:border-gray-700 text-sm text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 resize-none focus:outline-none focus:border-orange-500/40 focus:ring-1 focus:ring-orange-500/20 transition-all disabled:opacity-50"
/>
<div className="flex items-center justify-between px-1">
<div className="min-h-[1.25rem]">
{error && (
<p className="text-xs text-red-500 dark:text-red-400">{error}</p>
)}
</div>
<span className={`text-xs tabular-nums ${reason.length > maxChars ? 'text-red-500' : 'text-gray-400 dark:text-gray-500'}`}>
{reason.length}/{maxChars}
</span>
</div>
</div>
</div>
{/* Actions */}
<div className="flex border-t border-gray-200/80 dark:border-gray-700/50">
<button
onClick={onClose}
disabled={isLoading}
className="flex-1 px-4 py-3 text-[15px] font-medium text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-white/[0.03] transition-colors disabled:opacity-40 border-r border-gray-200/80 dark:border-gray-700/50"
>
Cancel
</button>
<button
onClick={handleSubmit}
disabled={!canSubmit}
className="flex-1 px-4 py-3 text-[15px] font-semibold text-orange-600 dark:text-orange-400 hover:bg-orange-50 dark:hover:bg-orange-500/10 transition-colors disabled:opacity-40 disabled:pointer-events-none"
>
{isLoading ? (
<span className="flex items-center justify-center gap-2">
<div className="w-4 h-4 border-2 border-orange-300 dark:border-orange-600 border-t-orange-600 dark:border-t-orange-400 rounded-full animate-spin" />
Submitting...
</span>
) : (
'Submit Report'
)}
</button>
</div>
</div>
</div>
);
return createPortal(modalContent, document.body);
}
+17
View File
@@ -12,6 +12,7 @@ import { useAuth } from '@/contexts/AuthContext';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { VersionBadge } from '@/components/ui/VersionBadge'; import { VersionBadge } from '@/components/ui/VersionBadge';
import { ChangePasswordModal } from '@/components/ui/ChangePasswordModal'; import { ChangePasswordModal } from '@/components/ui/ChangePasswordModal';
import { AddGoodreadsShelfModal } from '@/components/ui/AddGoodreadsShelfModal';
import { useSmartDropdownPosition } from '@/hooks/useSmartDropdownPosition'; import { useSmartDropdownPosition } from '@/hooks/useSmartDropdownPosition';
export function Header() { export function Header() {
@@ -20,6 +21,7 @@ export function Header() {
const [showMobileMenu, setShowMobileMenu] = useState(false); const [showMobileMenu, setShowMobileMenu] = useState(false);
const [showBookDate, setShowBookDate] = useState(false); const [showBookDate, setShowBookDate] = useState(false);
const [showChangePasswordModal, setShowChangePasswordModal] = useState(false); const [showChangePasswordModal, setShowChangePasswordModal] = useState(false);
const [showAddGoodreadsModal, setShowAddGoodreadsModal] = useState(false);
const { containerRef, dropdownRef, positionAbove, style } = useSmartDropdownPosition(showUserMenu); const { containerRef, dropdownRef, positionAbove, style } = useSmartDropdownPosition(showUserMenu);
// Check if user can change password (local users only) // Check if user can change password (local users only)
@@ -90,6 +92,15 @@ export function Header() {
> >
Profile Profile
</Link> </Link>
<button
onClick={() => {
setShowUserMenu(false);
setShowAddGoodreadsModal(true);
}}
className="w-full text-left px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
>
Add Goodreads Shelf
</button>
{canChangePassword && ( {canChangePassword && (
<button <button
onClick={() => { onClick={() => {
@@ -297,6 +308,12 @@ export function Header() {
isOpen={showChangePasswordModal} isOpen={showChangePasswordModal}
onClose={() => setShowChangePasswordModal(false)} onClose={() => setShowChangePasswordModal(false)}
/> />
{/* Add Goodreads Shelf Modal */}
<AddGoodreadsShelfModal
isOpen={showAddGoodreadsModal}
onClose={() => setShowAddGoodreadsModal(false)}
/>
</header> </header>
); );
} }
@@ -0,0 +1,360 @@
/**
* Component: Goodreads Shelves Section (Profile Page)
* Documentation: documentation/frontend/components.md
*/
'use client';
import React, { useState } from 'react';
import { useGoodreadsShelves, useDeleteGoodreadsShelf, GoodreadsShelf, ShelfBook } from '@/lib/hooks/useGoodreadsShelves';
import { AddGoodreadsShelfModal } from '@/components/ui/AddGoodreadsShelfModal';
import { AudiobookDetailsModal } from '@/components/audiobooks/AudiobookDetailsModal';
import { usePreferences } from '@/contexts/PreferencesContext';
import { cn } from '@/lib/utils/cn';
function formatRelativeTime(dateStr: string | null): string {
if (!dateStr) return 'Never';
const date = new Date(dateStr);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
if (diffMins < 1) return 'just now';
if (diffMins < 60) return `${diffMins}m ago`;
const diffHours = Math.floor(diffMins / 60);
if (diffHours < 24) return `${diffHours}h ago`;
const diffDays = Math.floor(diffHours / 24);
return `${diffDays}d ago`;
}
export function GoodreadsShelvesSection() {
const { shelves, isLoading } = useGoodreadsShelves();
const { deleteShelf, isLoading: isDeleting } = useDeleteGoodreadsShelf();
const { squareCovers } = usePreferences();
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
const [showAddModal, setShowAddModal] = useState(false);
const [selectedAsin, setSelectedAsin] = useState<string | null>(null);
const handleDelete = async (shelfId: string) => {
try {
await deleteShelf(shelfId);
setConfirmDeleteId(null);
} catch {
// Error handled by hook
}
};
return (
<section>
{/* Section Header */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-amber-50 to-orange-50 dark:from-amber-500/10 dark:to-orange-500/10 flex items-center justify-center ring-1 ring-amber-200/50 dark:ring-amber-500/10">
<svg className="w-[18px] h-[18px] text-amber-600 dark:text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25" />
</svg>
</div>
<div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-white leading-tight">
Goodreads Shelves
</h2>
{!isLoading && shelves.length > 0 && (
<p className="text-xs text-gray-400 dark:text-gray-500 mt-0.5">
{shelves.length} {shelves.length === 1 ? 'shelf' : 'shelves'} connected
</p>
)}
</div>
</div>
<button
onClick={() => setShowAddModal(true)}
className="inline-flex items-center gap-1.5 px-3.5 py-2 text-sm font-medium text-gray-600 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl hover:bg-gray-50 dark:hover:bg-gray-700/70 hover:border-gray-300 dark:hover:border-gray-600 transition-all duration-200 shadow-sm"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
Add Shelf
</button>
</div>
{/* Content */}
{isLoading ? (
<ShelfCardSkeleton squareCovers={squareCovers} />
) : shelves.length > 0 ? (
<div className="space-y-4">
{shelves.map((shelf) => (
<ShelfCard
key={shelf.id}
shelf={shelf}
squareCovers={squareCovers}
isDeleting={isDeleting && confirmDeleteId === shelf.id}
isConfirmingDelete={confirmDeleteId === shelf.id}
onDelete={() => handleDelete(shelf.id)}
onConfirmDelete={() => setConfirmDeleteId(shelf.id)}
onCancelDelete={() => setConfirmDeleteId(null)}
onBookClick={(asin) => setSelectedAsin(asin)}
/>
))}
</div>
) : (
<EmptyState onAdd={() => setShowAddModal(true)} />
)}
<AddGoodreadsShelfModal
isOpen={showAddModal}
onClose={() => setShowAddModal(false)}
/>
{/* Audiobook Detail Modal (read-only) */}
{selectedAsin && (
<AudiobookDetailsModal
asin={selectedAsin}
isOpen={true}
onClose={() => setSelectedAsin(null)}
hideRequestActions
/>
)}
</section>
);
}
/* ─── Empty State ─── */
function EmptyState({ onAdd }: { onAdd: () => void }) {
return (
<div className="rounded-2xl border border-dashed border-gray-200 dark:border-gray-700/40 p-10 sm:p-14 text-center">
<div className="mx-auto w-14 h-14 rounded-2xl bg-gradient-to-br from-amber-50 to-orange-50 dark:from-amber-500/10 dark:to-orange-500/10 flex items-center justify-center mb-5 ring-1 ring-amber-200/50 dark:ring-amber-500/10">
<svg className="w-7 h-7 text-amber-500 dark:text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25" />
</svg>
</div>
<h3 className="text-base font-semibold text-gray-700 dark:text-gray-200 mb-1.5">
Connect your reading list
</h3>
<p className="text-sm text-gray-400 dark:text-gray-500 max-w-xs mx-auto mb-7 leading-relaxed">
Link a Goodreads shelf and we&apos;ll automatically request the audiobook for every book you add.
</p>
<button
onClick={onAdd}
className="inline-flex items-center gap-2 px-5 py-2.5 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-xl transition-colors shadow-sm"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
Add Your First Shelf
</button>
</div>
);
}
/* ─── Loading Skeleton ─── */
function ShelfCardSkeleton({ squareCovers }: { squareCovers: boolean }) {
return (
<div className="rounded-2xl bg-white dark:bg-gray-800 border border-gray-100 dark:border-gray-700/30 p-6 sm:p-7">
<div className="mb-5">
<div className="h-[18px] w-52 bg-gray-100 dark:bg-gray-700/50 rounded-lg animate-pulse mb-2.5" />
<div className="flex items-center gap-2">
<div className="h-[22px] w-16 bg-gray-100 dark:bg-gray-700/50 rounded-md animate-pulse" />
<div className="h-3.5 w-24 bg-gray-100 dark:bg-gray-700/50 rounded-md animate-pulse" />
</div>
</div>
<div className="flex items-end">
{[...Array(5)].map((_, i) => (
<div
key={i}
className={cn(
'rounded-xl bg-gray-100 dark:bg-gray-700/40 animate-pulse flex-shrink-0 ring-2 ring-white dark:ring-gray-800',
squareCovers ? 'w-[80px] h-[80px]' : 'w-[72px] h-[108px]'
)}
style={{ marginLeft: i > 0 ? '-16px' : 0, zIndex: 5 - i }}
/>
))}
</div>
</div>
);
}
/* ─── Shelf Card ─── */
interface ShelfCardProps {
shelf: GoodreadsShelf;
squareCovers: boolean;
isDeleting: boolean;
isConfirmingDelete: boolean;
onDelete: () => void;
onConfirmDelete: () => void;
onCancelDelete: () => void;
onBookClick: (asin: string) => void;
}
function ShelfCard({
shelf,
squareCovers,
isDeleting,
isConfirmingDelete,
onDelete,
onConfirmDelete,
onCancelDelete,
onBookClick,
}: ShelfCardProps) {
const displayBooks = shelf.books.slice(0, 6);
const hasCovers = displayBooks.length > 0;
const remainingCount = Math.max(0, (shelf.bookCount || 0) - displayBooks.length);
const isSyncing = !shelf.lastSyncAt;
return (
<div className="group rounded-2xl bg-white dark:bg-gray-800 border border-gray-100 dark:border-gray-700/30 p-6 sm:p-7 transition-all duration-300 hover:shadow-lg hover:shadow-black/[0.04] dark:hover:shadow-black/20 hover:border-gray-200 dark:hover:border-gray-600/40">
{/* Top: Shelf info + actions */}
<div className={cn('flex items-start justify-between', (hasCovers || isSyncing) && 'mb-5')}>
<div className="min-w-0 flex-1">
<h3 className="font-semibold text-[15px] text-gray-900 dark:text-white truncate leading-snug">
{shelf.name}
</h3>
<div className="flex items-center gap-2 mt-2">
{shelf.bookCount != null && (
<span className="inline-flex items-center px-2 py-0.5 rounded-md text-xs font-medium bg-gray-100 dark:bg-gray-700/50 text-gray-500 dark:text-gray-400 tabular-nums">
{shelf.bookCount} {shelf.bookCount === 1 ? 'book' : 'books'}
</span>
)}
<span className="inline-flex items-center gap-1.5 text-xs text-gray-400 dark:text-gray-500">
{isSyncing ? (
<>
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-blue-500" />
</span>
Syncing&hellip;
</>
) : shelf.lastSyncAt ? (
<>
<span className="inline-block w-1.5 h-1.5 rounded-full bg-emerald-500" />
Synced {formatRelativeTime(shelf.lastSyncAt)}
</>
) : (
'Pending sync'
)}
</span>
</div>
</div>
{/* Delete action */}
<div className="flex-shrink-0 ml-4">
{isConfirmingDelete ? (
<div className="flex items-center gap-2">
<button
onClick={onDelete}
disabled={isDeleting}
className="px-3 py-1.5 text-xs font-semibold text-white bg-red-500 hover:bg-red-600 rounded-lg transition-colors disabled:opacity-50"
>
{isDeleting ? 'Removing\u2026' : 'Remove'}
</button>
<button
onClick={onCancelDelete}
disabled={isDeleting}
className="px-2 py-1.5 text-xs font-medium text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors"
>
Cancel
</button>
</div>
) : (
<button
onClick={onConfirmDelete}
className="p-2 text-gray-300 hover:text-red-400 dark:text-gray-600 dark:hover:text-red-400 transition-all duration-200 rounded-xl hover:bg-red-50 dark:hover:bg-red-500/10 opacity-0 group-hover:opacity-100 focus:opacity-100"
title="Remove shelf"
>
<svg className="w-[18px] h-[18px]" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
</svg>
</button>
)}
</div>
</div>
{/* Bottom: Stacked book covers */}
{hasCovers ? (
<CoverStack books={displayBooks} remainingCount={remainingCount} squareCovers={squareCovers} onBookClick={onBookClick} />
) : isSyncing ? (
<div className="flex items-end">
{[...Array(3)].map((_, i) => (
<div
key={i}
className={cn(
'rounded-xl bg-gray-50 dark:bg-gray-700/30 animate-pulse flex-shrink-0 ring-2 ring-white dark:ring-gray-800',
squareCovers ? 'w-[80px] h-[80px]' : 'w-[72px] h-[108px]'
)}
style={{ marginLeft: i > 0 ? '-16px' : 0, zIndex: 3 - i }}
/>
))}
</div>
) : null}
</div>
);
}
/* ─── Stacked Cover Display ─── */
function CoverStack({
books,
remainingCount,
squareCovers,
onBookClick,
}: {
books: ShelfBook[];
remainingCount: number;
squareCovers: boolean;
onBookClick: (asin: string) => void;
}) {
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
const coverSize = squareCovers
? 'w-[80px] aspect-square'
: 'w-[72px] aspect-[2/3]';
return (
<div className="flex items-end">
{books.map((book, i) => (
<div
key={i}
className={cn(
'relative rounded-xl overflow-hidden shadow-md flex-shrink-0',
'ring-2 ring-white dark:ring-gray-800',
'transition-all duration-300 ease-out',
hoveredIndex === i && 'scale-[1.18] shadow-xl',
coverSize,
book.asin ? 'cursor-pointer' : 'cursor-default'
)}
style={{
marginLeft: i > 0 ? '-16px' : 0,
zIndex: hoveredIndex === i ? 50 : books.length - i,
}}
onMouseEnter={() => setHoveredIndex(i)}
onMouseLeave={() => setHoveredIndex(null)}
onClick={() => book.asin && onBookClick(book.asin)}
title={book.asin ? `${book.title}${book.author ? ` by ${book.author}` : ''}` : undefined}
>
<img
src={book.coverUrl}
alt=""
className="w-full h-full object-cover"
loading="lazy"
draggable={false}
/>
</div>
))}
{remainingCount > 0 && (
<div
className={cn(
'rounded-xl flex items-center justify-center bg-gray-50 dark:bg-gray-700/30 border border-gray-100 dark:border-gray-700/40 flex-shrink-0 ring-2 ring-white dark:ring-gray-800',
coverSize
)}
style={{ marginLeft: '-16px', zIndex: 0 }}
>
<span className="text-sm font-semibold text-gray-400 dark:text-gray-500 tabular-nums">
+{remainingCount}
</span>
</div>
)}
</div>
);
}
@@ -22,6 +22,7 @@ import {
useInteractiveSearchEbookByAsin, useInteractiveSearchEbookByAsin,
useSelectEbookByAsin, useSelectEbookByAsin,
} from '@/lib/hooks/useRequests'; } from '@/lib/hooks/useRequests';
import { useReplaceWithTorrent } from '@/lib/hooks/useReportedIssues';
import { Audiobook } from '@/lib/hooks/useAudiobooks'; import { Audiobook } from '@/lib/hooks/useAudiobooks';
interface InteractiveTorrentSearchModalProps { interface InteractiveTorrentSearchModalProps {
@@ -36,6 +37,7 @@ interface InteractiveTorrentSearchModalProps {
fullAudiobook?: Audiobook; // Optional - only provided when called from details modal fullAudiobook?: Audiobook; // Optional - only provided when called from details modal
onSuccess?: () => void; onSuccess?: () => void;
searchMode?: 'audiobook' | 'ebook'; // Search mode - defaults to audiobook searchMode?: 'audiobook' | 'ebook'; // Search mode - defaults to audiobook
replaceIssueId?: string; // Optional - when set, confirm handler calls replace endpoint instead
} }
// Format relative time from publish date // Format relative time from publish date
@@ -87,11 +89,15 @@ export function InteractiveTorrentSearchModal({
fullAudiobook, fullAudiobook,
onSuccess, onSuccess,
searchMode = 'audiobook', searchMode = 'audiobook',
replaceIssueId,
}: InteractiveTorrentSearchModalProps) { }: InteractiveTorrentSearchModalProps) {
// Hooks for existing audiobook request flow // Hooks for existing audiobook request flow
const { searchTorrents: searchByRequestId, isLoading: isSearchingByRequest, error: searchByRequestError } = useInteractiveSearch(); const { searchTorrents: searchByRequestId, isLoading: isSearchingByRequest, error: searchByRequestError } = useInteractiveSearch();
const { selectTorrent, isLoading: isSelectingTorrent, error: selectTorrentError } = useSelectTorrent(); const { selectTorrent, isLoading: isSelectingTorrent, error: selectTorrentError } = useSelectTorrent();
// Hook for reported issue replacement flow
const { replaceWithTorrent, isLoading: isReplacing, error: replaceError } = useReplaceWithTorrent();
// Hooks for new audiobook flow // Hooks for new audiobook flow
const { searchTorrents: searchByAudiobook, isLoading: isSearchingByAudiobook, error: searchByAudiobookError } = useSearchTorrents(); const { searchTorrents: searchByAudiobook, isLoading: isSearchingByAudiobook, error: searchByAudiobookError } = useSearchTorrents();
const { requestWithTorrent, isLoading: isRequestingWithTorrent, error: requestWithTorrentError } = useRequestWithTorrent(); const { requestWithTorrent, isLoading: isRequestingWithTorrent, error: requestWithTorrentError } = useRequestWithTorrent();
@@ -124,14 +130,18 @@ export function InteractiveTorrentSearchModal({
const isSearching = isEbookMode const isSearching = isEbookMode
? (useAsinMode ? isSearchingEbooksByAsin : isSearchingEbooks) ? (useAsinMode ? isSearchingEbooksByAsin : isSearchingEbooks)
: (hasRequestId ? isSearchingByRequest : isSearchingByAudiobook); : (hasRequestId ? isSearchingByRequest : isSearchingByAudiobook);
const isDownloading = isEbookMode const isDownloading = replaceIssueId
? (useAsinMode ? isSelectingEbookByAsin : isSelectingEbook) ? isReplacing
: (hasRequestId ? isSelectingTorrent : isRequestingWithTorrent); : isEbookMode
const error = isEbookMode ? (useAsinMode ? isSelectingEbookByAsin : isSelectingEbook)
? (useAsinMode ? (searchEbooksByAsinError || selectEbookByAsinError) : (searchEbooksError || selectEbookError)) : (hasRequestId ? isSelectingTorrent : isRequestingWithTorrent);
: (hasRequestId const error = replaceIssueId
? (searchByRequestError || selectTorrentError) ? (replaceError || (hasRequestId ? searchByRequestError : searchByAudiobookError))
: (searchByAudiobookError || requestWithTorrentError)); : isEbookMode
? (useAsinMode ? (searchEbooksByAsinError || selectEbookByAsinError) : (searchEbooksError || selectEbookError))
: (hasRequestId
? (searchByRequestError || selectTorrentError)
: (searchByAudiobookError || requestWithTorrentError));
// Mount tracking for portal // Mount tracking for portal
useEffect(() => { setMounted(true); }, []); useEffect(() => { setMounted(true); }, []);
@@ -188,7 +198,7 @@ export function InteractiveTorrentSearchModal({
const customTitle = searchTitle !== audiobook.title ? searchTitle : undefined; const customTitle = searchTitle !== audiobook.title ? searchTitle : undefined;
data = await searchByRequestId(requestId, customTitle); data = await searchByRequestId(requestId, customTitle);
} else { } else {
const audiobookAsin = fullAudiobook?.asin; const audiobookAsin = fullAudiobook?.asin || asin;
data = await searchByAudiobook(searchTitle, audiobook.author, audiobookAsin); data = await searchByAudiobook(searchTitle, audiobook.author, audiobookAsin);
} }
setResults(data || []); setResults(data || []);
@@ -208,7 +218,10 @@ export function InteractiveTorrentSearchModal({
const handleConfirmDownload = async () => { const handleConfirmDownload = async () => {
if (!confirmTorrent) return; if (!confirmTorrent) return;
try { try {
if (isEbookMode) { if (replaceIssueId) {
// Reported issue replacement flow
await replaceWithTorrent(replaceIssueId, confirmTorrent);
} else if (isEbookMode) {
if (useAsinMode && asin) { if (useAsinMode && asin) {
await selectEbookByAsin(asin, confirmTorrent); await selectEbookByAsin(asin, confirmTorrent);
} else if (requestId) { } else if (requestId) {
@@ -0,0 +1,154 @@
/**
* Component: Add Goodreads Shelf Modal
* Documentation: documentation/frontend/components.md
*/
'use client';
import React, { useState } from 'react';
import { Modal } from './Modal';
import { Input } from './Input';
import { Button } from './Button';
import { useAddGoodreadsShelf } from '@/lib/hooks/useGoodreadsShelves';
interface AddGoodreadsShelfModalProps {
isOpen: boolean;
onClose: () => void;
}
const GOODREADS_RSS_PATTERN = /goodreads\.com\/review\/list_rss\//;
export function AddGoodreadsShelfModal({ isOpen, onClose }: AddGoodreadsShelfModalProps) {
const [rssUrl, setRssUrl] = useState('');
const [validationError, setValidationError] = useState('');
const [success, setSuccess] = useState(false);
const [successMessage, setSuccessMessage] = useState('');
const { addShelf, isLoading, error } = useAddGoodreadsShelf();
const validateUrl = (url: string): boolean => {
if (!url.trim()) {
setValidationError('RSS URL is required');
return false;
}
if (!GOODREADS_RSS_PATTERN.test(url)) {
setValidationError('Must be a Goodreads shelf RSS URL (goodreads.com/review/list_rss/...)');
return false;
}
setValidationError('');
return true;
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!validateUrl(rssUrl)) return;
try {
const shelf = await addShelf(rssUrl);
setSuccess(true);
setSuccessMessage(`Added shelf "${shelf.name}" successfully!`);
setRssUrl('');
setTimeout(() => {
setSuccess(false);
onClose();
}, 2000);
} catch {
// Error is handled by the hook
}
};
const handleClose = () => {
setRssUrl('');
setValidationError('');
setSuccess(false);
setSuccessMessage('');
onClose();
};
return (
<Modal isOpen={isOpen} onClose={handleClose} title="Add Goodreads Shelf" size="sm">
<div className="space-y-5">
{/* Visual header */}
<div className="flex items-center gap-4 pb-4 border-b border-gray-100 dark:border-gray-700/50">
<div className="w-11 h-11 rounded-xl bg-gradient-to-br from-amber-50 to-orange-50 dark:from-amber-500/10 dark:to-orange-500/10 flex items-center justify-center ring-1 ring-amber-200/50 dark:ring-amber-500/10 flex-shrink-0">
<svg className="w-5 h-5 text-amber-600 dark:text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m9.86-2.556a4.5 4.5 0 00-6.364-6.364L4.5 8.257a4.5 4.5 0 007.244 1.242" />
</svg>
</div>
<div className="min-w-0">
<p className="text-sm text-gray-600 dark:text-gray-300 leading-relaxed">
Paste your Goodreads shelf RSS URL. Books will be automatically requested as audiobooks during each sync.
</p>
</div>
</div>
{/* Success alert */}
{success && (
<div className="flex items-center gap-3 p-3.5 bg-emerald-50 dark:bg-emerald-500/10 border border-emerald-200 dark:border-emerald-500/20 rounded-xl">
<div className="w-8 h-8 rounded-lg bg-emerald-100 dark:bg-emerald-500/20 flex items-center justify-center flex-shrink-0">
<svg className="w-4 h-4 text-emerald-600 dark:text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
</div>
<p className="text-sm font-medium text-emerald-700 dark:text-emerald-300">{successMessage}</p>
</div>
)}
{/* Error alert */}
{error && (
<div className="flex items-center gap-3 p-3.5 bg-red-50 dark:bg-red-500/10 border border-red-200 dark:border-red-500/20 rounded-xl">
<div className="w-8 h-8 rounded-lg bg-red-100 dark:bg-red-500/20 flex items-center justify-center flex-shrink-0">
<svg className="w-4 h-4 text-red-600 dark:text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" />
</svg>
</div>
<p className="text-sm font-medium text-red-700 dark:text-red-300">{error}</p>
</div>
)}
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-5">
<div>
<Input
type="url"
label="Goodreads RSS URL"
value={rssUrl}
onChange={(e) => {
setRssUrl(e.target.value);
if (validationError) setValidationError('');
}}
placeholder="https://www.goodreads.com/review/list_rss/..."
error={validationError}
disabled={isLoading || success}
/>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-2 leading-relaxed">
Find it on Goodreads: My Books &rarr; select a shelf &rarr; RSS link at the bottom of the page.
</p>
</div>
<div className="flex justify-end gap-3 pt-2">
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleClose}
disabled={isLoading || success}
>
Cancel
</Button>
<Button
type="submit"
variant="primary"
size="sm"
loading={isLoading}
disabled={isLoading || success}
>
Add Shelf
</Button>
</div>
</form>
</div>
</Modal>
);
}
+21 -3
View File
@@ -5,7 +5,7 @@
'use client'; 'use client';
import React, { useState } from 'react'; import React, { useState, useEffect } from 'react';
import { Modal } from './Modal'; import { Modal } from './Modal';
import { Input } from './Input'; import { Input } from './Input';
import { Button } from './Button'; import { Button } from './Button';
@@ -22,6 +22,24 @@ export function ChangePasswordModal({ isOpen, onClose }: ChangePasswordModalProp
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false); const [success, setSuccess] = useState(false);
const [allowWeakPassword, setAllowWeakPassword] = useState(false);
// Fetch password policy when modal opens
useEffect(() => {
if (!isOpen) return;
const fetchPolicy = async () => {
try {
const response = await fetch('/api/auth/providers');
if (response.ok) {
const data = await response.json();
setAllowWeakPassword(data.allowWeakPassword === true);
}
} catch {
// Default to strict validation on error
}
};
fetchPolicy();
}, [isOpen]);
// Validation errors for individual fields // Validation errors for individual fields
const [errors, setErrors] = useState({ const [errors, setErrors] = useState({
@@ -47,7 +65,7 @@ export function ChangePasswordModal({ isOpen, onClose }: ChangePasswordModalProp
if (!newPassword) { if (!newPassword) {
newErrors.newPassword = 'New password is required'; newErrors.newPassword = 'New password is required';
isValid = false; isValid = false;
} else if (newPassword.length < 8) { } else if (!allowWeakPassword && newPassword.length < 8) {
newErrors.newPassword = 'Password must be at least 8 characters'; newErrors.newPassword = 'Password must be at least 8 characters';
isValid = false; isValid = false;
} else if (newPassword === currentPassword) { } else if (newPassword === currentPassword) {
@@ -211,7 +229,7 @@ export function ChangePasswordModal({ isOpen, onClose }: ChangePasswordModalProp
}} }}
placeholder="Enter your new password" placeholder="Enter your new password"
autoComplete="new-password" autoComplete="new-password"
helperText="Must be at least 8 characters" helperText={allowWeakPassword ? undefined : 'Must be at least 8 characters'}
error={errors.newPassword} error={errors.newPassword}
disabled={loading || success} disabled={loading || success}
/> />
+82
View File
@@ -0,0 +1,82 @@
/**
* Component: Notification Event Constants
* Documentation: documentation/backend/services/notifications.md
*
* Single source of truth for all notification event types and metadata.
* Add new events here all providers, API schemas, and UI labels derive from this.
*/
export type NotificationSeverity = 'info' | 'success' | 'error' | 'warning';
export type NotificationPriority = 'normal' | 'high';
/**
* Central registry of notification events.
*
* Each entry defines:
* - `label`: Human-readable name shown in the UI
* - `title`: Title used in notification messages
* - `emoji`: Emoji prefix for notification titles
* - `severity`: Drives provider formatting (colors, Apprise types, ntfy tags)
* - `priority`: Drives notification urgency (Pushover/ntfy priority levels)
*/
export const NOTIFICATION_EVENTS = {
request_pending_approval: {
label: 'Request Pending Approval',
title: 'New Request Pending Approval',
emoji: '\u{1F4EC}',
severity: 'info' as const,
priority: 'normal' as const,
},
request_approved: {
label: 'Request Approved',
title: 'Request Approved',
emoji: '\u2705',
severity: 'success' as const,
priority: 'normal' as const,
},
request_available: {
label: 'Audiobook Available',
title: 'Audiobook Available',
emoji: '\u{1F389}',
severity: 'success' as const,
priority: 'high' as const,
},
request_error: {
label: 'Request Error',
title: 'Request Error',
emoji: '\u274C',
severity: 'error' as const,
priority: 'high' as const,
},
issue_reported: {
label: 'Issue Reported',
title: 'Issue Reported',
emoji: '\u{1F6A9}',
severity: 'warning' as const,
priority: 'high' as const,
},
} as const;
/** Union type of all valid notification event keys */
export type NotificationEvent = keyof typeof NOTIFICATION_EVENTS;
/** Ordered array of all notification event keys (for Zod schemas, iteration) */
export const NOTIFICATION_EVENT_KEYS = Object.keys(NOTIFICATION_EVENTS) as [NotificationEvent, ...NotificationEvent[]];
/** Metadata shape for a single notification event */
export type NotificationEventMeta = (typeof NOTIFICATION_EVENTS)[NotificationEvent];
/** Helper: get event metadata by key */
export function getEventMeta(event: NotificationEvent) {
return NOTIFICATION_EVENTS[event];
}
/** Helper: get the human-readable label for an event */
export function getEventLabel(event: NotificationEvent): string {
return NOTIFICATION_EVENTS[event].label;
}
/** Record mapping all event keys to their labels (for UI dropdowns, etc.) */
export const EVENT_LABELS: Record<NotificationEvent, string> = Object.fromEntries(
Object.entries(NOTIFICATION_EVENTS).map(([key, meta]) => [key, meta.label])
) as Record<NotificationEvent, string>;
+1
View File
@@ -26,6 +26,7 @@ export interface Audiobook {
requestStatus?: string | null; // Status of request (if any) requestStatus?: string | null; // Status of request (if any)
requestId?: string | null; // ID of request (if any) requestId?: string | null; // ID of request (if any)
requestedByUsername?: string | null; // Username who requested (only if not current user) requestedByUsername?: string | null; // Username who requested (only if not current user)
hasReportedIssue?: boolean; // True if an open issue exists for this audiobook
} }
export function useAudiobooks(type: 'popular' | 'new-releases', limit: number = 20, page: number = 1) { export function useAudiobooks(type: 'popular' | 'new-releases', limit: number = 20, page: number = 1) {
+127
View File
@@ -0,0 +1,127 @@
/**
* Component: Goodreads Shelves Hook
* Documentation: documentation/frontend/components.md
*/
'use client';
import { useState } from 'react';
import useSWR, { mutate } from 'swr';
import { useAuth } from '@/contexts/AuthContext';
import { fetchWithAuth } from '@/lib/utils/api';
export interface ShelfBook {
coverUrl: string;
asin: string | null;
title: string;
author: string;
}
export interface GoodreadsShelf {
id: string;
name: string;
rssUrl: string;
lastSyncAt: string | null;
createdAt: string;
bookCount: number | null;
books: ShelfBook[];
}
const fetcher = (url: string) =>
fetchWithAuth(url).then((res) => res.json());
export function useGoodreadsShelves() {
const { accessToken } = useAuth();
const endpoint = accessToken ? '/api/user/goodreads-shelves' : null;
const { data, error, isLoading } = useSWR(
endpoint,
fetcher,
{ refreshInterval: 30000 }
);
return {
shelves: (data?.shelves || []) as GoodreadsShelf[],
isLoading,
error,
};
}
export function useAddGoodreadsShelf() {
const { accessToken } = useAuth();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const addShelf = async (rssUrl: string) => {
if (!accessToken) throw new Error('Not authenticated');
setIsLoading(true);
setError(null);
try {
const response = await fetchWithAuth('/api/user/goodreads-shelves', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ rssUrl }),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || data.error || 'Failed to add shelf');
}
// Revalidate shelves list
mutate((key) => typeof key === 'string' && key.includes('/api/user/goodreads-shelves'));
return data.shelf as GoodreadsShelf;
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
setError(message);
throw err;
} finally {
setIsLoading(false);
}
};
return { addShelf, isLoading, error };
}
export function useDeleteGoodreadsShelf() {
const { accessToken } = useAuth();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const deleteShelf = async (shelfId: string) => {
if (!accessToken) throw new Error('Not authenticated');
setIsLoading(true);
setError(null);
try {
const response = await fetchWithAuth(`/api/user/goodreads-shelves/${shelfId}`, {
method: 'DELETE',
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || data.error || 'Failed to remove shelf');
}
// Revalidate shelves list
mutate((key) => typeof key === 'string' && key.includes('/api/user/goodreads-shelves'));
return true;
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
setError(message);
throw err;
} finally {
setIsLoading(false);
}
};
return { deleteShelf, isLoading, error };
}
+168
View File
@@ -0,0 +1,168 @@
/**
* Component: Reported Issues Hooks
* Documentation: documentation/backend/services/reported-issues.md
*/
'use client';
import { useState } from 'react';
import useSWR, { mutate } from 'swr';
import { useAuth } from '@/contexts/AuthContext';
import { fetchWithAuth } from '@/lib/utils/api';
const fetcher = (url: string) =>
fetchWithAuth(url).then((res) => res.json());
/**
* Hook for reporting an issue with an audiobook (user action)
*/
export function useReportIssue() {
const { accessToken } = useAuth();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const reportIssue = async (
asin: string,
reason: string,
metadata?: { title?: string; author?: string; coverArtUrl?: string }
) => {
if (!accessToken) throw new Error('Not authenticated');
setIsLoading(true);
setError(null);
try {
const response = await fetchWithAuth(`/api/audiobooks/${asin}/report-issue`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reason, ...metadata }),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || 'Failed to report issue');
}
// Revalidate audiobook lists to show issue indicator
mutate((key) => typeof key === 'string' && key.includes('/api/audiobooks'));
return data.issue;
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
setError(message);
throw err;
} finally {
setIsLoading(false);
}
};
return { reportIssue, isLoading, error };
}
/**
* Hook for fetching open reported issues (admin dashboard)
*/
export function useAdminReportedIssues() {
const { accessToken } = useAuth();
const endpoint = accessToken ? '/api/admin/reported-issues' : null;
const { data, error, isLoading } = useSWR(endpoint, fetcher, {
refreshInterval: 10000,
});
return {
issues: data?.issues || [],
count: data?.count || 0,
isLoading,
error,
};
}
/**
* Hook for dismissing a reported issue (admin action)
*/
export function useDismissIssue() {
const { accessToken } = useAuth();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const dismissIssue = async (issueId: string) => {
if (!accessToken) throw new Error('Not authenticated');
setIsLoading(true);
setError(null);
try {
const response = await fetchWithAuth(`/api/admin/reported-issues/${issueId}/resolve`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'dismiss' }),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || 'Failed to dismiss issue');
}
// Revalidate issues list
mutate((key) => typeof key === 'string' && key.includes('/api/admin/reported-issues'));
return data.issue;
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
setError(message);
throw err;
} finally {
setIsLoading(false);
}
};
return { dismissIssue, isLoading, error };
}
/**
* Hook for replacing audiobook content via reported issue (admin action)
*/
export function useReplaceWithTorrent() {
const { accessToken } = useAuth();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const replaceWithTorrent = async (issueId: string, torrent: any) => {
if (!accessToken) throw new Error('Not authenticated');
setIsLoading(true);
setError(null);
try {
const response = await fetchWithAuth(`/api/admin/reported-issues/${issueId}/replace`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ torrent }),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || 'Failed to replace audiobook');
}
// Revalidate issues list and audiobook lists
mutate((key) => typeof key === 'string' && key.includes('/api/admin/reported-issues'));
mutate((key) => typeof key === 'string' && key.includes('/api/audiobooks'));
return data.request;
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
setError(message);
throw err;
} finally {
setIsLoading(false);
}
};
return { replaceWithTorrent, isLoading, error };
}
+58 -32
View File
@@ -8,10 +8,24 @@ import * as cheerio from 'cheerio';
import { RMABLogger } from '../utils/logger'; import { RMABLogger } from '../utils/logger';
import { getConfigService } from '../services/config.service'; import { getConfigService } from '../services/config.service';
import { AudibleRegion, AUDIBLE_REGIONS, DEFAULT_AUDIBLE_REGION } from '../types/audible'; import { AudibleRegion, AUDIBLE_REGIONS, DEFAULT_AUDIBLE_REGION } from '../types/audible';
import {
pickUserAgent,
getBrowserHeaders,
jitteredBackoff,
AdaptivePacer,
FetchResultMeta,
} from '../utils/scrape-resilience';
// Module-level logger // Module-level logger
const logger = RMABLogger.create('Audible'); const logger = RMABLogger.create('Audible');
/**
* Audible supports a pageSize query parameter (default ~20).
* Using 50 significantly reduces the number of HTTP requests needed
* for bulk operations like popular/new-release refreshes and search.
*/
const AUDIBLE_PAGE_SIZE = 50;
export interface AudibleAudiobook { export interface AudibleAudiobook {
asin: string; asin: string;
title: string; title: string;
@@ -40,6 +54,8 @@ export class AudibleService {
private baseUrl: string = 'https://www.audible.com'; private baseUrl: string = 'https://www.audible.com';
private region: AudibleRegion = 'us'; private region: AudibleRegion = 'us';
private initialized: boolean = false; private initialized: boolean = false;
private sessionUserAgent: string = '';
private pacer: AdaptivePacer = new AdaptivePacer();
constructor() { constructor() {
// Client will be created lazily on first use // Client will be created lazily on first use
@@ -77,18 +93,16 @@ export class AudibleService {
const configService = getConfigService(); const configService = getConfigService();
this.region = await configService.getAudibleRegion(); this.region = await configService.getAudibleRegion();
this.baseUrl = AUDIBLE_REGIONS[this.region].baseUrl; this.baseUrl = AUDIBLE_REGIONS[this.region].baseUrl;
this.sessionUserAgent = pickUserAgent();
this.pacer.reset();
logger.info(`Initializing Audible service with region: ${this.region} (${this.baseUrl})`); logger.info(`Initializing Audible service with region: ${this.region} (${this.baseUrl})`);
// Create axios client with region-specific base URL // Create axios client with region-specific base URL and realistic browser headers
this.client = axios.create({ this.client = axios.create({
baseURL: this.baseUrl, baseURL: this.baseUrl,
timeout: 15000, timeout: 15000,
headers: { headers: getBrowserHeaders(this.sessionUserAgent),
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language': 'en-US,en;q=0.9',
},
params: { params: {
ipRedirectOverride: 'true', // Prevent IP-based region redirects ipRedirectOverride: 'true', // Prevent IP-based region redirects
language: 'english', // Force English locale (prevents IP-based language serving for non-English IPs) language: 'english', // Force English locale (prevents IP-based language serving for non-English IPs)
@@ -101,14 +115,12 @@ export class AudibleService {
// Fallback to default region // Fallback to default region
this.region = DEFAULT_AUDIBLE_REGION; this.region = DEFAULT_AUDIBLE_REGION;
this.baseUrl = AUDIBLE_REGIONS[this.region].baseUrl; this.baseUrl = AUDIBLE_REGIONS[this.region].baseUrl;
this.sessionUserAgent = pickUserAgent();
this.pacer.reset();
this.client = axios.create({ this.client = axios.create({
baseURL: this.baseUrl, baseURL: this.baseUrl,
timeout: 15000, timeout: 15000,
headers: { headers: getBrowserHeaders(this.sessionUserAgent),
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language': 'en-US,en;q=0.9',
},
params: { params: {
ipRedirectOverride: 'true', ipRedirectOverride: 'true',
language: 'english', language: 'english',
@@ -119,24 +131,29 @@ export class AudibleService {
} }
/** /**
* Fetch with retry logic and exponential backoff * Fetch with retry logic and jittered exponential backoff.
* Retries on network errors and rate limiting (503, 429) * Returns the axios response plus metadata about retries encountered.
*/ */
private async fetchWithRetry( private async fetchWithRetry(
url: string, url: string,
config: any = {}, config: any = {},
maxRetries: number = 5 maxRetries: number = 5
): Promise<any> { ): Promise<{ data: any; meta: FetchResultMeta }> {
let lastError: Error | null = null; let lastError: Error | null = null;
let retriesUsed = 0;
let encountered503 = false;
for (let attempt = 0; attempt <= maxRetries; attempt++) { for (let attempt = 0; attempt <= maxRetries; attempt++) {
try { try {
return await this.client.get(url, config); const response = await this.client.get(url, config);
return { data: response, meta: { retriesUsed, encountered503 } };
} catch (error: any) { } catch (error: any) {
lastError = error; lastError = error;
const status = error.response?.status; const status = error.response?.status;
const isRetryable = !status || status === 503 || status === 429 || status >= 500; const isRetryable = !status || status === 503 || status === 429 || status >= 500;
if (status === 503) encountered503 = true;
// Don't retry on 404, 403, etc. // Don't retry on 404, 403, etc.
if (!isRetryable) { if (!isRetryable) {
throw error; throw error;
@@ -147,8 +164,10 @@ export class AudibleService {
break; break;
} }
// Exponential backoff: 2^attempt * 1000ms (1s, 2s, 4s, 8s...) retriesUsed++;
const backoffMs = Math.pow(2, attempt) * 1000;
// Jittered exponential backoff instead of predictable doubling
const backoffMs = jitteredBackoff(attempt);
logger.info(` Request failed (${status || 'network error'}), retrying in ${backoffMs}ms (attempt ${attempt + 1}/${maxRetries})...`); logger.info(` Request failed (${status || 'network error'}), retrying in ${backoffMs}ms (attempt ${attempt + 1}/${maxRetries})...`);
await this.delay(backoffMs); await this.delay(backoffMs);
@@ -210,15 +229,18 @@ export class AudibleService {
const audiobooks: AudibleAudiobook[] = []; const audiobooks: AudibleAudiobook[] = [];
let page = 1; let page = 1;
const maxPages = Math.ceil(limit / 20); // Audible shows ~20 items per page const maxPages = Math.ceil(limit / AUDIBLE_PAGE_SIZE);
this.pacer.reset();
while (audiobooks.length < limit && page <= maxPages) { while (audiobooks.length < limit && page <= maxPages) {
try { try {
logger.info(` Fetching page ${page}/${maxPages}...`); logger.info(` Fetching page ${page}/${maxPages}...`);
const response = await this.fetchWithRetry('/adblbestsellers', { const { data: response, meta } = await this.fetchWithRetry('/adblbestsellers', {
params: { params: {
ipRedirectOverride: 'true', // Explicitly include to prevent IP-based region redirects ipRedirectOverride: 'true', // Explicitly include to prevent IP-based region redirects
pageSize: AUDIBLE_PAGE_SIZE,
...(page > 1 ? { page } : {}), ...(page > 1 ? { page } : {}),
}, },
}); });
@@ -269,17 +291,17 @@ export class AudibleService {
logger.info(` Found ${foundOnPage} audiobooks on page ${page}`); logger.info(` Found ${foundOnPage} audiobooks on page ${page}`);
// If we got fewer than expected, probably no more pages // If we got significantly fewer than requested, probably no more pages
if (foundOnPage < 10) { if (foundOnPage < AUDIBLE_PAGE_SIZE / 2) {
logger.info(` Reached end of available pages`); logger.info(` Reached end of available pages`);
break; break;
} }
page++; page++;
// Add delay between pages to respect rate limiting // Adaptive delay between pages based on retry pressure
if (page <= maxPages && audiobooks.length < limit) { if (page <= maxPages && audiobooks.length < limit) {
await this.delay(1500); await this.delay(this.pacer.reportPageResult(meta));
} }
} catch (error) { } catch (error) {
logger.error(`Failed to fetch page ${page} of popular audiobooks`, { logger.error(`Failed to fetch page ${page} of popular audiobooks`, {
@@ -305,15 +327,18 @@ export class AudibleService {
const audiobooks: AudibleAudiobook[] = []; const audiobooks: AudibleAudiobook[] = [];
let page = 1; let page = 1;
const maxPages = Math.ceil(limit / 20); // Audible shows ~20 items per page const maxPages = Math.ceil(limit / AUDIBLE_PAGE_SIZE);
this.pacer.reset();
while (audiobooks.length < limit && page <= maxPages) { while (audiobooks.length < limit && page <= maxPages) {
try { try {
logger.info(` Fetching page ${page}/${maxPages}...`); logger.info(` Fetching page ${page}/${maxPages}...`);
const response = await this.fetchWithRetry('/newreleases', { const { data: response, meta } = await this.fetchWithRetry('/newreleases', {
params: { params: {
ipRedirectOverride: 'true', // Explicitly include to prevent IP-based region redirects ipRedirectOverride: 'true', // Explicitly include to prevent IP-based region redirects
pageSize: AUDIBLE_PAGE_SIZE,
...(page > 1 ? { page } : {}), ...(page > 1 ? { page } : {}),
}, },
}); });
@@ -363,17 +388,17 @@ export class AudibleService {
logger.info(` Found ${foundOnPage} audiobooks on page ${page}`); logger.info(` Found ${foundOnPage} audiobooks on page ${page}`);
// If we got fewer than expected, probably no more pages // If we got significantly fewer than requested, probably no more pages
if (foundOnPage < 10) { if (foundOnPage < AUDIBLE_PAGE_SIZE / 2) {
logger.info(` Reached end of available pages`); logger.info(` Reached end of available pages`);
break; break;
} }
page++; page++;
// Add delay between pages to respect rate limiting // Adaptive delay between pages based on retry pressure
if (page <= maxPages && audiobooks.length < limit) { if (page <= maxPages && audiobooks.length < limit) {
await this.delay(1500); await this.delay(this.pacer.reportPageResult(meta));
} }
} catch (error) { } catch (error) {
logger.error(`Failed to fetch page ${page} of new releases`, { logger.error(`Failed to fetch page ${page} of new releases`, {
@@ -398,10 +423,11 @@ export class AudibleService {
try { try {
logger.info(` Searching for "${query}"...`); logger.info(` Searching for "${query}"...`);
const response = await this.fetchWithRetry('/search', { const { data: response } = await this.fetchWithRetry('/search', {
params: { params: {
ipRedirectOverride: 'true', // Explicitly include to prevent IP-based region redirects ipRedirectOverride: 'true', // Explicitly include to prevent IP-based region redirects
keywords: query, keywords: query,
pageSize: AUDIBLE_PAGE_SIZE,
page, page,
}, },
}); });
@@ -470,7 +496,7 @@ export class AudibleService {
results: audiobooks, results: audiobooks,
totalResults, totalResults,
page, page,
hasMore: audiobooks.length > 0 && totalResults > page * 20, hasMore: audiobooks.length > 0 && totalResults > page * AUDIBLE_PAGE_SIZE,
}; };
} catch (error) { } catch (error) {
logger.error('Search failed', { error: error instanceof Error ? error.message : String(error) }); logger.error('Search failed', { error: error instanceof Error ? error.message : String(error) });
@@ -581,7 +607,7 @@ export class AudibleService {
*/ */
private async scrapeAudibleDetails(asin: string): Promise<AudibleAudiobook | null> { private async scrapeAudibleDetails(asin: string): Promise<AudibleAudiobook | null> {
try { try {
const response = await this.fetchWithRetry(`/pd/${asin}`, { const { data: response } = await this.fetchWithRetry(`/pd/${asin}`, {
params: { params: {
ipRedirectOverride: 'true', // Explicitly include to prevent IP-based region redirects ipRedirectOverride: 'true', // Explicitly include to prevent IP-based region redirects
}, },
+10
View File
@@ -406,6 +406,16 @@ export class NZBGetService implements IDownloadClient {
} }
} }
/** Not applicable for usenet clients */
async getCategories(): Promise<string[]> {
return [];
}
/** Not applicable for usenet clients */
async setCategory(_id: string, _category: string): Promise<void> {
// No-op: post-import category is scoped to torrent clients
}
// ========================================================================= // =========================================================================
// Category Management // Category Management
// ========================================================================= // =========================================================================
+51 -2
View File
@@ -87,7 +87,7 @@ export class ProwlarrService {
headers: { headers: {
'X-Api-Key': this.apiKey, 'X-Api-Key': this.apiKey,
}, },
timeout: 30000, // 30 seconds timeout: 60000, // 60 seconds - some indexers (e.g. yggtorrent) enforce a 30s wait before download
paramsSerializer: { paramsSerializer: {
serialize: (params) => { serialize: (params) => {
// Custom serializer to handle arrays correctly for Prowlarr API // Custom serializer to handle arrays correctly for Prowlarr API
@@ -208,6 +208,55 @@ export class ProwlarrService {
} }
} }
/**
* Search with multiple query variations to increase coverage
* Fires 2 queries per call: "title author" and "title", then deduplicates by guid
*/
async searchWithVariations(
title: string,
author: string,
filters?: SearchFilters
): Promise<TorrentResult[]> {
const queries = [
`${title} ${author}`,
title,
];
logger.info(`Searching with ${queries.length} query variations`, { queries });
const allResults: TorrentResult[] = [];
for (const query of queries) {
try {
const results = await this.search(query, filters);
logger.info(`Query "${query}" returned ${results.length} results`);
allResults.push(...results);
} catch (error) {
logger.error(`Query "${query}" failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
// Continue with other queries even if one fails
}
}
const deduplicated = this.deduplicateResults(allResults);
logger.info(`Multi-query search: ${allResults.length} total → ${deduplicated.length} after dedup (${allResults.length - deduplicated.length} duplicates removed)`);
return deduplicated;
}
/**
* Deduplicate results by guid, preserving order (first occurrence wins)
*/
private deduplicateResults(results: TorrentResult[]): TorrentResult[] {
const seen = new Set<string>();
return results.filter(result => {
if (seen.has(result.guid)) {
return false;
}
seen.add(result.guid);
return true;
});
}
/** /**
* Get list of configured indexers * Get list of configured indexers
*/ */
@@ -265,7 +314,7 @@ export class ProwlarrService {
limit: 100, limit: 100,
extended: 1, extended: 1,
}, },
timeout: 30000, timeout: 60000,
responseType: 'text', // Get XML as text responseType: 'text', // Get XML as text
}); });
+29 -5
View File
@@ -729,6 +729,26 @@ export class QBittorrentService implements IDownloadClient {
} }
} }
/**
* Get all configured categories from qBittorrent
*/
async getCategories(): Promise<string[]> {
if (!this.cookie) {
await this.login();
}
try {
const response = await this.client.get('/torrents/categories', {
headers: { Cookie: this.cookie },
});
return Object.keys(response.data || {});
} catch (error) {
logger.error('Failed to get categories', { error: error instanceof Error ? error.message : String(error) });
return [];
}
}
/** /**
* Set category for torrent * Set category for torrent
*/ */
@@ -1089,7 +1109,8 @@ export class QBittorrentService implements IDownloadClient {
stalledDL: 'downloading', stalledDL: 'downloading',
stalledUP: 'seeding', stalledUP: 'seeding',
pausedDL: 'paused', pausedDL: 'paused',
pausedUP: 'paused', // pausedUP = download finished, paused on upload side (e.g. RDT-Client, ratio met)
pausedUP: 'seeding',
queuedDL: 'queued', queuedDL: 'queued',
queuedUP: 'seeding', queuedUP: 'seeding',
checkingDL: 'checking', checkingDL: 'checking',
@@ -1105,7 +1126,8 @@ export class QBittorrentService implements IDownloadClient {
forcedMetaDL: 'downloading', forcedMetaDL: 'downloading',
// qBittorrent v5.0+ renamed paused → stopped // qBittorrent v5.0+ renamed paused → stopped
stoppedDL: 'paused', stoppedDL: 'paused',
stoppedUP: 'paused', // stoppedUP = download finished, stopped on upload side (qBittorrent v5.0+)
stoppedUP: 'seeding',
// Other states // Other states
checkingResumeData: 'checking', checkingResumeData: 'checking',
moving: 'downloading', moving: 'downloading',
@@ -1142,11 +1164,12 @@ export class QBittorrentService implements IDownloadClient {
stalledDL: 'downloading', stalledDL: 'downloading',
stalledUP: 'completed', stalledUP: 'completed',
pausedDL: 'paused', pausedDL: 'paused',
pausedUP: 'paused', // pausedUP = download finished, paused on upload side (e.g. RDT-Client, ratio met)
pausedUP: 'completed',
queuedDL: 'queued', queuedDL: 'queued',
queuedUP: 'completed', queuedUP: 'completed',
checkingDL: 'checking', checkingDL: 'checking',
checkingUP: 'checking', checkingUP: 'completed',
error: 'failed', error: 'failed',
missingFiles: 'failed', missingFiles: 'failed',
allocating: 'downloading', allocating: 'downloading',
@@ -1158,7 +1181,8 @@ export class QBittorrentService implements IDownloadClient {
forcedMetaDL: 'downloading', forcedMetaDL: 'downloading',
// qBittorrent v5.0+ renamed paused → stopped // qBittorrent v5.0+ renamed paused → stopped
stoppedDL: 'paused', stoppedDL: 'paused',
stoppedUP: 'paused', // stoppedUP = download finished, stopped on upload side (qBittorrent v5.0+)
stoppedUP: 'completed',
// Other states // Other states
checkingResumeData: 'checking', checkingResumeData: 'checking',
moving: 'downloading', moving: 'downloading',
+10
View File
@@ -825,6 +825,16 @@ export class SABnzbdService implements IDownloadClient {
await this.archiveCompletedNZB(id); await this.archiveCompletedNZB(id);
} }
/** Not applicable for usenet clients */
async getCategories(): Promise<string[]> {
return [];
}
/** Not applicable for usenet clients */
async setCategory(_id: string, _category: string): Promise<void> {
// No-op: post-import category is scoped to torrent clients
}
/** /**
* Map NZBInfo to the unified DownloadInfo format. * Map NZBInfo to the unified DownloadInfo format.
*/ */
@@ -441,6 +441,29 @@ export class TransmissionService implements IDownloadClient {
// No-op: torrents are managed by the seeding cleanup scheduler // No-op: torrents are managed by the seeding cleanup scheduler
} }
/**
* Get available categories/labels.
* Transmission uses free-form labels no predefined list to fetch.
*/
async getCategories(): Promise<string[]> {
return [];
}
/**
* Set the label for a torrent.
* Uses the torrent-set RPC method to replace the labels array.
*/
async setCategory(id: string, category: string): Promise<void> {
try {
const torrent = await this.getTorrentByHash(id);
await this.rpc('torrent-set', { ids: [torrent.hashString], labels: [category] });
logger.info(`Set label for torrent ${id}: ${category}`);
} catch (error) {
logger.error('Failed to set label', { error: error instanceof Error ? error.message : String(error) });
throw new Error('Failed to set torrent label');
}
}
// ========================================================================= // =========================================================================
// Internal Helpers // Internal Helpers
// ========================================================================= // =========================================================================
@@ -177,4 +177,22 @@ export interface IDownloadClient {
* @param id - Download ID * @param id - Download ID
*/ */
postProcess(id: string): Promise<void>; postProcess(id: string): Promise<void>;
/**
* Get available categories/labels from the download client.
* - qBittorrent: Returns configured category names
* - Transmission: Returns empty array (uses free-form labels)
* - Usenet clients: Returns empty array (feature scoped to torrent clients)
*/
getCategories(): Promise<string[]>;
/**
* Set the category/label for a download.
* - qBittorrent: Sets torrent category
* - Transmission: Sets torrent label
* - Usenet clients: No-op
* @param id - Download ID
* @param category - Category/label name to assign
*/
setCategory(id: string, category: string): Promise<void>;
} }
+32
View File
@@ -253,3 +253,35 @@ export async function requireSetupIncomplete(
return handler(request); return handler(request);
} }
/**
* Middleware: Require setup incomplete OR authenticated admin
* For endpoints shared between the setup wizard and admin settings.
* Allows access during setup (no auth needed) or after setup (admin auth required).
*/
export async function requireSetupIncompleteOrAdmin(
request: NextRequest,
handler: (request: NextRequest) => Promise<NextResponse>
): Promise<NextResponse> {
let setupComplete = false;
try {
const config = await prisma.configuration.findUnique({
where: { key: 'setup_completed' },
});
setupComplete = config?.value === 'true';
} catch {
// If database is not ready, setup is definitely not complete — allow through
return handler(request);
}
if (!setupComplete) {
// Setup in progress — allow unauthenticated access (setup wizard)
return handler(request);
}
// Setup is complete — require admin authentication
return requireAuth(request, (authenticatedReq) =>
requireAdmin(authenticatedReq, () => handler(request))
);
}
@@ -44,6 +44,12 @@ export async function processAudibleRefresh(payload: AudibleRefreshPayload): Pro
// Fetch popular and new releases - 200 items each // Fetch popular and new releases - 200 items each
const popular = await audibleService.getPopularAudiobooks(200); const popular = await audibleService.getPopularAudiobooks(200);
// Batch cooldown between popular and new releases to reduce detection
const batchCooldownMs = 15000 + Math.floor(Math.random() * 15000);
logger.info(`Batch cooldown: waiting ${Math.round(batchCooldownMs / 1000)}s before fetching new releases...`);
await new Promise(resolve => setTimeout(resolve, batchCooldownMs));
const newReleases = await audibleService.getNewReleases(200); const newReleases = await audibleService.getNewReleases(200);
logger.info(`Fetched ${popular.length} popular, ${newReleases.length} new releases from Audible`); logger.info(`Fetched ${popular.length} popular, ${newReleases.length} new releases from Audible`);
@@ -180,6 +180,9 @@ export async function processOrganizeFiles(payload: OrganizeFilesPayload): Promi
}, },
}); });
// Apply post-import category to torrent client if configured
await applyPostImportCategory(requestId, logger);
logger.info(`Request ${requestId} completed successfully - status: downloaded`, { logger.info(`Request ${requestId} completed successfully - status: downloaded`, {
success: true, success: true,
message: 'Files organized successfully', message: 'Files organized successfully',
@@ -606,6 +609,9 @@ async function processEbookOrganization(
}, },
}); });
// Apply post-import category to torrent client if configured
await applyPostImportCategory(requestId, logger);
logger.info(`Ebook request ${requestId} completed - status: downloaded (terminal)`); logger.info(`Ebook request ${requestId} completed - status: downloaded (terminal)`);
// Send "available" notification for ebooks at downloaded state // Send "available" notification for ebooks at downloaded state
@@ -753,6 +759,59 @@ async function createEbookRequestIfEnabled(
} }
} }
// =========================================================================
// POST-IMPORT CATEGORY
// =========================================================================
/**
* Apply post-import category to the download client after successful import.
* Only applies to torrent clients (qBittorrent/Transmission) when configured.
* Non-fatal: logs a warning on failure but does not fail the job.
*/
async function applyPostImportCategory(
requestId: string,
logger: RMABLogger
): Promise<void> {
try {
// Get download history to find client type and download ID
const downloadHistory = await prisma.downloadHistory.findFirst({
where: { requestId },
orderBy: { createdAt: 'desc' },
});
if (!downloadHistory?.downloadClientId || !downloadHistory?.downloadClient) {
return;
}
const clientType = downloadHistory.downloadClient as DownloadClientType;
// Only applies to torrent clients
const protocol = CLIENT_PROTOCOL_MAP[clientType];
if (protocol !== 'torrent') {
return;
}
// Get client config and check if postImportCategory is set
const configService = getConfigService();
const manager = getDownloadClientManager(configService);
const clients = await manager.getAllClients();
const clientConfig = clients.find(c => c.enabled && c.type === clientType);
if (!clientConfig?.postImportCategory) {
return;
}
logger.info(`Applying post-import category "${clientConfig.postImportCategory}" to download ${downloadHistory.downloadClientId}`);
const service = await manager.createClientFromConfig(clientConfig);
await service.setCategory(downloadHistory.downloadClientId, clientConfig.postImportCategory);
logger.info(`Post-import category applied successfully`);
} catch (error) {
logger.warn(`Failed to apply post-import category: ${error instanceof Error ? error.message : String(error)}`);
}
}
// ========================================================================= // =========================================================================
// DOWNLOAD CLEANUP // DOWNLOAD CLEANUP
// ========================================================================= // =========================================================================
@@ -75,10 +75,7 @@ export async function processSearchIndexers(payload: SearchIndexersPayload): Pro
// Get Prowlarr service // Get Prowlarr service
const prowlarr = await getProwlarrService(); const prowlarr = await getProwlarrService();
// Build search query (title only - cast wide net, let ranking filter) logger.info(`Searching for: "${audiobook.title}" by "${audiobook.author}"`);
const searchQuery = audiobook.title;
logger.info(`Searching for: "${searchQuery}"`);
// Search Prowlarr for each group and combine results // Search Prowlarr for each group and combine results
const allResults = []; const allResults = [];
@@ -88,7 +85,7 @@ export async function processSearchIndexers(payload: SearchIndexersPayload): Pro
logger.info(`Searching group ${i + 1}/${groups.length}: ${getGroupDescription(group)}`); logger.info(`Searching group ${i + 1}/${groups.length}: ${getGroupDescription(group)}`);
try { try {
const groupResults = await prowlarr.search(searchQuery, { const groupResults = await prowlarr.searchWithVariations(audiobook.title, audiobook.author, {
categories: group.categories, categories: group.categories,
indexerIds: group.indexerIds, indexerIds: group.indexerIds,
minSeeders: 1, // Only torrents with at least 1 seeder minSeeders: 1, // Only torrents with at least 1 seeder
@@ -6,36 +6,30 @@
* to all enabled backends subscribed to the event. * to all enabled backends subscribed to the event.
*/ */
import { getNotificationService } from '../services/notification.service'; import { getNotificationService } from '../services/notification';
import { RMABLogger } from '../utils/logger'; import { RMABLogger } from '../utils/logger';
import type { SendNotificationPayload } from '../services/job-queue.service';
export interface SendNotificationPayload { // Re-export for consumers that import from this module
jobId?: string; export type { SendNotificationPayload } from '../services/job-queue.service';
event: 'request_pending_approval' | 'request_approved' | 'request_available' | 'request_error';
requestId: string;
title: string;
author: string;
userName: string;
message?: string;
timestamp: Date;
}
/** /**
* Process send notification job * Process send notification job
* Calls NotificationService to send notifications to all enabled backends * Calls NotificationService to send notifications to all enabled backends
*/ */
export async function processSendNotification(payload: SendNotificationPayload): Promise<void> { export async function processSendNotification(payload: SendNotificationPayload): Promise<void> {
const { event, requestId, title, author, userName, message, jobId } = payload; const { event, requestId, issueId, title, author, userName, message, jobId } = payload;
const logger = RMABLogger.forJob(jobId, 'SendNotification'); const logger = RMABLogger.forJob(jobId, 'SendNotification');
logger.info(`Processing notification: ${event}`, { requestId }); logger.info(`Processing notification: ${event}`, { requestId: requestId || issueId });
try { try {
const notificationService = getNotificationService(); const notificationService = getNotificationService();
await notificationService.sendNotification({ await notificationService.sendNotification({
event, event,
requestId, requestId,
issueId,
title, title,
author, author,
userName, userName,
@@ -0,0 +1,42 @@
/**
* Component: Sync Goodreads Shelves Processor
* Documentation: documentation/backend/services/scheduler.md
*
* Dedicated processor for syncing Goodreads shelf RSS feeds.
* Resolves books to Audible ASINs and creates requests.
*/
import { RMABLogger } from '../utils/logger';
export interface SyncGoodreadsShelvesPayload {
jobId?: string;
scheduledJobId?: string;
/** If set, only process this specific shelf (used for immediate sync on add) */
shelfId?: string;
/** Max Audible lookups per shelf. 0 = unlimited. */
maxLookupsPerShelf?: number;
}
export async function processSyncGoodreadsShelves(payload: SyncGoodreadsShelvesPayload): Promise<any> {
const { jobId, shelfId, maxLookupsPerShelf } = payload;
const logger = RMABLogger.forJob(jobId, 'SyncGoodreadsShelves');
logger.info(shelfId
? `Starting immediate Goodreads sync for shelf ${shelfId}...`
: 'Starting scheduled Goodreads shelves sync...'
);
const { processGoodreadsShelves } = await import('../services/goodreads-sync.service');
const stats = await processGoodreadsShelves(logger, {
shelfId,
maxLookupsPerShelf: maxLookupsPerShelf ?? (shelfId ? 0 : undefined),
});
logger.info('Goodreads sync complete', { stats });
return {
success: true,
message: shelfId ? 'Goodreads shelf synced' : 'Goodreads shelves synced',
...stats,
};
}
+1 -1
View File
@@ -186,7 +186,7 @@ export async function deleteABSItem(itemId: string): Promise<void> {
throw new Error('Audiobookshelf not configured'); throw new Error('Audiobookshelf not configured');
} }
const url = `${serverUrl.replace(/\/$/, '')}/api/items/${itemId}`; const url = `${serverUrl.replace(/\/$/, '')}/api/items/${itemId}?hard=1`;
const response = await fetch(url, { const response = await fetch(url, {
method: 'DELETE', method: 'DELETE',
+5 -1
View File
@@ -150,7 +150,11 @@ export class LocalAuthProvider implements IAuthProvider {
return { success: false, error: 'Username must be at least 3 characters' }; return { success: false, error: 'Username must be at least 3 characters' };
} }
if (!password || password.length < 8) { const allowWeakPassword = process.env.ALLOW_WEAK_PASSWORD === 'true';
if (!password) {
return { success: false, error: 'Password is required' };
}
if (!allowWeakPassword && password.length < 8) {
return { success: false, error: 'Password must be at least 8 characters' }; return { success: false, error: 'Password must be at least 8 characters' };
} }
@@ -35,6 +35,7 @@ export interface DownloadClientConfig {
localPath?: string; localPath?: string;
category?: string; // Default: 'readmeabook' category?: string; // Default: 'readmeabook'
customPath?: string; // Relative sub-path appended to download_dir customPath?: string; // Relative sub-path appended to download_dir
postImportCategory?: string; // Category to assign after import (torrent clients only)
} }
+33
View File
@@ -95,6 +95,39 @@ export class EncryptionService {
} }
} }
/**
* Check if a value matches the format produced by encrypt().
* Validates: 3 colon-separated base64 parts where IV=16 bytes, authTag=16 bytes.
*/
isEncryptedFormat(value: string): boolean {
if (typeof value !== 'string') return false;
const parts = value.split(':');
if (parts.length !== 3) return false;
const [ivBase64, authTagBase64, encryptedBase64] = parts;
// All parts must be non-empty valid base64
const base64Regex = /^[A-Za-z0-9+/]+=*$/;
if (!ivBase64 || !authTagBase64 || !encryptedBase64) return false;
if (!base64Regex.test(ivBase64) || !base64Regex.test(authTagBase64) || !base64Regex.test(encryptedBase64)) {
return false;
}
try {
const iv = Buffer.from(ivBase64, 'base64');
const authTag = Buffer.from(authTagBase64, 'base64');
// IV and authTag must decode to exactly the expected byte lengths
if (iv.length !== IV_LENGTH) return false;
if (authTag.length !== AUTH_TAG_LENGTH) return false;
return true;
} catch {
return false;
}
}
/** /**
* Generate a random encryption key (32 bytes) * Generate a random encryption key (32 bytes)
* @returns Base64-encoded random key * @returns Base64-encoded random key
+357
View File
@@ -0,0 +1,357 @@
/**
* Component: Goodreads Shelf Sync Service
* Documentation: documentation/backend/services/goodreads-sync.md
*
* Fetches Goodreads shelf RSS feeds, resolves books to Audible ASINs,
* and creates requests via the shared request-creator service.
*/
import axios from 'axios';
import { XMLParser } from 'fast-xml-parser';
import { prisma } from '@/lib/db';
import { getAudibleService } from '@/lib/integrations/audible.service';
import { createRequestForUser } from '@/lib/services/request-creator.service';
import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('GoodreadsSync');
/** Default max Audible lookups per shelf per scheduled sync cycle */
const DEFAULT_MAX_LOOKUPS_PER_SHELF = 10;
/** Days before retrying a noMatch book */
const NO_MATCH_RETRY_DAYS = 7;
interface GoodreadsRssBook {
bookId: string;
title: string;
author: string;
coverUrl?: string;
}
/**
* Parse a Goodreads RSS feed XML into structured book data.
*/
function parseGoodreadsRss(xml: string): { shelfName: string; books: GoodreadsRssBook[] } {
const parser = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: '@_',
allowBooleanAttributes: true,
});
const parsed = parser.parse(xml);
const channel = parsed?.rss?.channel;
if (!channel) {
throw new Error('Invalid Goodreads RSS: no channel element');
}
const shelfName = typeof channel.title === 'string' ? channel.title : 'Goodreads Shelf';
// Normalize items to array
let items = channel.item;
if (!items) return { shelfName, books: [] };
if (!Array.isArray(items)) items = [items];
const books: GoodreadsRssBook[] = [];
for (const item of items) {
const bookId = item.book_id?.toString();
if (!bookId) continue;
const title = (item.title || '').toString().trim();
const authorName = (item.author_name || '').toString().trim();
// Goodreads RSS has book_image_url or book_medium_image_url
const coverUrl = (item.book_large_image_url || item.book_medium_image_url || item.book_image_url || '').toString().trim() || undefined;
if (title && authorName) {
books.push({ bookId, title, author: authorName, coverUrl });
}
}
return { shelfName, books };
}
/**
* Fetch and validate a Goodreads RSS URL.
* Returns the parsed shelf name and books if valid.
*/
export async function fetchAndValidateRss(rssUrl: string): Promise<{ shelfName: string; books: GoodreadsRssBook[] }> {
const response = await axios.get(rssUrl, { timeout: 15000 });
return parseGoodreadsRss(response.data);
}
export interface GoodreadsSyncStats {
shelvesProcessed: number;
booksFound: number;
lookupsPerformed: number;
requestsCreated: number;
errors: number;
}
export interface GoodreadsSyncOptions {
/** Process only this shelf ID (for immediate single-shelf sync) */
shelfId?: string;
/** Max Audible lookups per shelf. 0 = unlimited. Default: 10 for scheduled, unlimited for immediate. */
maxLookupsPerShelf?: number;
}
/**
* Process Goodreads shelves: fetch RSS, resolve ASINs, create requests.
* Called from the dedicated sync_goodreads_shelves processor.
*/
export async function processGoodreadsShelves(
jobLogger?: ReturnType<typeof RMABLogger.forJob>,
options: GoodreadsSyncOptions = {}
): Promise<GoodreadsSyncStats> {
const log = jobLogger || logger;
const stats: GoodreadsSyncStats = { shelvesProcessed: 0, booksFound: 0, lookupsPerformed: 0, requestsCreated: 0, errors: 0 };
const maxLookups = options.maxLookupsPerShelf ?? DEFAULT_MAX_LOOKUPS_PER_SHELF;
const whereClause = options.shelfId ? { id: options.shelfId } : {};
const shelves = await prisma.goodreadsShelf.findMany({
where: whereClause,
include: { user: { select: { id: true, plexUsername: true } } },
});
if (shelves.length === 0) {
log.info(options.shelfId ? 'Shelf not found' : 'No Goodreads shelves configured, skipping');
return stats;
}
log.info(`Processing ${shelves.length} Goodreads shelf(s)${maxLookups > 0 ? ` (max ${maxLookups} lookups/shelf)` : ' (unlimited lookups)'}`);
for (const shelf of shelves) {
try {
await processShelf(shelf, stats, log, maxLookups);
stats.shelvesProcessed++;
} catch (error) {
stats.errors++;
log.error(`Failed to process shelf "${shelf.name}" for user ${shelf.user.plexUsername}: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
log.info(`Goodreads sync complete: ${stats.shelvesProcessed} shelves, ${stats.booksFound} books, ${stats.lookupsPerformed} lookups, ${stats.requestsCreated} requests created, ${stats.errors} errors`);
return stats;
}
async function processShelf(
shelf: { id: string; rssUrl: string; name: string; user: { id: string; plexUsername: string } },
stats: GoodreadsSyncStats,
log: ReturnType<typeof RMABLogger.forJob> | ReturnType<typeof RMABLogger.create>,
maxLookups: number
) {
log.info(`Fetching RSS for shelf "${shelf.name}" (user: ${shelf.user.plexUsername})`);
let rssData: { shelfName: string; books: GoodreadsRssBook[] };
try {
rssData = await fetchAndValidateRss(shelf.rssUrl);
} catch (error) {
log.error(`Failed to fetch RSS for shelf "${shelf.name}": ${error instanceof Error ? error.message : 'Unknown error'}`);
return;
}
const books = rssData.books;
stats.booksFound += books.length;
log.info(`Found ${books.length} books in shelf "${shelf.name}"`);
let lookupsThisCycle = 0;
const unlimitedLookups = maxLookups === 0;
for (const book of books) {
// Look up existing mapping
let mapping = await prisma.goodreadsBookMapping.findUnique({
where: { goodreadsBookId: book.bookId },
});
if (!mapping) {
// No mapping exists — perform Audible lookup if under cap
if (!unlimitedLookups && lookupsThisCycle >= maxLookups) {
continue; // Will be resolved in a future cycle
}
mapping = await performAudibleLookup(book, log);
lookupsThisCycle++;
stats.lookupsPerformed++;
// If lookup found an ASIN, fall through to create request immediately
if (!mapping?.audibleAsin) {
continue;
}
}
// Mapping exists with noMatch — check if we should retry
if (mapping.noMatch) {
if (mapping.lastSearchAt) {
const daysSinceSearch = (Date.now() - mapping.lastSearchAt.getTime()) / (1000 * 60 * 60 * 24);
if (daysSinceSearch >= NO_MATCH_RETRY_DAYS && (unlimitedLookups || lookupsThisCycle < maxLookups)) {
log.info(`Retrying Audible lookup for "${book.title}" (${NO_MATCH_RETRY_DAYS}+ days since last search)`);
mapping = await performAudibleLookup(book, log, mapping.id);
lookupsThisCycle++;
stats.lookupsPerformed++;
// If retry found an ASIN, fall through to create request
if (!mapping?.audibleAsin) {
continue;
}
} else {
continue; // Still no match, skip
}
} else {
continue;
}
}
// Mapping has ASIN — try to create request
if (mapping.audibleAsin) {
try {
const result = await createRequestForUser(shelf.user.id, {
asin: mapping.audibleAsin,
title: mapping.title,
author: mapping.author,
coverArtUrl: mapping.coverUrl || undefined,
});
if (result.success) {
stats.requestsCreated++;
log.info(`Created request for "${mapping.title}" by ${mapping.author} (ASIN: ${mapping.audibleAsin})`);
}
// If not success, it's already available/requested/duplicate — silently skip
} catch (error) {
log.error(`Failed to create request for "${mapping.title}": ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
}
// Collect enriched book data (coverUrl + ASIN) for display
const bookIds = books.map(b => b.bookId);
const mappings = bookIds.length > 0
? await prisma.goodreadsBookMapping.findMany({
where: { goodreadsBookId: { in: bookIds } },
select: { goodreadsBookId: true, audibleAsin: true, title: true, author: true, coverUrl: true },
})
: [];
const mappingsByBookId = new Map(mappings.map(m => [m.goodreadsBookId, m]));
// Look up AudibleCache records for high-quality cached cover URLs
const matchedAsins = mappings
.map(m => m.audibleAsin)
.filter((asin): asin is string => !!asin);
const cachedCovers = matchedAsins.length > 0
? await prisma.audibleCache.findMany({
where: { asin: { in: matchedAsins } },
select: { asin: true, coverArtUrl: true, cachedCoverPath: true },
})
: [];
const coverByAsin = new Map(
cachedCovers
.filter(c => c.cachedCoverPath || c.coverArtUrl)
.map(c => {
let coverUrl = c.coverArtUrl || '';
if (c.cachedCoverPath) {
const filename = c.cachedCoverPath.split('/').pop();
coverUrl = `/api/cache/thumbnails/${filename}`;
}
return [c.asin, coverUrl] as const;
})
);
const bookData = books
.map(b => {
const mapping = mappingsByBookId.get(b.bookId);
// Prefer cached cover (local proxy) > mapping cover > Goodreads RSS cover
const coverUrl = coverByAsin.get(mapping?.audibleAsin || '') || mapping?.coverUrl || b.coverUrl;
if (!coverUrl) return null;
return {
coverUrl,
asin: mapping?.audibleAsin || null,
title: mapping?.title || b.title,
author: mapping?.author || b.author,
};
})
.filter((b): b is NonNullable<typeof b> => b !== null)
.slice(0, 8);
// Update shelf metadata
await prisma.goodreadsShelf.update({
where: { id: shelf.id },
data: {
lastSyncAt: new Date(),
bookCount: books.length,
coverUrls: bookData.length > 0 ? JSON.stringify(bookData) : null,
},
});
}
async function performAudibleLookup(
book: GoodreadsRssBook,
log: ReturnType<typeof RMABLogger.forJob> | ReturnType<typeof RMABLogger.create>,
existingMappingId?: string
): Promise<any> {
const audibleService = getAudibleService();
try {
const searchQuery = `${book.title} ${book.author}`;
log.info(`Searching Audible for: "${searchQuery}"`);
const searchResult = await audibleService.search(searchQuery);
const firstResult = searchResult.results[0];
if (firstResult?.asin) {
log.info(`Audible match: "${book.title}" → ASIN ${firstResult.asin} ("${firstResult.title}" by ${firstResult.author})`);
// Use clean Audible/Audnexus metadata instead of Goodreads data
// (Goodreads titles contain series info like "(The Empyrean, #1)" that pollute indexer searches)
const data = {
title: firstResult.title,
author: firstResult.author,
audibleAsin: firstResult.asin,
coverUrl: firstResult.coverArtUrl || book.coverUrl || null,
noMatch: false,
lastSearchAt: new Date(),
};
if (existingMappingId) {
return prisma.goodreadsBookMapping.update({ where: { id: existingMappingId }, data });
}
return prisma.goodreadsBookMapping.create({
data: { goodreadsBookId: book.bookId, ...data },
});
}
// No match found
log.info(`No Audible match for "${book.title}" by ${book.author}`);
const noMatchData = {
title: book.title,
author: book.author,
coverUrl: book.coverUrl || null,
noMatch: true,
lastSearchAt: new Date(),
audibleAsin: null,
};
if (existingMappingId) {
return prisma.goodreadsBookMapping.update({ where: { id: existingMappingId }, data: noMatchData });
}
return prisma.goodreadsBookMapping.create({
data: { goodreadsBookId: book.bookId, ...noMatchData },
});
} catch (error) {
log.error(`Audible lookup failed for "${book.title}": ${error instanceof Error ? error.message : 'Unknown error'}`);
// Still create/update mapping so we don't retry every cycle
const errorData = {
title: book.title,
author: book.author,
coverUrl: book.coverUrl || null,
noMatch: true,
lastSearchAt: new Date(),
};
if (existingMappingId) {
return prisma.goodreadsBookMapping.update({ where: { id: existingMappingId }, data: errorData });
}
return prisma.goodreadsBookMapping.create({
data: { goodreadsBookId: book.bookId, ...errorData },
});
}
}
+41 -4
View File
@@ -9,6 +9,7 @@ import { prisma } from '../db';
import { TorrentResult } from '../utils/ranking-algorithm'; import { TorrentResult } from '../utils/ranking-algorithm';
import { DownloadClientType } from '../interfaces/download-client.interface'; import { DownloadClientType } from '../interfaces/download-client.interface';
import { RMABLogger } from '../utils/logger'; import { RMABLogger } from '../utils/logger';
import type { NotificationEvent } from '@/lib/constants/notification-events';
const logger = RMABLogger.create('JobQueue'); const logger = RMABLogger.create('JobQueue');
@@ -25,6 +26,7 @@ export type JobType =
| 'retry_failed_imports' | 'retry_failed_imports'
| 'cleanup_seeded_torrents' | 'cleanup_seeded_torrents'
| 'monitor_rss_feeds' | 'monitor_rss_feeds'
| 'sync_goodreads_shelves'
| 'send_notification' | 'send_notification'
// Ebook-specific job types // Ebook-specific job types
| 'search_ebook' | 'search_ebook'
@@ -100,6 +102,12 @@ export interface CleanupSeededTorrentsPayload extends JobPayload {
scheduledJobId?: string; scheduledJobId?: string;
} }
export interface SyncGoodreadsShelvesPayload extends JobPayload {
scheduledJobId?: string;
shelfId?: string;
maxLookupsPerShelf?: number;
}
// Ebook-specific payload interfaces // Ebook-specific payload interfaces
export interface SearchEbookPayload extends JobPayload { export interface SearchEbookPayload extends JobPayload {
requestId: string; requestId: string;
@@ -140,8 +148,9 @@ export interface MonitorDirectDownloadPayload extends JobPayload {
} }
export interface SendNotificationPayload extends JobPayload { export interface SendNotificationPayload extends JobPayload {
event: 'request_pending_approval' | 'request_approved' | 'request_available' | 'request_error'; event: NotificationEvent;
requestId: string; requestId?: string;
issueId?: string;
title: string; title: string;
author: string; author: string;
userName: string; userName: string;
@@ -340,6 +349,12 @@ export class JobQueueService {
return await processCleanupSeededTorrents(payloadWithJobId); return await processCleanupSeededTorrents(payloadWithJobId);
}); });
this.queue.process('sync_goodreads_shelves', 1, async (job: BullJob<SyncGoodreadsShelvesPayload>) => {
const { processSyncGoodreadsShelves } = await import('../processors/sync-goodreads-shelves.processor');
const payloadWithJobId = await this.ensureJobRecord(job, 'sync_goodreads_shelves');
return await processSyncGoodreadsShelves(payloadWithJobId);
});
// Send notification processor // Send notification processor
this.queue.process('send_notification', 5, async (job: BullJob<SendNotificationPayload>) => { this.queue.process('send_notification', 5, async (job: BullJob<SendNotificationPayload>) => {
const { processSendNotification } = await import('../processors/send-notification.processor'); const { processSendNotification } = await import('../processors/send-notification.processor');
@@ -695,6 +710,23 @@ export class JobQueueService {
); );
} }
/**
* Add sync Goodreads shelves job
*/
async addSyncGoodreadsShelvesJob(scheduledJobId?: string, shelfId?: string, maxLookupsPerShelf?: number): Promise<string> {
return await this.addJob(
'sync_goodreads_shelves',
{
scheduledJobId,
shelfId,
maxLookupsPerShelf,
} as SyncGoodreadsShelvesPayload,
{
priority: 7,
}
);
}
// ========================================================================= // =========================================================================
// EBOOK-SPECIFIC JOB METHODS // EBOOK-SPECIFIC JOB METHODS
// ========================================================================= // =========================================================================
@@ -911,7 +943,7 @@ export class JobQueueService {
* Add notification job * Add notification job
*/ */
async addNotificationJob( async addNotificationJob(
event: 'request_pending_approval' | 'request_approved' | 'request_available' | 'request_error', event: NotificationEvent,
requestId: string, requestId: string,
title: string, title: string,
author: string, author: string,
@@ -923,11 +955,16 @@ export class JobQueueService {
'send_notification', 'send_notification',
{ {
event, event,
requestId, // issue_reported passes an issue ID, not a request ID — omit from payload
// so addJob doesn't try to create a FK to the requests table.
// The ID is still available in the notification payload for display.
requestId: event === 'issue_reported' ? undefined : requestId,
title, title,
author, author,
userName, userName,
message, message,
// Pass the original ID for notification display (e.g., Discord footer)
...(event === 'issue_reported' && { issueId: requestId }),
timestamp: new Date(), timestamp: new Date(),
} as SendNotificationPayload, } as SendNotificationPayload,
{ {
-380
View File
@@ -1,380 +0,0 @@
/**
* Component: Notification Service
* Documentation: documentation/backend/services/notifications.md
*/
import { getEncryptionService } from './encryption.service';
import { RMABLogger } from '../utils/logger';
import { prisma } from '../db';
const logger = RMABLogger.create('NotificationService');
// Event types
export type NotificationEvent =
| 'request_pending_approval'
| 'request_approved'
| 'request_available'
| 'request_error';
// Backend types
export type NotificationBackendType =
| 'discord'
| 'pushover'
| 'email'
| 'slack'
| 'telegram'
| 'webhook';
// Config interfaces
export interface DiscordConfig {
webhookUrl: string;
username?: string;
avatarUrl?: string;
}
export interface PushoverConfig {
userKey: string;
appToken: string;
device?: string;
priority?: number;
}
export type NotificationConfig = DiscordConfig | PushoverConfig;
// Notification payload
export interface NotificationPayload {
event: NotificationEvent;
requestId: string;
title: string;
author: string;
userName: string;
message?: string; // For error events
timestamp: Date;
}
// Discord embed colors by event type
const DISCORD_COLORS = {
request_pending_approval: 0xfbbf24, // yellow-400
request_approved: 0x22c55e, // green-500
request_available: 0x3b82f6, // blue-500
request_error: 0xef4444, // red-500
};
// Discord embed titles
const DISCORD_TITLES = {
request_pending_approval: '📬 New Request Pending Approval',
request_approved: '✅ Request Approved',
request_available: '🎉 Audiobook Available',
request_error: '❌ Request Error',
};
// Pushover priorities
const PUSHOVER_PRIORITIES = {
request_pending_approval: 0, // Normal
request_approved: 0, // Normal
request_available: 1, // High
request_error: 1, // High
};
export class NotificationService {
private encryptionService = getEncryptionService();
/**
* Send notification to all enabled backends subscribed to the event
*/
async sendNotification(payload: NotificationPayload): Promise<void> {
try {
// Get all enabled backends subscribed to this event
const backends = await prisma.notificationBackend.findMany({
where: {
enabled: true,
events: {
array_contains: payload.event,
},
},
});
if (backends.length === 0) {
logger.debug(`No backends subscribed to event: ${payload.event}`);
return;
}
logger.info(`Sending notification to ${backends.length} backend(s)`, {
event: payload.event,
requestId: payload.requestId,
});
// Send to all backends in parallel (atomic per-backend)
const results = await Promise.allSettled(
backends.map((backend) =>
this.sendToBackend(backend.type as NotificationBackendType, backend.config, payload)
)
);
// Log results
const successful = results.filter((r) => r.status === 'fulfilled').length;
const failed = results.filter((r) => r.status === 'rejected').length;
logger.info(`Notification sent: ${successful} succeeded, ${failed} failed`, {
event: payload.event,
requestId: payload.requestId,
});
// Log individual failures
results.forEach((result, index) => {
if (result.status === 'rejected') {
logger.error(`Failed to send to backend ${backends[index].name}`, {
error: result.reason instanceof Error ? result.reason.message : String(result.reason),
backend: backends[index].type,
});
}
});
} catch (error) {
logger.error('Failed to send notifications', {
error: error instanceof Error ? error.message : String(error),
event: payload.event,
requestId: payload.requestId,
});
// Don't throw - non-blocking
}
}
/**
* Route notification to type-specific sender
*/
private async sendToBackend(
type: NotificationBackendType,
config: any,
payload: NotificationPayload
): Promise<void> {
// Decrypt config
const decryptedConfig = this.decryptConfig(config);
switch (type) {
case 'discord':
return this.sendDiscord(decryptedConfig as DiscordConfig, payload);
case 'pushover':
return this.sendPushover(decryptedConfig as PushoverConfig, payload);
default:
throw new Error(`Unsupported backend type: ${type}`);
}
}
/**
* Send Discord webhook notification
*/
private async sendDiscord(config: DiscordConfig, payload: NotificationPayload): Promise<void> {
const embed = this.formatDiscordEmbed(payload);
const body = {
username: config.username || 'ReadMeABook',
avatar_url: config.avatarUrl,
embeds: [embed],
};
const response = await fetch(config.webhookUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!response.ok) {
const errorText = await response.text().catch(() => 'Unknown error');
throw new Error(`Discord webhook failed: ${response.status} ${errorText}`);
}
}
/**
* Send Pushover notification
*/
private async sendPushover(config: PushoverConfig, payload: NotificationPayload): Promise<void> {
const { title, message } = this.formatPushoverMessage(payload);
const body = new URLSearchParams({
token: config.appToken,
user: config.userKey,
title,
message,
priority: String(config.priority ?? PUSHOVER_PRIORITIES[payload.event]),
...(config.device && { device: config.device }),
});
const response = await fetch('https://api.pushover.net/1/messages.json', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
});
if (!response.ok) {
const errorText = await response.text().catch(() => 'Unknown error');
throw new Error(`Pushover API failed: ${response.status} ${errorText}`);
}
const result = await response.json();
if (result.status !== 1) {
throw new Error(`Pushover API error: ${JSON.stringify(result.errors || 'Unknown error')}`);
}
}
/**
* Format Discord rich embed
*/
private formatDiscordEmbed(payload: NotificationPayload): any {
const { event, title, author, userName, message, requestId, timestamp } = payload;
const fields = [
{ name: 'Title', value: title, inline: false },
{ name: 'Author', value: author, inline: true },
{ name: 'Requested By', value: userName, inline: true },
];
if (message) {
fields.push({ name: 'Error', value: message, inline: false });
}
return {
title: DISCORD_TITLES[event],
color: DISCORD_COLORS[event],
fields,
footer: {
text: `Request ID: ${requestId}`,
},
timestamp: timestamp.toISOString(),
};
}
/**
* Format Pushover message
*/
private formatPushoverMessage(payload: NotificationPayload): { title: string; message: string } {
const { event, title, author, userName, message } = payload;
let eventTitle = '';
let eventEmoji = '';
switch (event) {
case 'request_pending_approval':
eventTitle = 'New Request Pending Approval';
eventEmoji = '📬';
break;
case 'request_approved':
eventTitle = 'Request Approved';
eventEmoji = '✅';
break;
case 'request_available':
eventTitle = 'Audiobook Available';
eventEmoji = '🎉';
break;
case 'request_error':
eventTitle = 'Request Error';
eventEmoji = '❌';
break;
}
const messageLines = [
`${eventEmoji} ${eventTitle}`,
'',
`📚 ${title}`,
`✍️ ${author}`,
`👤 Requested by: ${userName}`,
];
if (message) {
messageLines.push('', `⚠️ Error: ${message}`);
}
return {
title: eventTitle,
message: messageLines.join('\n'),
};
}
/**
* Decrypt sensitive config values
*/
private decryptConfig(config: any): any {
const decrypted = { ...config };
// Discord: decrypt webhookUrl
if (decrypted.webhookUrl && this.isEncrypted(decrypted.webhookUrl)) {
decrypted.webhookUrl = this.encryptionService.decrypt(decrypted.webhookUrl);
}
// Pushover: decrypt userKey and appToken
if (decrypted.userKey && this.isEncrypted(decrypted.userKey)) {
decrypted.userKey = this.encryptionService.decrypt(decrypted.userKey);
}
if (decrypted.appToken && this.isEncrypted(decrypted.appToken)) {
decrypted.appToken = this.encryptionService.decrypt(decrypted.appToken);
}
return decrypted;
}
/**
* Check if a value is encrypted (has iv:authTag:data format)
*/
private isEncrypted(value: string): boolean {
return value.includes(':') && value.split(':').length === 3;
}
/**
* Encrypt sensitive config values before saving
*/
encryptConfig(type: NotificationBackendType, config: any): any {
const encrypted = { ...config };
switch (type) {
case 'discord':
if (encrypted.webhookUrl && !this.isEncrypted(encrypted.webhookUrl)) {
encrypted.webhookUrl = this.encryptionService.encrypt(encrypted.webhookUrl);
}
break;
case 'pushover':
if (encrypted.userKey && !this.isEncrypted(encrypted.userKey)) {
encrypted.userKey = this.encryptionService.encrypt(encrypted.userKey);
}
if (encrypted.appToken && !this.isEncrypted(encrypted.appToken)) {
encrypted.appToken = this.encryptionService.encrypt(encrypted.appToken);
}
break;
}
return encrypted;
}
/**
* Mask sensitive config values for API responses
*/
maskConfig(type: NotificationBackendType, config: any): any {
const masked = { ...config };
switch (type) {
case 'discord':
if (masked.webhookUrl) {
masked.webhookUrl = '••••••••';
}
break;
case 'pushover':
if (masked.userKey) {
masked.userKey = '••••••••';
}
if (masked.appToken) {
masked.appToken = '••••••••';
}
break;
}
return masked;
}
}
// Singleton instance
let notificationService: NotificationService | null = null;
export function getNotificationService(): NotificationService {
if (!notificationService) {
notificationService = new NotificationService();
}
return notificationService;
}
@@ -0,0 +1,57 @@
/**
* Notification Provider Interface
* Documentation: documentation/backend/services/notifications.md
*/
// Re-export event types from central source of truth
export type { NotificationEvent } from '@/lib/constants/notification-events';
// Backend type — string-based, registry is the runtime source of truth
export type NotificationBackendType = string;
// Notification payload
export interface NotificationPayload {
event: import('@/lib/constants/notification-events').NotificationEvent;
requestId?: string;
issueId?: string;
title: string;
author: string;
userName: string;
message?: string; // For error/issue events
timestamp: Date;
}
// Provider config field definition for dynamic UI rendering
export interface ProviderConfigField {
name: string;
label: string;
type: 'text' | 'password' | 'select' | 'number';
required: boolean;
placeholder?: string;
defaultValue?: string | number;
options?: { label: string; value: string | number }[];
}
// Provider metadata for self-describing providers
export interface ProviderMetadata {
type: string;
displayName: string;
description: string;
iconLabel: string;
iconColor: string;
configFields: ProviderConfigField[];
}
export interface INotificationProvider {
/** Provider identifier */
type: string;
/** Config field names that need encryption/masking */
sensitiveFields: string[];
/** Self-describing metadata for UI and validation */
metadata: ProviderMetadata;
/** Send notification with already-decrypted config */
send(config: Record<string, any>, payload: NotificationPayload): Promise<void>;
}
+46
View File
@@ -0,0 +1,46 @@
/**
* Notification Service - Public API
* Documentation: documentation/backend/services/notifications.md
*/
// Interface + shared types
export type {
INotificationProvider,
NotificationEvent,
NotificationBackendType,
NotificationPayload,
ProviderConfigField,
ProviderMetadata,
} from './INotificationProvider';
// Centralized event constants (re-exported for convenience)
export {
NOTIFICATION_EVENTS,
NOTIFICATION_EVENT_KEYS,
EVENT_LABELS,
getEventMeta,
getEventLabel,
} from '@/lib/constants/notification-events';
export type { NotificationSeverity, NotificationPriority, NotificationEventMeta } from '@/lib/constants/notification-events';
// Core service
export {
NotificationService,
getNotificationService,
registerProvider,
getProvider,
getRegisteredProviderTypes,
getAllProviderMetadata,
} from './notification.service';
// Provider types
export type { AppriseConfig } from './providers/apprise.provider';
export type { DiscordConfig } from './providers/discord.provider';
export type { NtfyConfig } from './providers/ntfy.provider';
export type { PushoverConfig } from './providers/pushover.provider';
// Provider classes
export { AppriseProvider } from './providers/apprise.provider';
export { DiscordProvider } from './providers/discord.provider';
export { NtfyProvider } from './providers/ntfy.provider';
export { PushoverProvider } from './providers/pushover.provider';
@@ -0,0 +1,228 @@
/**
* Component: Notification Service
* Documentation: documentation/backend/services/notifications.md
*/
import { getEncryptionService } from '../encryption.service';
import { RMABLogger } from '../../utils/logger';
import { prisma } from '../../db';
import { INotificationProvider, NotificationPayload, ProviderMetadata } from './INotificationProvider';
import { AppriseProvider } from './providers/apprise.provider';
import { DiscordProvider } from './providers/discord.provider';
import { NtfyProvider } from './providers/ntfy.provider';
import { PushoverProvider } from './providers/pushover.provider';
const logger = RMABLogger.create('NotificationService');
// Provider registry
const providers = new Map<string, INotificationProvider>();
export function registerProvider(provider: INotificationProvider): void {
providers.set(provider.type, provider);
}
export function getProvider(type: string): INotificationProvider | undefined {
return providers.get(type);
}
// Register built-in providers
registerProvider(new AppriseProvider());
registerProvider(new DiscordProvider());
registerProvider(new NtfyProvider());
registerProvider(new PushoverProvider());
export function getRegisteredProviderTypes(): string[] {
return Array.from(providers.keys());
}
export function getAllProviderMetadata(): ProviderMetadata[] {
return Array.from(providers.values()).map((p) => p.metadata);
}
export class NotificationService {
private encryptionService = getEncryptionService();
/**
* Send notification to all enabled backends subscribed to the event
*/
async sendNotification(payload: NotificationPayload): Promise<void> {
try {
// Get all enabled backends subscribed to this event
const backends = await prisma.notificationBackend.findMany({
where: {
enabled: true,
events: {
array_contains: payload.event,
},
},
});
if (backends.length === 0) {
logger.debug(`No backends subscribed to event: ${payload.event}`);
return;
}
logger.info(`Sending notification to ${backends.length} backend(s)`, {
event: payload.event,
requestId: payload.requestId,
});
// Send to all backends in parallel (atomic per-backend)
const results = await Promise.allSettled(
backends.map((backend) =>
this.sendToBackend(backend.type, backend.config, payload)
)
);
// Log results
const successful = results.filter((r) => r.status === 'fulfilled').length;
const failed = results.filter((r) => r.status === 'rejected').length;
logger.info(`Notification sent: ${successful} succeeded, ${failed} failed`, {
event: payload.event,
requestId: payload.requestId,
});
// Log individual failures
results.forEach((result, index) => {
if (result.status === 'rejected') {
logger.error(`Failed to send to backend ${backends[index].name}`, {
error: result.reason instanceof Error ? result.reason.message : String(result.reason),
backend: backends[index].type,
});
}
});
} catch (error) {
logger.error('Failed to send notifications', {
error: error instanceof Error ? error.message : String(error),
event: payload.event,
requestId: payload.requestId,
});
// Don't throw - non-blocking
}
}
/**
* Route notification to type-specific provider
*/
async sendToBackend(
type: string,
config: any,
payload: NotificationPayload
): Promise<void> {
const provider = getProvider(type);
if (!provider) {
throw new Error(`Unsupported backend type: ${type}`);
}
const decryptedConfig = this.decryptConfig(provider.sensitiveFields, config);
return provider.send(decryptedConfig, payload);
}
/**
* Encrypt sensitive config values before saving
*/
encryptConfig(type: string, config: any): any {
const provider = getProvider(type);
if (!provider) {
return { ...config };
}
const encrypted = { ...config };
for (const field of provider.sensitiveFields) {
if (encrypted[field] && !this.encryptionService.isEncryptedFormat(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;
}
/**
* Re-encrypt any sensitive fields that were stored as plaintext due to
* the isEncrypted() false-positive bug (URLs with exactly 2 colons).
* Safe to call multiple times skips already-encrypted values.
*/
async reEncryptUnprotectedBackends(): Promise<number> {
let fixed = 0;
try {
const backends = await prisma.notificationBackend.findMany();
for (const backend of backends) {
const provider = getProvider(backend.type);
if (!provider) continue;
const config = backend.config as any;
let needsUpdate = false;
const updatedConfig = { ...config };
for (const field of provider.sensitiveFields) {
if (updatedConfig[field] && !this.encryptionService.isEncryptedFormat(updatedConfig[field])) {
updatedConfig[field] = this.encryptionService.encrypt(updatedConfig[field]);
needsUpdate = true;
}
}
if (needsUpdate) {
await prisma.notificationBackend.update({
where: { id: backend.id },
data: { config: updatedConfig },
});
fixed++;
logger.info(`Re-encrypted plaintext sensitive fields for backend: ${backend.name}`);
}
}
if (fixed > 0) {
logger.warn(`Re-encrypted ${fixed} backend(s) with unprotected sensitive fields`);
}
} catch (error) {
logger.error('Failed to re-encrypt backends', {
error: error instanceof Error ? error.message : String(error),
});
}
return fixed;
}
/**
* Decrypt sensitive config values
*/
private decryptConfig(sensitiveFields: string[], config: any): any {
const decrypted = { ...config };
for (const field of sensitiveFields) {
if (decrypted[field] && this.encryptionService.isEncryptedFormat(decrypted[field])) {
decrypted[field] = this.encryptionService.decrypt(decrypted[field]);
}
}
return decrypted;
}
}
// Singleton instance
let notificationService: NotificationService | null = null;
export function getNotificationService(): NotificationService {
if (!notificationService) {
notificationService = new NotificationService();
}
return notificationService;
}
@@ -0,0 +1,130 @@
/**
* Component: Apprise Notification Provider
* Documentation: documentation/backend/services/notifications.md
*/
import { INotificationProvider, NotificationPayload, ProviderMetadata } from '../INotificationProvider';
import { getEventMeta, type NotificationSeverity } from '@/lib/constants/notification-events';
export interface AppriseConfig {
serverUrl: string;
urls?: string;
configKey?: string;
tag?: string;
authToken?: string;
}
// Apprise notification types by severity
const SEVERITY_TYPES: Record<NotificationSeverity, string> = {
info: 'info',
success: 'success',
error: 'failure',
warning: 'warning',
};
export class AppriseProvider implements INotificationProvider {
type = 'apprise' as const;
sensitiveFields = ['urls', 'authToken'];
metadata: ProviderMetadata = {
type: 'apprise',
displayName: 'Apprise',
description: 'Send notifications via Apprise API to 100+ services',
iconLabel: 'A',
iconColor: 'bg-purple-500',
configFields: [
{ name: 'serverUrl', label: 'Server URL', type: 'text', required: true, placeholder: 'http://apprise:8000' },
{ name: 'urls', label: 'Notification URLs', type: 'password', required: false, placeholder: 'slack://token, discord://webhook_id/token, ...' },
{ name: 'configKey', label: 'Config Key', type: 'text', required: false, placeholder: 'Persistent configuration key' },
{ name: 'tag', label: 'Tag', type: 'text', required: false, placeholder: 'Filter tag for stateful config' },
{ name: 'authToken', label: 'Auth Token', type: 'password', required: false, placeholder: 'Optional API auth token' },
],
};
async send(config: Record<string, any>, payload: NotificationPayload): Promise<void> {
const appriseConfig = config as unknown as AppriseConfig;
const meta = getEventMeta(payload.event);
const { title, body } = this.formatMessage(payload);
const serverUrl = appriseConfig.serverUrl.replace(/\/+$/, '');
const notificationType = SEVERITY_TYPES[meta.severity];
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (appriseConfig.authToken) {
headers['Authorization'] = `Bearer ${appriseConfig.authToken}`;
}
// Stateful mode: use configKey endpoint
if (appriseConfig.configKey) {
const url = `${serverUrl}/notify/${appriseConfig.configKey}`;
const requestBody: Record<string, string> = {
title,
body,
type: notificationType,
};
if (appriseConfig.tag) {
requestBody.tag = appriseConfig.tag;
}
const response = await fetch(url, {
method: 'POST',
headers,
body: JSON.stringify(requestBody),
});
if (!response.ok) {
const errorText = await response.text().catch(() => 'Unknown error');
throw new Error(`Apprise API failed: ${response.status} ${errorText}`);
}
return;
}
// Stateless mode: send URLs directly
if (!appriseConfig.urls) {
throw new Error('Apprise requires either notification URLs or a config key');
}
const url = `${serverUrl}/notify/`;
const requestBody = {
urls: appriseConfig.urls,
title,
body,
type: notificationType,
};
const response = await fetch(url, {
method: 'POST',
headers,
body: JSON.stringify(requestBody),
});
if (!response.ok) {
const errorText = await response.text().catch(() => 'Unknown error');
throw new Error(`Apprise API failed: ${response.status} ${errorText}`);
}
}
private formatMessage(payload: NotificationPayload): { title: string; body: string } {
const { event, title, author, userName, message } = payload;
const meta = getEventMeta(event);
const isIssue = event === 'issue_reported';
const messageLines = [
`\u{1F4DA} ${title}`,
`\u270D\uFE0F ${author}`,
`\u{1F464} ${isIssue ? 'Reported by' : 'Requested by'}: ${userName}`,
];
if (message) {
messageLines.push(isIssue ? `\u{1F4DD} Reason: ${message}` : `\u26A0\uFE0F Error: ${message}`);
}
return {
title: meta.title,
body: messageLines.join('\n'),
};
}
}
@@ -0,0 +1,86 @@
/**
* Component: Discord Notification Provider
* Documentation: documentation/backend/services/notifications.md
*/
import { INotificationProvider, NotificationPayload, ProviderMetadata } from '../INotificationProvider';
import { getEventMeta, type NotificationSeverity } from '@/lib/constants/notification-events';
export interface DiscordConfig {
webhookUrl: string;
username?: string;
avatarUrl?: string;
}
// Discord embed colors by severity
const SEVERITY_COLORS: Record<NotificationSeverity, number> = {
info: 0xfbbf24, // yellow-400
success: 0x22c55e, // green-500
error: 0xef4444, // red-500
warning: 0xf97316, // orange-500
};
export class DiscordProvider implements INotificationProvider {
type = 'discord' as const;
sensitiveFields = ['webhookUrl'];
metadata: ProviderMetadata = {
type: 'discord',
displayName: 'Discord',
description: 'Send notifications via Discord webhook',
iconLabel: 'D',
iconColor: 'bg-indigo-500',
configFields: [
{ name: 'webhookUrl', label: 'Webhook URL', type: 'text', required: true, placeholder: 'https://discord.com/api/webhooks/...' },
{ name: 'username', label: 'Username', type: 'text', required: false, placeholder: 'ReadMeABook', defaultValue: 'ReadMeABook' },
{ name: 'avatarUrl', label: 'Avatar URL', type: 'text', required: false, placeholder: 'https://example.com/avatar.png', defaultValue: '' },
],
};
async send(config: Record<string, any>, payload: NotificationPayload): Promise<void> {
const discordConfig = config as unknown as DiscordConfig;
const embed = this.formatEmbed(payload);
const body = {
username: discordConfig.username || 'ReadMeABook',
avatar_url: discordConfig.avatarUrl,
embeds: [embed],
};
const response = await fetch(discordConfig.webhookUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!response.ok) {
const errorText = await response.text().catch(() => 'Unknown error');
throw new Error(`Discord webhook failed: ${response.status} ${errorText}`);
}
}
private formatEmbed(payload: NotificationPayload): any {
const { event, title, author, userName, message, requestId, timestamp } = payload;
const meta = getEventMeta(event);
const isIssue = event === 'issue_reported';
const fields = [
{ name: 'Title', value: title, inline: false },
{ name: 'Author', value: author, inline: true },
{ name: isIssue ? 'Reported By' : 'Requested By', value: userName, inline: true },
];
if (message) {
fields.push({ name: isIssue ? 'Reason' : 'Error', value: message, inline: false });
}
return {
title: `${meta.emoji} ${meta.title}`,
color: SEVERITY_COLORS[meta.severity],
fields,
footer: {
text: isIssue ? `Issue ID: ${payload.issueId}` : `Request ID: ${requestId}`,
},
timestamp: timestamp.toISOString(),
};
}
}
@@ -0,0 +1,105 @@
/**
* Component: ntfy Notification Provider
* Documentation: documentation/backend/services/notifications.md
*/
import { INotificationProvider, NotificationPayload, ProviderMetadata } from '../INotificationProvider';
import { getEventMeta, type NotificationSeverity, type NotificationPriority } from '@/lib/constants/notification-events';
export interface NtfyConfig {
serverUrl?: string;
topic: string;
accessToken?: string;
priority?: number;
}
const DEFAULT_SERVER_URL = 'https://ntfy.sh';
// ntfy priorities by notification priority (1=min, 2=low, 3=default, 4=high, 5=urgent)
const PRIORITY_MAP: Record<NotificationPriority, number> = {
normal: 3,
high: 4,
};
// ntfy tags (emojis) by severity
const SEVERITY_TAGS: Record<NotificationSeverity, string[]> = {
info: ['mailbox_with_mail'],
success: ['white_check_mark'],
error: ['x'],
warning: ['triangular_flag_on_post'],
};
export class NtfyProvider implements INotificationProvider {
type = 'ntfy' as const;
sensitiveFields = ['accessToken'];
metadata: ProviderMetadata = {
type: 'ntfy',
displayName: 'ntfy',
description: 'Send notifications via ntfy pub/sub',
iconLabel: 'N',
iconColor: 'bg-teal-500',
configFields: [
{ name: 'serverUrl', label: 'Server URL', type: 'text', required: false, placeholder: 'https://ntfy.sh', defaultValue: 'https://ntfy.sh' },
{ name: 'topic', label: 'Topic', type: 'text', required: true, placeholder: 'readmeabook' },
{ name: 'accessToken', label: 'Access Token', type: 'password', required: false, placeholder: 'tk_...' },
],
};
async send(config: Record<string, any>, payload: NotificationPayload): Promise<void> {
const ntfyConfig = config as unknown as NtfyConfig;
const meta = getEventMeta(payload.event);
const { title, message } = this.formatMessage(payload);
// ntfy JSON publishing requires POSTing to the base server URL (not the topic URL).
// The topic is included in the JSON body. See: https://docs.ntfy.sh/publish/#publish-as-json
const url = (ntfyConfig.serverUrl || DEFAULT_SERVER_URL).replace(/\/+$/, '');
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (ntfyConfig.accessToken) {
headers['Authorization'] = `Bearer ${ntfyConfig.accessToken}`;
}
const body = {
topic: ntfyConfig.topic,
title,
message,
priority: ntfyConfig.priority ?? PRIORITY_MAP[meta.priority],
tags: SEVERITY_TAGS[meta.severity],
};
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 meta = getEventMeta(event);
const isIssue = event === 'issue_reported';
const messageLines = [
`\u{1F4DA} ${title}`,
`\u270D\uFE0F ${author}`,
`\u{1F464} ${isIssue ? 'Reported by' : 'Requested by'}: ${userName}`,
];
if (message) {
messageLines.push(isIssue ? `\u{1F4DD} Reason: ${message}` : `\u26A0\uFE0F Error: ${message}`);
}
return {
title: meta.title,
message: messageLines.join('\n'),
};
}
}
@@ -0,0 +1,101 @@
/**
* Component: Pushover Notification Provider
* Documentation: documentation/backend/services/notifications.md
*/
import { INotificationProvider, NotificationPayload, ProviderMetadata } from '../INotificationProvider';
import { getEventMeta, type NotificationPriority } from '@/lib/constants/notification-events';
export interface PushoverConfig {
userKey: string;
appToken: string;
device?: string;
priority?: number;
}
// Pushover priorities by notification priority (Normal=0, High=1)
const PRIORITY_MAP: Record<NotificationPriority, number> = {
normal: 0,
high: 1,
};
export class PushoverProvider implements INotificationProvider {
type = 'pushover' as const;
sensitiveFields = ['userKey', 'appToken'];
metadata: ProviderMetadata = {
type: 'pushover',
displayName: 'Pushover',
description: 'Send notifications via Pushover API',
iconLabel: 'P',
iconColor: 'bg-blue-500',
configFields: [
{ name: 'userKey', label: 'User Key', type: 'text', required: true, placeholder: 'Your Pushover user key' },
{ name: 'appToken', label: 'App Token', type: 'text', required: true, placeholder: 'Your Pushover app token' },
{ name: 'device', label: 'Device', type: 'text', required: false, placeholder: 'Optional device name' },
{
name: 'priority', label: 'Priority', type: 'select', required: false, defaultValue: 0,
options: [
{ label: 'Lowest', value: -2 },
{ label: 'Low', value: -1 },
{ label: 'Normal', value: 0 },
{ label: 'High', value: 1 },
{ label: 'Emergency', value: 2 },
],
},
],
};
async send(config: Record<string, any>, payload: NotificationPayload): Promise<void> {
const pushoverConfig = config as unknown as PushoverConfig;
const meta = getEventMeta(payload.event);
const { title, message } = this.formatMessage(payload);
const body = new URLSearchParams({
token: pushoverConfig.appToken,
user: pushoverConfig.userKey,
title,
message,
priority: String(pushoverConfig.priority ?? PRIORITY_MAP[meta.priority]),
...(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;
const meta = getEventMeta(event);
const isIssue = event === 'issue_reported';
const messageLines = [
`${meta.emoji} ${meta.title}`,
'',
`\u{1F4DA} ${title}`,
`\u270D\uFE0F ${author}`,
`\u{1F464} ${isIssue ? 'Reported by' : 'Requested by'}: ${userName}`,
];
if (message) {
messageLines.push('', isIssue ? `\u{1F4DD} Reason: ${message}` : `\u26A0\uFE0F Error: ${message}`);
}
return {
title: meta.title,
message: messageLines.join('\n'),
};
}
}
+413
View File
@@ -0,0 +1,413 @@
/**
* Component: Reported Issue Service
* Documentation: documentation/backend/services/reported-issues.md
*
* Handles user-reported problems with available audiobooks.
* Supports dismiss (admin closes) and replace (admin picks new torrent) workflows.
*/
import { prisma } from '@/lib/db';
import { findPlexMatch } from '@/lib/utils/audiobook-matcher';
import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('ReportedIssue');
/**
* Report an issue with an available audiobook
*/
export async function reportIssue(
asin: string,
reporterId: string,
reason: string,
metadata?: { title?: string; author?: string; coverArtUrl?: string }
) {
// Validate the book is in the library
const plexMatch = await findPlexMatch({
asin,
title: metadata?.title || '',
author: metadata?.author || '',
});
if (!plexMatch) {
throw new ReportedIssueError('This audiobook is not currently in your library', 404);
}
// Find or create audiobook record for this ASIN
let audiobook = await prisma.audiobook.findFirst({
where: { audibleAsin: asin },
});
if (!audiobook) {
audiobook = await prisma.audiobook.create({
data: {
audibleAsin: asin,
title: metadata?.title || 'Unknown Title',
author: metadata?.author || 'Unknown Author',
coverArtUrl: metadata?.coverArtUrl,
status: 'requested',
},
});
logger.info(`Created audiobook record for ASIN ${asin} to link reported issue`);
}
// Check for existing open issue
const existingIssue = await prisma.reportedIssue.findFirst({
where: {
audiobookId: audiobook.id,
status: 'open',
},
});
if (existingIssue) {
throw new ReportedIssueError('An issue has already been reported for this audiobook', 409);
}
const issue = await prisma.reportedIssue.create({
data: {
audiobookId: audiobook.id,
reporterId,
reason,
},
include: {
audiobook: { select: { title: true, author: true, audibleAsin: true } },
reporter: { select: { plexUsername: true } },
},
});
logger.info(`Issue reported for "${audiobook.title}" by user ${reporterId}`);
// Queue notification (non-blocking)
try {
const { getJobQueueService } = await import('./job-queue.service');
const jobQueue = getJobQueueService();
await jobQueue.addNotificationJob(
'issue_reported',
issue.id,
audiobook.title,
audiobook.author,
issue.reporter.plexUsername,
reason
);
} catch (error) {
logger.error('Failed to queue issue_reported notification', {
error: error instanceof Error ? error.message : String(error),
});
}
return issue;
}
/**
* Dismiss a reported issue (admin action)
*/
export async function dismissIssue(issueId: string, adminUserId: string) {
const issue = await prisma.reportedIssue.findUnique({
where: { id: issueId },
});
if (!issue) {
throw new ReportedIssueError('Issue not found', 404);
}
if (issue.status !== 'open') {
throw new ReportedIssueError('Issue is already resolved', 409);
}
const updated = await prisma.reportedIssue.update({
where: { id: issueId },
data: {
status: 'dismissed',
resolvedAt: new Date(),
resolvedById: adminUserId,
},
});
logger.info(`Issue ${issueId} dismissed by admin ${adminUserId}`);
return updated;
}
/**
* Replace audiobook content for a reported issue (atomic admin action):
* 1. Validate issue is open
* 2. Delete old content (via request delete or direct library deletion)
* 3. Create new request + start download with selected torrent
* 4. Resolve issue as "replaced"
*/
export async function replaceAudiobook(
issueId: string,
adminUserId: string,
torrent: any
) {
const issue = await prisma.reportedIssue.findUnique({
where: { id: issueId },
include: {
audiobook: {
select: {
id: true,
title: true,
author: true,
audibleAsin: true,
coverArtUrl: true,
narrator: true,
plexGuid: true,
absItemId: true,
},
},
},
});
if (!issue) {
throw new ReportedIssueError('Issue not found', 404);
}
if (issue.status !== 'open') {
throw new ReportedIssueError('Issue is already resolved', 409);
}
const audiobook = issue.audiobook;
// Step 1: Find existing active request for this audiobook
const existingRequest = await prisma.request.findFirst({
where: {
audiobookId: audiobook.id,
type: 'audiobook',
deletedAt: null,
},
orderBy: { createdAt: 'desc' },
});
// Step 2: Delete old content
if (existingRequest) {
// Has an RMAB request — use deleteRequest which handles torrent cleanup, files, library backend
const { deleteRequest } = await import('./request-delete.service');
const deleteResult = await deleteRequest(existingRequest.id, adminUserId);
if (!deleteResult.success) {
logger.warn(`deleteRequest partial failure for ${existingRequest.id}: ${deleteResult.error}`);
// Continue anyway - we want replacement to proceed
}
logger.info(`Deleted existing request ${existingRequest.id} for replacement`);
} else {
// No RMAB request — book was added to library outside RMAB
await deleteFromLibrary(audiobook);
logger.info(`Deleted library content directly for "${audiobook.title}" (no RMAB request)`);
}
// Step 3: Reset audiobook record for new request
await prisma.audiobook.update({
where: { id: audiobook.id },
data: {
status: 'requested',
plexGuid: null,
absItemId: null,
filePath: null,
fileFormat: null,
fileSizeBytes: null,
filesHash: null,
},
});
// Step 4: Create new request + start download (admin-initiated, no approval needed)
const newRequest = await prisma.request.create({
data: {
userId: adminUserId,
audiobookId: audiobook.id,
status: 'downloading',
type: 'audiobook',
progress: 0,
},
include: {
audiobook: true,
user: { select: { id: true, plexUsername: true } },
},
});
// Queue download job with selected torrent
const { getJobQueueService } = await import('./job-queue.service');
const jobQueue = getJobQueueService();
await jobQueue.addDownloadJob(
newRequest.id,
{
id: audiobook.id,
title: audiobook.title,
author: audiobook.author,
},
torrent
);
// Step 5: Resolve issue
await prisma.reportedIssue.update({
where: { id: issueId },
data: {
status: 'replaced',
resolvedAt: new Date(),
resolvedById: adminUserId,
},
});
logger.info(`Issue ${issueId} resolved via replacement. New request: ${newRequest.id}`);
return { issue, request: newRequest };
}
/**
* Get all open issues with audiobook metadata and reporter info (admin list)
*/
export async function getOpenIssues() {
return prisma.reportedIssue.findMany({
where: { status: 'open' },
include: {
audiobook: {
select: {
id: true,
title: true,
author: true,
coverArtUrl: true,
audibleAsin: true,
},
},
reporter: {
select: {
id: true,
plexUsername: true,
avatarUrl: true,
},
},
},
orderBy: { createdAt: 'desc' },
});
}
/**
* Batch query for open issues by ASINs (used for enrichment in audiobook-matcher)
*/
export async function getOpenIssuesByAsins(asins: string[]): Promise<Set<string>> {
if (asins.length === 0) return new Set();
const issues = await prisma.reportedIssue.findMany({
where: {
status: 'open',
audiobook: {
audibleAsin: { in: asins },
},
},
select: {
audiobook: {
select: { audibleAsin: true },
},
},
});
return new Set(
issues
.map((i) => i.audiobook.audibleAsin)
.filter((asin): asin is string => asin !== null)
);
}
/**
* Delete audiobook content from library backend directly (no RMAB request).
* Used when a book was added to Plex/ABS outside of RMAB.
* Mirrors the library deletion logic from request-delete.service.ts lines 280-440.
*/
async function deleteFromLibrary(audiobook: {
id: string;
title: string;
author: string;
audibleAsin: string | null;
plexGuid: string | null;
absItemId: string | null;
}) {
const { getConfigService } = await import('./config.service');
const configService = getConfigService();
const backendMode = await configService.getBackendMode();
// Delete from library backend API
if (backendMode === 'audiobookshelf') {
// absItemId may be null if the book was added outside RMAB.
// Fall back to looking up the ABS item ID from plex_library by ASIN
// (plexGuid stores the ABS item ID when using ABS backend).
let itemId = audiobook.absItemId;
if (!itemId && audiobook.audibleAsin) {
const libraryRecord = await prisma.plexLibrary.findFirst({
where: {
OR: [
{ asin: audiobook.audibleAsin },
{ plexGuid: { contains: audiobook.audibleAsin } },
],
},
select: { plexGuid: true },
});
itemId = libraryRecord?.plexGuid ?? null;
}
if (itemId) {
try {
const { deleteABSItem } = await import('./audiobookshelf/api');
await deleteABSItem(itemId);
logger.info(`Deleted ABS item ${itemId} for "${audiobook.title}"`);
} catch (error) {
logger.error(`Failed to delete ABS item ${itemId}`, {
error: error instanceof Error ? error.message : String(error),
});
}
} else {
logger.warn(`No ABS item ID found for "${audiobook.title}" (ASIN: ${audiobook.audibleAsin}) — skipping ABS deletion`);
}
} else if (backendMode === 'plex' && audiobook.plexGuid) {
try {
const plexLibraryRecord = await prisma.plexLibrary.findUnique({
where: { plexGuid: audiobook.plexGuid },
select: { plexRatingKey: true },
});
if (plexLibraryRecord?.plexRatingKey) {
const plexServerUrl = (await configService.get('plex_url')) || '';
const plexToken = (await configService.get('plex_token')) || '';
if (plexServerUrl && plexToken) {
const { getPlexService } = await import('../integrations/plex.service');
const plexService = getPlexService();
await plexService.deleteItem(plexServerUrl, plexToken, plexLibraryRecord.plexRatingKey);
logger.info(`Deleted Plex item ${plexLibraryRecord.plexRatingKey} for "${audiobook.title}"`);
}
}
} catch (error) {
logger.error(`Failed to delete Plex item for "${audiobook.title}"`, {
error: error instanceof Error ? error.message : String(error),
});
}
}
// Delete plex_library records by ASIN
if (audiobook.audibleAsin) {
try {
const result = await prisma.plexLibrary.deleteMany({
where: {
OR: [
{ asin: audiobook.audibleAsin },
{ plexGuid: { contains: audiobook.audibleAsin } },
],
},
});
if (result.count > 0) {
logger.info(`Deleted ${result.count} plex_library record(s) by ASIN "${audiobook.audibleAsin}"`);
}
} catch (error) {
logger.error(`Failed to delete plex_library records for ASIN "${audiobook.audibleAsin}"`, {
error: error instanceof Error ? error.message : String(error),
});
}
}
}
/**
* Custom error class for reported issues
*/
export class ReportedIssueError extends Error {
constructor(
message: string,
public statusCode: number
) {
super(message);
this.name = 'ReportedIssueError';
}
}
+267
View File
@@ -0,0 +1,267 @@
/**
* Component: Request Creator Service
* Documentation: documentation/backend/services/requests.md
*
* Shared request-creation logic used by both the API route and Goodreads sync.
* Encapsulates: duplicate detection, library check, Audnexus enrichment,
* audiobook record creation, approval flow, notification queuing, and search job triggering.
*/
import { prisma } from '@/lib/db';
import { getJobQueueService } from '@/lib/services/job-queue.service';
import { findPlexMatch } from '@/lib/utils/audiobook-matcher';
import { getAudibleService } from '@/lib/integrations/audible.service';
import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('RequestCreator');
export interface CreateRequestInput {
asin: string;
title: string;
author: string;
narrator?: string;
description?: string;
coverArtUrl?: string;
}
export interface CreateRequestOptions {
skipAutoSearch?: boolean;
}
export type CreateRequestResult =
| { success: true; request: any }
| { success: false; reason: 'already_available' | 'being_processed' | 'duplicate' | 'user_not_found'; message: string };
/**
* Create a request for a user, with full duplicate detection, library checks,
* Audnexus enrichment, approval flow, notifications, and search job triggering.
*/
export async function createRequestForUser(
userId: string,
audiobook: CreateRequestInput,
options: CreateRequestOptions = {}
): Promise<CreateRequestResult> {
const { skipAutoSearch = false } = options;
// Check for existing active request (downloaded/available) for this ASIN
const existingActiveRequest = await prisma.request.findFirst({
where: {
audiobook: { audibleAsin: audiobook.asin },
type: 'audiobook',
status: { in: ['downloaded', 'available'] },
deletedAt: null,
},
});
if (existingActiveRequest) {
const status = existingActiveRequest.status;
return {
success: false,
reason: status === 'available' ? 'already_available' : 'being_processed',
message: status === 'available'
? 'This audiobook is already available in your library'
: 'This audiobook is being processed and will be available soon',
};
}
// Check if audiobook is already in Plex/ABS library
const plexMatch = await findPlexMatch({
asin: audiobook.asin,
title: audiobook.title,
author: audiobook.author,
narrator: audiobook.narrator,
});
if (plexMatch) {
return {
success: false,
reason: 'already_available',
message: 'This audiobook is already available in your library',
};
}
// Fetch full details from Audnexus for year/series
let year: number | undefined;
let series: string | undefined;
let seriesPart: string | undefined;
try {
const audibleService = getAudibleService();
const audnexusData = await audibleService.getAudiobookDetails(audiobook.asin);
if (audnexusData?.releaseDate) {
try {
const releaseYear = new Date(audnexusData.releaseDate).getFullYear();
if (!isNaN(releaseYear)) {
year = releaseYear;
}
} catch {
// Ignore parse errors
}
}
if (audnexusData?.series) series = audnexusData.series;
if (audnexusData?.seriesPart) seriesPart = audnexusData.seriesPart;
} catch (error) {
logger.warn(`Failed to fetch Audnexus data for ASIN ${audiobook.asin}: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
// Find or create audiobook record
let audiobookRecord = await prisma.audiobook.findFirst({
where: { audibleAsin: audiobook.asin },
});
if (!audiobookRecord) {
audiobookRecord = await prisma.audiobook.create({
data: {
audibleAsin: audiobook.asin,
title: audiobook.title,
author: audiobook.author,
narrator: audiobook.narrator,
description: audiobook.description,
coverArtUrl: audiobook.coverArtUrl,
year,
series,
seriesPart,
status: 'requested',
},
});
logger.debug(`Created audiobook ${audiobookRecord.id} for ASIN ${audiobook.asin}`);
} else {
// Update existing record with clean metadata (e.g. Audnexus title replacing Goodreads title)
const updates: Record<string, any> = {};
if (audiobook.title && audiobook.title !== audiobookRecord.title) updates.title = audiobook.title;
if (audiobook.author && audiobook.author !== audiobookRecord.author) updates.author = audiobook.author;
if (audiobook.coverArtUrl && !audiobookRecord.coverArtUrl) updates.coverArtUrl = audiobook.coverArtUrl;
if (year) updates.year = year;
if (series) updates.series = series;
if (seriesPart) updates.seriesPart = seriesPart;
if (Object.keys(updates).length > 0) {
audiobookRecord = await prisma.audiobook.update({
where: { id: audiobookRecord.id },
data: updates,
});
}
}
// Check if user already has an active request for this audiobook
const existingRequest = await prisma.request.findFirst({
where: {
userId,
audiobookId: audiobookRecord.id,
type: 'audiobook',
deletedAt: null,
},
});
if (existingRequest) {
const canReRequest = ['failed', 'warn', 'cancelled'].includes(existingRequest.status);
if (!canReRequest) {
return {
success: false,
reason: 'duplicate',
message: 'You have already requested this audiobook',
};
}
// Delete existing failed/warn/cancelled request
logger.debug(`Deleting existing ${existingRequest.status} request ${existingRequest.id} to allow re-request`);
await prisma.request.delete({ where: { id: existingRequest.id } });
}
// Check ANY user's active request for same audiobook (avoid duplicate processing)
const anyActiveRequest = await prisma.request.findFirst({
where: {
audiobookId: audiobookRecord.id,
type: 'audiobook',
status: { notIn: ['failed', 'warn', 'cancelled', 'available', 'downloaded'] },
deletedAt: null,
},
});
if (anyActiveRequest && anyActiveRequest.userId !== userId) {
return {
success: false,
reason: 'being_processed',
message: 'This audiobook is already being requested by another user',
};
}
// Determine if approval is needed
let needsApproval = false;
let shouldTriggerSearch = !skipAutoSearch;
const user = await prisma.user.findUnique({
where: { id: userId },
select: { role: true, autoApproveRequests: true, plexUsername: true },
});
if (!user) {
return { success: false, reason: 'user_not_found', message: 'User not found' };
}
if (user.role === 'admin') {
needsApproval = false;
} else {
if (user.autoApproveRequests === true) {
needsApproval = false;
} else if (user.autoApproveRequests === false) {
needsApproval = true;
} else {
const globalConfig = await prisma.configuration.findUnique({
where: { key: 'auto_approve_requests' },
});
const globalAutoApprove = globalConfig === null ? true : globalConfig.value === 'true';
needsApproval = !globalAutoApprove;
}
}
let initialStatus: string;
if (needsApproval) {
initialStatus = 'awaiting_approval';
shouldTriggerSearch = false;
} else if (skipAutoSearch) {
initialStatus = 'awaiting_search';
} else {
initialStatus = 'pending';
}
// Create request
const newRequest = await prisma.request.create({
data: {
userId,
audiobookId: audiobookRecord.id,
status: initialStatus,
type: 'audiobook',
progress: 0,
},
include: {
audiobook: true,
user: { select: { id: true, plexUsername: true } },
},
});
const jobQueue = getJobQueueService();
// Send notification
const notificationType = initialStatus === 'awaiting_approval' ? 'request_pending_approval' : 'request_approved';
await jobQueue.addNotificationJob(
notificationType,
newRequest.id,
audiobookRecord.title,
audiobookRecord.author,
user.plexUsername || 'Unknown User'
).catch((error) => {
logger.error('Failed to queue notification', { error: error instanceof Error ? error.message : String(error) });
});
// Trigger search job
if (shouldTriggerSearch) {
await jobQueue.addSearchJob(newRequest.id, {
id: audiobookRecord.id,
title: audiobookRecord.title,
author: audiobookRecord.author,
asin: audiobookRecord.audibleAsin || undefined,
});
}
return { success: true, request: newRequest };
}
+22 -1
View File
@@ -4,12 +4,13 @@
*/ */
import { getJobQueueService, ScanPlexPayload } from './job-queue.service'; import { getJobQueueService, ScanPlexPayload } from './job-queue.service';
import { getNotificationService } from './notification';
import { prisma } from '../db'; import { prisma } from '../db';
import { RMABLogger } from '../utils/logger'; import { RMABLogger } from '../utils/logger';
const logger = RMABLogger.create('Scheduler'); const logger = RMABLogger.create('Scheduler');
export type ScheduledJobType = 'plex_library_scan' | 'plex_recently_added_check' | 'audible_refresh' | 'retry_missing_torrents' | 'retry_failed_imports' | 'cleanup_seeded_torrents' | 'monitor_rss_feeds'; export type ScheduledJobType = 'plex_library_scan' | 'plex_recently_added_check' | 'audible_refresh' | 'retry_missing_torrents' | 'retry_failed_imports' | 'cleanup_seeded_torrents' | 'monitor_rss_feeds' | 'sync_goodreads_shelves';
export interface ScheduledJob { export interface ScheduledJob {
id: string; id: string;
@@ -49,6 +50,9 @@ export class SchedulerService {
async start(): Promise<void> { async start(): Promise<void> {
logger.info('Initializing scheduler service...'); logger.info('Initializing scheduler service...');
// Re-encrypt any notification backends with plaintext sensitive fields
await getNotificationService().reEncryptUnprotectedBackends();
// Create default jobs if they don't exist // Create default jobs if they don't exist
await this.ensureDefaultJobs(); await this.ensureDefaultJobs();
@@ -115,6 +119,13 @@ export class SchedulerService {
enabled: true, // Enable by default enabled: true, // Enable by default
payload: {}, payload: {},
}, },
{
name: 'Sync Goodreads Shelves',
type: 'sync_goodreads_shelves' as ScheduledJobType,
schedule: '0 */6 * * *', // Every 6 hours
enabled: true, // Enable by default
payload: {},
},
]; ];
for (const defaultJob of defaults) { for (const defaultJob of defaults) {
@@ -314,6 +325,9 @@ export class SchedulerService {
case 'monitor_rss_feeds': case 'monitor_rss_feeds':
bullJobId = await this.triggerMonitorRssFeeds(job); bullJobId = await this.triggerMonitorRssFeeds(job);
break; break;
case 'sync_goodreads_shelves':
bullJobId = await this.triggerSyncGoodreadsShelves(job);
break;
default: default:
throw new Error(`Unknown job type: ${job.type}`); throw new Error(`Unknown job type: ${job.type}`);
} }
@@ -578,6 +592,13 @@ export class SchedulerService {
private async triggerCleanupSeededTorrents(job: any): Promise<string> { private async triggerCleanupSeededTorrents(job: any): Promise<string> {
return await this.jobQueue.addCleanupSeededTorrentsJob(job.id); return await this.jobQueue.addCleanupSeededTorrentsJob(job.id);
} }
/**
* Trigger Goodreads shelves sync
*/
private async triggerSyncGoodreadsShelves(job: any): Promise<string> {
return await this.jobQueue.addSyncGoodreadsShelvesJob(job.id);
}
} }
// Singleton instance // Singleton instance
+8 -1
View File
@@ -3,7 +3,7 @@
* Documentation: documentation/integrations/audible.md * Documentation: documentation/integrations/audible.md
*/ */
export type AudibleRegion = 'us' | 'ca' | 'uk' | 'au' | 'in' | 'de'; export type AudibleRegion = 'us' | 'ca' | 'uk' | 'au' | 'in' | 'de' | 'es';
export interface AudibleRegionConfig { export interface AudibleRegionConfig {
code: AudibleRegion; code: AudibleRegion;
@@ -56,6 +56,13 @@ export const AUDIBLE_REGIONS: Record<AudibleRegion, AudibleRegionConfig> = {
audnexusParam: 'de', audnexusParam: 'de',
isEnglish: false, isEnglish: false,
}, },
es: {
code: 'es',
name: 'Spain',
baseUrl: 'https://www.audible.es',
audnexusParam: 'es',
isEnglish: false,
}
}; };
export const DEFAULT_AUDIBLE_REGION: AudibleRegion = 'us'; export const DEFAULT_AUDIBLE_REGION: AudibleRegion = 'us';

Some files were not shown because too many files have changed in this diff Show More