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:
kikootwo
2026-02-11 16:49:55 -05:00
parent b013538b63
commit 20c8fb0898
69 changed files with 4167 additions and 766 deletions
@@ -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)
+14 -8
View File
@@ -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
View File
@@ -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
)}
</>
);
}
+14
View File
@@ -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(),
});
+2 -1
View File
@@ -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
View File
@@ -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 });
}
});
}
+174
View File
@@ -0,0 +1,174 @@
/**
* Component: Goodreads Shelves API Routes
* Documentation: documentation/backend/services/goodreads-sync.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
import { fetchAndValidateRss } from '@/lib/services/goodreads-sync.service';
import { getJobQueueService } from '@/lib/services/job-queue.service';
import { z } from 'zod';
import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('API.GoodreadsShelves');
const GOODREADS_RSS_PATTERN = /goodreads\.com\/review\/list_rss\//;
const AddShelfSchema = z.object({
rssUrl: z.string().url().refine(
(url) => GOODREADS_RSS_PATTERN.test(url),
{ message: 'URL must be a Goodreads shelf RSS URL (goodreads.com/review/list_rss/...)' }
),
});
/**
* GET /api/user/goodreads-shelves
* List the current user's Goodreads shelves with book counts and covers
*/
export async function GET(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
try {
if (!req.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const shelves = await prisma.goodreadsShelf.findMany({
where: { userId: req.user.id },
orderBy: { createdAt: 'desc' },
});
const shelvesWithMeta = shelves.map((shelf) => {
// Normalize coverUrls: old format (string[]) → new format ({coverUrl,asin,title,author}[])
let books: { coverUrl: string; asin: string | null; title: string; author: string }[] = [];
if (shelf.coverUrls) {
const parsed = JSON.parse(shelf.coverUrls);
if (Array.isArray(parsed)) {
books = parsed.map((item: unknown) => {
if (typeof item === 'string') {
return { coverUrl: item, asin: null, title: '', author: '' };
}
const obj = item as Record<string, unknown>;
return {
coverUrl: (obj.coverUrl as string) || '',
asin: (obj.asin as string) || null,
title: (obj.title as string) || '',
author: (obj.author as string) || '',
};
});
}
}
return {
id: shelf.id,
name: shelf.name,
rssUrl: shelf.rssUrl,
lastSyncAt: shelf.lastSyncAt,
createdAt: shelf.createdAt,
bookCount: shelf.bookCount ?? null,
books,
};
});
return NextResponse.json({ success: true, shelves: shelvesWithMeta });
} catch (error) {
logger.error('Failed to list shelves', { error: error instanceof Error ? error.message : String(error) });
return NextResponse.json({ error: 'Failed to list shelves' }, { status: 500 });
}
});
}
/**
* POST /api/user/goodreads-shelves
* Add a new Goodreads shelf subscription
*/
export async function POST(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
try {
if (!req.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await req.json();
const { rssUrl } = AddShelfSchema.parse(body);
// Check for duplicate
const existing = await prisma.goodreadsShelf.findUnique({
where: { userId_rssUrl: { userId: req.user.id, rssUrl } },
});
if (existing) {
return NextResponse.json(
{ error: 'DuplicateShelf', message: 'You have already added this shelf' },
{ status: 409 }
);
}
// Validate by fetching the RSS feed
let shelfName: string;
let bookCount: number;
let initialBooks: { coverUrl: string; asin: null; title: string; author: string }[] = [];
try {
const rssData = await fetchAndValidateRss(rssUrl);
shelfName = rssData.shelfName;
bookCount = rssData.books.length;
initialBooks = rssData.books
.filter(b => b.coverUrl)
.slice(0, 8)
.map(b => ({ coverUrl: b.coverUrl!, asin: null, title: b.title, author: b.author }));
} catch (error) {
return NextResponse.json(
{
error: 'InvalidRSS',
message: `Could not fetch or parse the RSS feed: ${error instanceof Error ? error.message : 'Unknown error'}`,
},
{ status: 400 }
);
}
const shelf = await prisma.goodreadsShelf.create({
data: {
userId: req.user.id,
name: shelfName,
rssUrl,
bookCount,
coverUrls: initialBooks.length > 0 ? JSON.stringify(initialBooks) : null,
},
});
// Trigger immediate sync for this shelf (unlimited lookups, process all books)
try {
const jobQueue = getJobQueueService();
await jobQueue.addSyncGoodreadsShelvesJob(undefined, shelf.id, 0);
logger.info(`Triggered immediate sync for shelf "${shelfName}" (${shelf.id})`);
} catch (error) {
logger.error('Failed to trigger immediate shelf sync', { error: error instanceof Error ? error.message : String(error) });
}
return NextResponse.json({
success: true,
shelf: {
id: shelf.id,
name: shelf.name,
rssUrl: shelf.rssUrl,
lastSyncAt: shelf.lastSyncAt,
createdAt: shelf.createdAt,
bookCount: shelf.bookCount,
books: initialBooks,
},
bookCount,
}, { status: 201 });
} catch (error) {
logger.error('Failed to add shelf', { error: error instanceof Error ? error.message : String(error) });
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: 'ValidationError', details: error.errors },
{ status: 400 }
);
}
return NextResponse.json({ error: 'Failed to add shelf' }, { status: 500 });
}
});
}
+9
View File
@@ -196,3 +196,12 @@ body {
.animate-toast-in {
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
View File
@@ -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);
}
+17
View File
@@ -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&apos;ll automatically request the audiobook for every book you add.
</p>
<button
onClick={onAdd}
className="inline-flex items-center gap-2 px-5 py-2.5 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-xl transition-colors shadow-sm"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
Add Your First Shelf
</button>
</div>
);
}
/* ─── Loading Skeleton ─── */
function ShelfCardSkeleton({ squareCovers }: { squareCovers: boolean }) {
return (
<div className="rounded-2xl bg-white dark:bg-gray-800 border border-gray-100 dark:border-gray-700/30 p-6 sm:p-7">
<div className="mb-5">
<div className="h-[18px] w-52 bg-gray-100 dark:bg-gray-700/50 rounded-lg animate-pulse mb-2.5" />
<div className="flex items-center gap-2">
<div className="h-[22px] w-16 bg-gray-100 dark:bg-gray-700/50 rounded-md animate-pulse" />
<div className="h-3.5 w-24 bg-gray-100 dark:bg-gray-700/50 rounded-md animate-pulse" />
</div>
</div>
<div className="flex items-end">
{[...Array(5)].map((_, i) => (
<div
key={i}
className={cn(
'rounded-xl bg-gray-100 dark:bg-gray-700/40 animate-pulse flex-shrink-0 ring-2 ring-white dark:ring-gray-800',
squareCovers ? 'w-[80px] h-[80px]' : 'w-[72px] h-[108px]'
)}
style={{ marginLeft: i > 0 ? '-16px' : 0, zIndex: 5 - i }}
/>
))}
</div>
</div>
);
}
/* ─── Shelf Card ─── */
interface ShelfCardProps {
shelf: GoodreadsShelf;
squareCovers: boolean;
isDeleting: boolean;
isConfirmingDelete: boolean;
onDelete: () => void;
onConfirmDelete: () => void;
onCancelDelete: () => void;
onBookClick: (asin: string) => void;
}
function ShelfCard({
shelf,
squareCovers,
isDeleting,
isConfirmingDelete,
onDelete,
onConfirmDelete,
onCancelDelete,
onBookClick,
}: ShelfCardProps) {
const displayBooks = shelf.books.slice(0, 6);
const hasCovers = displayBooks.length > 0;
const remainingCount = Math.max(0, (shelf.bookCount || 0) - displayBooks.length);
const isSyncing = !shelf.lastSyncAt;
return (
<div className="group rounded-2xl bg-white dark:bg-gray-800 border border-gray-100 dark:border-gray-700/30 p-6 sm:p-7 transition-all duration-300 hover:shadow-lg hover:shadow-black/[0.04] dark:hover:shadow-black/20 hover:border-gray-200 dark:hover:border-gray-600/40">
{/* Top: Shelf info + actions */}
<div className={cn('flex items-start justify-between', (hasCovers || isSyncing) && 'mb-5')}>
<div className="min-w-0 flex-1">
<h3 className="font-semibold text-[15px] text-gray-900 dark:text-white truncate leading-snug">
{shelf.name}
</h3>
<div className="flex items-center gap-2 mt-2">
{shelf.bookCount != null && (
<span className="inline-flex items-center px-2 py-0.5 rounded-md text-xs font-medium bg-gray-100 dark:bg-gray-700/50 text-gray-500 dark:text-gray-400 tabular-nums">
{shelf.bookCount} {shelf.bookCount === 1 ? 'book' : 'books'}
</span>
)}
<span className="inline-flex items-center gap-1.5 text-xs text-gray-400 dark:text-gray-500">
{isSyncing ? (
<>
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-blue-500" />
</span>
Syncing&hellip;
</>
) : shelf.lastSyncAt ? (
<>
<span className="inline-block w-1.5 h-1.5 rounded-full bg-emerald-500" />
Synced {formatRelativeTime(shelf.lastSyncAt)}
</>
) : (
'Pending sync'
)}
</span>
</div>
</div>
{/* Delete action */}
<div className="flex-shrink-0 ml-4">
{isConfirmingDelete ? (
<div className="flex items-center gap-2">
<button
onClick={onDelete}
disabled={isDeleting}
className="px-3 py-1.5 text-xs font-semibold text-white bg-red-500 hover:bg-red-600 rounded-lg transition-colors disabled:opacity-50"
>
{isDeleting ? 'Removing\u2026' : 'Remove'}
</button>
<button
onClick={onCancelDelete}
disabled={isDeleting}
className="px-2 py-1.5 text-xs font-medium text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors"
>
Cancel
</button>
</div>
) : (
<button
onClick={onConfirmDelete}
className="p-2 text-gray-300 hover:text-red-400 dark:text-gray-600 dark:hover:text-red-400 transition-all duration-200 rounded-xl hover:bg-red-50 dark:hover:bg-red-500/10 opacity-0 group-hover:opacity-100 focus:opacity-100"
title="Remove shelf"
>
<svg className="w-[18px] h-[18px]" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
</svg>
</button>
)}
</div>
</div>
{/* Bottom: Stacked book covers */}
{hasCovers ? (
<CoverStack books={displayBooks} remainingCount={remainingCount} squareCovers={squareCovers} onBookClick={onBookClick} />
) : isSyncing ? (
<div className="flex items-end">
{[...Array(3)].map((_, i) => (
<div
key={i}
className={cn(
'rounded-xl bg-gray-50 dark:bg-gray-700/30 animate-pulse flex-shrink-0 ring-2 ring-white dark:ring-gray-800',
squareCovers ? 'w-[80px] h-[80px]' : 'w-[72px] h-[108px]'
)}
style={{ marginLeft: i > 0 ? '-16px' : 0, zIndex: 3 - i }}
/>
))}
</div>
) : null}
</div>
);
}
/* ─── Stacked Cover Display ─── */
function CoverStack({
books,
remainingCount,
squareCovers,
onBookClick,
}: {
books: ShelfBook[];
remainingCount: number;
squareCovers: boolean;
onBookClick: (asin: string) => void;
}) {
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
const coverSize = squareCovers
? 'w-[80px] aspect-square'
: 'w-[72px] aspect-[2/3]';
return (
<div className="flex items-end">
{books.map((book, i) => (
<div
key={i}
className={cn(
'relative rounded-xl overflow-hidden shadow-md flex-shrink-0',
'ring-2 ring-white dark:ring-gray-800',
'transition-all duration-300 ease-out',
hoveredIndex === i && 'scale-[1.18] shadow-xl',
coverSize,
book.asin ? 'cursor-pointer' : 'cursor-default'
)}
style={{
marginLeft: i > 0 ? '-16px' : 0,
zIndex: hoveredIndex === i ? 50 : books.length - i,
}}
onMouseEnter={() => setHoveredIndex(i)}
onMouseLeave={() => setHoveredIndex(null)}
onClick={() => book.asin && onBookClick(book.asin)}
title={book.asin ? `${book.title}${book.author ? ` by ${book.author}` : ''}` : undefined}
>
<img
src={book.coverUrl}
alt=""
className="w-full h-full object-cover"
loading="lazy"
draggable={false}
/>
</div>
))}
{remainingCount > 0 && (
<div
className={cn(
'rounded-xl flex items-center justify-center bg-gray-50 dark:bg-gray-700/30 border border-gray-100 dark:border-gray-700/40 flex-shrink-0 ring-2 ring-white dark:ring-gray-800',
coverSize
)}
style={{ marginLeft: '-16px', zIndex: 0 }}
>
<span className="text-sm font-semibold text-gray-400 dark:text-gray-500 tabular-nums">
+{remainingCount}
</span>
</div>
)}
</div>
);
}
@@ -22,6 +22,7 @@ import {
useInteractiveSearchEbookByAsin,
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 &rarr; select a shelf &rarr; RSS link at the bottom of the page.
</p>
</div>
<div className="flex justify-end gap-3 pt-2">
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleClose}
disabled={isLoading || success}
>
Cancel
</Button>
<Button
type="submit"
variant="primary"
size="sm"
loading={isLoading}
disabled={isLoading || success}
>
Add Shelf
</Button>
</div>
</form>
</div>
</Modal>
);
}
+82
View File
@@ -0,0 +1,82 @@
/**
* Component: Notification Event Constants
* Documentation: documentation/backend/services/notifications.md
*
* Single source of truth for all notification event types and metadata.
* Add new events here — all providers, API schemas, and UI labels derive from this.
*/
export type NotificationSeverity = 'info' | 'success' | 'error' | 'warning';
export type NotificationPriority = 'normal' | 'high';
/**
* Central registry of notification events.
*
* Each entry defines:
* - `label`: Human-readable name shown in the UI
* - `title`: Title used in notification messages
* - `emoji`: Emoji prefix for notification titles
* - `severity`: Drives provider formatting (colors, Apprise types, ntfy tags)
* - `priority`: Drives notification urgency (Pushover/ntfy priority levels)
*/
export const NOTIFICATION_EVENTS = {
request_pending_approval: {
label: 'Request Pending Approval',
title: 'New Request Pending Approval',
emoji: '\u{1F4EC}',
severity: 'info' as const,
priority: 'normal' as const,
},
request_approved: {
label: 'Request Approved',
title: 'Request Approved',
emoji: '\u2705',
severity: 'success' as const,
priority: 'normal' as const,
},
request_available: {
label: 'Audiobook Available',
title: 'Audiobook Available',
emoji: '\u{1F389}',
severity: 'success' as const,
priority: 'high' as const,
},
request_error: {
label: 'Request Error',
title: 'Request Error',
emoji: '\u274C',
severity: 'error' as const,
priority: 'high' as const,
},
issue_reported: {
label: 'Issue Reported',
title: 'Issue Reported',
emoji: '\u{1F6A9}',
severity: 'warning' as const,
priority: 'high' as const,
},
} as const;
/** Union type of all valid notification event keys */
export type NotificationEvent = keyof typeof NOTIFICATION_EVENTS;
/** Ordered array of all notification event keys (for Zod schemas, iteration) */
export const NOTIFICATION_EVENT_KEYS = Object.keys(NOTIFICATION_EVENTS) as [NotificationEvent, ...NotificationEvent[]];
/** Metadata shape for a single notification event */
export type NotificationEventMeta = (typeof NOTIFICATION_EVENTS)[NotificationEvent];
/** Helper: get event metadata by key */
export function getEventMeta(event: NotificationEvent) {
return NOTIFICATION_EVENTS[event];
}
/** Helper: get the human-readable label for an event */
export function getEventLabel(event: NotificationEvent): string {
return NOTIFICATION_EVENTS[event].label;
}
/** Record mapping all event keys to their labels (for UI dropdowns, etc.) */
export const EVENT_LABELS: Record<NotificationEvent, string> = Object.fromEntries(
Object.entries(NOTIFICATION_EVENTS).map(([key, meta]) => [key, meta.label])
) as Record<NotificationEvent, string>;
+1
View File
@@ -26,6 +26,7 @@ export interface Audiobook {
requestStatus?: string | null; // Status of request (if any)
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) {
+127
View File
@@ -0,0 +1,127 @@
/**
* Component: Goodreads Shelves Hook
* Documentation: documentation/frontend/components.md
*/
'use client';
import { useState } from 'react';
import useSWR, { mutate } from 'swr';
import { useAuth } from '@/contexts/AuthContext';
import { fetchWithAuth } from '@/lib/utils/api';
export interface ShelfBook {
coverUrl: string;
asin: string | null;
title: string;
author: string;
}
export interface GoodreadsShelf {
id: string;
name: string;
rssUrl: string;
lastSyncAt: string | null;
createdAt: string;
bookCount: number | null;
books: ShelfBook[];
}
const fetcher = (url: string) =>
fetchWithAuth(url).then((res) => res.json());
export function useGoodreadsShelves() {
const { accessToken } = useAuth();
const endpoint = accessToken ? '/api/user/goodreads-shelves' : null;
const { data, error, isLoading } = useSWR(
endpoint,
fetcher,
{ refreshInterval: 30000 }
);
return {
shelves: (data?.shelves || []) as GoodreadsShelf[],
isLoading,
error,
};
}
export function useAddGoodreadsShelf() {
const { accessToken } = useAuth();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const addShelf = async (rssUrl: string) => {
if (!accessToken) throw new Error('Not authenticated');
setIsLoading(true);
setError(null);
try {
const response = await fetchWithAuth('/api/user/goodreads-shelves', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ rssUrl }),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || data.error || 'Failed to add shelf');
}
// Revalidate shelves list
mutate((key) => typeof key === 'string' && key.includes('/api/user/goodreads-shelves'));
return data.shelf as GoodreadsShelf;
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
setError(message);
throw err;
} finally {
setIsLoading(false);
}
};
return { addShelf, isLoading, error };
}
export function useDeleteGoodreadsShelf() {
const { accessToken } = useAuth();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const deleteShelf = async (shelfId: string) => {
if (!accessToken) throw new Error('Not authenticated');
setIsLoading(true);
setError(null);
try {
const response = await fetchWithAuth(`/api/user/goodreads-shelves/${shelfId}`, {
method: 'DELETE',
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || data.error || 'Failed to remove shelf');
}
// Revalidate shelves list
mutate((key) => typeof key === 'string' && key.includes('/api/user/goodreads-shelves'));
return true;
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
setError(message);
throw err;
} finally {
setIsLoading(false);
}
};
return { deleteShelf, isLoading, error };
}
+168
View File
@@ -0,0 +1,168 @@
/**
* Component: Reported Issues Hooks
* Documentation: documentation/backend/services/reported-issues.md
*/
'use client';
import { useState } from 'react';
import useSWR, { mutate } from 'swr';
import { useAuth } from '@/contexts/AuthContext';
import { fetchWithAuth } from '@/lib/utils/api';
const fetcher = (url: string) =>
fetchWithAuth(url).then((res) => res.json());
/**
* Hook for reporting an issue with an audiobook (user action)
*/
export function useReportIssue() {
const { accessToken } = useAuth();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const reportIssue = async (
asin: string,
reason: string,
metadata?: { title?: string; author?: string; coverArtUrl?: string }
) => {
if (!accessToken) throw new Error('Not authenticated');
setIsLoading(true);
setError(null);
try {
const response = await fetchWithAuth(`/api/audiobooks/${asin}/report-issue`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reason, ...metadata }),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || 'Failed to report issue');
}
// Revalidate audiobook lists to show issue indicator
mutate((key) => typeof key === 'string' && key.includes('/api/audiobooks'));
return data.issue;
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
setError(message);
throw err;
} finally {
setIsLoading(false);
}
};
return { reportIssue, isLoading, error };
}
/**
* Hook for fetching open reported issues (admin dashboard)
*/
export function useAdminReportedIssues() {
const { accessToken } = useAuth();
const endpoint = accessToken ? '/api/admin/reported-issues' : null;
const { data, error, isLoading } = useSWR(endpoint, fetcher, {
refreshInterval: 10000,
});
return {
issues: data?.issues || [],
count: data?.count || 0,
isLoading,
error,
};
}
/**
* Hook for dismissing a reported issue (admin action)
*/
export function useDismissIssue() {
const { accessToken } = useAuth();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const dismissIssue = async (issueId: string) => {
if (!accessToken) throw new Error('Not authenticated');
setIsLoading(true);
setError(null);
try {
const response = await fetchWithAuth(`/api/admin/reported-issues/${issueId}/resolve`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'dismiss' }),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || 'Failed to dismiss issue');
}
// Revalidate issues list
mutate((key) => typeof key === 'string' && key.includes('/api/admin/reported-issues'));
return data.issue;
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
setError(message);
throw err;
} finally {
setIsLoading(false);
}
};
return { dismissIssue, isLoading, error };
}
/**
* Hook for replacing audiobook content via reported issue (admin action)
*/
export function useReplaceWithTorrent() {
const { accessToken } = useAuth();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const replaceWithTorrent = async (issueId: string, torrent: any) => {
if (!accessToken) throw new Error('Not authenticated');
setIsLoading(true);
setError(null);
try {
const response = await fetchWithAuth(`/api/admin/reported-issues/${issueId}/replace`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ torrent }),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || 'Failed to replace audiobook');
}
// Revalidate issues list and audiobook lists
mutate((key) => typeof key === 'string' && key.includes('/api/admin/reported-issues'));
mutate((key) => typeof key === 'string' && key.includes('/api/audiobooks'));
return data.request;
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
setError(message);
throw err;
} finally {
setIsLoading(false);
}
};
return { replaceWithTorrent, isLoading, error };
}
+58 -32
View File
@@ -8,10 +8,24 @@ import * as cheerio from 'cheerio';
import { RMABLogger } from '../utils/logger';
import { 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
},
+2 -2
View File
@@ -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
});
+9 -5
View File
@@ -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,
};
}
+1 -1
View File
@@ -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',
+33
View File
@@ -95,6 +95,39 @@ export class EncryptionService {
}
}
/**
* Check if a value matches the format produced by encrypt().
* Validates: 3 colon-separated base64 parts where IV=16 bytes, authTag=16 bytes.
*/
isEncryptedFormat(value: string): boolean {
if (typeof value !== 'string') return false;
const parts = value.split(':');
if (parts.length !== 3) return false;
const [ivBase64, authTagBase64, encryptedBase64] = parts;
// All parts must be non-empty valid base64
const base64Regex = /^[A-Za-z0-9+/]+=*$/;
if (!ivBase64 || !authTagBase64 || !encryptedBase64) return false;
if (!base64Regex.test(ivBase64) || !base64Regex.test(authTagBase64) || !base64Regex.test(encryptedBase64)) {
return false;
}
try {
const iv = Buffer.from(ivBase64, 'base64');
const authTag = Buffer.from(authTagBase64, 'base64');
// IV and authTag must decode to exactly the expected byte lengths
if (iv.length !== IV_LENGTH) return false;
if (authTag.length !== AUTH_TAG_LENGTH) return false;
return true;
} catch {
return false;
}
}
/**
* Generate a random encryption key (32 bytes)
* @returns Base64-encoded random key
+357
View File
@@ -0,0 +1,357 @@
/**
* Component: Goodreads Shelf Sync Service
* Documentation: documentation/backend/services/goodreads-sync.md
*
* Fetches Goodreads shelf RSS feeds, resolves books to Audible ASINs,
* and creates requests via the shared request-creator service.
*/
import axios from 'axios';
import { XMLParser } from 'fast-xml-parser';
import { prisma } from '@/lib/db';
import { getAudibleService } from '@/lib/integrations/audible.service';
import { createRequestForUser } from '@/lib/services/request-creator.service';
import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('GoodreadsSync');
/** Default max Audible lookups per shelf per scheduled sync cycle */
const DEFAULT_MAX_LOOKUPS_PER_SHELF = 10;
/** Days before retrying a noMatch book */
const NO_MATCH_RETRY_DAYS = 7;
interface GoodreadsRssBook {
bookId: string;
title: string;
author: string;
coverUrl?: string;
}
/**
* Parse a Goodreads RSS feed XML into structured book data.
*/
function parseGoodreadsRss(xml: string): { shelfName: string; books: GoodreadsRssBook[] } {
const parser = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: '@_',
allowBooleanAttributes: true,
});
const parsed = parser.parse(xml);
const channel = parsed?.rss?.channel;
if (!channel) {
throw new Error('Invalid Goodreads RSS: no channel element');
}
const shelfName = typeof channel.title === 'string' ? channel.title : 'Goodreads Shelf';
// Normalize items to array
let items = channel.item;
if (!items) return { shelfName, books: [] };
if (!Array.isArray(items)) items = [items];
const books: GoodreadsRssBook[] = [];
for (const item of items) {
const bookId = item.book_id?.toString();
if (!bookId) continue;
const title = (item.title || '').toString().trim();
const authorName = (item.author_name || '').toString().trim();
// Goodreads RSS has book_image_url or book_medium_image_url
const coverUrl = (item.book_large_image_url || item.book_medium_image_url || item.book_image_url || '').toString().trim() || undefined;
if (title && authorName) {
books.push({ bookId, title, author: authorName, coverUrl });
}
}
return { shelfName, books };
}
/**
* Fetch and validate a Goodreads RSS URL.
* Returns the parsed shelf name and books if valid.
*/
export async function fetchAndValidateRss(rssUrl: string): Promise<{ shelfName: string; books: GoodreadsRssBook[] }> {
const response = await axios.get(rssUrl, { timeout: 15000 });
return parseGoodreadsRss(response.data);
}
export interface GoodreadsSyncStats {
shelvesProcessed: number;
booksFound: number;
lookupsPerformed: number;
requestsCreated: number;
errors: number;
}
export interface GoodreadsSyncOptions {
/** Process only this shelf ID (for immediate single-shelf sync) */
shelfId?: string;
/** Max Audible lookups per shelf. 0 = unlimited. Default: 10 for scheduled, unlimited for immediate. */
maxLookupsPerShelf?: number;
}
/**
* Process Goodreads shelves: fetch RSS, resolve ASINs, create requests.
* Called from the dedicated sync_goodreads_shelves processor.
*/
export async function processGoodreadsShelves(
jobLogger?: ReturnType<typeof RMABLogger.forJob>,
options: GoodreadsSyncOptions = {}
): Promise<GoodreadsSyncStats> {
const log = jobLogger || logger;
const stats: GoodreadsSyncStats = { shelvesProcessed: 0, booksFound: 0, lookupsPerformed: 0, requestsCreated: 0, errors: 0 };
const maxLookups = options.maxLookupsPerShelf ?? DEFAULT_MAX_LOOKUPS_PER_SHELF;
const whereClause = options.shelfId ? { id: options.shelfId } : {};
const shelves = await prisma.goodreadsShelf.findMany({
where: whereClause,
include: { user: { select: { id: true, plexUsername: true } } },
});
if (shelves.length === 0) {
log.info(options.shelfId ? 'Shelf not found' : 'No Goodreads shelves configured, skipping');
return stats;
}
log.info(`Processing ${shelves.length} Goodreads shelf(s)${maxLookups > 0 ? ` (max ${maxLookups} lookups/shelf)` : ' (unlimited lookups)'}`);
for (const shelf of shelves) {
try {
await processShelf(shelf, stats, log, maxLookups);
stats.shelvesProcessed++;
} catch (error) {
stats.errors++;
log.error(`Failed to process shelf "${shelf.name}" for user ${shelf.user.plexUsername}: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
log.info(`Goodreads sync complete: ${stats.shelvesProcessed} shelves, ${stats.booksFound} books, ${stats.lookupsPerformed} lookups, ${stats.requestsCreated} requests created, ${stats.errors} errors`);
return stats;
}
async function processShelf(
shelf: { id: string; rssUrl: string; name: string; user: { id: string; plexUsername: string } },
stats: GoodreadsSyncStats,
log: ReturnType<typeof RMABLogger.forJob> | ReturnType<typeof RMABLogger.create>,
maxLookups: number
) {
log.info(`Fetching RSS for shelf "${shelf.name}" (user: ${shelf.user.plexUsername})`);
let rssData: { shelfName: string; books: GoodreadsRssBook[] };
try {
rssData = await fetchAndValidateRss(shelf.rssUrl);
} catch (error) {
log.error(`Failed to fetch RSS for shelf "${shelf.name}": ${error instanceof Error ? error.message : 'Unknown error'}`);
return;
}
const books = rssData.books;
stats.booksFound += books.length;
log.info(`Found ${books.length} books in shelf "${shelf.name}"`);
let lookupsThisCycle = 0;
const unlimitedLookups = maxLookups === 0;
for (const book of books) {
// Look up existing mapping
let mapping = await prisma.goodreadsBookMapping.findUnique({
where: { goodreadsBookId: book.bookId },
});
if (!mapping) {
// No mapping exists — perform Audible lookup if under cap
if (!unlimitedLookups && lookupsThisCycle >= maxLookups) {
continue; // Will be resolved in a future cycle
}
mapping = await performAudibleLookup(book, log);
lookupsThisCycle++;
stats.lookupsPerformed++;
// If lookup found an ASIN, fall through to create request immediately
if (!mapping?.audibleAsin) {
continue;
}
}
// Mapping exists with noMatch — check if we should retry
if (mapping.noMatch) {
if (mapping.lastSearchAt) {
const daysSinceSearch = (Date.now() - mapping.lastSearchAt.getTime()) / (1000 * 60 * 60 * 24);
if (daysSinceSearch >= NO_MATCH_RETRY_DAYS && (unlimitedLookups || lookupsThisCycle < maxLookups)) {
log.info(`Retrying Audible lookup for "${book.title}" (${NO_MATCH_RETRY_DAYS}+ days since last search)`);
mapping = await performAudibleLookup(book, log, mapping.id);
lookupsThisCycle++;
stats.lookupsPerformed++;
// If retry found an ASIN, fall through to create request
if (!mapping?.audibleAsin) {
continue;
}
} else {
continue; // Still no match, skip
}
} else {
continue;
}
}
// Mapping has ASIN — try to create request
if (mapping.audibleAsin) {
try {
const result = await createRequestForUser(shelf.user.id, {
asin: mapping.audibleAsin,
title: mapping.title,
author: mapping.author,
coverArtUrl: mapping.coverUrl || undefined,
});
if (result.success) {
stats.requestsCreated++;
log.info(`Created request for "${mapping.title}" by ${mapping.author} (ASIN: ${mapping.audibleAsin})`);
}
// If not success, it's already available/requested/duplicate — silently skip
} catch (error) {
log.error(`Failed to create request for "${mapping.title}": ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
}
// Collect enriched book data (coverUrl + ASIN) for display
const bookIds = books.map(b => b.bookId);
const mappings = bookIds.length > 0
? await prisma.goodreadsBookMapping.findMany({
where: { goodreadsBookId: { in: bookIds } },
select: { goodreadsBookId: true, audibleAsin: true, title: true, author: true, coverUrl: true },
})
: [];
const mappingsByBookId = new Map(mappings.map(m => [m.goodreadsBookId, m]));
// Look up AudibleCache records for high-quality cached cover URLs
const matchedAsins = mappings
.map(m => m.audibleAsin)
.filter((asin): asin is string => !!asin);
const cachedCovers = matchedAsins.length > 0
? await prisma.audibleCache.findMany({
where: { asin: { in: matchedAsins } },
select: { asin: true, coverArtUrl: true, cachedCoverPath: true },
})
: [];
const coverByAsin = new Map(
cachedCovers
.filter(c => c.cachedCoverPath || c.coverArtUrl)
.map(c => {
let coverUrl = c.coverArtUrl || '';
if (c.cachedCoverPath) {
const filename = c.cachedCoverPath.split('/').pop();
coverUrl = `/api/cache/thumbnails/${filename}`;
}
return [c.asin, coverUrl] as const;
})
);
const bookData = books
.map(b => {
const mapping = mappingsByBookId.get(b.bookId);
// Prefer cached cover (local proxy) > mapping cover > Goodreads RSS cover
const coverUrl = coverByAsin.get(mapping?.audibleAsin || '') || mapping?.coverUrl || b.coverUrl;
if (!coverUrl) return null;
return {
coverUrl,
asin: mapping?.audibleAsin || null,
title: mapping?.title || b.title,
author: mapping?.author || b.author,
};
})
.filter((b): b is NonNullable<typeof b> => b !== null)
.slice(0, 8);
// Update shelf metadata
await prisma.goodreadsShelf.update({
where: { id: shelf.id },
data: {
lastSyncAt: new Date(),
bookCount: books.length,
coverUrls: bookData.length > 0 ? JSON.stringify(bookData) : null,
},
});
}
async function performAudibleLookup(
book: GoodreadsRssBook,
log: ReturnType<typeof RMABLogger.forJob> | ReturnType<typeof RMABLogger.create>,
existingMappingId?: string
): Promise<any> {
const audibleService = getAudibleService();
try {
const searchQuery = `${book.title} ${book.author}`;
log.info(`Searching Audible for: "${searchQuery}"`);
const searchResult = await audibleService.search(searchQuery);
const firstResult = searchResult.results[0];
if (firstResult?.asin) {
log.info(`Audible match: "${book.title}" → ASIN ${firstResult.asin} ("${firstResult.title}" by ${firstResult.author})`);
// Use clean Audible/Audnexus metadata instead of Goodreads data
// (Goodreads titles contain series info like "(The Empyrean, #1)" that pollute indexer searches)
const data = {
title: firstResult.title,
author: firstResult.author,
audibleAsin: firstResult.asin,
coverUrl: firstResult.coverArtUrl || book.coverUrl || null,
noMatch: false,
lastSearchAt: new Date(),
};
if (existingMappingId) {
return prisma.goodreadsBookMapping.update({ where: { id: existingMappingId }, data });
}
return prisma.goodreadsBookMapping.create({
data: { goodreadsBookId: book.bookId, ...data },
});
}
// No match found
log.info(`No Audible match for "${book.title}" by ${book.author}`);
const noMatchData = {
title: book.title,
author: book.author,
coverUrl: book.coverUrl || null,
noMatch: true,
lastSearchAt: new Date(),
audibleAsin: null,
};
if (existingMappingId) {
return prisma.goodreadsBookMapping.update({ where: { id: existingMappingId }, data: noMatchData });
}
return prisma.goodreadsBookMapping.create({
data: { goodreadsBookId: book.bookId, ...noMatchData },
});
} catch (error) {
log.error(`Audible lookup failed for "${book.title}": ${error instanceof Error ? error.message : 'Unknown error'}`);
// Still create/update mapping so we don't retry every cycle
const errorData = {
title: book.title,
author: book.author,
coverUrl: book.coverUrl || null,
noMatch: true,
lastSearchAt: new Date(),
};
if (existingMappingId) {
return prisma.goodreadsBookMapping.update({ where: { id: existingMappingId }, data: errorData });
}
return prisma.goodreadsBookMapping.create({
data: { goodreadsBookId: book.bookId, ...errorData },
});
}
}
+41 -4
View File
@@ -9,6 +9,7 @@ import { prisma } from '../db';
import { TorrentResult } from '../utils/ranking-algorithm';
import { 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;
}
+10
View File
@@ -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'),
};
}
+413
View File
@@ -0,0 +1,413 @@
/**
* Component: Reported Issue Service
* Documentation: documentation/backend/services/reported-issues.md
*
* Handles user-reported problems with available audiobooks.
* Supports dismiss (admin closes) and replace (admin picks new torrent) workflows.
*/
import { prisma } from '@/lib/db';
import { findPlexMatch } from '@/lib/utils/audiobook-matcher';
import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('ReportedIssue');
/**
* Report an issue with an available audiobook
*/
export async function reportIssue(
asin: string,
reporterId: string,
reason: string,
metadata?: { title?: string; author?: string; coverArtUrl?: string }
) {
// Validate the book is in the library
const plexMatch = await findPlexMatch({
asin,
title: metadata?.title || '',
author: metadata?.author || '',
});
if (!plexMatch) {
throw new ReportedIssueError('This audiobook is not currently in your library', 404);
}
// Find or create audiobook record for this ASIN
let audiobook = await prisma.audiobook.findFirst({
where: { audibleAsin: asin },
});
if (!audiobook) {
audiobook = await prisma.audiobook.create({
data: {
audibleAsin: asin,
title: metadata?.title || 'Unknown Title',
author: metadata?.author || 'Unknown Author',
coverArtUrl: metadata?.coverArtUrl,
status: 'requested',
},
});
logger.info(`Created audiobook record for ASIN ${asin} to link reported issue`);
}
// Check for existing open issue
const existingIssue = await prisma.reportedIssue.findFirst({
where: {
audiobookId: audiobook.id,
status: 'open',
},
});
if (existingIssue) {
throw new ReportedIssueError('An issue has already been reported for this audiobook', 409);
}
const issue = await prisma.reportedIssue.create({
data: {
audiobookId: audiobook.id,
reporterId,
reason,
},
include: {
audiobook: { select: { title: true, author: true, audibleAsin: true } },
reporter: { select: { plexUsername: true } },
},
});
logger.info(`Issue reported for "${audiobook.title}" by user ${reporterId}`);
// Queue notification (non-blocking)
try {
const { getJobQueueService } = await import('./job-queue.service');
const jobQueue = getJobQueueService();
await jobQueue.addNotificationJob(
'issue_reported',
issue.id,
audiobook.title,
audiobook.author,
issue.reporter.plexUsername,
reason
);
} catch (error) {
logger.error('Failed to queue issue_reported notification', {
error: error instanceof Error ? error.message : String(error),
});
}
return issue;
}
/**
* Dismiss a reported issue (admin action)
*/
export async function dismissIssue(issueId: string, adminUserId: string) {
const issue = await prisma.reportedIssue.findUnique({
where: { id: issueId },
});
if (!issue) {
throw new ReportedIssueError('Issue not found', 404);
}
if (issue.status !== 'open') {
throw new ReportedIssueError('Issue is already resolved', 409);
}
const updated = await prisma.reportedIssue.update({
where: { id: issueId },
data: {
status: 'dismissed',
resolvedAt: new Date(),
resolvedById: adminUserId,
},
});
logger.info(`Issue ${issueId} dismissed by admin ${adminUserId}`);
return updated;
}
/**
* Replace audiobook content for a reported issue (atomic admin action):
* 1. Validate issue is open
* 2. Delete old content (via request delete or direct library deletion)
* 3. Create new request + start download with selected torrent
* 4. Resolve issue as "replaced"
*/
export async function replaceAudiobook(
issueId: string,
adminUserId: string,
torrent: any
) {
const issue = await prisma.reportedIssue.findUnique({
where: { id: issueId },
include: {
audiobook: {
select: {
id: true,
title: true,
author: true,
audibleAsin: true,
coverArtUrl: true,
narrator: true,
plexGuid: true,
absItemId: true,
},
},
},
});
if (!issue) {
throw new ReportedIssueError('Issue not found', 404);
}
if (issue.status !== 'open') {
throw new ReportedIssueError('Issue is already resolved', 409);
}
const audiobook = issue.audiobook;
// Step 1: Find existing active request for this audiobook
const existingRequest = await prisma.request.findFirst({
where: {
audiobookId: audiobook.id,
type: 'audiobook',
deletedAt: null,
},
orderBy: { createdAt: 'desc' },
});
// Step 2: Delete old content
if (existingRequest) {
// Has an RMAB request — use deleteRequest which handles torrent cleanup, files, library backend
const { deleteRequest } = await import('./request-delete.service');
const deleteResult = await deleteRequest(existingRequest.id, adminUserId);
if (!deleteResult.success) {
logger.warn(`deleteRequest partial failure for ${existingRequest.id}: ${deleteResult.error}`);
// Continue anyway - we want replacement to proceed
}
logger.info(`Deleted existing request ${existingRequest.id} for replacement`);
} else {
// No RMAB request — book was added to library outside RMAB
await deleteFromLibrary(audiobook);
logger.info(`Deleted library content directly for "${audiobook.title}" (no RMAB request)`);
}
// Step 3: Reset audiobook record for new request
await prisma.audiobook.update({
where: { id: audiobook.id },
data: {
status: 'requested',
plexGuid: null,
absItemId: null,
filePath: null,
fileFormat: null,
fileSizeBytes: null,
filesHash: null,
},
});
// Step 4: Create new request + start download (admin-initiated, no approval needed)
const newRequest = await prisma.request.create({
data: {
userId: adminUserId,
audiobookId: audiobook.id,
status: 'downloading',
type: 'audiobook',
progress: 0,
},
include: {
audiobook: true,
user: { select: { id: true, plexUsername: true } },
},
});
// Queue download job with selected torrent
const { getJobQueueService } = await import('./job-queue.service');
const jobQueue = getJobQueueService();
await jobQueue.addDownloadJob(
newRequest.id,
{
id: audiobook.id,
title: audiobook.title,
author: audiobook.author,
},
torrent
);
// Step 5: Resolve issue
await prisma.reportedIssue.update({
where: { id: issueId },
data: {
status: 'replaced',
resolvedAt: new Date(),
resolvedById: adminUserId,
},
});
logger.info(`Issue ${issueId} resolved via replacement. New request: ${newRequest.id}`);
return { issue, request: newRequest };
}
/**
* Get all open issues with audiobook metadata and reporter info (admin list)
*/
export async function getOpenIssues() {
return prisma.reportedIssue.findMany({
where: { status: 'open' },
include: {
audiobook: {
select: {
id: true,
title: true,
author: true,
coverArtUrl: true,
audibleAsin: true,
},
},
reporter: {
select: {
id: true,
plexUsername: true,
avatarUrl: true,
},
},
},
orderBy: { createdAt: 'desc' },
});
}
/**
* Batch query for open issues by ASINs (used for enrichment in audiobook-matcher)
*/
export async function getOpenIssuesByAsins(asins: string[]): Promise<Set<string>> {
if (asins.length === 0) return new Set();
const issues = await prisma.reportedIssue.findMany({
where: {
status: 'open',
audiobook: {
audibleAsin: { in: asins },
},
},
select: {
audiobook: {
select: { audibleAsin: true },
},
},
});
return new Set(
issues
.map((i) => i.audiobook.audibleAsin)
.filter((asin): asin is string => asin !== null)
);
}
/**
* Delete audiobook content from library backend directly (no RMAB request).
* Used when a book was added to Plex/ABS outside of RMAB.
* Mirrors the library deletion logic from request-delete.service.ts lines 280-440.
*/
async function deleteFromLibrary(audiobook: {
id: string;
title: string;
author: string;
audibleAsin: string | null;
plexGuid: string | null;
absItemId: string | null;
}) {
const { getConfigService } = await import('./config.service');
const configService = getConfigService();
const backendMode = await configService.getBackendMode();
// Delete from library backend API
if (backendMode === 'audiobookshelf') {
// absItemId may be null if the book was added outside RMAB.
// Fall back to looking up the ABS item ID from plex_library by ASIN
// (plexGuid stores the ABS item ID when using ABS backend).
let itemId = audiobook.absItemId;
if (!itemId && audiobook.audibleAsin) {
const libraryRecord = await prisma.plexLibrary.findFirst({
where: {
OR: [
{ asin: audiobook.audibleAsin },
{ plexGuid: { contains: audiobook.audibleAsin } },
],
},
select: { plexGuid: true },
});
itemId = libraryRecord?.plexGuid ?? null;
}
if (itemId) {
try {
const { deleteABSItem } = await import('./audiobookshelf/api');
await deleteABSItem(itemId);
logger.info(`Deleted ABS item ${itemId} for "${audiobook.title}"`);
} catch (error) {
logger.error(`Failed to delete ABS item ${itemId}`, {
error: error instanceof Error ? error.message : String(error),
});
}
} else {
logger.warn(`No ABS item ID found for "${audiobook.title}" (ASIN: ${audiobook.audibleAsin}) — skipping ABS deletion`);
}
} else if (backendMode === 'plex' && audiobook.plexGuid) {
try {
const plexLibraryRecord = await prisma.plexLibrary.findUnique({
where: { plexGuid: audiobook.plexGuid },
select: { plexRatingKey: true },
});
if (plexLibraryRecord?.plexRatingKey) {
const plexServerUrl = (await configService.get('plex_url')) || '';
const plexToken = (await configService.get('plex_token')) || '';
if (plexServerUrl && plexToken) {
const { getPlexService } = await import('../integrations/plex.service');
const plexService = getPlexService();
await plexService.deleteItem(plexServerUrl, plexToken, plexLibraryRecord.plexRatingKey);
logger.info(`Deleted Plex item ${plexLibraryRecord.plexRatingKey} for "${audiobook.title}"`);
}
}
} catch (error) {
logger.error(`Failed to delete Plex item for "${audiobook.title}"`, {
error: error instanceof Error ? error.message : String(error),
});
}
}
// Delete plex_library records by ASIN
if (audiobook.audibleAsin) {
try {
const result = await prisma.plexLibrary.deleteMany({
where: {
OR: [
{ asin: audiobook.audibleAsin },
{ plexGuid: { contains: audiobook.audibleAsin } },
],
},
});
if (result.count > 0) {
logger.info(`Deleted ${result.count} plex_library record(s) by ASIN "${audiobook.audibleAsin}"`);
}
} catch (error) {
logger.error(`Failed to delete plex_library records for ASIN "${audiobook.audibleAsin}"`, {
error: error instanceof Error ? error.message : String(error),
});
}
}
}
/**
* Custom error class for reported issues
*/
export class ReportedIssueError extends Error {
constructor(
message: string,
public statusCode: number
) {
super(message);
this.name = 'ReportedIssueError';
}
}
+267
View File
@@ -0,0 +1,267 @@
/**
* Component: Request Creator Service
* Documentation: documentation/backend/services/requests.md
*
* Shared request-creation logic used by both the API route and Goodreads sync.
* Encapsulates: duplicate detection, library check, Audnexus enrichment,
* audiobook record creation, approval flow, notification queuing, and search job triggering.
*/
import { prisma } from '@/lib/db';
import { getJobQueueService } from '@/lib/services/job-queue.service';
import { findPlexMatch } from '@/lib/utils/audiobook-matcher';
import { getAudibleService } from '@/lib/integrations/audible.service';
import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('RequestCreator');
export interface CreateRequestInput {
asin: string;
title: string;
author: string;
narrator?: string;
description?: string;
coverArtUrl?: string;
}
export interface CreateRequestOptions {
skipAutoSearch?: boolean;
}
export type CreateRequestResult =
| { success: true; request: any }
| { success: false; reason: 'already_available' | 'being_processed' | 'duplicate' | 'user_not_found'; message: string };
/**
* Create a request for a user, with full duplicate detection, library checks,
* Audnexus enrichment, approval flow, notifications, and search job triggering.
*/
export async function createRequestForUser(
userId: string,
audiobook: CreateRequestInput,
options: CreateRequestOptions = {}
): Promise<CreateRequestResult> {
const { skipAutoSearch = false } = options;
// Check for existing active request (downloaded/available) for this ASIN
const existingActiveRequest = await prisma.request.findFirst({
where: {
audiobook: { audibleAsin: audiobook.asin },
type: 'audiobook',
status: { in: ['downloaded', 'available'] },
deletedAt: null,
},
});
if (existingActiveRequest) {
const status = existingActiveRequest.status;
return {
success: false,
reason: status === 'available' ? 'already_available' : 'being_processed',
message: status === 'available'
? 'This audiobook is already available in your library'
: 'This audiobook is being processed and will be available soon',
};
}
// Check if audiobook is already in Plex/ABS library
const plexMatch = await findPlexMatch({
asin: audiobook.asin,
title: audiobook.title,
author: audiobook.author,
narrator: audiobook.narrator,
});
if (plexMatch) {
return {
success: false,
reason: 'already_available',
message: 'This audiobook is already available in your library',
};
}
// Fetch full details from Audnexus for year/series
let year: number | undefined;
let series: string | undefined;
let seriesPart: string | undefined;
try {
const audibleService = getAudibleService();
const audnexusData = await audibleService.getAudiobookDetails(audiobook.asin);
if (audnexusData?.releaseDate) {
try {
const releaseYear = new Date(audnexusData.releaseDate).getFullYear();
if (!isNaN(releaseYear)) {
year = releaseYear;
}
} catch {
// Ignore parse errors
}
}
if (audnexusData?.series) series = audnexusData.series;
if (audnexusData?.seriesPart) seriesPart = audnexusData.seriesPart;
} catch (error) {
logger.warn(`Failed to fetch Audnexus data for ASIN ${audiobook.asin}: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
// Find or create audiobook record
let audiobookRecord = await prisma.audiobook.findFirst({
where: { audibleAsin: audiobook.asin },
});
if (!audiobookRecord) {
audiobookRecord = await prisma.audiobook.create({
data: {
audibleAsin: audiobook.asin,
title: audiobook.title,
author: audiobook.author,
narrator: audiobook.narrator,
description: audiobook.description,
coverArtUrl: audiobook.coverArtUrl,
year,
series,
seriesPart,
status: 'requested',
},
});
logger.debug(`Created audiobook ${audiobookRecord.id} for ASIN ${audiobook.asin}`);
} else {
// Update existing record with clean metadata (e.g. Audnexus title replacing Goodreads title)
const updates: Record<string, any> = {};
if (audiobook.title && audiobook.title !== audiobookRecord.title) updates.title = audiobook.title;
if (audiobook.author && audiobook.author !== audiobookRecord.author) updates.author = audiobook.author;
if (audiobook.coverArtUrl && !audiobookRecord.coverArtUrl) updates.coverArtUrl = audiobook.coverArtUrl;
if (year) updates.year = year;
if (series) updates.series = series;
if (seriesPart) updates.seriesPart = seriesPart;
if (Object.keys(updates).length > 0) {
audiobookRecord = await prisma.audiobook.update({
where: { id: audiobookRecord.id },
data: updates,
});
}
}
// Check if user already has an active request for this audiobook
const existingRequest = await prisma.request.findFirst({
where: {
userId,
audiobookId: audiobookRecord.id,
type: 'audiobook',
deletedAt: null,
},
});
if (existingRequest) {
const canReRequest = ['failed', 'warn', 'cancelled'].includes(existingRequest.status);
if (!canReRequest) {
return {
success: false,
reason: 'duplicate',
message: 'You have already requested this audiobook',
};
}
// Delete existing failed/warn/cancelled request
logger.debug(`Deleting existing ${existingRequest.status} request ${existingRequest.id} to allow re-request`);
await prisma.request.delete({ where: { id: existingRequest.id } });
}
// Check ANY user's active request for same audiobook (avoid duplicate processing)
const anyActiveRequest = await prisma.request.findFirst({
where: {
audiobookId: audiobookRecord.id,
type: 'audiobook',
status: { notIn: ['failed', 'warn', 'cancelled', 'available', 'downloaded'] },
deletedAt: null,
},
});
if (anyActiveRequest && anyActiveRequest.userId !== userId) {
return {
success: false,
reason: 'being_processed',
message: 'This audiobook is already being requested by another user',
};
}
// Determine if approval is needed
let needsApproval = false;
let shouldTriggerSearch = !skipAutoSearch;
const user = await prisma.user.findUnique({
where: { id: userId },
select: { role: true, autoApproveRequests: true, plexUsername: true },
});
if (!user) {
return { success: false, reason: 'user_not_found', message: 'User not found' };
}
if (user.role === 'admin') {
needsApproval = false;
} else {
if (user.autoApproveRequests === true) {
needsApproval = false;
} else if (user.autoApproveRequests === false) {
needsApproval = true;
} else {
const globalConfig = await prisma.configuration.findUnique({
where: { key: 'auto_approve_requests' },
});
const globalAutoApprove = globalConfig === null ? true : globalConfig.value === 'true';
needsApproval = !globalAutoApprove;
}
}
let initialStatus: string;
if (needsApproval) {
initialStatus = 'awaiting_approval';
shouldTriggerSearch = false;
} else if (skipAutoSearch) {
initialStatus = 'awaiting_search';
} else {
initialStatus = 'pending';
}
// Create request
const newRequest = await prisma.request.create({
data: {
userId,
audiobookId: audiobookRecord.id,
status: initialStatus,
type: 'audiobook',
progress: 0,
},
include: {
audiobook: true,
user: { select: { id: true, plexUsername: true } },
},
});
const jobQueue = getJobQueueService();
// Send notification
const notificationType = initialStatus === 'awaiting_approval' ? 'request_pending_approval' : 'request_approved';
await jobQueue.addNotificationJob(
notificationType,
newRequest.id,
audiobookRecord.title,
audiobookRecord.author,
user.plexUsername || 'Unknown User'
).catch((error) => {
logger.error('Failed to queue notification', { error: error instanceof Error ? error.message : String(error) });
});
// Trigger search job
if (shouldTriggerSearch) {
await jobQueue.addSearchJob(newRequest.id, {
id: audiobookRecord.id,
title: audiobookRecord.title,
author: audiobookRecord.author,
asin: audiobookRecord.audibleAsin || undefined,
});
}
return { success: true, request: newRequest };
}
+22 -1
View File
@@ -4,12 +4,13 @@
*/
import { getJobQueueService, ScanPlexPayload } from './job-queue.service';
import { 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
+8
View File
@@ -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;
+100
View File
@@ -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({
+3 -3
View File
@@ -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 () => {
+2
View File
@@ -45,6 +45,8 @@ export const createPrismaMock = () => ({
bookDateConfig: createModelMock(),
bookDateRecommendation: createModelMock(),
bookDateSwipe: createModelMock(),
goodreadsShelf: createModelMock(),
goodreadsBookMapping: createModelMock(),
$queryRaw: vi.fn(),
$disconnect: vi.fn(),
});
+8 -8
View File
@@ -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);
});
+41 -4
View File
@@ -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',
+7 -8
View File
@@ -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 () => {
+112
View File
@@ -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',
+6
View File
@@ -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 () => {
+89 -4
View File
@@ -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');
+8 -7
View File
@@ -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 () => {
+11 -1
View File
@@ -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',
+147
View File
@@ -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);
});
});