From 20c8fb0898f45515b823366c9e672a2ddfc66b7c Mon Sep 17 00:00:00 2001 From: kikootwo Date: Wed, 11 Feb 2026 16:49:55 -0500 Subject: [PATCH] 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. --- .../backend/services/notifications.md | 20 +- documentation/phase3/qbittorrent.md | 22 +- .../migration.sql | 46 ++ .../migration.sql | 3 + .../migration.sql | 32 ++ prisma/schema.prisma | 74 +++- .../components/ReportedIssuesSection.tsx | 242 ++++++++++ src/app/admin/page.tsx | 14 + .../NotificationsTab/NotificationsTab.tsx | 8 +- src/app/api/admin/notifications/[id]/route.ts | 3 +- src/app/api/admin/notifications/route.ts | 3 +- .../reported-issues/[id]/replace/route.ts | 87 ++++ .../reported-issues/[id]/resolve/route.ts | 74 ++++ src/app/api/admin/reported-issues/route.ts | 39 ++ .../audiobooks/[asin]/report-issue/route.ts | 69 +++ src/app/api/requests/route.ts | 276 +----------- .../api/user/goodreads-shelves/[id]/route.ts | 50 +++ src/app/api/user/goodreads-shelves/route.ts | 174 ++++++++ src/app/globals.css | 9 + src/app/profile/page.tsx | 376 ++++++---------- src/components/audiobooks/AudiobookCard.tsx | 1 + .../audiobooks/AudiobookDetailsModal.tsx | 48 ++ .../audiobooks/ReportIssueModal.tsx | 143 ++++++ src/components/layout/Header.tsx | 17 + .../profile/GoodreadsShelvesSection.tsx | 360 +++++++++++++++ .../InteractiveTorrentSearchModal.tsx | 33 +- src/components/ui/AddGoodreadsShelfModal.tsx | 154 +++++++ src/lib/constants/notification-events.ts | 82 ++++ src/lib/hooks/useAudiobooks.ts | 1 + src/lib/hooks/useGoodreadsShelves.ts | 127 ++++++ src/lib/hooks/useReportedIssues.ts | 168 +++++++ src/lib/integrations/audible.service.ts | 90 ++-- src/lib/integrations/prowlarr.service.ts | 4 +- src/lib/integrations/qbittorrent.service.ts | 14 +- .../processors/audible-refresh.processor.ts | 6 + .../processors/send-notification.processor.ts | 18 +- .../sync-goodreads-shelves.processor.ts | 42 ++ src/lib/services/audiobookshelf/api.ts | 2 +- src/lib/services/encryption.service.ts | 33 ++ src/lib/services/goodreads-sync.service.ts | 357 +++++++++++++++ src/lib/services/job-queue.service.ts | 45 +- .../notification/INotificationProvider.ts | 15 +- src/lib/services/notification/index.ts | 10 + .../notification/notification.service.ts | 59 ++- .../providers/apprise.provider.ts | 35 +- .../providers/discord.provider.ts | 33 +- .../notification/providers/ntfy.provider.ts | 52 +-- .../providers/pushover.provider.ts | 50 +-- src/lib/services/reported-issue.service.ts | 413 ++++++++++++++++++ src/lib/services/request-creator.service.ts | 267 +++++++++++ src/lib/services/scheduler.service.ts | 23 +- src/lib/utils/audiobook-matcher.ts | 8 + src/lib/utils/scrape-resilience.ts | 100 +++++ .../notification-triggers.integration.test.ts | 3 + tests/app/profile.page.test.tsx | 6 +- .../ui/ChangePasswordModal.test.tsx | 8 +- tests/helpers/prisma.ts | 2 + tests/integrations/audible.service.test.ts | 16 +- .../integrations/qbittorrent.service.test.ts | 45 +- .../audible-refresh.processor.test.ts | 16 +- .../send-notification.processor.test.ts | 3 +- tests/services/apprise.provider.test.ts | 15 +- tests/services/encryption.service.test.ts | 112 +++++ .../job-queue-notifications.service.test.ts | 3 +- tests/services/job-queue.service.test.ts | 6 + tests/services/notification.service.test.ts | 93 +++- tests/services/ntfy.provider.test.ts | 15 +- tests/services/scheduler.service.test.ts | 12 +- tests/utils/scrape-resilience.test.ts | 147 +++++++ 69 files changed, 4167 insertions(+), 766 deletions(-) create mode 100644 prisma/migrations/20260211000000_add_goodreads_sync/migration.sql create mode 100644 prisma/migrations/20260211100000_add_goodreads_shelf_metadata/migration.sql create mode 100644 prisma/migrations/20260211200000_add_reported_issues/migration.sql create mode 100644 src/app/admin/components/ReportedIssuesSection.tsx create mode 100644 src/app/api/admin/reported-issues/[id]/replace/route.ts create mode 100644 src/app/api/admin/reported-issues/[id]/resolve/route.ts create mode 100644 src/app/api/admin/reported-issues/route.ts create mode 100644 src/app/api/audiobooks/[asin]/report-issue/route.ts create mode 100644 src/app/api/user/goodreads-shelves/[id]/route.ts create mode 100644 src/app/api/user/goodreads-shelves/route.ts create mode 100644 src/components/audiobooks/ReportIssueModal.tsx create mode 100644 src/components/profile/GoodreadsShelvesSection.tsx create mode 100644 src/components/ui/AddGoodreadsShelfModal.tsx create mode 100644 src/lib/constants/notification-events.ts create mode 100644 src/lib/hooks/useGoodreadsShelves.ts create mode 100644 src/lib/hooks/useReportedIssues.ts create mode 100644 src/lib/processors/sync-goodreads-shelves.processor.ts create mode 100644 src/lib/services/goodreads-sync.service.ts create mode 100644 src/lib/services/reported-issue.service.ts create mode 100644 src/lib/services/request-creator.service.ts create mode 100644 src/lib/utils/scrape-resilience.ts create mode 100644 tests/utils/scrape-resilience.test.ts diff --git a/documentation/backend/services/notifications.md b/documentation/backend/services/notifications.md index 96a5fec..41172a5 100644 --- a/documentation/backend/services/notifications.md +++ b/documentation/backend/services/notifications.md @@ -7,7 +7,7 @@ Sends notifications for audiobook request events (pending approval, approved, av ## Key Details - **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, notification URLs) - **Delivery:** Async via Bull job queue (priority 5) - **Failure Handling:** Non-blocking, Promise.allSettled (one backend fails, others succeed) @@ -35,6 +35,7 @@ model NotificationBackend { | request_approved | Admin approves OR auto-approval | Request approved (manual or auto) | | request_available | Plex/ABS scan completes | Audiobook available in library | | request_error | Download/import fails | Request failed at any stage | +| issue_reported | User reports issue | User reports problem with available audiobook | ## Notification Triggers @@ -67,6 +68,10 @@ model NotificationBackend { - After `status: 'failed'` or `status: 'warn'` update → request_error - 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 **Encrypted Values:** @@ -91,13 +96,14 @@ model NotificationBackend { - Format: Event title + book details + user + error (if applicable) **Discord (Rich Embeds):** -- Color-coded by event (yellow=pending, green=approved, blue=available, red=error) -- Fields: Title, Author, Requested By, Error (if applicable) -- Footer: Request ID +- Color-coded by event (yellow=pending, green=approved, blue=available, red=error, orange=issue) +- Fields: Title, Author, Requested/Reported By, Error/Reason (if applicable) +- Footer: Request/Issue ID - Timestamp: Event time -**ntfy (JSON with Tags):** -- Tags: mailbox_with_mail, white_check_mark, tada, x (rendered as emojis by ntfy) +**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 @@ -142,7 +148,7 @@ model NotificationBackend { **Modal Features:** - Type-first selection (user clicks "Add Discord" or "Add Pushover") - 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) - Save button (validates and creates/updates backend) diff --git a/documentation/phase3/qbittorrent.md b/documentation/phase3/qbittorrent.md index 66f2563..76aa7c7 100644 --- a/documentation/phase3/qbittorrent.md +++ b/documentation/phase3/qbittorrent.md @@ -175,19 +175,19 @@ interface TorrentInfo { } type TorrentState = - // Core states + // Core states (*DL = download phase, *UP = upload/post-download phase) | 'downloading' | 'uploading' - | 'stalledDL' | 'stalledUP' - | 'pausedDL' | 'pausedUP' - | 'queuedDL' | 'queuedUP' - | 'checkingDL' | 'checkingUP' + | 'stalledDL' | 'stalledUP' // stalledUP → completed (download done) + | 'pausedDL' | 'pausedUP' // pausedUP → completed (download done, paused seeding) + | 'queuedDL' | 'queuedUP' // queuedUP → completed (download done) + | 'checkingDL' | 'checkingUP' // checkingUP → completed (download done, rechecking) | 'error' | 'missingFiles' | 'allocating' // Forced states (user clicked "Force Resume") - | 'forcedDL' | 'forcedUP' + | 'forcedDL' | 'forcedUP' // forcedUP → completed (download done) // Metadata fetching | 'metaDL' | 'forcedMetaDL' // qBittorrent v5.0+ (renamed paused → stopped) - | 'stoppedDL' | 'stoppedUP' + | 'stoppedDL' | 'stoppedUP' // stoppedUP → completed (download done) // Other | 'checkingResumeData' | 'moving'; ``` @@ -241,7 +241,13 @@ type TorrentState = - Adding all 8 missing states to `TorrentState` type union - Adding mappings to both `mapState()` (legacy) and `mapStateToDownloadStatus()` (unified interface) - `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 diff --git a/prisma/migrations/20260211000000_add_goodreads_sync/migration.sql b/prisma/migrations/20260211000000_add_goodreads_sync/migration.sql new file mode 100644 index 0000000..1c95487 --- /dev/null +++ b/prisma/migrations/20260211000000_add_goodreads_sync/migration.sql @@ -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; diff --git a/prisma/migrations/20260211100000_add_goodreads_shelf_metadata/migration.sql b/prisma/migrations/20260211100000_add_goodreads_shelf_metadata/migration.sql new file mode 100644 index 0000000..8f7b77d --- /dev/null +++ b/prisma/migrations/20260211100000_add_goodreads_shelf_metadata/migration.sql @@ -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; diff --git a/prisma/migrations/20260211200000_add_reported_issues/migration.sql b/prisma/migrations/20260211200000_add_reported_issues/migration.sql new file mode 100644 index 0000000..441bcba --- /dev/null +++ b/prisma/migrations/20260211200000_add_reported_issues/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index bf35442..5780358 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -64,6 +64,9 @@ model User { requests Request[] bookDateRecommendations BookDateRecommendation[] bookDateSwipes BookDateSwipe[] + goodreadsShelves GoodreadsShelf[] + reportedIssues ReportedIssue[] @relation("Reporter") + resolvedIssues ReportedIssue[] @relation("Resolver") @@index([plexId]) @@index([role]) @@ -197,7 +200,8 @@ model Audiobook { completedAt DateTime? @map("completed_at") // Relations - requests Request[] + requests Request[] + reportedIssues ReportedIssue[] @@index([audibleAsin]) @@index([plexGuid]) @@ -456,3 +460,71 @@ model NotificationBackend { @@index([enabled]) @@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 + 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") +} diff --git a/src/app/admin/components/ReportedIssuesSection.tsx b/src/app/admin/components/ReportedIssuesSection.tsx new file mode 100644 index 0000000..975baa9 --- /dev/null +++ b/src/app/admin/components/ReportedIssuesSection.tsx @@ -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 + )} + </> + ); +} diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 2e342cf..9fcd9b7 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -12,6 +12,7 @@ import { MetricCard } from './components/MetricCard'; import { ActiveDownloadsTable } from './components/ActiveDownloadsTable'; import { RecentRequestsTable } from './components/RecentRequestsTable'; import { ToastProvider, useToast } from '@/components/ui/Toast'; +import { ReportedIssuesSection } from './components/ReportedIssuesSection'; import { formatDistanceToNow } from 'date-fns'; 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( '/api/admin/settings', authenticatedFetcher, @@ -578,6 +587,11 @@ function AdminDashboardContent() { <PendingApprovalSection requests={pendingApprovalData.requests} /> )} + {/* Reported Issues */} + {reportedIssuesData?.issues && reportedIssuesData.issues.length > 0 && ( + <ReportedIssuesSection issues={reportedIssuesData.issues} /> + )} + {/* Active Downloads */} <div className="mb-8"> <h2 className="text-xl font-bold text-gray-900 dark:text-gray-100 mb-4"> diff --git a/src/app/admin/settings/tabs/NotificationsTab/NotificationsTab.tsx b/src/app/admin/settings/tabs/NotificationsTab/NotificationsTab.tsx index 0c97111..2da0801 100644 --- a/src/app/admin/settings/tabs/NotificationsTab/NotificationsTab.tsx +++ b/src/app/admin/settings/tabs/NotificationsTab/NotificationsTab.tsx @@ -3,6 +3,7 @@ import { useState, useEffect } from 'react'; import { RMABLogger } from '@/lib/utils/logger'; import { fetchWithAuth } from '@/lib/utils/api'; +import { EVENT_LABELS } from '@/lib/constants/notification-events'; const logger = RMABLogger.create('NotificationsTab'); @@ -43,12 +44,7 @@ interface ModalState { backend?: NotificationBackend; } -const eventLabels: Record<string, string> = { - request_pending_approval: 'Request Pending Approval', - request_approved: 'Request Approved', - request_available: 'Audiobook Available', - request_error: 'Request Error', -}; +const eventLabels: Record<string, string> = EVENT_LABELS; export function NotificationsTab() { const [backends, setBackends] = useState<NotificationBackend[]>([]); diff --git a/src/app/api/admin/notifications/[id]/route.ts b/src/app/api/admin/notifications/[id]/route.ts index d2f854b..1501b88 100644 --- a/src/app/api/admin/notifications/[id]/route.ts +++ b/src/app/api/admin/notifications/[id]/route.ts @@ -7,6 +7,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth'; import { prisma } from '@/lib/db'; import { getNotificationService } from '@/lib/services/notification'; +import { NOTIFICATION_EVENT_KEYS } from '@/lib/constants/notification-events'; import { RMABLogger } from '@/lib/utils/logger'; import { z } from 'zod'; @@ -15,7 +16,7 @@ const logger = RMABLogger.create('API.Admin.Notifications.Id'); const UpdateBackendSchema = z.object({ name: z.string().min(1).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(), }); diff --git a/src/app/api/admin/notifications/route.ts b/src/app/api/admin/notifications/route.ts index 44dab48..2be7ad5 100644 --- a/src/app/api/admin/notifications/route.ts +++ b/src/app/api/admin/notifications/route.ts @@ -7,6 +7,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth'; import { prisma } from '@/lib/db'; import { getNotificationService, getRegisteredProviderTypes } from '@/lib/services/notification'; +import { NOTIFICATION_EVENT_KEYS } from '@/lib/constants/notification-events'; import { RMABLogger } from '@/lib/utils/logger'; import { z } from 'zod'; @@ -16,7 +17,7 @@ const CreateBackendSchema = z.object({ type: z.string().refine((val) => getRegisteredProviderTypes().includes(val), { message: 'Unsupported notification provider type' }), name: z.string().min(1), config: z.record(z.any()), - events: z.array(z.enum(['request_pending_approval', 'request_approved', 'request_available', 'request_error'])).min(1), + events: z.array(z.enum(NOTIFICATION_EVENT_KEYS)).min(1), enabled: z.boolean().default(true), }); diff --git a/src/app/api/admin/reported-issues/[id]/replace/route.ts b/src/app/api/admin/reported-issues/[id]/replace/route.ts new file mode 100644 index 0000000..41f5ce7 --- /dev/null +++ b/src/app/api/admin/reported-issues/[id]/replace/route.ts @@ -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 } + ); + } + }); + }); +} diff --git a/src/app/api/admin/reported-issues/[id]/resolve/route.ts b/src/app/api/admin/reported-issues/[id]/resolve/route.ts new file mode 100644 index 0000000..9ec8a53 --- /dev/null +++ b/src/app/api/admin/reported-issues/[id]/resolve/route.ts @@ -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 } + ); + } + }); + }); +} diff --git a/src/app/api/admin/reported-issues/route.ts b/src/app/api/admin/reported-issues/route.ts new file mode 100644 index 0000000..9efc37c --- /dev/null +++ b/src/app/api/admin/reported-issues/route.ts @@ -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 } + ); + } + }); + }); +} diff --git a/src/app/api/audiobooks/[asin]/report-issue/route.ts b/src/app/api/audiobooks/[asin]/report-issue/route.ts new file mode 100644 index 0000000..bf951ea --- /dev/null +++ b/src/app/api/audiobooks/[asin]/report-issue/route.ts @@ -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 } + ); + } + }); +} diff --git a/src/app/api/requests/route.ts b/src/app/api/requests/route.ts index ae0a73d..194302a 100644 --- a/src/app/api/requests/route.ts +++ b/src/app/api/requests/route.ts @@ -6,11 +6,9 @@ import { NextRequest, NextResponse } from 'next/server'; import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth'; 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 { RMABLogger } from '@/lib/utils/logger'; +import { createRequestForUser } from '@/lib/services/request-creator.service'; const logger = RMABLogger.create('API.Requests'); @@ -45,274 +43,34 @@ export async function POST(request: NextRequest) { const body = await req.json(); const { audiobook } = CreateRequestSchema.parse(body); - // First check: Is there an existing audiobook request in 'downloaded' or 'available' status? - // 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 } }, - }, - }); + const skipAutoSearch = req.nextUrl.searchParams.get('skipAutoSearch') === 'true'; - if (existingActiveRequest) { - 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({ + const result = await createRequestForUser(req.user.id, { asin: audiobook.asin, title: audiobook.title, author: audiobook.author, 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( - { - error: 'AlreadyAvailable', - message: 'This audiobook is already available in your Plex library', - plexGuid: plexMatch.plexGuid, - }, - { status: 409 } + { error: mapped.error, message: result.message }, + { status: mapped.status } ); } - // 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({ success: true, - request: newRequest, + request: result.request, }, { status: 201 }); } catch (error) { logger.error('Failed to create request', { error: error instanceof Error ? error.message : String(error) }); diff --git a/src/app/api/user/goodreads-shelves/[id]/route.ts b/src/app/api/user/goodreads-shelves/[id]/route.ts new file mode 100644 index 0000000..ed072f1 --- /dev/null +++ b/src/app/api/user/goodreads-shelves/[id]/route.ts @@ -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 }); + } + }); +} diff --git a/src/app/api/user/goodreads-shelves/route.ts b/src/app/api/user/goodreads-shelves/route.ts new file mode 100644 index 0000000..9736619 --- /dev/null +++ b/src/app/api/user/goodreads-shelves/route.ts @@ -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 }); + } + }); +} diff --git a/src/app/globals.css b/src/app/globals.css index 176c9f6..c56e79d 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -196,3 +196,12 @@ body { .animate-toast-in { 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; +} diff --git a/src/app/profile/page.tsx b/src/app/profile/page.tsx index f82ca15..1d88640 100644 --- a/src/app/profile/page.tsx +++ b/src/app/profile/page.tsx @@ -11,80 +11,63 @@ import { RequestCard } from '@/components/requests/RequestCard'; import { useAuth } from '@/contexts/AuthContext'; import { useRequests } from '@/lib/hooks/useRequests'; 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() { const { user } = useAuth(); - // Always show only the current user's own requests (even for admins) const { requests, isLoading } = useRequests(undefined, 50, true); - // Calculate statistics const stats = useMemo(() => { if (!requests.length) { - return { - total: 0, - completed: 0, - active: 0, - waiting: 0, - failed: 0, - cancelled: 0, - }; + return { total: 0, completed: 0, active: 0, waiting: 0, failed: 0, cancelled: 0 }; } - return { total: requests.length, completed: requests.filter((r: any) => ['available', 'downloaded'].includes(r.status)).length, - active: requests.filter((r: any) => - ['pending', 'searching', 'downloading', 'processing'].includes(r.status) - ).length, - waiting: requests.filter((r: any) => - ['awaiting_search', 'awaiting_import'].includes(r.status) - ).length, + active: requests.filter((r: any) => ['pending', 'searching', 'downloading', 'processing'].includes(r.status)).length, + waiting: requests.filter((r: any) => ['awaiting_search', 'awaiting_import'].includes(r.status)).length, failed: requests.filter((r: any) => r.status === 'failed').length, cancelled: requests.filter((r: any) => r.status === 'cancelled').length, }; }, [requests]); - // Get active downloads (downloading or processing) const activeDownloads = useMemo(() => { - return requests.filter((r: any) => - ['downloading', 'processing'].includes(r.status) - ); + return requests.filter((r: any) => ['downloading', 'processing'].includes(r.status)); }, [requests]); - // Get recent requests (last 5) const recentRequests = useMemo(() => { return [...requests] .sort((a: any, b: any) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) .slice(0, 5); }, [requests]); - // Redirect to login if not authenticated if (!user) { return ( <div className="min-h-screen"> <Header /> - <main className="container mx-auto px-4 py-8 max-w-7xl"> - <div className="text-center py-16 space-y-4"> - <svg - className="mx-auto h-16 w-16 text-gray-400" - 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" - /> + <main className="container mx-auto px-4 py-20 max-w-5xl text-center"> + <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 className="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}> + <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" /> </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> + <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> </div> ); @@ -94,183 +77,83 @@ export default function ProfilePage() { <div className="min-h-screen"> <Header /> - <main className="container mx-auto px-4 py-8 max-w-7xl space-y-8"> - {/* User Info Card */} - <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-center gap-4 sm:gap-6"> + <main className="container mx-auto px-4 py-8 max-w-5xl space-y-10"> + {/* Profile Card — gradient banner + avatar + info + stats */} + <section className="rounded-2xl overflow-hidden bg-white dark:bg-gray-800 border border-gray-100 dark:border-gray-700/50 shadow-sm"> + {/* 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 */} - <div className="flex-shrink-0"> - {user.avatarUrl ? ( - <img - src={user.avatarUrl} - alt={user.username} - className="w-24 h-24 rounded-full" - /> - ) : ( - <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()} - </div> - )} - </div> + {user.avatarUrl ? ( + <img + src={user.avatarUrl} + alt={user.username} + className="w-28 h-28 rounded-full ring-4 ring-white dark:ring-gray-800 shadow-lg object-cover mb-5" + /> + ) : ( + <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"> + {user.username.charAt(0).toUpperCase()} + </div> + )} - {/* User Details */} - <div className="flex-1 space-y-2 text-center sm:text-left"> - <h1 className="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-gray-100"> - {user.username} - </h1> - {user.email && ( - <p className="text-gray-600 dark:text-gray-400"> - {user.email} - </p> - )} - <div className="flex items-center gap-2"> - <span - className={cn( - 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium', - user.role === 'admin' - ? 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200' - : 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300' - )} - > - {user.role === 'admin' ? 'Administrator' : 'User'} - </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> + {/* Name + Email + Badge */} + <h1 className="text-3xl font-bold text-gray-900 dark:text-white"> + {user.username} + </h1> + {user.email && ( + <p className="text-base text-gray-500 dark:text-gray-400 mt-1"> + {user.email} + </p> + )} + <div className="mt-3"> + <span + className={cn( + 'inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold uppercase tracking-wide', + user.role === 'admin' + ? 'bg-purple-50 text-purple-600 dark:bg-purple-500/15 dark:text-purple-400' + : 'bg-gray-100 text-gray-500 dark:bg-gray-700/50 dark:text-gray-400' + )} + > + {user.role === 'admin' ? 'Administrator' : 'User'} + </span> </div> </div> - {/* Active 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="M13 10V3L4 14h7v7l9-11h-7z" /> - </svg> + {/* Stats Strip */} + <div className="grid grid-cols-3 sm:grid-cols-6 gap-px bg-gray-100 dark:bg-gray-700/30"> + {statConfig.map((stat) => ( + <div + key={stat.key} + className="py-5 sm:py-6 px-3 text-center bg-white dark:bg-gray-800" + > + <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> - <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> + </section> - {/* Waiting 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-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> + {/* Goodreads Shelves */} + <GoodreadsShelvesSection /> {/* Active Downloads */} {activeDownloads.length > 0 && ( - <div className="space-y-4"> - <div className="flex items-center justify-between"> - <h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100"> + <section> + <div className="flex items-center justify-between mb-5"> + <h2 className="text-xl font-bold text-gray-900 dark:text-white"> Active Downloads </h2> <a 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> </div> <div className="space-y-4"> @@ -278,21 +161,23 @@ export default function ProfilePage() { <RequestCard key={request.id} request={request} showActions={false} /> ))} </div> - </div> + </section> )} {/* Recent Requests */} - <div className="space-y-4"> - <div className="flex items-center justify-between"> - <h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100"> + <section> + <div className="flex items-center justify-between mb-5"> + <h2 className="text-xl font-bold text-gray-900 dark:text-white"> Recent Requests </h2> - <a - href="/requests" - className="text-sm text-blue-600 dark:text-blue-400 hover:underline" - > - View All Requests → - </a> + {requests.length > 0 && ( + <a + 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 + </a> + )} </div> {isLoading ? ( @@ -300,14 +185,14 @@ export default function ProfilePage() { {[1, 2, 3].map((i) => ( <div 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="w-24 h-36 bg-gray-300 dark:bg-gray-700 rounded"></div> - <div className="flex-1 space-y-3"> - <div className="h-6 bg-gray-300 dark:bg-gray-700 rounded w-3/4"></div> - <div className="h-4 bg-gray-300 dark:bg-gray-700 rounded w-1/2"></div> - <div className="h-6 bg-gray-300 dark:bg-gray-700 rounded w-24"></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 py-1"> + <div className="h-6 bg-gray-100 dark:bg-gray-700/50 rounded w-3/4" /> + <div className="h-4 bg-gray-100 dark:bg-gray-700/50 rounded w-1/2" /> + <div className="h-6 bg-gray-100 dark:bg-gray-700/50 rounded w-24" /> </div> </div> </div> @@ -320,47 +205,34 @@ export default function ProfilePage() { ))} </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 - 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" stroke="currentColor" viewBox="0 0 24 24" + strokeWidth={1.5} > - <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" - /> + <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" /> </svg> - <div className="space-y-2"> - <h3 className="text-xl font-semibold text-gray-900 dark:text-gray-100"> - No requests yet - </h3> - <p className="text-gray-600 dark:text-gray-400"> - Start by searching for audiobooks and requesting them - </p> - </div> - <div className="pt-4"> - <a - href="/search" - 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" - > - <svg className="w-5 h-5" 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> - Search Audiobooks - </a> - </div> + <p className="text-base font-medium text-gray-500 dark:text-gray-400"> + No requests yet + </p> + <p className="text-sm text-gray-400 dark:text-gray-600 mt-1"> + Search for audiobooks to get started + </p> + <a + href="/search" + 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" + > + <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}> + <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> + Search Audiobooks + </a> </div> )} - </div> + </section> </main> </div> ); diff --git a/src/components/audiobooks/AudiobookCard.tsx b/src/components/audiobooks/AudiobookCard.tsx index 4d7c0b6..9b9ecc3 100644 --- a/src/components/audiobooks/AudiobookCard.tsx +++ b/src/components/audiobooks/AudiobookCard.tsx @@ -244,6 +244,7 @@ export function AudiobookCard({ requestStatus={audiobook.requestStatus} isAvailable={audiobook.isAvailable} requestedByUsername={audiobook.requestedByUsername} + hasReportedIssue={audiobook.hasReportedIssue} /> </> ); diff --git a/src/components/audiobooks/AudiobookDetailsModal.tsx b/src/components/audiobooks/AudiobookDetailsModal.tsx index f7c788e..3a759c8 100644 --- a/src/components/audiobooks/AudiobookDetailsModal.tsx +++ b/src/components/audiobooks/AudiobookDetailsModal.tsx @@ -16,6 +16,7 @@ import { useCreateRequest, useEbookStatus, useFetchEbookByAsin } from '@/lib/hoo import { useAuth } from '@/contexts/AuthContext'; import { usePreferences } from '@/contexts/PreferencesContext'; import { InteractiveTorrentSearchModal } from '@/components/requests/InteractiveTorrentSearchModal'; +import { ReportIssueModal } from '@/components/audiobooks/ReportIssueModal'; interface AudiobookDetailsModalProps { asin: string; @@ -27,6 +28,7 @@ interface AudiobookDetailsModalProps { isAvailable?: boolean; requestedByUsername?: string | null; hideRequestActions?: boolean; + hasReportedIssue?: boolean; } // Status helper @@ -65,6 +67,7 @@ export function AudiobookDetailsModal({ isAvailable = false, requestedByUsername = null, hideRequestActions = false, + hasReportedIssue = false, }: AudiobookDetailsModalProps) { const { user } = useAuth(); const { squareCovers } = usePreferences(); @@ -79,6 +82,7 @@ export function AudiobookDetailsModal({ const [mounted, setMounted] = useState(false); const [showInteractiveSearch, setShowInteractiveSearch] = useState(false); const [showInteractiveSearchEbook, setShowInteractiveSearchEbook] = useState(false); + const [showReportIssue, setShowReportIssue] = useState(false); const [asinCopied, setAsinCopied] = useState(false); const status = getStatusInfo(isAvailable, requestStatus, requestedByUsername); @@ -316,6 +320,33 @@ export function AudiobookDetailsModal({ </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 */} <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 && ( @@ -526,6 +557,7 @@ export function AudiobookDetailsModal({ )} </> )} + </div> </div> )} @@ -594,6 +626,22 @@ export function AudiobookDetailsModal({ </div>, 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} + /> + )} </> ); } diff --git a/src/components/audiobooks/ReportIssueModal.tsx b/src/components/audiobooks/ReportIssueModal.tsx new file mode 100644 index 0000000..0bb7283 --- /dev/null +++ b/src/components/audiobooks/ReportIssueModal.tsx @@ -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); +} diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx index b3f172b..6fafe8b 100644 --- a/src/components/layout/Header.tsx +++ b/src/components/layout/Header.tsx @@ -12,6 +12,7 @@ import { useAuth } from '@/contexts/AuthContext'; import { Button } from '@/components/ui/Button'; import { VersionBadge } from '@/components/ui/VersionBadge'; import { ChangePasswordModal } from '@/components/ui/ChangePasswordModal'; +import { AddGoodreadsShelfModal } from '@/components/ui/AddGoodreadsShelfModal'; import { useSmartDropdownPosition } from '@/hooks/useSmartDropdownPosition'; export function Header() { @@ -20,6 +21,7 @@ export function Header() { const [showMobileMenu, setShowMobileMenu] = useState(false); const [showBookDate, setShowBookDate] = useState(false); const [showChangePasswordModal, setShowChangePasswordModal] = useState(false); + const [showAddGoodreadsModal, setShowAddGoodreadsModal] = useState(false); const { containerRef, dropdownRef, positionAbove, style } = useSmartDropdownPosition(showUserMenu); // Check if user can change password (local users only) @@ -90,6 +92,15 @@ export function Header() { > Profile </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 && ( <button onClick={() => { @@ -297,6 +308,12 @@ export function Header() { isOpen={showChangePasswordModal} onClose={() => setShowChangePasswordModal(false)} /> + + {/* Add Goodreads Shelf Modal */} + <AddGoodreadsShelfModal + isOpen={showAddGoodreadsModal} + onClose={() => setShowAddGoodreadsModal(false)} + /> </header> ); } diff --git a/src/components/profile/GoodreadsShelvesSection.tsx b/src/components/profile/GoodreadsShelvesSection.tsx new file mode 100644 index 0000000..0b8d5e5 --- /dev/null +++ b/src/components/profile/GoodreadsShelvesSection.tsx @@ -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'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… + </> + ) : 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> + ); +} diff --git a/src/components/requests/InteractiveTorrentSearchModal.tsx b/src/components/requests/InteractiveTorrentSearchModal.tsx index 841eff7..aae716e 100644 --- a/src/components/requests/InteractiveTorrentSearchModal.tsx +++ b/src/components/requests/InteractiveTorrentSearchModal.tsx @@ -22,6 +22,7 @@ import { useInteractiveSearchEbookByAsin, useSelectEbookByAsin, } from '@/lib/hooks/useRequests'; +import { useReplaceWithTorrent } from '@/lib/hooks/useReportedIssues'; import { Audiobook } from '@/lib/hooks/useAudiobooks'; interface InteractiveTorrentSearchModalProps { @@ -36,6 +37,7 @@ interface InteractiveTorrentSearchModalProps { fullAudiobook?: Audiobook; // Optional - only provided when called from details modal onSuccess?: () => void; 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 @@ -87,11 +89,15 @@ export function InteractiveTorrentSearchModal({ fullAudiobook, onSuccess, searchMode = 'audiobook', + replaceIssueId, }: InteractiveTorrentSearchModalProps) { // Hooks for existing audiobook request flow const { searchTorrents: searchByRequestId, isLoading: isSearchingByRequest, error: searchByRequestError } = useInteractiveSearch(); 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 const { searchTorrents: searchByAudiobook, isLoading: isSearchingByAudiobook, error: searchByAudiobookError } = useSearchTorrents(); const { requestWithTorrent, isLoading: isRequestingWithTorrent, error: requestWithTorrentError } = useRequestWithTorrent(); @@ -124,14 +130,18 @@ export function InteractiveTorrentSearchModal({ const isSearching = isEbookMode ? (useAsinMode ? isSearchingEbooksByAsin : isSearchingEbooks) : (hasRequestId ? isSearchingByRequest : isSearchingByAudiobook); - const isDownloading = isEbookMode - ? (useAsinMode ? isSelectingEbookByAsin : isSelectingEbook) - : (hasRequestId ? isSelectingTorrent : isRequestingWithTorrent); - const error = isEbookMode - ? (useAsinMode ? (searchEbooksByAsinError || selectEbookByAsinError) : (searchEbooksError || selectEbookError)) - : (hasRequestId - ? (searchByRequestError || selectTorrentError) - : (searchByAudiobookError || requestWithTorrentError)); + const isDownloading = replaceIssueId + ? isReplacing + : isEbookMode + ? (useAsinMode ? isSelectingEbookByAsin : isSelectingEbook) + : (hasRequestId ? isSelectingTorrent : isRequestingWithTorrent); + const error = replaceIssueId + ? (replaceError || (hasRequestId ? searchByRequestError : searchByAudiobookError)) + : isEbookMode + ? (useAsinMode ? (searchEbooksByAsinError || selectEbookByAsinError) : (searchEbooksError || selectEbookError)) + : (hasRequestId + ? (searchByRequestError || selectTorrentError) + : (searchByAudiobookError || requestWithTorrentError)); // Mount tracking for portal useEffect(() => { setMounted(true); }, []); @@ -188,7 +198,7 @@ export function InteractiveTorrentSearchModal({ const customTitle = searchTitle !== audiobook.title ? searchTitle : undefined; data = await searchByRequestId(requestId, customTitle); } else { - const audiobookAsin = fullAudiobook?.asin; + const audiobookAsin = fullAudiobook?.asin || asin; data = await searchByAudiobook(searchTitle, audiobook.author, audiobookAsin); } setResults(data || []); @@ -208,7 +218,10 @@ export function InteractiveTorrentSearchModal({ const handleConfirmDownload = async () => { if (!confirmTorrent) return; try { - if (isEbookMode) { + if (replaceIssueId) { + // Reported issue replacement flow + await replaceWithTorrent(replaceIssueId, confirmTorrent); + } else if (isEbookMode) { if (useAsinMode && asin) { await selectEbookByAsin(asin, confirmTorrent); } else if (requestId) { diff --git a/src/components/ui/AddGoodreadsShelfModal.tsx b/src/components/ui/AddGoodreadsShelfModal.tsx new file mode 100644 index 0000000..dd0489b --- /dev/null +++ b/src/components/ui/AddGoodreadsShelfModal.tsx @@ -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 → select a shelf → 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> + ); +} diff --git a/src/lib/constants/notification-events.ts b/src/lib/constants/notification-events.ts new file mode 100644 index 0000000..929d0eb --- /dev/null +++ b/src/lib/constants/notification-events.ts @@ -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>; diff --git a/src/lib/hooks/useAudiobooks.ts b/src/lib/hooks/useAudiobooks.ts index 227865d..22486b6 100644 --- a/src/lib/hooks/useAudiobooks.ts +++ b/src/lib/hooks/useAudiobooks.ts @@ -26,6 +26,7 @@ export interface Audiobook { requestStatus?: string | null; // Status of request (if any) requestId?: string | null; // ID of request (if any) 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) { diff --git a/src/lib/hooks/useGoodreadsShelves.ts b/src/lib/hooks/useGoodreadsShelves.ts new file mode 100644 index 0000000..c803663 --- /dev/null +++ b/src/lib/hooks/useGoodreadsShelves.ts @@ -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 }; +} diff --git a/src/lib/hooks/useReportedIssues.ts b/src/lib/hooks/useReportedIssues.ts new file mode 100644 index 0000000..62f23cb --- /dev/null +++ b/src/lib/hooks/useReportedIssues.ts @@ -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 }; +} diff --git a/src/lib/integrations/audible.service.ts b/src/lib/integrations/audible.service.ts index 7915fe7..6d601f7 100644 --- a/src/lib/integrations/audible.service.ts +++ b/src/lib/integrations/audible.service.ts @@ -8,10 +8,24 @@ import * as cheerio from 'cheerio'; import { RMABLogger } from '../utils/logger'; import { getConfigService } from '../services/config.service'; import { AudibleRegion, AUDIBLE_REGIONS, DEFAULT_AUDIBLE_REGION } from '../types/audible'; +import { + pickUserAgent, + getBrowserHeaders, + jitteredBackoff, + AdaptivePacer, + FetchResultMeta, +} from '../utils/scrape-resilience'; // Module-level logger 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 { asin: string; title: string; @@ -40,6 +54,8 @@ export class AudibleService { private baseUrl: string = 'https://www.audible.com'; private region: AudibleRegion = 'us'; private initialized: boolean = false; + private sessionUserAgent: string = ''; + private pacer: AdaptivePacer = new AdaptivePacer(); constructor() { // Client will be created lazily on first use @@ -77,18 +93,16 @@ export class AudibleService { const configService = getConfigService(); this.region = await configService.getAudibleRegion(); this.baseUrl = AUDIBLE_REGIONS[this.region].baseUrl; + this.sessionUserAgent = pickUserAgent(); + this.pacer.reset(); 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({ baseURL: this.baseUrl, timeout: 15000, - headers: { - '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', - }, + headers: getBrowserHeaders(this.sessionUserAgent), params: { ipRedirectOverride: 'true', // Prevent IP-based region redirects 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 this.region = DEFAULT_AUDIBLE_REGION; this.baseUrl = AUDIBLE_REGIONS[this.region].baseUrl; + this.sessionUserAgent = pickUserAgent(); + this.pacer.reset(); this.client = axios.create({ baseURL: this.baseUrl, timeout: 15000, - headers: { - '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', - }, + headers: getBrowserHeaders(this.sessionUserAgent), params: { ipRedirectOverride: 'true', language: 'english', @@ -119,24 +131,29 @@ export class AudibleService { } /** - * Fetch with retry logic and exponential backoff - * Retries on network errors and rate limiting (503, 429) + * Fetch with retry logic and jittered exponential backoff. + * Returns the axios response plus metadata about retries encountered. */ private async fetchWithRetry( url: string, config: any = {}, maxRetries: number = 5 - ): Promise<any> { + ): Promise<{ data: any; meta: FetchResultMeta }> { let lastError: Error | null = null; + let retriesUsed = 0; + let encountered503 = false; for (let attempt = 0; attempt <= maxRetries; attempt++) { 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) { lastError = error; const status = error.response?.status; const isRetryable = !status || status === 503 || status === 429 || status >= 500; + if (status === 503) encountered503 = true; + // Don't retry on 404, 403, etc. if (!isRetryable) { throw error; @@ -147,8 +164,10 @@ export class AudibleService { break; } - // Exponential backoff: 2^attempt * 1000ms (1s, 2s, 4s, 8s...) - const backoffMs = Math.pow(2, attempt) * 1000; + retriesUsed++; + + // 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})...`); await this.delay(backoffMs); @@ -210,15 +229,18 @@ export class AudibleService { const audiobooks: AudibleAudiobook[] = []; 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) { try { logger.info(` Fetching page ${page}/${maxPages}...`); - const response = await this.fetchWithRetry('/adblbestsellers', { + const { data: response, meta } = await this.fetchWithRetry('/adblbestsellers', { params: { ipRedirectOverride: 'true', // Explicitly include to prevent IP-based region redirects + pageSize: AUDIBLE_PAGE_SIZE, ...(page > 1 ? { page } : {}), }, }); @@ -269,17 +291,17 @@ export class AudibleService { logger.info(` Found ${foundOnPage} audiobooks on page ${page}`); - // If we got fewer than expected, probably no more pages - if (foundOnPage < 10) { + // If we got significantly fewer than requested, probably no more pages + if (foundOnPage < AUDIBLE_PAGE_SIZE / 2) { logger.info(` Reached end of available pages`); break; } page++; - // Add delay between pages to respect rate limiting + // Adaptive delay between pages based on retry pressure if (page <= maxPages && audiobooks.length < limit) { - await this.delay(1500); + await this.delay(this.pacer.reportPageResult(meta)); } } catch (error) { logger.error(`Failed to fetch page ${page} of popular audiobooks`, { @@ -305,15 +327,18 @@ export class AudibleService { const audiobooks: AudibleAudiobook[] = []; 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) { try { logger.info(` Fetching page ${page}/${maxPages}...`); - const response = await this.fetchWithRetry('/newreleases', { + const { data: response, meta } = await this.fetchWithRetry('/newreleases', { params: { ipRedirectOverride: 'true', // Explicitly include to prevent IP-based region redirects + pageSize: AUDIBLE_PAGE_SIZE, ...(page > 1 ? { page } : {}), }, }); @@ -363,17 +388,17 @@ export class AudibleService { logger.info(` Found ${foundOnPage} audiobooks on page ${page}`); - // If we got fewer than expected, probably no more pages - if (foundOnPage < 10) { + // If we got significantly fewer than requested, probably no more pages + if (foundOnPage < AUDIBLE_PAGE_SIZE / 2) { logger.info(` Reached end of available pages`); break; } page++; - // Add delay between pages to respect rate limiting + // Adaptive delay between pages based on retry pressure if (page <= maxPages && audiobooks.length < limit) { - await this.delay(1500); + await this.delay(this.pacer.reportPageResult(meta)); } } catch (error) { logger.error(`Failed to fetch page ${page} of new releases`, { @@ -398,10 +423,11 @@ export class AudibleService { try { logger.info(` Searching for "${query}"...`); - const response = await this.fetchWithRetry('/search', { + const { data: response } = await this.fetchWithRetry('/search', { params: { ipRedirectOverride: 'true', // Explicitly include to prevent IP-based region redirects keywords: query, + pageSize: AUDIBLE_PAGE_SIZE, page, }, }); @@ -470,7 +496,7 @@ export class AudibleService { results: audiobooks, totalResults, page, - hasMore: audiobooks.length > 0 && totalResults > page * 20, + hasMore: audiobooks.length > 0 && totalResults > page * AUDIBLE_PAGE_SIZE, }; } catch (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> { try { - const response = await this.fetchWithRetry(`/pd/${asin}`, { + const { data: response } = await this.fetchWithRetry(`/pd/${asin}`, { params: { ipRedirectOverride: 'true', // Explicitly include to prevent IP-based region redirects }, diff --git a/src/lib/integrations/prowlarr.service.ts b/src/lib/integrations/prowlarr.service.ts index 71dcd96..abb6e63 100644 --- a/src/lib/integrations/prowlarr.service.ts +++ b/src/lib/integrations/prowlarr.service.ts @@ -87,7 +87,7 @@ export class ProwlarrService { headers: { '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: { serialize: (params) => { // Custom serializer to handle arrays correctly for Prowlarr API @@ -314,7 +314,7 @@ export class ProwlarrService { limit: 100, extended: 1, }, - timeout: 30000, + timeout: 60000, responseType: 'text', // Get XML as text }); diff --git a/src/lib/integrations/qbittorrent.service.ts b/src/lib/integrations/qbittorrent.service.ts index 7ac3190..77aec95 100644 --- a/src/lib/integrations/qbittorrent.service.ts +++ b/src/lib/integrations/qbittorrent.service.ts @@ -1109,7 +1109,8 @@ export class QBittorrentService implements IDownloadClient { stalledDL: 'downloading', stalledUP: 'seeding', pausedDL: 'paused', - pausedUP: 'paused', + // pausedUP = download finished, paused on upload side (e.g. RDT-Client, ratio met) + pausedUP: 'seeding', queuedDL: 'queued', queuedUP: 'seeding', checkingDL: 'checking', @@ -1125,7 +1126,8 @@ export class QBittorrentService implements IDownloadClient { forcedMetaDL: 'downloading', // qBittorrent v5.0+ renamed paused → stopped stoppedDL: 'paused', - stoppedUP: 'paused', + // stoppedUP = download finished, stopped on upload side (qBittorrent v5.0+) + stoppedUP: 'seeding', // Other states checkingResumeData: 'checking', moving: 'downloading', @@ -1162,11 +1164,12 @@ export class QBittorrentService implements IDownloadClient { stalledDL: 'downloading', stalledUP: 'completed', pausedDL: 'paused', - pausedUP: 'paused', + // pausedUP = download finished, paused on upload side (e.g. RDT-Client, ratio met) + pausedUP: 'completed', queuedDL: 'queued', queuedUP: 'completed', checkingDL: 'checking', - checkingUP: 'checking', + checkingUP: 'completed', error: 'failed', missingFiles: 'failed', allocating: 'downloading', @@ -1178,7 +1181,8 @@ export class QBittorrentService implements IDownloadClient { forcedMetaDL: 'downloading', // qBittorrent v5.0+ renamed paused → stopped stoppedDL: 'paused', - stoppedUP: 'paused', + // stoppedUP = download finished, stopped on upload side (qBittorrent v5.0+) + stoppedUP: 'completed', // Other states checkingResumeData: 'checking', moving: 'downloading', diff --git a/src/lib/processors/audible-refresh.processor.ts b/src/lib/processors/audible-refresh.processor.ts index 150e52b..817d831 100644 --- a/src/lib/processors/audible-refresh.processor.ts +++ b/src/lib/processors/audible-refresh.processor.ts @@ -44,6 +44,12 @@ export async function processAudibleRefresh(payload: AudibleRefreshPayload): Pro // Fetch popular and new releases - 200 items each 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); logger.info(`Fetched ${popular.length} popular, ${newReleases.length} new releases from Audible`); diff --git a/src/lib/processors/send-notification.processor.ts b/src/lib/processors/send-notification.processor.ts index 061db9a..b30939a 100644 --- a/src/lib/processors/send-notification.processor.ts +++ b/src/lib/processors/send-notification.processor.ts @@ -8,34 +8,28 @@ import { getNotificationService } from '../services/notification'; import { RMABLogger } from '../utils/logger'; +import type { SendNotificationPayload } from '../services/job-queue.service'; -export interface SendNotificationPayload { - jobId?: string; - event: 'request_pending_approval' | 'request_approved' | 'request_available' | 'request_error'; - requestId: string; - title: string; - author: string; - userName: string; - message?: string; - timestamp: Date; -} +// Re-export for consumers that import from this module +export type { SendNotificationPayload } from '../services/job-queue.service'; /** * Process send notification job * Calls NotificationService to send notifications to all enabled backends */ 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'); - logger.info(`Processing notification: ${event}`, { requestId }); + logger.info(`Processing notification: ${event}`, { requestId: requestId || issueId }); try { const notificationService = getNotificationService(); await notificationService.sendNotification({ event, requestId, + issueId, title, author, userName, diff --git a/src/lib/processors/sync-goodreads-shelves.processor.ts b/src/lib/processors/sync-goodreads-shelves.processor.ts new file mode 100644 index 0000000..21f25c1 --- /dev/null +++ b/src/lib/processors/sync-goodreads-shelves.processor.ts @@ -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, + }; +} diff --git a/src/lib/services/audiobookshelf/api.ts b/src/lib/services/audiobookshelf/api.ts index 911a4b9..db50895 100644 --- a/src/lib/services/audiobookshelf/api.ts +++ b/src/lib/services/audiobookshelf/api.ts @@ -186,7 +186,7 @@ export async function deleteABSItem(itemId: string): Promise<void> { 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, { method: 'DELETE', diff --git a/src/lib/services/encryption.service.ts b/src/lib/services/encryption.service.ts index 239f22b..c1b18e9 100644 --- a/src/lib/services/encryption.service.ts +++ b/src/lib/services/encryption.service.ts @@ -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) * @returns Base64-encoded random key diff --git a/src/lib/services/goodreads-sync.service.ts b/src/lib/services/goodreads-sync.service.ts new file mode 100644 index 0000000..1c90a5d --- /dev/null +++ b/src/lib/services/goodreads-sync.service.ts @@ -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 }, + }); + } +} diff --git a/src/lib/services/job-queue.service.ts b/src/lib/services/job-queue.service.ts index cdabe6a..94653fd 100644 --- a/src/lib/services/job-queue.service.ts +++ b/src/lib/services/job-queue.service.ts @@ -9,6 +9,7 @@ import { prisma } from '../db'; import { TorrentResult } from '../utils/ranking-algorithm'; import { DownloadClientType } from '../interfaces/download-client.interface'; import { RMABLogger } from '../utils/logger'; +import type { NotificationEvent } from '@/lib/constants/notification-events'; const logger = RMABLogger.create('JobQueue'); @@ -25,6 +26,7 @@ export type JobType = | 'retry_failed_imports' | 'cleanup_seeded_torrents' | 'monitor_rss_feeds' + | 'sync_goodreads_shelves' | 'send_notification' // Ebook-specific job types | 'search_ebook' @@ -100,6 +102,12 @@ export interface CleanupSeededTorrentsPayload extends JobPayload { scheduledJobId?: string; } +export interface SyncGoodreadsShelvesPayload extends JobPayload { + scheduledJobId?: string; + shelfId?: string; + maxLookupsPerShelf?: number; +} + // Ebook-specific payload interfaces export interface SearchEbookPayload extends JobPayload { requestId: string; @@ -140,8 +148,9 @@ export interface MonitorDirectDownloadPayload extends JobPayload { } export interface SendNotificationPayload extends JobPayload { - event: 'request_pending_approval' | 'request_approved' | 'request_available' | 'request_error'; - requestId: string; + event: NotificationEvent; + requestId?: string; + issueId?: string; title: string; author: string; userName: string; @@ -340,6 +349,12 @@ export class JobQueueService { 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 this.queue.process('send_notification', 5, async (job: BullJob<SendNotificationPayload>) => { 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 // ========================================================================= @@ -911,7 +943,7 @@ export class JobQueueService { * Add notification job */ async addNotificationJob( - event: 'request_pending_approval' | 'request_approved' | 'request_available' | 'request_error', + event: NotificationEvent, requestId: string, title: string, author: string, @@ -923,11 +955,16 @@ export class JobQueueService { 'send_notification', { 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, author, userName, message, + // Pass the original ID for notification display (e.g., Discord footer) + ...(event === 'issue_reported' && { issueId: requestId }), timestamp: new Date(), } as SendNotificationPayload, { diff --git a/src/lib/services/notification/INotificationProvider.ts b/src/lib/services/notification/INotificationProvider.ts index a0907be..1210337 100644 --- a/src/lib/services/notification/INotificationProvider.ts +++ b/src/lib/services/notification/INotificationProvider.ts @@ -3,24 +3,21 @@ * Documentation: documentation/backend/services/notifications.md */ -// Event types -export type NotificationEvent = - | 'request_pending_approval' - | 'request_approved' - | 'request_available' - | 'request_error'; +// 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: NotificationEvent; - requestId: string; + event: import('@/lib/constants/notification-events').NotificationEvent; + requestId?: string; + issueId?: string; title: string; author: string; userName: string; - message?: string; // For error events + message?: string; // For error/issue events timestamp: Date; } diff --git a/src/lib/services/notification/index.ts b/src/lib/services/notification/index.ts index 566758f..f162443 100644 --- a/src/lib/services/notification/index.ts +++ b/src/lib/services/notification/index.ts @@ -13,6 +13,16 @@ export type { 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, diff --git a/src/lib/services/notification/notification.service.ts b/src/lib/services/notification/notification.service.ts index c17e910..305548b 100644 --- a/src/lib/services/notification/notification.service.ts +++ b/src/lib/services/notification/notification.service.ts @@ -130,7 +130,7 @@ export class NotificationService { const encrypted = { ...config }; for (const field of provider.sensitiveFields) { - if (encrypted[field] && !this.isEncrypted(encrypted[field])) { + if (encrypted[field] && !this.encryptionService.isEncryptedFormat(encrypted[field])) { encrypted[field] = this.encryptionService.encrypt(encrypted[field]); } } @@ -155,25 +155,66 @@ export class NotificationService { 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.isEncrypted(decrypted[field])) { + if (decrypted[field] && this.encryptionService.isEncryptedFormat(decrypted[field])) { decrypted[field] = this.encryptionService.decrypt(decrypted[field]); } } return decrypted; } - - /** - * Check if a value is encrypted (has iv:authTag:data format) - */ - private isEncrypted(value: string): boolean { - return value.includes(':') && value.split(':').length === 3; - } } // Singleton instance diff --git a/src/lib/services/notification/providers/apprise.provider.ts b/src/lib/services/notification/providers/apprise.provider.ts index 85f6d86..8b7d845 100644 --- a/src/lib/services/notification/providers/apprise.provider.ts +++ b/src/lib/services/notification/providers/apprise.provider.ts @@ -4,6 +4,7 @@ */ import { INotificationProvider, NotificationPayload, ProviderMetadata } from '../INotificationProvider'; +import { getEventMeta, type NotificationSeverity } from '@/lib/constants/notification-events'; export interface AppriseConfig { serverUrl: string; @@ -13,12 +14,12 @@ export interface AppriseConfig { authToken?: string; } -// Apprise notification types by event -const APPRISE_TYPES: Record<string, string> = { - request_pending_approval: 'info', - request_approved: 'success', - request_available: 'success', - request_error: 'failure', +// Apprise notification types by severity +const SEVERITY_TYPES: Record<NotificationSeverity, string> = { + info: 'info', + success: 'success', + error: 'failure', + warning: 'warning', }; export class AppriseProvider implements INotificationProvider { @@ -41,10 +42,11 @@ export class AppriseProvider implements INotificationProvider { 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 = APPRISE_TYPES[payload.event] || 'info'; + const notificationType = SEVERITY_TYPES[meta.severity]; const headers: Record<string, string> = { 'Content-Type': 'application/json', @@ -107,26 +109,21 @@ export class AppriseProvider implements INotificationProvider { private formatMessage(payload: NotificationPayload): { title: string; body: string } { const { event, title, author, userName, message } = payload; + const meta = getEventMeta(event); - const eventTitles: Record<string, string> = { - request_pending_approval: 'New Request Pending Approval', - request_approved: 'Request Approved', - request_available: 'Audiobook Available', - request_error: 'Request Error', - }; - + const isIssue = event === 'issue_reported'; const messageLines = [ - `📚 ${title}`, - `✍️ ${author}`, - `👤 Requested by: ${userName}`, + `\u{1F4DA} ${title}`, + `\u270D\uFE0F ${author}`, + `\u{1F464} ${isIssue ? 'Reported by' : 'Requested by'}: ${userName}`, ]; if (message) { - messageLines.push(`⚠️ Error: ${message}`); + messageLines.push(isIssue ? `\u{1F4DD} Reason: ${message}` : `\u26A0\uFE0F Error: ${message}`); } return { - title: eventTitles[event], + title: meta.title, body: messageLines.join('\n'), }; } diff --git a/src/lib/services/notification/providers/discord.provider.ts b/src/lib/services/notification/providers/discord.provider.ts index c07ed43..a9aaee3 100644 --- a/src/lib/services/notification/providers/discord.provider.ts +++ b/src/lib/services/notification/providers/discord.provider.ts @@ -4,6 +4,7 @@ */ import { INotificationProvider, NotificationPayload, ProviderMetadata } from '../INotificationProvider'; +import { getEventMeta, type NotificationSeverity } from '@/lib/constants/notification-events'; export interface DiscordConfig { webhookUrl: string; @@ -11,20 +12,12 @@ export interface DiscordConfig { avatarUrl?: string; } -// Discord embed colors by event type -const DISCORD_COLORS = { - request_pending_approval: 0xfbbf24, // yellow-400 - request_approved: 0x22c55e, // green-500 - request_available: 0x3b82f6, // blue-500 - request_error: 0xef4444, // red-500 -}; - -// Discord embed titles -const DISCORD_TITLES = { - request_pending_approval: '📬 New Request Pending Approval', - request_approved: '✅ Request Approved', - request_available: '🎉 Audiobook Available', - request_error: '❌ Request Error', +// 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 { @@ -67,23 +60,25 @@ export class DiscordProvider implements INotificationProvider { 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: 'Requested By', value: userName, inline: true }, + { name: isIssue ? 'Reported By' : 'Requested By', value: userName, inline: true }, ]; if (message) { - fields.push({ name: 'Error', value: message, inline: false }); + fields.push({ name: isIssue ? 'Reason' : 'Error', value: message, inline: false }); } return { - title: DISCORD_TITLES[event], - color: DISCORD_COLORS[event], + title: `${meta.emoji} ${meta.title}`, + color: SEVERITY_COLORS[meta.severity], fields, footer: { - text: `Request ID: ${requestId}`, + text: isIssue ? `Issue ID: ${payload.issueId}` : `Request ID: ${requestId}`, }, timestamp: timestamp.toISOString(), }; diff --git a/src/lib/services/notification/providers/ntfy.provider.ts b/src/lib/services/notification/providers/ntfy.provider.ts index 110e692..539f4fb 100644 --- a/src/lib/services/notification/providers/ntfy.provider.ts +++ b/src/lib/services/notification/providers/ntfy.provider.ts @@ -4,6 +4,7 @@ */ import { INotificationProvider, NotificationPayload, ProviderMetadata } from '../INotificationProvider'; +import { getEventMeta, type NotificationSeverity, type NotificationPriority } from '@/lib/constants/notification-events'; export interface NtfyConfig { serverUrl?: string; @@ -14,20 +15,18 @@ export interface NtfyConfig { const DEFAULT_SERVER_URL = 'https://ntfy.sh'; -// ntfy priorities by event type (1=min, 2=low, 3=default, 4=high, 5=urgent) -const NTFY_PRIORITIES = { - request_pending_approval: 3, // Default - request_approved: 3, // Default - request_available: 4, // High - request_error: 4, // High +// ntfy 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 event type -const NTFY_TAGS = { - request_pending_approval: ['mailbox_with_mail'], - request_approved: ['white_check_mark'], - request_available: ['tada'], - request_error: ['x'], +// 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 { @@ -48,10 +47,12 @@ export class NtfyProvider implements INotificationProvider { 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); - const serverUrl = (ntfyConfig.serverUrl || DEFAULT_SERVER_URL).replace(/\/+$/, ''); - const url = `${serverUrl}/${ntfyConfig.topic}`; + // 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', @@ -65,8 +66,8 @@ export class NtfyProvider implements INotificationProvider { topic: ntfyConfig.topic, title, message, - priority: ntfyConfig.priority ?? NTFY_PRIORITIES[payload.event], - tags: NTFY_TAGS[payload.event], + priority: ntfyConfig.priority ?? PRIORITY_MAP[meta.priority], + tags: SEVERITY_TAGS[meta.severity], }; const response = await fetch(url, { @@ -83,26 +84,21 @@ export class NtfyProvider implements INotificationProvider { private formatMessage(payload: NotificationPayload): { title: string; message: string } { const { event, title, author, userName, message } = payload; + const meta = getEventMeta(event); - const eventTitles = { - request_pending_approval: 'New Request Pending Approval', - request_approved: 'Request Approved', - request_available: 'Audiobook Available', - request_error: 'Request Error', - }; - + const isIssue = event === 'issue_reported'; const messageLines = [ - `📚 ${title}`, - `✍️ ${author}`, - `👤 Requested by: ${userName}`, + `\u{1F4DA} ${title}`, + `\u270D\uFE0F ${author}`, + `\u{1F464} ${isIssue ? 'Reported by' : 'Requested by'}: ${userName}`, ]; if (message) { - messageLines.push(`⚠️ Error: ${message}`); + messageLines.push(isIssue ? `\u{1F4DD} Reason: ${message}` : `\u26A0\uFE0F Error: ${message}`); } return { - title: eventTitles[event], + title: meta.title, message: messageLines.join('\n'), }; } diff --git a/src/lib/services/notification/providers/pushover.provider.ts b/src/lib/services/notification/providers/pushover.provider.ts index 3635b1d..29ee69b 100644 --- a/src/lib/services/notification/providers/pushover.provider.ts +++ b/src/lib/services/notification/providers/pushover.provider.ts @@ -4,6 +4,7 @@ */ import { INotificationProvider, NotificationPayload, ProviderMetadata } from '../INotificationProvider'; +import { getEventMeta, type NotificationPriority } from '@/lib/constants/notification-events'; export interface PushoverConfig { userKey: string; @@ -12,12 +13,10 @@ export interface PushoverConfig { priority?: number; } -// Pushover priorities by event type -const PUSHOVER_PRIORITIES = { - request_pending_approval: 0, // Normal - request_approved: 0, // Normal - request_available: 1, // High - request_error: 1, // High +// Pushover priorities by notification priority (Normal=0, High=1) +const PRIORITY_MAP: Record<NotificationPriority, number> = { + normal: 0, + high: 1, }; export class PushoverProvider implements INotificationProvider { @@ -48,6 +47,7 @@ export class PushoverProvider implements INotificationProvider { 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({ @@ -55,7 +55,7 @@ export class PushoverProvider implements INotificationProvider { user: pushoverConfig.userKey, title, message, - priority: String(pushoverConfig.priority ?? PUSHOVER_PRIORITIES[payload.event]), + priority: String(pushoverConfig.priority ?? PRIORITY_MAP[meta.priority]), ...(pushoverConfig.device && { device: pushoverConfig.device }), }); @@ -78,43 +78,23 @@ export class PushoverProvider implements INotificationProvider { private formatMessage(payload: NotificationPayload): { title: string; message: string } { const { event, title, author, userName, message } = payload; + const meta = getEventMeta(event); - 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 isIssue = event === 'issue_reported'; const messageLines = [ - `${eventEmoji} ${eventTitle}`, + `${meta.emoji} ${meta.title}`, '', - `📚 ${title}`, - `✍️ ${author}`, - `👤 Requested by: ${userName}`, + `\u{1F4DA} ${title}`, + `\u270D\uFE0F ${author}`, + `\u{1F464} ${isIssue ? 'Reported by' : 'Requested by'}: ${userName}`, ]; if (message) { - messageLines.push('', `⚠️ Error: ${message}`); + messageLines.push('', isIssue ? `\u{1F4DD} Reason: ${message}` : `\u26A0\uFE0F Error: ${message}`); } return { - title: eventTitle, + title: meta.title, message: messageLines.join('\n'), }; } diff --git a/src/lib/services/reported-issue.service.ts b/src/lib/services/reported-issue.service.ts new file mode 100644 index 0000000..795aac7 --- /dev/null +++ b/src/lib/services/reported-issue.service.ts @@ -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'; + } +} diff --git a/src/lib/services/request-creator.service.ts b/src/lib/services/request-creator.service.ts new file mode 100644 index 0000000..ed068e4 --- /dev/null +++ b/src/lib/services/request-creator.service.ts @@ -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 }; +} diff --git a/src/lib/services/scheduler.service.ts b/src/lib/services/scheduler.service.ts index 541ee5c..964fda0 100644 --- a/src/lib/services/scheduler.service.ts +++ b/src/lib/services/scheduler.service.ts @@ -4,12 +4,13 @@ */ import { getJobQueueService, ScanPlexPayload } from './job-queue.service'; +import { getNotificationService } from './notification'; import { prisma } from '../db'; import { RMABLogger } from '../utils/logger'; 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 { id: string; @@ -49,6 +50,9 @@ export class SchedulerService { async start(): Promise<void> { 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 await this.ensureDefaultJobs(); @@ -115,6 +119,13 @@ export class SchedulerService { enabled: true, // Enable by default 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) { @@ -314,6 +325,9 @@ export class SchedulerService { case 'monitor_rss_feeds': bullJobId = await this.triggerMonitorRssFeeds(job); break; + case 'sync_goodreads_shelves': + bullJobId = await this.triggerSyncGoodreadsShelves(job); + break; default: throw new Error(`Unknown job type: ${job.type}`); } @@ -578,6 +592,13 @@ export class SchedulerService { private async triggerCleanupSeededTorrents(job: any): Promise<string> { 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 diff --git a/src/lib/utils/audiobook-matcher.ts b/src/lib/utils/audiobook-matcher.ts index 67ca135..d1f193d 100644 --- a/src/lib/utils/audiobook-matcher.ts +++ b/src/lib/utils/audiobook-matcher.ts @@ -241,11 +241,19 @@ export async function enrichAudiobooksWithMatches( } } + // Enrich with reported issue status + const { getOpenIssuesByAsins } = await import('@/lib/services/reported-issue.service'); + const asinsWithIssues = await getOpenIssuesByAsins(asins); + for (const result of results) { + (result as any).hasReportedIssue = asinsWithIssues.has(result.asin); + } + logger.debug('Batch summary', { total: results.length, available: results.filter(r => r.isAvailable).length, notAvailable: results.filter(r => !r.isAvailable).length, requested: userId ? results.filter(r => (r as any).isRequested).length : 'N/A', + reportedIssues: asinsWithIssues.size, }); return results; diff --git a/src/lib/utils/scrape-resilience.ts b/src/lib/utils/scrape-resilience.ts new file mode 100644 index 0000000..fce4cbc --- /dev/null +++ b/src/lib/utils/scrape-resilience.ts @@ -0,0 +1,100 @@ +/** + * Component: Scrape Resilience Utilities + * Documentation: documentation/integrations/audible.md + * + * Anti-503 resilience for Audible scraping: UA rotation, jittered backoff, + * browser-like headers, adaptive pacing, and circuit breaker. + */ + +/** Pool of modern browser User-Agent strings */ +const USER_AGENTS = [ + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36', + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0', + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_2) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Safari/605.1.15', + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0', + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', +] as const; + +/** Randomly select a User-Agent (call once per session, not per request) */ +export function pickUserAgent(): string { + return USER_AGENTS[Math.floor(Math.random() * USER_AGENTS.length)]; +} + +/** Build a full set of realistic browser headers for the given UA */ +export function getBrowserHeaders(userAgent: string): Record<string, string> { + return { + 'User-Agent': userAgent, + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8', + 'Accept-Language': 'en-US,en;q=0.9', + 'Accept-Encoding': 'gzip, deflate, br', + 'Connection': 'keep-alive', + 'Sec-Fetch-Site': 'none', + 'Sec-Fetch-Mode': 'navigate', + 'Sec-Fetch-Dest': 'document', + 'Sec-Fetch-User': '?1', + 'Upgrade-Insecure-Requests': '1', + }; +} + +/** + * Jittered exponential backoff: 2^attempt * baseMs * random(0.5, 1.5) + * Avoids predictable retry timing that is trivially fingerprinted. + */ +export function jitteredBackoff(attempt: number, baseMs: number = 1000): number { + const jitter = 0.5 + Math.random(); // 0.5 – 1.5 + return Math.round(Math.pow(2, attempt) * baseMs * jitter); +} + +/** Random integer in [minMs, maxMs] */ +export function randomDelay(minMs: number, maxMs: number): number { + return minMs + Math.floor(Math.random() * (maxMs - minMs + 1)); +} + +/** Metadata returned alongside each fetch result */ +export interface FetchResultMeta { + retriesUsed: number; + encountered503: boolean; +} + +/** + * Adaptive pacer that increases inter-page delays when retries are needed, + * and triggers a circuit-breaker cooldown after consecutive retry-pages. + */ +export class AdaptivePacer { + private consecutiveRetryPages = 0; + private static readonly CIRCUIT_BREAKER_THRESHOLD = 3; + + /** Report the result of a page fetch and get the recommended delay before the next page. */ + reportPageResult(meta: FetchResultMeta): number { + if (meta.retriesUsed > 0) { + this.consecutiveRetryPages++; + + // Circuit breaker: pause 45-60s after sustained retries + if (this.consecutiveRetryPages >= AdaptivePacer.CIRCUIT_BREAKER_THRESHOLD) { + this.consecutiveRetryPages = 0; + return randomDelay(45_000, 60_000); + } + + // Adaptive increase: multiply delay range by 1 + 0.5 * consecutive + const multiplier = 1 + 0.5 * this.consecutiveRetryPages; + return randomDelay( + Math.round(2000 * multiplier), + Math.round(4000 * multiplier), + ); + } + + // Successful page – gradually recover + if (this.consecutiveRetryPages > 0) { + this.consecutiveRetryPages--; + } + + // Base delay range + return randomDelay(2000, 4000); + } + + /** Reset state (call between batches or on re-initialization). */ + reset(): void { + this.consecutiveRetryPages = 0; + } +} diff --git a/tests/api/notification-triggers.integration.test.ts b/tests/api/notification-triggers.integration.test.ts index e1943cc..5ebfa59 100644 --- a/tests/api/notification-triggers.integration.test.ts +++ b/tests/api/notification-triggers.integration.test.ts @@ -83,6 +83,7 @@ describe('Notification Triggers - Integration Tests', () => { id: 'user-1', role: 'user', autoApproveRequests: false, + plexUsername: 'testuser', }); prismaMock.request.create.mockResolvedValue({ @@ -150,6 +151,7 @@ describe('Notification Triggers - Integration Tests', () => { id: 'user-1', role: 'user', autoApproveRequests: true, + plexUsername: 'testuser', }); prismaMock.request.create.mockResolvedValue({ @@ -213,6 +215,7 @@ describe('Notification Triggers - Integration Tests', () => { id: 'user-1', role: 'user', autoApproveRequests: true, + plexUsername: 'testuser', }); prismaMock.request.create.mockResolvedValue({ diff --git a/tests/app/profile.page.test.tsx b/tests/app/profile.page.test.tsx index ffee3d4..71e2e19 100644 --- a/tests/app/profile.page.test.tsx +++ b/tests/app/profile.page.test.tsx @@ -36,7 +36,7 @@ vi.mock('@/components/requests/RequestCard', () => ({ const getStatValue = (label: string) => { const labelNode = screen.getByText(label); const container = labelNode.parentElement; - const valueNode = container?.querySelector('p:nth-of-type(2)'); + const valueNode = container?.querySelector('div:first-child'); return valueNode?.textContent; }; @@ -55,7 +55,7 @@ describe('ProfilePage', () => { const { default: ProfilePage } = await import('@/app/profile/page'); render(<ProfilePage />); - expect(screen.getByText('Authentication Required')).toBeInTheDocument(); + expect(screen.getByText('Sign in required')).toBeInTheDocument(); expect(screen.getByText('Please log in to view your profile')).toBeInTheDocument(); }); @@ -87,7 +87,7 @@ describe('ProfilePage', () => { expect(getStatValue('Total')).toBe('6'); expect(getStatValue('Active')).toBe('2'); expect(getStatValue('Waiting')).toBe('1'); - expect(getStatValue('Completed')).toBe('1'); + expect(getStatValue('Complete')).toBe('1'); expect(getStatValue('Failed')).toBe('1'); expect(getStatValue('Cancelled')).toBe('1'); diff --git a/tests/components/ui/ChangePasswordModal.test.tsx b/tests/components/ui/ChangePasswordModal.test.tsx index 981341e..f1d1382 100644 --- a/tests/components/ui/ChangePasswordModal.test.tsx +++ b/tests/components/ui/ChangePasswordModal.test.tsx @@ -27,7 +27,7 @@ describe('ChangePasswordModal', () => { }); it('rejects submission when access token is missing', async () => { - const fetchMock = vi.fn(); + const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({}) }); vi.stubGlobal('fetch', fetchMock); render(<ChangePasswordModal isOpen onClose={vi.fn()} />); @@ -48,10 +48,8 @@ describe('ChangePasswordModal', () => { expect(screen.getByText('Not authenticated')).toBeInTheDocument(); }); - expect(fetchMock).not.toHaveBeenCalledWith( - '/api/auth/change-password', - expect.anything() - ); + // Only the password policy fetch should have fired (useEffect on mount), not a password change call + expect(fetchMock).not.toHaveBeenCalledWith('/api/auth/change-password', expect.anything()); }); it('submits successfully and auto-closes after showing success', async () => { diff --git a/tests/helpers/prisma.ts b/tests/helpers/prisma.ts index 3c9a4e8..fc551c1 100644 --- a/tests/helpers/prisma.ts +++ b/tests/helpers/prisma.ts @@ -45,6 +45,8 @@ export const createPrismaMock = () => ({ bookDateConfig: createModelMock(), bookDateRecommendation: createModelMock(), bookDateSwipe: createModelMock(), + goodreadsShelf: createModelMock(), + goodreadsBookMapping: createModelMock(), $queryRaw: vi.fn(), $disconnect: vi.fn(), }); diff --git a/tests/integrations/audible.service.test.ts b/tests/integrations/audible.service.test.ts index 35989aa..59e8792 100644 --- a/tests/integrations/audible.service.test.ts +++ b/tests/integrations/audible.service.test.ts @@ -134,14 +134,14 @@ describe('AudibleService', () => { it('paginates new releases and respects delays between pages', async () => { configServiceMock.getAudibleRegion.mockResolvedValue('us'); clientMock.get - .mockResolvedValueOnce({ data: buildListHtml(10, 0) }) - .mockResolvedValueOnce({ data: buildListHtml(5, 10) }); + .mockResolvedValueOnce({ data: buildListHtml(50, 0) }) + .mockResolvedValueOnce({ data: buildListHtml(25, 50) }); const service = new AudibleService(); const delaySpy = vi.spyOn(service as any, 'delay').mockResolvedValue(undefined); - const results = await service.getNewReleases(25); + const results = await service.getNewReleases(75); - expect(results).toHaveLength(15); + expect(results).toHaveLength(75); expect(delaySpy).toHaveBeenCalledTimes(1); }); @@ -345,14 +345,14 @@ describe('AudibleService', () => { it('paginates popular audiobooks across pages', async () => { configServiceMock.getAudibleRegion.mockResolvedValue('us'); clientMock.get - .mockResolvedValueOnce({ data: buildListHtml(10, 0) }) - .mockResolvedValueOnce({ data: buildListHtml(10, 10) }); + .mockResolvedValueOnce({ data: buildListHtml(50, 0) }) + .mockResolvedValueOnce({ data: buildListHtml(25, 50) }); const service = new AudibleService(); const delaySpy = vi.spyOn(service as any, 'delay').mockResolvedValue(undefined); - const results = await service.getPopularAudiobooks(25); + const results = await service.getPopularAudiobooks(75); - expect(results).toHaveLength(20); + expect(results).toHaveLength(75); expect(delaySpy).toHaveBeenCalledTimes(1); }); diff --git a/tests/integrations/qbittorrent.service.test.ts b/tests/integrations/qbittorrent.service.test.ts index 21279c4..b6dd580 100644 --- a/tests/integrations/qbittorrent.service.test.ts +++ b/tests/integrations/qbittorrent.service.test.ts @@ -153,12 +153,12 @@ describe('QBittorrentService', () => { expect(progress.state).toBe('paused'); }); - it('maps stoppedUP to paused', () => { + it('maps stoppedUP to completed (download finished, stopped on upload side)', () => { const service = new QBittorrentService('http://qb', 'user', 'pass'); const progress = service.getDownloadProgress({ progress: 1.0, downloaded: 1000, size: 1000, dlspeed: 0, eta: 0, state: 'stoppedUP', } as any); - expect(progress.state).toBe('paused'); + expect(progress.state).toBe('completed'); }); }); @@ -180,6 +180,24 @@ describe('QBittorrentService', () => { }); }); + describe('mapState - pausedUP/stoppedUP as completion states (RDT-Client compatibility)', () => { + it('maps pausedUP to completed (download finished, paused on upload side)', () => { + const service = new QBittorrentService('http://qb', 'user', 'pass'); + const progress = service.getDownloadProgress({ + progress: 0.5, downloaded: 0, size: 0, dlspeed: 0, eta: 0, state: 'pausedUP', + } as any); + expect(progress.state).toBe('completed'); + }); + + it('maps pausedDL to paused (download not finished)', () => { + const service = new QBittorrentService('http://qb', 'user', 'pass'); + const progress = service.getDownloadProgress({ + progress: 0.3, downloaded: 300, size: 1000, dlspeed: 0, eta: 0, state: 'pausedDL', + } as any); + expect(progress.state).toBe('paused'); + }); + }); + describe('mapStateToDownloadStatus - forced and new states via getDownload', () => { it('maps forcedUP to seeding status (triggers completion in monitor)', async () => { const service = new QBittorrentService('http://qb', 'user', 'pass'); @@ -218,7 +236,7 @@ describe('QBittorrentService', () => { expect(info!.status).toBe('downloading'); }); - it('maps stoppedUP to paused status (qBittorrent v5.x)', async () => { + it('maps stoppedUP to seeding status (qBittorrent v5.x, triggers completion)', async () => { const service = new QBittorrentService('http://qb', 'user', 'pass'); (service as any).cookie = 'SID=stopped'; clientMock.get.mockResolvedValueOnce({ @@ -233,7 +251,26 @@ describe('QBittorrentService', () => { const info = await service.getDownload('abc123'); expect(info).not.toBeNull(); - expect(info!.status).toBe('paused'); + expect(info!.status).toBe('seeding'); + }); + + it('maps pausedUP to seeding status (RDT-Client: download finished, paused on upload side)', async () => { + const service = new QBittorrentService('http://qb', 'user', 'pass'); + (service as any).cookie = 'SID=pausedup'; + clientMock.get.mockResolvedValueOnce({ + data: [{ + hash: 'd5d767f07e5d9027f7f9d9b50b877386dc92b177', name: 'Audiobook', size: 0, progress: 0.5, + dlspeed: 0, upspeed: 0, downloaded: 0, uploaded: 0, + eta: 0, state: 'pausedUP', category: 'readmeabook', tags: '', + save_path: '/data/torrents/readmeabook', content_path: '/data/torrents/readmeabook/Audiobook', + completion_on: 1769135244, added_on: 1769135108, + }], + }); + + const info = await service.getDownload('d5d767f07e5d9027f7f9d9b50b877386dc92b177'); + + expect(info).not.toBeNull(); + expect(info!.status).toBe('seeding'); }); it('maps stoppedDL to paused status (qBittorrent v5.x)', async () => { diff --git a/tests/processors/audible-refresh.processor.test.ts b/tests/processors/audible-refresh.processor.test.ts index 6d88b1e..3deb01b 100644 --- a/tests/processors/audible-refresh.processor.test.ts +++ b/tests/processors/audible-refresh.processor.test.ts @@ -3,7 +3,7 @@ * Documentation: documentation/backend/services/scheduler.md */ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { createPrismaMock } from '../helpers/prisma'; const prismaMock = createPrismaMock(); @@ -29,8 +29,20 @@ vi.mock('@/lib/services/thumbnail-cache.service', () => ({ })); describe('processAudibleRefresh', () => { + let origSetTimeout: typeof global.setTimeout; + beforeEach(() => { vi.clearAllMocks(); + origSetTimeout = global.setTimeout; + // Replace setTimeout so the batch cooldown resolves instantly + global.setTimeout = ((fn: (...args: any[]) => void) => { + fn(); + return 0 as ReturnType<typeof setTimeout>; + }) as any; + }); + + afterEach(() => { + global.setTimeout = origSetTimeout; }); it('refreshes popular and new releases, caching thumbnails', async () => { @@ -110,5 +122,3 @@ describe('processAudibleRefresh', () => { await expect(processAudibleRefresh({ jobId: 'job-2' })).rejects.toThrow('DB down'); }); }); - - diff --git a/tests/processors/send-notification.processor.test.ts b/tests/processors/send-notification.processor.test.ts index c19bb82..c33c59a 100644 --- a/tests/processors/send-notification.processor.test.ts +++ b/tests/processors/send-notification.processor.test.ts @@ -4,6 +4,7 @@ */ import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { NotificationEvent } from '@/lib/constants/notification-events'; const notificationServiceMock = vi.hoisted(() => ({ sendNotification: vi.fn(), @@ -92,7 +93,7 @@ describe('processSendNotification', () => { it('processes all event types correctly', async () => { const { processSendNotification } = await import('@/lib/processors/send-notification.processor'); - const events: Array<'request_pending_approval' | 'request_approved' | 'request_available' | 'request_error'> = [ + const events: NotificationEvent[] = [ 'request_pending_approval', 'request_approved', 'request_available', diff --git a/tests/services/apprise.provider.test.ts b/tests/services/apprise.provider.test.ts index 898bf3e..1ea0b86 100644 --- a/tests/services/apprise.provider.test.ts +++ b/tests/services/apprise.provider.test.ts @@ -18,6 +18,7 @@ prismaMock.notificationBackend = { const encryptionMock = vi.hoisted(() => ({ encrypt: vi.fn((value: string) => `enc:${value}`), decrypt: vi.fn((value: string) => value.replace('enc:', '')), + isEncryptedFormat: vi.fn((value: string) => typeof value === 'string' && value.startsWith('enc:')), })); const fetchMock = vi.hoisted(() => vi.fn()); @@ -370,14 +371,12 @@ describe('AppriseProvider', () => { const { NotificationService } = await import('@/lib/services/notification'); const service = new NotificationService(); - // Use iv:authTag:data format to pass isEncrypted() check - // Note: the value must have exactly 3 colon-separated segments await service.sendToBackend( 'apprise', { serverUrl: 'http://apprise:8000', - urls: 'iv:tag:encryptedUrlsData', - authToken: 'iv:tag:mytoken123', + urls: 'enc:encryptedUrlsData', + authToken: 'enc:mytoken123', }, { event: 'request_approved', @@ -390,16 +389,16 @@ describe('AppriseProvider', () => { ); // Verify decrypt was called for the sensitive fields - expect(encryptionMock.decrypt).toHaveBeenCalledWith('iv:tag:encryptedUrlsData'); - expect(encryptionMock.decrypt).toHaveBeenCalledWith('iv:tag:mytoken123'); + expect(encryptionMock.decrypt).toHaveBeenCalledWith('enc:encryptedUrlsData'); + expect(encryptionMock.decrypt).toHaveBeenCalledWith('enc:mytoken123'); // Verify the decrypted values reach the fetch call expect(fetchMock).toHaveBeenCalledTimes(1); const fetchCall = fetchMock.mock.calls[0]; - expect(fetchCall[1].headers['Authorization']).toBe('Bearer iv:tag:mytoken123'); + expect(fetchCall[1].headers['Authorization']).toBe('Bearer mytoken123'); const body = JSON.parse(fetchCall[1].body); - expect(body.urls).toBe('iv:tag:encryptedUrlsData'); + expect(body.urls).toBe('encryptedUrlsData'); }); it('does not decrypt non-sensitive fields', async () => { diff --git a/tests/services/encryption.service.test.ts b/tests/services/encryption.service.test.ts index 33a4474..4be7701 100644 --- a/tests/services/encryption.service.test.ts +++ b/tests/services/encryption.service.test.ts @@ -51,4 +51,116 @@ describe('EncryptionService', () => { expect(typeof key).toBe('string'); expect(key.length).toBeGreaterThan(40); }); + + describe('isEncryptedFormat', () => { + async function createService() { + process.env.CONFIG_ENCRYPTION_KEY = 'c'.repeat(32); + vi.resetModules(); + const { EncryptionService } = await import('@/lib/services/encryption.service'); + return new EncryptionService(); + } + + it('returns true for values produced by encrypt()', async () => { + const service = await createService(); + + const encrypted = service.encrypt('hello world'); + expect(service.isEncryptedFormat(encrypted)).toBe(true); + }); + + it('returns true for various encrypted values (round-trip)', async () => { + const service = await createService(); + + const testValues = [ + 'simple', + 'tgram://1234567890:PLPe1Hh-VhbRC3MoT5QngwkPHoMTD/-100181291455/', + 'slack://tokenA/tokenB/tokenC', + 'https://hooks.slack.com/services/T00/B00/xxx', + 'a', + 'a'.repeat(1000), + 'json://user:password@host:8080/path', + ]; + + for (const val of testValues) { + const encrypted = service.encrypt(val); + expect(service.isEncryptedFormat(encrypted)).toBe(true); + expect(service.decrypt(encrypted)).toBe(val); + } + }); + + it('returns false for Telegram notification URLs (the reported bug)', async () => { + const service = await createService(); + + // This URL has exactly 3 colon-separated parts, which fooled the old check + expect(service.isEncryptedFormat( + 'tgram://1234567890:PLPe1Hh-VhbRC3MoT5QngwkPHoMTD/-100181291455/' + )).toBe(false); + }); + + it('returns false for common notification URL schemes', async () => { + const service = await createService(); + + const urls = [ + 'slack://tokenA/tokenB/tokenC', + 'discord://webhook_id/webhook_token', + 'mailto://user:pass@gmail.com', + 'json://user:pass@hostname', + 'https://hooks.slack.com/services/T00/B00/xxx', + 'gotify://hostname/token', + 'ntfy://topic', + 'tgram://bot_token:chat_id/', + ]; + + for (const url of urls) { + expect(service.isEncryptedFormat(url)).toBe(false); + } + }); + + it('returns false for non-string values', async () => { + const service = await createService(); + + expect(service.isEncryptedFormat(null as any)).toBe(false); + expect(service.isEncryptedFormat(undefined as any)).toBe(false); + expect(service.isEncryptedFormat(123 as any)).toBe(false); + expect(service.isEncryptedFormat({} as any)).toBe(false); + }); + + it('returns false for strings with wrong number of colon parts', async () => { + const service = await createService(); + + expect(service.isEncryptedFormat('no-colons-at-all')).toBe(false); + expect(service.isEncryptedFormat('one:part')).toBe(false); + expect(service.isEncryptedFormat('a:b:c:d')).toBe(false); + }); + + it('returns false for 3-part strings with invalid base64', async () => { + const service = await createService(); + + // Contains characters not in base64 alphabet + expect(service.isEncryptedFormat('not base64!:also not!:data')).toBe(false); + expect(service.isEncryptedFormat('//invalid:##bad:data')).toBe(false); + }); + + it('returns false for 3-part base64 strings with wrong decoded lengths', async () => { + const service = await createService(); + + // Valid base64, but wrong byte lengths (not 16 bytes each) + const shortIv = Buffer.from('short').toString('base64'); // 5 bytes + const shortTag = Buffer.from('alsoshort').toString('base64'); // 9 bytes + const data = Buffer.from('somedata').toString('base64'); + + expect(service.isEncryptedFormat(`${shortIv}:${shortTag}:${data}`)).toBe(false); + }); + + it('returns false for empty string', async () => { + const service = await createService(); + expect(service.isEncryptedFormat('')).toBe(false); + }); + + it('returns false for 3-part string with empty segments', async () => { + const service = await createService(); + expect(service.isEncryptedFormat('::data')).toBe(false); + expect(service.isEncryptedFormat('iv::data')).toBe(false); + expect(service.isEncryptedFormat('iv:tag:')).toBe(false); + }); + }); }); diff --git a/tests/services/job-queue-notifications.service.test.ts b/tests/services/job-queue-notifications.service.test.ts index ce1d62e..1fc7335 100644 --- a/tests/services/job-queue-notifications.service.test.ts +++ b/tests/services/job-queue-notifications.service.test.ts @@ -4,6 +4,7 @@ */ import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { NotificationEvent } from '@/lib/constants/notification-events'; describe('JobQueueService - Notification Integration', () => { beforeEach(() => { @@ -50,7 +51,7 @@ describe('JobQueueService - Notification Integration', () => { }); it('handles all event types', () => { - const events: Array<'request_pending_approval' | 'request_approved' | 'request_available' | 'request_error'> = [ + const events: NotificationEvent[] = [ 'request_pending_approval', 'request_approved', 'request_available', diff --git a/tests/services/job-queue.service.test.ts b/tests/services/job-queue.service.test.ts index 884cefa..78e3998 100644 --- a/tests/services/job-queue.service.test.ts +++ b/tests/services/job-queue.service.test.ts @@ -21,6 +21,7 @@ const processorsMock = vi.hoisted(() => ({ processRetryMissingTorrents: vi.fn().mockResolvedValue('ok'), processRetryFailedImports: vi.fn().mockResolvedValue('ok'), processCleanupSeededTorrents: vi.fn().mockResolvedValue('ok'), + processSyncGoodreadsShelves: vi.fn().mockResolvedValue('ok'), // Ebook processors processSearchEbook: vi.fn().mockResolvedValue('ok'), processStartDirectDownload: vi.fn().mockResolvedValue('ok'), @@ -115,6 +116,10 @@ vi.mock('@/lib/processors/cleanup-seeded-torrents.processor', () => ({ processCleanupSeededTorrents: processorsMock.processCleanupSeededTorrents, })); +vi.mock('@/lib/processors/sync-goodreads-shelves.processor', () => ({ + processSyncGoodreadsShelves: processorsMock.processSyncGoodreadsShelves, +})); + // Ebook processors vi.mock('@/lib/processors/search-ebook.processor', () => ({ processSearchEbook: processorsMock.processSearchEbook, @@ -559,6 +564,7 @@ describe('JobQueueService', () => { expect(processorsMock.processRetryMissingTorrents).toHaveBeenCalled(); expect(processorsMock.processRetryFailedImports).toHaveBeenCalled(); expect(processorsMock.processCleanupSeededTorrents).toHaveBeenCalled(); + expect(processorsMock.processSyncGoodreadsShelves).toHaveBeenCalled(); }); it('returns repeatable jobs from the queue', async () => { diff --git a/tests/services/notification.service.test.ts b/tests/services/notification.service.test.ts index 678bffc..f74b2c5 100644 --- a/tests/services/notification.service.test.ts +++ b/tests/services/notification.service.test.ts @@ -18,6 +18,7 @@ prismaMock.notificationBackend = { const encryptionMock = vi.hoisted(() => ({ encrypt: vi.fn((value: string) => `enc:${value}`), decrypt: vi.fn((value: string) => value.replace('enc:', '')), + isEncryptedFormat: vi.fn((value: string) => typeof value === 'string' && value.startsWith('enc:')), })); const fetchMock = vi.hoisted(() => vi.fn()); @@ -196,10 +197,9 @@ describe('NotificationService', () => { const { NotificationService } = await import('@/lib/services/notification'); const service = new NotificationService(); - // Use iv:authTag:data format to pass isEncrypted() check await service.sendToBackend( 'pushover', - { userKey: 'iv:tag:user123', appToken: 'iv:tag:app456', priority: 1 }, + { userKey: 'enc:user123', appToken: 'enc:app456', priority: 1 }, { event: 'request_approved', requestId: 'req-1', @@ -210,8 +210,8 @@ describe('NotificationService', () => { } ); - expect(encryptionMock.decrypt).toHaveBeenCalledWith('iv:tag:user123'); - expect(encryptionMock.decrypt).toHaveBeenCalledWith('iv:tag:app456'); + expect(encryptionMock.decrypt).toHaveBeenCalledWith('enc:user123'); + expect(encryptionMock.decrypt).toHaveBeenCalledWith('enc:app456'); expect(fetchMock).toHaveBeenCalled(); }); @@ -519,6 +519,91 @@ describe('NotificationService', () => { }); }); + describe('reEncryptUnprotectedBackends', () => { + it('re-encrypts plaintext sensitive fields stored due to isEncrypted bug', async () => { + // Simulate a backend with a Telegram URL stored as plaintext (the bug) + prismaMock.notificationBackend.findMany.mockResolvedValue([ + { + id: 'backend-1', + type: 'apprise', + name: 'Telegram via Apprise', + config: { + serverUrl: 'http://apprise:8000', + urls: 'tgram://1234567890:PLPe1Hh-VhbRC3MoT5QngwkPHoMTD/-100181291455/', + }, + events: ['request_available'], + enabled: true, + createdAt: new Date(), + updatedAt: new Date(), + }, + ]); + + prismaMock.notificationBackend.update.mockResolvedValue({} as any); + + // Mock isEncryptedFormat to return false for the plaintext URL + encryptionMock.isEncryptedFormat.mockImplementation( + (value: string) => typeof value === 'string' && value.startsWith('enc:') + ); + + const { NotificationService } = await import('@/lib/services/notification'); + const service = new NotificationService(); + + const fixed = await service.reEncryptUnprotectedBackends(); + + expect(fixed).toBe(1); + expect(encryptionMock.encrypt).toHaveBeenCalledWith( + 'tgram://1234567890:PLPe1Hh-VhbRC3MoT5QngwkPHoMTD/-100181291455/' + ); + expect(prismaMock.notificationBackend.update).toHaveBeenCalledWith({ + where: { id: 'backend-1' }, + data: { + config: { + serverUrl: 'http://apprise:8000', + urls: 'enc:tgram://1234567890:PLPe1Hh-VhbRC3MoT5QngwkPHoMTD/-100181291455/', + }, + }, + }); + }); + + it('skips backends with already-encrypted fields', async () => { + prismaMock.notificationBackend.findMany.mockResolvedValue([ + { + id: 'backend-1', + type: 'discord', + name: 'Discord', + config: { webhookUrl: 'enc:https://discord.com/webhook', username: 'Bot' }, + events: ['request_available'], + enabled: true, + createdAt: new Date(), + updatedAt: new Date(), + }, + ]); + + encryptionMock.isEncryptedFormat.mockImplementation( + (value: string) => typeof value === 'string' && value.startsWith('enc:') + ); + + const { NotificationService } = await import('@/lib/services/notification'); + const service = new NotificationService(); + + const fixed = await service.reEncryptUnprotectedBackends(); + + expect(fixed).toBe(0); + expect(prismaMock.notificationBackend.update).not.toHaveBeenCalled(); + }); + + it('returns 0 when no backends exist', async () => { + prismaMock.notificationBackend.findMany.mockResolvedValue([]); + + const { NotificationService } = await import('@/lib/services/notification'); + const service = new NotificationService(); + + const fixed = await service.reEncryptUnprotectedBackends(); + + expect(fixed).toBe(0); + }); + }); + describe('maskConfig', () => { it('masks sensitive Discord config values', async () => { const { NotificationService } = await import('@/lib/services/notification'); diff --git a/tests/services/ntfy.provider.test.ts b/tests/services/ntfy.provider.test.ts index a47f349..366daf3 100644 --- a/tests/services/ntfy.provider.test.ts +++ b/tests/services/ntfy.provider.test.ts @@ -18,6 +18,7 @@ prismaMock.notificationBackend = { const encryptionMock = vi.hoisted(() => ({ encrypt: vi.fn((value: string) => `enc:${value}`), decrypt: vi.fn((value: string) => value.replace('enc:', '')), + isEncryptedFormat: vi.fn((value: string) => typeof value === 'string' && value.startsWith('enc:')), })); const fetchMock = vi.hoisted(() => vi.fn()); @@ -65,7 +66,8 @@ describe('NtfyProvider', () => { expect(fetchMock).toHaveBeenCalledTimes(1); const fetchCall = fetchMock.mock.calls[0]; - expect(fetchCall[0]).toBe('https://ntfy.example.com/audiobooks'); + // ntfy JSON publishing: POST to base server URL, topic is in JSON body + expect(fetchCall[0]).toBe('https://ntfy.example.com'); expect(fetchCall[1].method).toBe('POST'); expect(fetchCall[1].headers['Content-Type']).toBe('application/json'); expect(fetchCall[1].headers['Authorization']).toBe('Bearer tk_mytoken123'); @@ -104,7 +106,7 @@ describe('NtfyProvider', () => { ); const fetchCall = fetchMock.mock.calls[0]; - expect(fetchCall[0]).toBe('https://ntfy.sh/audiobooks'); + expect(fetchCall[0]).toBe('https://ntfy.sh'); }); it('does not include Authorization header when accessToken is not provided', async () => { @@ -210,7 +212,7 @@ describe('NtfyProvider', () => { ); const fetchCall = fetchMock.mock.calls[0]; - expect(fetchCall[0]).toBe('https://ntfy.example.com/audiobooks'); + expect(fetchCall[0]).toBe('https://ntfy.example.com'); }); it('throws descriptive error when API returns non-OK response', async () => { @@ -275,13 +277,12 @@ describe('NtfyProvider', () => { const { NotificationService } = await import('@/lib/services/notification'); const service = new NotificationService(); - // Use iv:authTag:data format to pass isEncrypted() check await service.sendToBackend( 'ntfy', { serverUrl: 'https://ntfy.example.com', topic: 'audiobooks', - accessToken: 'iv:tag:tk_mytoken123', + accessToken: 'enc:tk_mytoken123', }, { event: 'request_approved', @@ -294,12 +295,12 @@ describe('NtfyProvider', () => { ); // Verify decrypt was called for the sensitive field - expect(encryptionMock.decrypt).toHaveBeenCalledWith('iv:tag:tk_mytoken123'); + expect(encryptionMock.decrypt).toHaveBeenCalledWith('enc:tk_mytoken123'); // Verify the decrypted value reaches the fetch call expect(fetchMock).toHaveBeenCalledTimes(1); const fetchCall = fetchMock.mock.calls[0]; - expect(fetchCall[1].headers['Authorization']).toBe('Bearer iv:tag:tk_mytoken123'); + expect(fetchCall[1].headers['Authorization']).toBe('Bearer tk_mytoken123'); }); it('does not decrypt non-sensitive fields', async () => { diff --git a/tests/services/scheduler.service.test.ts b/tests/services/scheduler.service.test.ts index 5724f5d..a64b021 100644 --- a/tests/services/scheduler.service.test.ts +++ b/tests/services/scheduler.service.test.ts @@ -18,6 +18,7 @@ const jobQueueMock = vi.hoisted(() => ({ addRetryFailedImportsJob: vi.fn(), addCleanupSeededTorrentsJob: vi.fn(), addMonitorRssFeedsJob: vi.fn(), + addSyncGoodreadsShelvesJob: vi.fn(), })); const configServiceMock = vi.hoisted(() => ({ @@ -25,6 +26,10 @@ const configServiceMock = vi.hoisted(() => ({ getMany: vi.fn(), })); +const notificationServiceMock = vi.hoisted(() => ({ + reEncryptUnprotectedBackends: vi.fn().mockResolvedValue(0), +})); + vi.mock('@/lib/services/job-queue.service', () => ({ getJobQueueService: () => jobQueueMock, })); @@ -33,6 +38,10 @@ vi.mock('@/lib/services/config.service', () => ({ getConfigService: () => configServiceMock, })); +vi.mock('@/lib/services/notification', () => ({ + getNotificationService: () => notificationServiceMock, +})); + vi.mock('@/lib/db', () => ({ prisma: prismaMock, })); @@ -69,7 +78,7 @@ describe('SchedulerService', () => { const service = new SchedulerService(); await service.start(); - expect(prismaMock.scheduledJob.create).toHaveBeenCalledTimes(7); + expect(prismaMock.scheduledJob.create).toHaveBeenCalledTimes(8); expect(jobQueueMock.addRepeatableJob).toHaveBeenCalledWith( 'audible_refresh', { scheduledJobId: 'job-1' }, @@ -280,6 +289,7 @@ describe('SchedulerService', () => { ['retry_failed_imports', 'addRetryFailedImportsJob'], ['cleanup_seeded_torrents', 'addCleanupSeededTorrentsJob'], ['monitor_rss_feeds', 'addMonitorRssFeedsJob'], + ['sync_goodreads_shelves', 'addSyncGoodreadsShelvesJob'], ])('triggers %s jobs with job queue', async (type, queueMethod) => { prismaMock.scheduledJob.findUnique.mockResolvedValue({ id: 'job-type', diff --git a/tests/utils/scrape-resilience.test.ts b/tests/utils/scrape-resilience.test.ts new file mode 100644 index 0000000..68b6ad1 --- /dev/null +++ b/tests/utils/scrape-resilience.test.ts @@ -0,0 +1,147 @@ +/** + * Component: Scrape Resilience Utility Tests + * Documentation: documentation/integrations/audible.md + */ + +import { describe, expect, it } from 'vitest'; +import { + pickUserAgent, + getBrowserHeaders, + jitteredBackoff, + randomDelay, + AdaptivePacer, +} from '@/lib/utils/scrape-resilience'; + +describe('pickUserAgent', () => { + it('returns a string containing Mozilla', () => { + const ua = pickUserAgent(); + expect(typeof ua).toBe('string'); + expect(ua).toContain('Mozilla'); + }); + + it('returns values from the known pool', () => { + const seen = new Set<string>(); + for (let i = 0; i < 100; i++) { + seen.add(pickUserAgent()); + } + // Should have picked at least 2 different UAs over 100 draws + expect(seen.size).toBeGreaterThanOrEqual(2); + for (const ua of seen) { + expect(ua).toContain('Mozilla/5.0'); + } + }); +}); + +describe('getBrowserHeaders', () => { + it('includes all expected header keys', () => { + const headers = getBrowserHeaders('TestUA/1.0'); + expect(headers['User-Agent']).toBe('TestUA/1.0'); + expect(headers['Accept']).toBeDefined(); + expect(headers['Accept-Language']).toBeDefined(); + expect(headers['Accept-Encoding']).toBeDefined(); + expect(headers['Connection']).toBeDefined(); + expect(headers['Sec-Fetch-Site']).toBeDefined(); + expect(headers['Sec-Fetch-Mode']).toBeDefined(); + expect(headers['Sec-Fetch-Dest']).toBeDefined(); + expect(headers['Sec-Fetch-User']).toBeDefined(); + expect(headers['Upgrade-Insecure-Requests']).toBeDefined(); + }); +}); + +describe('jitteredBackoff', () => { + it('returns values within the expected jitter range', () => { + for (let attempt = 0; attempt < 5; attempt++) { + for (let i = 0; i < 50; i++) { + const value = jitteredBackoff(attempt, 1000); + const base = Math.pow(2, attempt) * 1000; + // Jitter range is 0.5x – 1.5x + expect(value).toBeGreaterThanOrEqual(Math.round(base * 0.5)); + expect(value).toBeLessThanOrEqual(Math.round(base * 1.5)); + } + } + }); + + it('uses custom base ms', () => { + const value = jitteredBackoff(0, 500); + // attempt=0: 1 * 500 * [0.5..1.5] → [250..750] + expect(value).toBeGreaterThanOrEqual(250); + expect(value).toBeLessThanOrEqual(750); + }); +}); + +describe('randomDelay', () => { + it('returns values within bounds', () => { + for (let i = 0; i < 100; i++) { + const val = randomDelay(100, 200); + expect(val).toBeGreaterThanOrEqual(100); + expect(val).toBeLessThanOrEqual(200); + } + }); +}); + +describe('AdaptivePacer', () => { + it('returns base delay range when no retries needed', () => { + const pacer = new AdaptivePacer(); + for (let i = 0; i < 50; i++) { + const delay = pacer.reportPageResult({ retriesUsed: 0, encountered503: false }); + expect(delay).toBeGreaterThanOrEqual(2000); + expect(delay).toBeLessThanOrEqual(4000); + } + }); + + it('increases delay when retries occurred', () => { + const pacer = new AdaptivePacer(); + // First retry page: consecutiveRetryPages becomes 1, multiplier = 1.5 + const delay = pacer.reportPageResult({ retriesUsed: 2, encountered503: true }); + // Range: [2000*1.5, 4000*1.5] = [3000, 6000] + expect(delay).toBeGreaterThanOrEqual(3000); + expect(delay).toBeLessThanOrEqual(6000); + }); + + it('triggers circuit breaker after 3 consecutive retry pages', () => { + const pacer = new AdaptivePacer(); + const retryMeta = { retriesUsed: 1, encountered503: true }; + + pacer.reportPageResult(retryMeta); // consecutive = 1 + pacer.reportPageResult(retryMeta); // consecutive = 2 + const cooldown = pacer.reportPageResult(retryMeta); // consecutive = 3 → circuit breaker + + expect(cooldown).toBeGreaterThanOrEqual(45000); + expect(cooldown).toBeLessThanOrEqual(60000); + }); + + it('recovers gradually after successful pages', () => { + const pacer = new AdaptivePacer(); + const retryMeta = { retriesUsed: 1, encountered503: true }; + const successMeta = { retriesUsed: 0, encountered503: false }; + + // Build up to 2 consecutive retries + pacer.reportPageResult(retryMeta); // consecutive = 1 + pacer.reportPageResult(retryMeta); // consecutive = 2 + + // Success decrements: consecutive goes from 2 → 1 + const delay = pacer.reportPageResult(successMeta); + expect(delay).toBeGreaterThanOrEqual(2000); + expect(delay).toBeLessThanOrEqual(4000); + + // Another success: consecutive goes from 1 → 0 + const delay2 = pacer.reportPageResult(successMeta); + expect(delay2).toBeGreaterThanOrEqual(2000); + expect(delay2).toBeLessThanOrEqual(4000); + }); + + it('resets state', () => { + const pacer = new AdaptivePacer(); + const retryMeta = { retriesUsed: 1, encountered503: true }; + + pacer.reportPageResult(retryMeta); // consecutive = 1 + pacer.reportPageResult(retryMeta); // consecutive = 2 + pacer.reset(); + + // After reset, should be back to base range behavior for retries + const delay = pacer.reportPageResult(retryMeta); + // consecutive = 1 again, multiplier = 1.5 → [3000, 6000] + expect(delay).toBeGreaterThanOrEqual(3000); + expect(delay).toBeLessThanOrEqual(6000); + }); +});