mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-02 20:30:10 +00:00
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.
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "goodreads_shelves" (
|
||||
"id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"rss_url" TEXT NOT NULL,
|
||||
"last_sync_at" TIMESTAMP(3),
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "goodreads_shelves_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "goodreads_book_mappings" (
|
||||
"id" TEXT NOT NULL,
|
||||
"goodreads_book_id" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"author" TEXT NOT NULL,
|
||||
"audible_asin" TEXT,
|
||||
"cover_url" TEXT,
|
||||
"no_match" BOOLEAN NOT NULL DEFAULT false,
|
||||
"last_search_at" TIMESTAMP(3),
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "goodreads_book_mappings_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "goodreads_shelves_user_id_idx" ON "goodreads_shelves"("user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "goodreads_shelves_user_id_rss_url_key" ON "goodreads_shelves"("user_id", "rss_url");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "goodreads_book_mappings_goodreads_book_id_key" ON "goodreads_book_mappings"("goodreads_book_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "goodreads_book_mappings_goodreads_book_id_idx" ON "goodreads_book_mappings"("goodreads_book_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "goodreads_book_mappings_audible_asin_idx" ON "goodreads_book_mappings"("audible_asin");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "goodreads_shelves" ADD CONSTRAINT "goodreads_shelves_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,3 @@
|
||||
-- Add cached book count and cover URLs to goodreads_shelves for rich UI display
|
||||
ALTER TABLE "goodreads_shelves" ADD COLUMN "book_count" INTEGER;
|
||||
ALTER TABLE "goodreads_shelves" ADD COLUMN "cover_urls" TEXT;
|
||||
@@ -0,0 +1,32 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "reported_issues" (
|
||||
"id" TEXT NOT NULL,
|
||||
"audiobook_id" TEXT NOT NULL,
|
||||
"reporter_id" TEXT NOT NULL,
|
||||
"reason" VARCHAR(250) NOT NULL,
|
||||
"status" TEXT NOT NULL DEFAULT 'open',
|
||||
"resolved_at" TIMESTAMP(3),
|
||||
"resolved_by_id" TEXT,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "reported_issues_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "reported_issues_audiobook_id_idx" ON "reported_issues"("audiobook_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "reported_issues_reporter_id_idx" ON "reported_issues"("reporter_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "reported_issues_status_idx" ON "reported_issues"("status");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "reported_issues" ADD CONSTRAINT "reported_issues_audiobook_id_fkey" FOREIGN KEY ("audiobook_id") REFERENCES "audiobooks"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "reported_issues" ADD CONSTRAINT "reported_issues_reporter_id_fkey" FOREIGN KEY ("reporter_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "reported_issues" ADD CONSTRAINT "reported_issues_resolved_by_id_fkey" FOREIGN KEY ("resolved_by_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
+73
-1
@@ -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 <title>
|
||||
rssUrl String @map("rss_url") @db.Text
|
||||
lastSyncAt DateTime? @map("last_sync_at")
|
||||
bookCount Int? @map("book_count")
|
||||
coverUrls String? @map("cover_urls") @db.Text // JSON array of cover image URLs
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
// Relations
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([userId, rssUrl])
|
||||
@@index([userId])
|
||||
@@map("goodreads_shelves")
|
||||
}
|
||||
|
||||
model GoodreadsBookMapping {
|
||||
id String @id @default(uuid())
|
||||
goodreadsBookId String @unique @map("goodreads_book_id")
|
||||
title String
|
||||
author String
|
||||
audibleAsin String? @map("audible_asin")
|
||||
coverUrl String? @map("cover_url") @db.Text
|
||||
noMatch Boolean @default(false) @map("no_match")
|
||||
lastSearchAt DateTime? @map("last_search_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@index([goodreadsBookId])
|
||||
@@index([audibleAsin])
|
||||
@@map("goodreads_book_mappings")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* Component: Admin Reported Issues Section
|
||||
* Documentation: documentation/backend/services/reported-issues.md
|
||||
*
|
||||
* Displays open reported issues on the admin dashboard.
|
||||
* Allows dismiss or search-for-replacement actions.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useToast } from '@/components/ui/Toast';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { InteractiveTorrentSearchModal } from '@/components/requests/InteractiveTorrentSearchModal';
|
||||
import { fetchJSON } from '@/lib/utils/api';
|
||||
import { mutate } from 'swr';
|
||||
|
||||
interface ReportedIssue {
|
||||
id: string;
|
||||
reason: string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
audiobook: {
|
||||
id: string;
|
||||
title: string;
|
||||
author: string;
|
||||
coverArtUrl: string | null;
|
||||
audibleAsin: string | null;
|
||||
};
|
||||
reporter: {
|
||||
id: string;
|
||||
plexUsername: string;
|
||||
avatarUrl: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
interface ReportedIssuesSectionProps {
|
||||
issues: ReportedIssue[];
|
||||
}
|
||||
|
||||
export function ReportedIssuesSection({ issues }: ReportedIssuesSectionProps) {
|
||||
const toast = useToast();
|
||||
const [loadingStates, setLoadingStates] = useState<Record<string, boolean>>({});
|
||||
const [replaceIssue, setReplaceIssue] = useState<ReportedIssue | null>(null);
|
||||
|
||||
const handleDismiss = async (issueId: string) => {
|
||||
setLoadingStates((prev) => ({ ...prev, [issueId]: true }));
|
||||
|
||||
try {
|
||||
await fetchJSON(`/api/admin/reported-issues/${issueId}/resolve`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ action: 'dismiss' }),
|
||||
});
|
||||
|
||||
toast.success('Issue dismissed');
|
||||
await mutate((key: unknown) => typeof key === 'string' && key.includes('/api/admin/reported-issues'));
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
`Failed to dismiss issue: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
} finally {
|
||||
setLoadingStates((prev) => ({ ...prev, [issueId]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleReplaceSuccess = async () => {
|
||||
toast.success('Replacement download started');
|
||||
setReplaceIssue(null);
|
||||
await mutate((key: unknown) => typeof key === 'string' && key.includes('/api/admin/reported-issues'));
|
||||
await mutate((key: unknown) => typeof key === 'string' && key.includes('/api/admin/metrics'));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-8">
|
||||
{/* Section Header */}
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg
|
||||
className="w-6 h-6 text-orange-600 dark:text-orange-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M3 21v-4m0 0V5a2 2 0 012-2h6.5l1 1H21l-3 6 3 6h-8.5l-1-1H5a2 2 0 00-2 2zm9-13.5V9"
|
||||
/>
|
||||
</svg>
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-gray-100">
|
||||
Reported Issues
|
||||
</h2>
|
||||
</div>
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200">
|
||||
{issues.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Issues Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{issues.map((issue) => {
|
||||
const isLoading = loadingStates[issue.id] || false;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={issue.id}
|
||||
className="bg-white dark:bg-gray-800 border-2 border-orange-200 dark:border-orange-800 rounded-lg shadow-sm hover:shadow-md transition-shadow overflow-hidden"
|
||||
>
|
||||
{/* Card Content */}
|
||||
<div className="p-4">
|
||||
<div className="flex gap-3">
|
||||
{/* Cover Image */}
|
||||
<div className="flex-shrink-0">
|
||||
{issue.audiobook.coverArtUrl ? (
|
||||
<img
|
||||
src={issue.audiobook.coverArtUrl}
|
||||
alt={issue.audiobook.title}
|
||||
className="w-16 h-16 rounded object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-16 h-16 rounded bg-gray-200 dark:bg-gray-700 flex items-center justify-center">
|
||||
<svg
|
||||
className="w-8 h-8 text-gray-400 dark:text-gray-600"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path d="M9 4.804A7.968 7.968 0 005.5 4c-1.255 0-2.443.29-3.5.804v10A7.969 7.969 0 015.5 14c1.669 0 3.218.51 4.5 1.385A7.962 7.962 0 0114.5 14c1.255 0 2.443.29 3.5.804v-10A7.968 7.968 0 0014.5 4c-1.255 0-2.443.29-3.5.804V12a1 1 0 11-2 0V4.804z" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-sm font-bold text-gray-900 dark:text-gray-100 truncate">
|
||||
{issue.audiobook.title}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 truncate">
|
||||
{issue.audiobook.author}
|
||||
</p>
|
||||
|
||||
{/* Reporter */}
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
{issue.reporter.avatarUrl ? (
|
||||
<img
|
||||
src={issue.reporter.avatarUrl}
|
||||
alt={issue.reporter.plexUsername}
|
||||
className="w-5 h-5 rounded-full"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-5 h-5 rounded-full bg-gray-300 dark:bg-gray-600 flex items-center justify-center">
|
||||
<svg
|
||||
className="w-3 h-3 text-gray-600 dark:text-gray-400"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
<span className="text-xs text-gray-600 dark:text-gray-400">
|
||||
{issue.reporter.plexUsername}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Timestamp */}
|
||||
<p className="text-xs text-gray-500 dark:text-gray-500 mt-1">
|
||||
{formatDistanceToNow(new Date(issue.createdAt), { addSuffix: true })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reason */}
|
||||
<p className="mt-3 text-sm text-gray-700 dark:text-gray-300 line-clamp-2 break-words bg-orange-50 dark:bg-orange-900/20 rounded-lg px-3 py-2 border border-orange-100 dark:border-orange-800/50">
|
||||
{issue.reason}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="border-t border-orange-200 dark:border-orange-800 bg-gray-50 dark:bg-gray-900/50 px-4 py-3 flex gap-2">
|
||||
<button
|
||||
onClick={() => handleDismiss(issue.id)}
|
||||
disabled={isLoading}
|
||||
className="flex-1 inline-flex items-center justify-center gap-2 px-3 py-2 bg-gray-500 hover:bg-gray-600 disabled:bg-gray-400 disabled:cursor-not-allowed text-white text-sm font-medium rounded-lg transition-colors"
|
||||
>
|
||||
{isLoading ? (
|
||||
<svg className="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
)}
|
||||
<span>Dismiss</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setReplaceIssue(issue)}
|
||||
disabled={isLoading}
|
||||
className="flex-1 inline-flex items-center justify-center gap-2 px-3 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 disabled:cursor-not-allowed text-white text-sm font-medium rounded-lg transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<span>Replace</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Interactive Search Modal for Replacement */}
|
||||
{replaceIssue && createPortal(
|
||||
<div className="fixed inset-0 z-[60]">
|
||||
<InteractiveTorrentSearchModal
|
||||
isOpen={!!replaceIssue}
|
||||
onClose={() => setReplaceIssue(null)}
|
||||
onSuccess={handleReplaceSuccess}
|
||||
audiobook={{
|
||||
title: replaceIssue.audiobook.title,
|
||||
author: replaceIssue.audiobook.author,
|
||||
}}
|
||||
asin={replaceIssue.audiobook.audibleAsin || undefined}
|
||||
replaceIssueId={replaceIssue.id}
|
||||
/>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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[]>([]);
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Component: Admin Replace Audiobook API
|
||||
* Documentation: documentation/backend/services/reported-issues.md
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { replaceAudiobook, ReportedIssueError } from '@/lib/services/reported-issue.service';
|
||||
import { z } from 'zod';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
|
||||
const logger = RMABLogger.create('API.Admin.ReportedIssues.Replace');
|
||||
|
||||
const ReplaceSchema = z.object({
|
||||
torrent: z.object({
|
||||
guid: z.string(),
|
||||
title: z.string(),
|
||||
size: z.number(),
|
||||
seeders: z.number().optional(),
|
||||
leechers: z.number().optional(),
|
||||
indexer: z.string(),
|
||||
indexerId: z.number().optional(),
|
||||
downloadUrl: z.string(),
|
||||
infoUrl: z.string().optional(),
|
||||
publishDate: z.string().transform((str) => new Date(str)),
|
||||
infoHash: z.string().optional(),
|
||||
format: z.enum(['M4B', 'M4A', 'MP3', 'OTHER']).optional(),
|
||||
bitrate: z.string().optional(),
|
||||
hasChapters: z.boolean().optional(),
|
||||
protocol: z.enum(['torrent', 'usenet']).optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/admin/reported-issues/[id]/replace
|
||||
* Atomically replace audiobook content: delete old → create new request → start download → resolve issue
|
||||
*/
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
return requireAdmin(req, async () => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized', message: 'User not authenticated' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const body = await req.json();
|
||||
const { torrent } = ReplaceSchema.parse(body);
|
||||
|
||||
const result = await replaceAudiobook(id, req.user.id, torrent);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
request: result.request,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'ValidationError', details: error.errors },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (error instanceof ReportedIssueError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'ReplaceError', message: error.message },
|
||||
{ status: error.statusCode }
|
||||
);
|
||||
}
|
||||
|
||||
logger.error('Failed to replace audiobook', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ error: 'ServerError', message: 'Failed to replace audiobook' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Component: Admin Resolve Reported Issue API
|
||||
* Documentation: documentation/backend/services/reported-issues.md
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { dismissIssue, ReportedIssueError } from '@/lib/services/reported-issue.service';
|
||||
import { z } from 'zod';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
|
||||
const logger = RMABLogger.create('API.Admin.ReportedIssues.Resolve');
|
||||
|
||||
const ResolveSchema = z.object({
|
||||
action: z.enum(['dismiss']),
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/admin/reported-issues/[id]/resolve
|
||||
* Dismiss a reported issue
|
||||
*/
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
return requireAdmin(req, async () => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized', message: 'User not authenticated' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const body = await req.json();
|
||||
const { action } = ResolveSchema.parse(body);
|
||||
|
||||
if (action === 'dismiss') {
|
||||
const issue = await dismissIssue(id, req.user.id);
|
||||
return NextResponse.json({ success: true, issue });
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: 'InvalidAction', message: 'Unknown action' },
|
||||
{ status: 400 }
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'ValidationError', details: error.errors },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (error instanceof ReportedIssueError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'ResolveError', message: error.message },
|
||||
{ status: error.statusCode }
|
||||
);
|
||||
}
|
||||
|
||||
logger.error('Failed to resolve issue', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ error: 'ServerError', message: 'Failed to resolve issue' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Component: Admin Reported Issues List API
|
||||
* Documentation: documentation/backend/services/reported-issues.md
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { getOpenIssues } from '@/lib/services/reported-issue.service';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
|
||||
const logger = RMABLogger.create('API.Admin.ReportedIssues');
|
||||
|
||||
/**
|
||||
* GET /api/admin/reported-issues
|
||||
* Get all open reported issues with audiobook metadata and reporter info
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
return requireAdmin(req, async () => {
|
||||
try {
|
||||
const issues = await getOpenIssues();
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
issues,
|
||||
count: issues.length,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch reported issues', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ error: 'ServerError', message: 'Failed to fetch reported issues' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
+17
-259
@@ -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) });
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+124
-252
@@ -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>
|
||||
);
|
||||
|
||||
@@ -244,6 +244,7 @@ export function AudiobookCard({
|
||||
requestStatus={audiobook.requestStatus}
|
||||
isAvailable={audiobook.isAvailable}
|
||||
requestedByUsername={audiobook.requestedByUsername}
|
||||
hasReportedIssue={audiobook.hasReportedIssue}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
|
||||
@@ -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'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -45,6 +45,8 @@ export const createPrismaMock = () => ({
|
||||
bookDateConfig: createModelMock(),
|
||||
bookDateRecommendation: createModelMock(),
|
||||
bookDateSwipe: createModelMock(),
|
||||
goodreadsShelf: createModelMock(),
|
||||
goodreadsBookMapping: createModelMock(),
|
||||
$queryRaw: vi.fn(),
|
||||
$disconnect: vi.fn(),
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user