diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..e5cc735 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,23 @@ +{ + "workbench.colorCustomizations": { + "activityBar.activeBackground": "#21b789", + "activityBar.background": "#21b789", + "activityBar.foreground": "#15202b", + "activityBar.inactiveForeground": "#15202b99", + "activityBarBadge.background": "#f3e4fa", + "activityBarBadge.foreground": "#15202b", + "commandCenter.border": "#e7e7e799", + "sash.hoverBorder": "#21b789", + "statusBar.background": "#198c69", + "statusBar.foreground": "#e7e7e7", + "statusBarItem.hoverBackground": "#21b789", + "statusBarItem.remoteBackground": "#198c69", + "statusBarItem.remoteForeground": "#e7e7e7", + "tab.activeBorder": "#21b789", + "titleBar.activeBackground": "#198c69", + "titleBar.activeForeground": "#e7e7e7", + "titleBar.inactiveBackground": "#198c6999", + "titleBar.inactiveForeground": "#e7e7e799" + }, + "peacock.color": "#198c69" +} diff --git a/FEATURE.md b/FEATURE.md new file mode 100644 index 0000000..f38ea3c --- /dev/null +++ b/FEATURE.md @@ -0,0 +1 @@ +Add gemini API integration for BookDate diff --git a/package-lock.json b/package-lock.json index f0a3dbe..cc16af1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "readmeabook", - "version": "1.0.14", + "version": "1.0.15", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "readmeabook", - "version": "1.0.14", + "version": "1.0.15", "dependencies": { "@heroicons/react": "^2.2.0", "@prisma/client": "^6.19.0", @@ -299,7 +299,7 @@ "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -309,7 +309,7 @@ "version": "7.28.5", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -343,7 +343,7 @@ "version": "7.28.5", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/types": "^7.28.5" @@ -403,7 +403,7 @@ "version": "7.28.5", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.27.1", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index a0d5a26..40ac5b6 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -66,6 +66,7 @@ model User { bookDateRecommendations BookDateRecommendation[] bookDateSwipes BookDateSwipe[] goodreadsShelves GoodreadsShelf[] + hardcoverShelves HardcoverShelf[] reportedIssues ReportedIssue[] @relation("Reporter") resolvedIssues ReportedIssue[] @relation("Resolver") @@ -530,3 +531,45 @@ model GoodreadsBookMapping { @@index([audibleAsin]) @@map("goodreads_book_mappings") } + +// ============================================================================ +// HARDCOVER SYNC TABLES +// Per-user Hardcover list subscriptions + global book-to-ASIN mapping cache +// ============================================================================ + +model HardcoverShelf { + id String @id @default(uuid()) + userId String @map("user_id") + name String // Extracted from Hardcover API list name or status + listId String @map("list_id") // Hardcover List ID or Status ID + apiToken String @map("api_token") @db.Text // User's personal access token for hardcover api + 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, listId]) + @@index([userId]) + @@map("hardcover_shelves") +} + +model HardcoverBookMapping { + id String @id @default(uuid()) + hardcoverBookId String @unique @map("hardcover_book_id") // Internal ID from Hardcover + 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([hardcoverBookId]) + @@index([audibleAsin]) + @@map("hardcover_book_mappings") +} diff --git a/src/app/api/user/hardcover-shelves/[id]/route.ts b/src/app/api/user/hardcover-shelves/[id]/route.ts new file mode 100644 index 0000000..479b146 --- /dev/null +++ b/src/app/api/user/hardcover-shelves/[id]/route.ts @@ -0,0 +1,55 @@ +/** + * Component: Hardcover Shelf Delete Route + * Documentation: documentation/backend/services/hardcover-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.HardcoverShelves'); + +/** + * DELETE /api/user/hardcover-shelves/[id] + * Remove a Hardcover 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.hardcoverShelf.findUnique({ + where: { id }, + }); + + if (!shelf) { + return NextResponse.json({ error: 'List not found' }, { status: 404 }); + } + + // Ownership check + if (shelf.userId !== req.user.id) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + await prisma.hardcoverShelf.delete({ where: { id } }); + + return NextResponse.json({ success: true }); + } catch (error) { + logger.error('Failed to delete list', { + error: error instanceof Error ? error.message : String(error), + }); + return NextResponse.json( + { error: 'Failed to delete list' }, + { status: 500 }, + ); + } + }); +} diff --git a/src/app/api/user/hardcover-shelves/route.ts b/src/app/api/user/hardcover-shelves/route.ts new file mode 100644 index 0000000..c1489b3 --- /dev/null +++ b/src/app/api/user/hardcover-shelves/route.ts @@ -0,0 +1,206 @@ +/** + * Component: Hardcover Shelves API Routes + * Documentation: documentation/backend/services/hardcover-sync.md + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth'; +import { prisma } from '@/lib/db'; +import { fetchHardcoverList } from '@/lib/services/hardcover-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.HardcoverShelves'); + +const AddShelfSchema = z.object({ + listId: z.string().min(1, { message: 'List ID is required' }), + apiToken: z.string().min(1, { message: 'API Token is required' }), +}); + +/** + * GET /api/user/hardcover-shelves + * List the current user's Hardcover lists 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.hardcoverShelf.findMany({ + where: { userId: req.user.id }, + orderBy: { createdAt: 'desc' }, + }); + + const shelvesWithMeta = shelves.map((shelf) => { + 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; + 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, + listId: shelf.listId, + 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 Hardcover lists', { + error: error instanceof Error ? error.message : String(error), + }); + return NextResponse.json( + { error: 'Failed to list Hardcover lists' }, + { status: 500 }, + ); + } + }); +} + +/** + * POST /api/user/hardcover-shelves + * Add a new Hardcover list 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 { listId, apiToken } = AddShelfSchema.parse(body); + + // Check for duplicate + const existing = await prisma.hardcoverShelf.findUnique({ + where: { userId_listId: { userId: req.user.id, listId } }, + }); + + if (existing) { + return NextResponse.json( + { + error: 'DuplicateShelf', + message: 'You have already added this list', + }, + { status: 409 }, + ); + } + + // Validate by fetching the Hardcover GraphQL feed + let listName: string; + let bookCount: number; + let initialBooks: { + coverUrl: string; + asin: null; + title: string; + author: string; + }[] = []; + try { + const fetchedData = await fetchHardcoverList(apiToken, listId); + listName = fetchedData.listName; + bookCount = fetchedData.books.length; + initialBooks = fetchedData.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: 'InvalidHardcoverList', + message: `Could not fetch the Hardcover list. Check your Token and List ID: ${error instanceof Error ? error.message : 'Unknown error'}`, + }, + { status: 400 }, + ); + } + + const shelf = await prisma.hardcoverShelf.create({ + data: { + userId: req.user.id, + name: listName, + listId, + apiToken, + 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.addSyncHardcoverShelvesJob(undefined, shelf.id, 0); + logger.info( + `Triggered immediate sync for Hardcover list "${listName}" (${shelf.id})`, + ); + } catch (error) { + logger.error('Failed to trigger immediate list sync', { + error: error instanceof Error ? error.message : String(error), + }); + } + + return NextResponse.json( + { + success: true, + shelf: { + id: shelf.id, + name: shelf.name, + listId: shelf.listId, + lastSyncAt: shelf.lastSyncAt, + createdAt: shelf.createdAt, + bookCount: shelf.bookCount, + books: initialBooks, + }, + bookCount, + }, + { status: 201 }, + ); + } catch (error) { + logger.error('Failed to add Hardcover list', { + 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 Hardcover list' }, + { status: 500 }, + ); + } + }); +} diff --git a/src/app/profile/page.tsx b/src/app/profile/page.tsx index 1d88640..19ad798 100644 --- a/src/app/profile/page.tsx +++ b/src/app/profile/page.tsx @@ -12,6 +12,7 @@ import { useAuth } from '@/contexts/AuthContext'; import { useRequests } from '@/lib/hooks/useRequests'; import { cn } from '@/lib/utils/cn'; import { GoodreadsShelvesSection } from '@/components/profile/GoodreadsShelvesSection'; +import { HardcoverShelvesSection } from '@/components/profile/HardcoverShelvesSection'; const statConfig = [ { key: 'total', label: 'Total', color: 'text-gray-900 dark:text-white' }, @@ -19,7 +20,11 @@ const statConfig = [ { 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' }, + { + key: 'cancelled', + label: 'Cancelled', + color: 'text-gray-400 dark:text-gray-500', + }, ] as const; type StatKey = (typeof statConfig)[number]['key']; @@ -30,25 +35,45 @@ export default function ProfilePage() { 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, + 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, failed: requests.filter((r: any) => r.status === 'failed').length, cancelled: requests.filter((r: any) => r.status === 'cancelled').length, }; }, [requests]); const activeDownloads = useMemo(() => { - return requests.filter((r: any) => ['downloading', 'processing'].includes(r.status)); + return requests.filter((r: any) => + ['downloading', 'processing'].includes(r.status), + ); }, [requests]); const recentRequests = useMemo(() => { return [...requests] - .sort((a: any, b: any) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) + .sort( + (a: any, b: any) => + new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), + ) .slice(0, 5); }, [requests]); @@ -58,8 +83,18 @@ export default function ProfilePage() {
- - + +

@@ -113,7 +148,7 @@ export default function ProfilePage() { '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' + : 'bg-gray-100 text-gray-500 dark:bg-gray-700/50 dark:text-gray-400', )} > {user.role === 'admin' ? 'Administrator' : 'User'} @@ -128,7 +163,12 @@ export default function ProfilePage() { key={stat.key} className="py-5 sm:py-6 px-3 text-center bg-white dark:bg-gray-800" > -
+
{isLoading ? '\u2013' : stats[stat.key as StatKey]}
@@ -142,6 +182,9 @@ export default function ProfilePage() { {/* Goodreads Shelves */} + {/* Hardcover Lists */} + + {/* Active Downloads */} {activeDownloads.length > 0 && (
@@ -158,7 +201,11 @@ export default function ProfilePage() {
{activeDownloads.map((request: any) => ( - + ))}
@@ -201,7 +248,11 @@ export default function ProfilePage() { ) : recentRequests.length > 0 ? (
{recentRequests.map((request: any) => ( - + ))}
) : ( @@ -213,7 +264,11 @@ export default function ProfilePage() { viewBox="0 0 24 24" strokeWidth={1.5} > - +

No requests yet @@ -225,8 +280,18 @@ export default function ProfilePage() { 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" > - - + + Search Audiobooks diff --git a/src/components/profile/HardcoverShelvesSection.tsx b/src/components/profile/HardcoverShelvesSection.tsx new file mode 100644 index 0000000..67abc15 --- /dev/null +++ b/src/components/profile/HardcoverShelvesSection.tsx @@ -0,0 +1,434 @@ +/** + * Component: Hardcover Shelves Section (Profile Page) + * Documentation: documentation/frontend/components.md + */ + +'use client'; + +import React, { useState } from 'react'; +import { + useHardcoverShelves, + useDeleteHardcoverShelf, + HardcoverShelf, + ShelfBook, +} from '@/lib/hooks/useHardcoverShelves'; +import { AddHardcoverShelfModal } from '@/components/ui/AddHardcoverShelfModal'; +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 HardcoverShelvesSection() { + const { shelves, isLoading } = useHardcoverShelves(); + const { deleteShelf, isLoading: isDeleting } = useDeleteHardcoverShelf(); + const { squareCovers } = usePreferences(); + const [confirmDeleteId, setConfirmDeleteId] = useState(null); + const [showAddModal, setShowAddModal] = useState(false); + const [selectedAsin, setSelectedAsin] = useState(null); + + const handleDelete = async (shelfId: string) => { + try { + await deleteShelf(shelfId); + setConfirmDeleteId(null); + } catch { + // Error handled by hook + } + }; + + return ( +
+ {/* Section Header */} +

+
+
+ + + +
+
+

+ Hardcover Lists +

+ {!isLoading && shelves.length > 0 && ( +

+ {shelves.length} {shelves.length === 1 ? 'list' : 'lists'}{' '} + connected +

+ )} +
+
+ + +
+ + {/* Content */} + {isLoading ? ( + + ) : shelves.length > 0 ? ( +
+ {shelves.map((shelf) => ( + handleDelete(shelf.id)} + onConfirmDelete={() => setConfirmDeleteId(shelf.id)} + onCancelDelete={() => setConfirmDeleteId(null)} + onBookClick={(asin) => setSelectedAsin(asin)} + /> + ))} +
+ ) : ( + setShowAddModal(true)} /> + )} + + setShowAddModal(false)} + /> + + {/* Audiobook Detail Modal (read-only) */} + {selectedAsin && ( + setSelectedAsin(null)} + hideRequestActions + /> + )} + + ); +} + +/* ─── Empty State ─── */ + +function EmptyState({ onAdd }: { onAdd: () => void }) { + return ( +
+
+ + + +
+ +

+ Connect your reading list +

+

+ Link a Hardcover list and we'll automatically request the audiobook for + every book you add. +

+ + +
+ ); +} + +/* ─── Loading Skeleton ─── */ + +function ShelfCardSkeleton({ squareCovers }: { squareCovers: boolean }) { + return ( +
+
+
+
+
+
+
+
+
+ {[...Array(5)].map((_, i) => ( +
0 ? '-16px' : 0, zIndex: 5 - i }} + /> + ))} +
+
+ ); +} + +/* ─── Shelf Card ─── */ + +interface ShelfCardProps { + shelf: HardcoverShelf; + 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 ( +
+ {/* Top: Shelf info + actions */} +
+
+

+ {shelf.name} +

+
+ {shelf.bookCount != null && ( + + {shelf.bookCount} {shelf.bookCount === 1 ? 'book' : 'books'} + + )} + + {isSyncing ? ( + <> + + + + + Syncing… + + ) : shelf.lastSyncAt ? ( + <> + + Synced {formatRelativeTime(shelf.lastSyncAt)} + + ) : ( + 'Pending sync' + )} + +
+
+ + {/* Delete action */} +
+ {isConfirmingDelete ? ( +
+ + +
+ ) : ( + + )} +
+
+ + {/* Bottom: Stacked book covers */} + {hasCovers ? ( + + ) : isSyncing ? ( +
+ {[...Array(3)].map((_, i) => ( +
0 ? '-16px' : 0, zIndex: 3 - i }} + /> + ))} +
+ ) : null} +
+ ); +} + +/* ─── Stacked Cover Display ─── */ + +function CoverStack({ + books, + remainingCount, + squareCovers, + onBookClick, +}: { + books: ShelfBook[]; + remainingCount: number; + squareCovers: boolean; + onBookClick: (asin: string) => void; +}) { + const [hoveredIndex, setHoveredIndex] = useState(null); + const coverSize = squareCovers + ? 'w-[80px] aspect-square' + : 'w-[72px] aspect-[2/3]'; + + return ( +
+ {books.map((book, 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 + } + > + +
+ ))} + {remainingCount > 0 && ( +
+ + +{remainingCount} + +
+ )} +
+ ); +} diff --git a/src/components/ui/AddHardcoverShelfModal.tsx b/src/components/ui/AddHardcoverShelfModal.tsx new file mode 100644 index 0000000..6ac45f7 --- /dev/null +++ b/src/components/ui/AddHardcoverShelfModal.tsx @@ -0,0 +1,206 @@ +/** + * Component: Add Hardcover 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 { useAddHardcoverShelf } from '@/lib/hooks/useHardcoverShelves'; + +interface AddHardcoverShelfModalProps { + isOpen: boolean; + onClose: () => void; +} + +export function AddHardcoverShelfModal({ + isOpen, + onClose, +}: AddHardcoverShelfModalProps) { + const [apiToken, setApiToken] = useState(''); + const [listId, setListId] = useState(''); + const [validationError, setValidationError] = useState(''); + const [success, setSuccess] = useState(false); + const [successMessage, setSuccessMessage] = useState(''); + const { addShelf, isLoading, error } = useAddHardcoverShelf(); + + const validateInput = (): boolean => { + if (!apiToken.trim()) { + setValidationError('Hardcover API Token is required'); + return false; + } + if (!listId.trim()) { + setValidationError('Hardcover List ID or Status ID is required'); + return false; + } + setValidationError(''); + return true; + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!validateInput()) return; + + try { + const shelf = await addShelf(apiToken.trim(), listId.trim()); + setSuccess(true); + setSuccessMessage(`Added list "${shelf.name}" successfully!`); + setApiToken(''); + setListId(''); + + setTimeout(() => { + setSuccess(false); + onClose(); + }, 2000); + } catch { + // Error is handled by the hook + } + }; + + const handleClose = () => { + setApiToken(''); + setListId(''); + setValidationError(''); + setSuccess(false); + setSuccessMessage(''); + onClose(); + }; + + return ( + +
+ {/* Visual header */} +
+
+ + + +
+
+

+ Provides your Hardcover API token and the ID of the list you want + to sync. +

+
+
+ + {/* Success alert */} + {success && ( +
+
+ + + +
+

+ {successMessage} +

+
+ )} + + {/* Error alert */} + {error && ( +
+
+ + + +
+

+ {error} +

+
+ )} + + {/* Form */} +
+
+ { + setApiToken(e.target.value); + if (validationError) setValidationError(''); + }} + placeholder="eyJhb..." + disabled={isLoading || success} + /> + { + setListId(e.target.value); + if (validationError) setValidationError(''); + }} + placeholder="1234 or uuid" + error={validationError} + disabled={isLoading || success} + /> +
+ +
+ + +
+
+
+
+ ); +} diff --git a/src/lib/hooks/useHardcoverShelves.ts b/src/lib/hooks/useHardcoverShelves.ts new file mode 100644 index 0000000..6dd24a6 --- /dev/null +++ b/src/lib/hooks/useHardcoverShelves.ts @@ -0,0 +1,135 @@ +/** + * Component: Hardcover 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 HardcoverShelf { + id: string; + name: string; + listId: string; + lastSyncAt: string | null; + createdAt: string; + bookCount: number | null; + books: ShelfBook[]; +} + +const fetcher = (url: string) => fetchWithAuth(url).then((res) => res.json()); + +export function useHardcoverShelves() { + const { accessToken } = useAuth(); + + const endpoint = accessToken ? '/api/user/hardcover-shelves' : null; + + const { data, error, isLoading } = useSWR(endpoint, fetcher, { + refreshInterval: 30000, + }); + + return { + shelves: (data?.shelves || []) as HardcoverShelf[], + isLoading, + error, + }; +} + +export function useAddHardcoverShelf() { + const { accessToken } = useAuth(); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const addShelf = async (apiToken: string, listId: string) => { + if (!accessToken) throw new Error('Not authenticated'); + + setIsLoading(true); + setError(null); + + try { + const response = await fetchWithAuth('/api/user/hardcover-shelves', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ apiToken, listId }), + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.message || data.error || 'Failed to add list'); + } + + // Revalidate shelves list + mutate( + (key) => + typeof key === 'string' && + key.includes('/api/user/hardcover-shelves'), + ); + + return data.shelf as HardcoverShelf; + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error'; + setError(message); + throw err; + } finally { + setIsLoading(false); + } + }; + + return { addShelf, isLoading, error }; +} + +export function useDeleteHardcoverShelf() { + const { accessToken } = useAuth(); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const deleteShelf = async (shelfId: string) => { + if (!accessToken) throw new Error('Not authenticated'); + + setIsLoading(true); + setError(null); + + try { + const response = await fetchWithAuth( + `/api/user/hardcover-shelves/${shelfId}`, + { + method: 'DELETE', + }, + ); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.message || data.error || 'Failed to remove list'); + } + + // Revalidate shelves list + mutate( + (key) => + typeof key === 'string' && + key.includes('/api/user/hardcover-shelves'), + ); + + return true; + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error'; + setError(message); + throw err; + } finally { + setIsLoading(false); + } + }; + + return { deleteShelf, isLoading, error }; +} diff --git a/src/lib/processors/sync-hardcover-shelves.processor.ts b/src/lib/processors/sync-hardcover-shelves.processor.ts new file mode 100644 index 0000000..e9746c1 --- /dev/null +++ b/src/lib/processors/sync-hardcover-shelves.processor.ts @@ -0,0 +1,46 @@ +/** + * Component: Sync Hardcover Shelves Processor + * Documentation: documentation/backend/services/scheduler.md + * + * Dedicated processor for syncing Hardcover lists. + * Resolves books to Audible ASINs and creates requests. + */ + +import { RMABLogger } from '../utils/logger'; + +export interface SyncHardcoverShelvesPayload { + jobId?: string; + scheduledJobId?: string; + /** If set, only process this specific list (used for immediate sync on add) */ + shelfId?: string; + /** Max Audible lookups per list. 0 = unlimited. */ + maxLookupsPerShelf?: number; +} + +export async function processSyncHardcoverShelves( + payload: SyncHardcoverShelvesPayload, +): Promise { + const { jobId, shelfId, maxLookupsPerShelf } = payload; + const logger = RMABLogger.forJob(jobId, 'SyncHardcoverShelves'); + + logger.info( + shelfId + ? `Starting immediate Hardcover sync for list ${shelfId}...` + : 'Starting scheduled Hardcover lists sync...', + ); + + const { processHardcoverShelves } = + await import('../services/hardcover-sync.service'); + const stats = await processHardcoverShelves(logger, { + shelfId, + maxLookupsPerShelf: maxLookupsPerShelf ?? (shelfId ? 0 : undefined), + }); + + logger.info('Hardcover sync complete', { stats }); + + return { + success: true, + message: shelfId ? 'Hardcover list synced' : 'Hardcover lists synced', + ...stats, + }; +} diff --git a/src/lib/services/hardcover-sync.service.ts b/src/lib/services/hardcover-sync.service.ts new file mode 100644 index 0000000..2149c40 --- /dev/null +++ b/src/lib/services/hardcover-sync.service.ts @@ -0,0 +1,479 @@ +/** + * Component: Hardcover Shelf Sync Service + * Documentation: documentation/backend/services/hardcover-sync.md + * + * Fetches Hardcover books using their GraphQL API, resolves books to Audible ASINs, + * and creates requests via the shared request-creator service. + */ + +import axios from 'axios'; +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('HardcoverSync'); + +/** 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; + +const HARDCOVER_API_URL = 'https://api.hardcover.app/v1/graphql'; + +interface HardcoverApiBook { + bookId: string; + title: string; + author: string; + coverUrl?: string; +} + +/** + * Fetch a Hardcover List using their GraphQL API. + * This handles both 'status_id' user_books or 'list_id' list_books queries. + * For simplicity, we assume `listId` provided by the user is an Int corresponding to a list_id or status_id. + */ +export async function fetchHardcoverList( + apiToken: string, + listIdStr: string, +): Promise<{ listName: string; books: HardcoverApiBook[] }> { + // If we can parse as integer, it could be a List ID or Status ID. If UUID, we adjust query + const isUuid = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( + listIdStr, + ); + + // Example generic query to Hardcover. Adjust the table/format as needed for their schema. + // Hardcover lists use custom lists (list_books) or statuses (user_books). + // Assuming list_books for this implementation. + const query = ` + query GetListBooks($listId: Int!) { + list_books(where: {list_id: {_eq: $listId}}) { + list { + name + } + book { + id + title + author_books { + author { + name + } + } + cached_image + image { + url + } + } + } + } + `; + + // Provide fallback UUID query if Hardcover uses UUIDs instead. + const queryUuid = ` + query GetListBooksUuid($listId: uuid!) { + list_books(where: {list_id: {_eq: $listId}}) { + list { + name + } + book { + id + title + author_books { + author { + name + } + } + cached_image + image { + url + } + } + } + } + `; + + const response = await axios.post( + HARDCOVER_API_URL, + { + query: isUuid ? queryUuid : query, + variables: { + listId: isUuid ? listIdStr : parseInt(listIdStr, 10), + }, + }, + { + headers: { + Authorization: `Bearer ${apiToken}`, + 'Content-Type': 'application/json', + }, + timeout: 15000, + }, + ); + + if (response.data?.errors) { + throw new Error(`Hardcover API Error: ${response.data.errors[0]?.message}`); + } + + const listBooks = response.data?.data?.list_books || []; + let listName = 'Hardcover List'; + if (listBooks.length > 0 && listBooks[0].list?.name) { + listName = listBooks[0].list.name; + } + + const books: HardcoverApiBook[] = []; + for (const item of listBooks) { + const book = item.book; + if (!book || !book.id) continue; + + // Hardcover authors can be multiple, we pick the first one or join them + const authorName = book.author_books?.[0]?.author?.name || 'Unknown Author'; + const coverUrl = book.cached_image || book.image?.url || undefined; + + books.push({ + bookId: book.id.toString(), + title: book.title || 'Unknown Title', + author: authorName, + coverUrl, + }); + } + + return { listName, books }; +} + +export interface HardcoverSyncStats { + shelvesProcessed: number; + booksFound: number; + lookupsPerformed: number; + requestsCreated: number; + errors: number; +} + +export interface HardcoverSyncOptions { + shelfId?: string; + maxLookupsPerShelf?: number; +} + +export async function processHardcoverShelves( + jobLogger?: ReturnType, + options: HardcoverSyncOptions = {}, +): Promise { + const log = jobLogger || logger; + const stats: HardcoverSyncStats = { + 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.hardcoverShelf.findMany({ + where: whereClause, + include: { user: { select: { id: true, plexUsername: true } } }, + }); + + if (shelves.length === 0) { + log.info( + options.shelfId + ? 'Hardcover list not found' + : 'No Hardcover lists configured, skipping', + ); + return stats; + } + + log.info( + `Processing ${shelves.length} Hardcover list(s)${maxLookups > 0 ? ` (max ${maxLookups} lookups/list)` : ' (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 list "${shelf.name}" for user ${shelf.user.plexUsername}: ${error instanceof Error ? error.message : 'Unknown error'}`, + ); + } + } + + log.info( + `Hardcover sync complete: ${stats.shelvesProcessed} lists, ${stats.booksFound} books, ${stats.lookupsPerformed} lookups, ${stats.requestsCreated} requests created, ${stats.errors} errors`, + ); + return stats; +} + +async function processShelf( + shelf: { + id: string; + listId: string; + apiToken: string; + name: string; + user: { id: string; plexUsername: string }; + }, + stats: HardcoverSyncStats, + log: + | ReturnType + | ReturnType, + maxLookups: number, +) { + log.info( + `Fetching Hardcover List "${shelf.name}" (user: ${shelf.user.plexUsername})`, + ); + + let fetchedData: { listName: string; books: HardcoverApiBook[] }; + try { + fetchedData = await fetchHardcoverList(shelf.apiToken, shelf.listId); + } catch (error) { + log.error( + `Failed to fetch Hardcover list "${shelf.name}": ${error instanceof Error ? error.message : 'Unknown error'}`, + ); + return; + } + + const books = fetchedData.books; + stats.booksFound += books.length; + log.info( + `Found ${books.length} books in list "${shelf.name}" (Hardcover API)`, + ); + + let lookupsThisCycle = 0; + const unlimitedLookups = maxLookups === 0; + + for (const book of books) { + let mapping = await prisma.hardcoverBookMapping.findUnique({ + where: { hardcoverBookId: book.bookId }, + }); + + if (!mapping) { + if (!unlimitedLookups && lookupsThisCycle >= maxLookups) continue; + + mapping = await performAudibleLookup(book, log); + lookupsThisCycle++; + stats.lookupsPerformed++; + + if (!mapping?.audibleAsin) continue; + } + + 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 (!mapping?.audibleAsin) continue; + } else { + continue; + } + } else { + continue; + } + } + + 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})`, + ); + } + } catch (error) { + log.error( + `Failed to create request for "${mapping.title}": ${error instanceof Error ? error.message : 'Unknown error'}`, + ); + } + } + } + + // Collect enriched book data for display + const bookIds = books.map((b) => b.bookId); + const mappings = + bookIds.length > 0 + ? await prisma.hardcoverBookMapping.findMany({ + where: { hardcoverBookId: { in: bookIds } }, + select: { + hardcoverBookId: true, + audibleAsin: true, + title: true, + author: true, + coverUrl: true, + }, + }) + : []; + const mappingsByBookId = new Map(mappings.map((m) => [m.hardcoverBookId, m])); + + 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); + 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 => b !== null) + .slice(0, 8); + + const finalListName = + fetchedData.listName !== 'Hardcover List' + ? fetchedData.listName + : shelf.name; + + await prisma.hardcoverShelf.update({ + where: { id: shelf.id }, + data: { + name: finalListName, + lastSyncAt: new Date(), + bookCount: books.length, + coverUrls: bookData.length > 0 ? JSON.stringify(bookData) : null, + }, + }); +} + +async function performAudibleLookup( + book: HardcoverApiBook, + log: + | ReturnType + | ReturnType, + existingMappingId?: string, +): Promise { + const audibleService = getAudibleService(); + + try { + const fullQuery = `${book.title} ${book.author}`; + log.info(`Searching Audible for: "${fullQuery}"`); + + let searchResult = await audibleService.search(fullQuery); + let firstResult = searchResult.results[0]; + + if (!firstResult?.asin) { + const cleanTitle = book.title.replace(/\s*\(.*\)\s*$/, '').trim(); + if (cleanTitle !== book.title) { + const cleanQuery = `${cleanTitle} ${book.author}`; + log.info( + `No results with full title, retrying without series info: "${cleanQuery}"`, + ); + searchResult = await audibleService.search(cleanQuery); + firstResult = searchResult.results[0]; + } + } + + if (firstResult?.asin) { + log.info( + `Audible match: "${book.title}" → ASIN ${firstResult.asin} ("${firstResult.title}" by ${firstResult.author})`, + ); + + 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.hardcoverBookMapping.update({ + where: { id: existingMappingId }, + data, + }); + } + return prisma.hardcoverBookMapping.create({ + data: { hardcoverBookId: book.bookId, ...data }, + }); + } + + 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.hardcoverBookMapping.update({ + where: { id: existingMappingId }, + data: noMatchData, + }); + } + return prisma.hardcoverBookMapping.create({ + data: { hardcoverBookId: book.bookId, ...noMatchData }, + }); + } catch (error) { + log.error( + `Audible lookup failed for "${book.title}": ${error instanceof Error ? error.message : 'Unknown error'}`, + ); + + const errorData = { + title: book.title, + author: book.author, + coverUrl: book.coverUrl || null, + noMatch: true, + lastSearchAt: new Date(), + }; + + if (existingMappingId) { + return prisma.hardcoverBookMapping.update({ + where: { id: existingMappingId }, + data: errorData, + }); + } + return prisma.hardcoverBookMapping.create({ + data: { hardcoverBookId: book.bookId, ...errorData }, + }); + } +} diff --git a/src/lib/services/job-queue.service.ts b/src/lib/services/job-queue.service.ts index 76e8680..79ee5ab 100644 --- a/src/lib/services/job-queue.service.ts +++ b/src/lib/services/job-queue.service.ts @@ -27,6 +27,7 @@ export type JobType = | 'cleanup_seeded_torrents' | 'monitor_rss_feeds' | 'sync_goodreads_shelves' + | 'sync_hardcover_shelves' | 'send_notification' // Ebook-specific job types | 'search_ebook' @@ -63,8 +64,8 @@ export interface MonitorDownloadPayload extends JobPayload { downloadHistoryId: string; downloadClientId: string; downloadClient: DownloadClientType; - lastProgress?: number; // Previous poll's progress (0-100) for stall detection - stallCount?: number; // Consecutive polls with no progress change (drives backoff) + lastProgress?: number; // Previous poll's progress (0-100) for stall detection + stallCount?: number; // Consecutive polls with no progress change (drives backoff) pathWaitCount?: number; // Consecutive polls waiting for content_path to relocate to save_path } @@ -111,6 +112,12 @@ export interface SyncGoodreadsShelvesPayload extends JobPayload { maxLookupsPerShelf?: number; } +export interface SyncHardcoverShelvesPayload extends JobPayload { + scheduledJobId?: string; + shelfId?: string; + maxLookupsPerShelf?: number; +} + // Ebook-specific payload interfaces export interface SearchEbookPayload extends JobPayload { requestId: string; @@ -226,13 +233,15 @@ export class JobQueueService { 'failed', null, error.message, - error.stack + error.stack, ); // Handle permanent failures for specific job types after all retries exhausted if (job.name === 'monitor_download' && job.data) { const payload = job.data as MonitorDownloadPayload; - logger.error(`MonitorDownload job permanently failed for request ${payload.requestId} after ${job.attemptsMade} attempts`); + logger.error( + `MonitorDownload job permanently failed for request ${payload.requestId} after ${job.attemptsMade} attempts`, + ); // Update request status to failed (only happens after all retries exhausted) try { @@ -240,7 +249,9 @@ export class JobQueueService { where: { id: payload.requestId }, data: { status: 'failed', - errorMessage: error.message || 'Failed to monitor download after multiple retries', + errorMessage: + error.message || + 'Failed to monitor download after multiple retries', updatedAt: new Date(), }, }); @@ -256,7 +267,12 @@ export class JobQueueService { }); } } catch (updateError) { - logger.error('Failed to update request/download status', { error: updateError instanceof Error ? updateError.message : String(updateError) }); + logger.error('Failed to update request/download status', { + error: + updateError instanceof Error + ? updateError.message + : String(updateError), + }); } } }); @@ -280,106 +296,225 @@ export class JobQueueService { */ private startProcessors(): void { // Search indexers processor - this.queue.process('search_indexers', 2, async (job: BullJob) => { - const { processSearchIndexers } = await import('../processors/search-indexers.processor'); - return await processSearchIndexers(job.data); - }); + this.queue.process( + 'search_indexers', + 2, + async (job: BullJob) => { + const { processSearchIndexers } = + await import('../processors/search-indexers.processor'); + return await processSearchIndexers(job.data); + }, + ); // Download torrent processor - this.queue.process('download_torrent', 2, async (job: BullJob) => { - const { processDownloadTorrent } = await import('../processors/download-torrent.processor'); - return await processDownloadTorrent(job.data); - }); + this.queue.process( + 'download_torrent', + 2, + async (job: BullJob) => { + const { processDownloadTorrent } = + await import('../processors/download-torrent.processor'); + return await processDownloadTorrent(job.data); + }, + ); // Monitor download processor - this.queue.process('monitor_download', 2, async (job: BullJob) => { - const { processMonitorDownload } = await import('../processors/monitor-download.processor'); - return await processMonitorDownload(job.data); - }); + this.queue.process( + 'monitor_download', + 2, + async (job: BullJob) => { + const { processMonitorDownload } = + await import('../processors/monitor-download.processor'); + return await processMonitorDownload(job.data); + }, + ); // Organize files processor - this.queue.process('organize_files', 2, async (job: BullJob) => { - const { processOrganizeFiles } = await import('../processors/organize-files.processor'); - return await processOrganizeFiles(job.data); - }); + this.queue.process( + 'organize_files', + 2, + async (job: BullJob) => { + const { processOrganizeFiles } = + await import('../processors/organize-files.processor'); + return await processOrganizeFiles(job.data); + }, + ); // Scan Plex processor - this.queue.process('scan_plex', 1, async (job: BullJob) => { - const { processScanPlex } = await import('../processors/scan-plex.processor'); - return await processScanPlex(job.data); - }); + this.queue.process( + 'scan_plex', + 1, + async (job: BullJob) => { + const { processScanPlex } = + await import('../processors/scan-plex.processor'); + return await processScanPlex(job.data); + }, + ); // Scheduled job processors this.queue.process('plex_library_scan', 1, async (job: BullJob) => { // plex_library_scan is just an alias for scan_plex - const { processScanPlex } = await import('../processors/scan-plex.processor'); - const payloadWithJobId = await this.ensureJobRecord(job, 'plex_library_scan'); + const { processScanPlex } = + await import('../processors/scan-plex.processor'); + const payloadWithJobId = await this.ensureJobRecord( + job, + 'plex_library_scan', + ); return await processScanPlex(payloadWithJobId); }); - this.queue.process('plex_recently_added_check', 1, async (job: BullJob) => { - const { processPlexRecentlyAddedCheck } = await import('../processors/plex-recently-added.processor'); - const payloadWithJobId = await this.ensureJobRecord(job, 'plex_recently_added_check'); - return await processPlexRecentlyAddedCheck(payloadWithJobId); - }); + this.queue.process( + 'plex_recently_added_check', + 1, + async (job: BullJob) => { + const { processPlexRecentlyAddedCheck } = + await import('../processors/plex-recently-added.processor'); + const payloadWithJobId = await this.ensureJobRecord( + job, + 'plex_recently_added_check', + ); + return await processPlexRecentlyAddedCheck(payloadWithJobId); + }, + ); - this.queue.process('monitor_rss_feeds', 1, async (job: BullJob) => { - const { processMonitorRssFeeds } = await import('../processors/monitor-rss-feeds.processor'); - const payloadWithJobId = await this.ensureJobRecord(job, 'monitor_rss_feeds'); - return await processMonitorRssFeeds(payloadWithJobId); - }); + this.queue.process( + 'monitor_rss_feeds', + 1, + async (job: BullJob) => { + const { processMonitorRssFeeds } = + await import('../processors/monitor-rss-feeds.processor'); + const payloadWithJobId = await this.ensureJobRecord( + job, + 'monitor_rss_feeds', + ); + return await processMonitorRssFeeds(payloadWithJobId); + }, + ); - this.queue.process('audible_refresh', 1, async (job: BullJob) => { - const { processAudibleRefresh } = await import('../processors/audible-refresh.processor'); - const payloadWithJobId = await this.ensureJobRecord(job, 'audible_refresh'); - return await processAudibleRefresh(payloadWithJobId); - }); + this.queue.process( + 'audible_refresh', + 1, + async (job: BullJob) => { + const { processAudibleRefresh } = + await import('../processors/audible-refresh.processor'); + const payloadWithJobId = await this.ensureJobRecord( + job, + 'audible_refresh', + ); + return await processAudibleRefresh(payloadWithJobId); + }, + ); - this.queue.process('retry_missing_torrents', 1, async (job: BullJob) => { - const { processRetryMissingTorrents } = await import('../processors/retry-missing-torrents.processor'); - const payloadWithJobId = await this.ensureJobRecord(job, 'retry_missing_torrents'); - return await processRetryMissingTorrents(payloadWithJobId); - }); + this.queue.process( + 'retry_missing_torrents', + 1, + async (job: BullJob) => { + const { processRetryMissingTorrents } = + await import('../processors/retry-missing-torrents.processor'); + const payloadWithJobId = await this.ensureJobRecord( + job, + 'retry_missing_torrents', + ); + return await processRetryMissingTorrents(payloadWithJobId); + }, + ); - this.queue.process('retry_failed_imports', 1, async (job: BullJob) => { - const { processRetryFailedImports } = await import('../processors/retry-failed-imports.processor'); - const payloadWithJobId = await this.ensureJobRecord(job, 'retry_failed_imports'); - return await processRetryFailedImports(payloadWithJobId); - }); + this.queue.process( + 'retry_failed_imports', + 1, + async (job: BullJob) => { + const { processRetryFailedImports } = + await import('../processors/retry-failed-imports.processor'); + const payloadWithJobId = await this.ensureJobRecord( + job, + 'retry_failed_imports', + ); + return await processRetryFailedImports(payloadWithJobId); + }, + ); - this.queue.process('cleanup_seeded_torrents', 1, async (job: BullJob) => { - const { processCleanupSeededTorrents } = await import('../processors/cleanup-seeded-torrents.processor'); - const payloadWithJobId = await this.ensureJobRecord(job, 'cleanup_seeded_torrents'); - return await processCleanupSeededTorrents(payloadWithJobId); - }); + this.queue.process( + 'cleanup_seeded_torrents', + 1, + async (job: BullJob) => { + const { processCleanupSeededTorrents } = + await import('../processors/cleanup-seeded-torrents.processor'); + const payloadWithJobId = await this.ensureJobRecord( + job, + 'cleanup_seeded_torrents', + ); + return await processCleanupSeededTorrents(payloadWithJobId); + }, + ); - this.queue.process('sync_goodreads_shelves', 1, async (job: BullJob) => { - const { processSyncGoodreadsShelves } = await import('../processors/sync-goodreads-shelves.processor'); - const payloadWithJobId = await this.ensureJobRecord(job, 'sync_goodreads_shelves'); - return await processSyncGoodreadsShelves(payloadWithJobId); - }); + this.queue.process( + 'sync_goodreads_shelves', + 1, + async (job: BullJob) => { + const { processSyncGoodreadsShelves } = + await import('../processors/sync-goodreads-shelves.processor'); + const payloadWithJobId = await this.ensureJobRecord( + job, + 'sync_goodreads_shelves', + ); + return await processSyncGoodreadsShelves(payloadWithJobId); + }, + ); + + this.queue.process( + 'sync_hardcover_shelves', + 1, + async (job: BullJob) => { + const { processSyncHardcoverShelves } = + await import('../processors/sync-hardcover-shelves.processor'); + const payloadWithJobId = await this.ensureJobRecord( + job, + 'sync_hardcover_shelves', + ); + return await processSyncHardcoverShelves(payloadWithJobId); + }, + ); // Send notification processor - this.queue.process('send_notification', 2, async (job: BullJob) => { - const { processSendNotification } = await import('../processors/send-notification.processor'); - return await processSendNotification(job.data); - }); + this.queue.process( + 'send_notification', + 2, + async (job: BullJob) => { + const { processSendNotification } = + await import('../processors/send-notification.processor'); + return await processSendNotification(job.data); + }, + ); // Ebook-specific processors - this.queue.process('search_ebook', 2, async (job: BullJob) => { - const { processSearchEbook } = await import('../processors/search-ebook.processor'); - return await processSearchEbook(job.data); - }); + this.queue.process( + 'search_ebook', + 2, + async (job: BullJob) => { + const { processSearchEbook } = + await import('../processors/search-ebook.processor'); + return await processSearchEbook(job.data); + }, + ); - this.queue.process('start_direct_download', 2, async (job: BullJob) => { - const { processStartDirectDownload } = await import('../processors/direct-download.processor'); - return await processStartDirectDownload(job.data); - }); + this.queue.process( + 'start_direct_download', + 2, + async (job: BullJob) => { + const { processStartDirectDownload } = + await import('../processors/direct-download.processor'); + return await processStartDirectDownload(job.data); + }, + ); - this.queue.process('monitor_direct_download', 2, async (job: BullJob) => { - const { processMonitorDirectDownload } = await import('../processors/direct-download.processor'); - return await processMonitorDirectDownload(job.data); - }); + this.queue.process( + 'monitor_direct_download', + 2, + async (job: BullJob) => { + const { processMonitorDirectDownload } = + await import('../processors/direct-download.processor'); + return await processMonitorDirectDownload(job.data); + }, + ); } /** @@ -404,12 +539,17 @@ export class JobQueueService { if (existingJob) { // Update lastRun for the scheduled job if this is a timer-triggered job if (payload.scheduledJobId) { - await prisma.scheduledJob.update({ - where: { id: payload.scheduledJobId }, - data: { lastRun: new Date() }, - }).catch(err => { - logger.error(`Failed to update lastRun for scheduled job ${payload.scheduledJobId}`, { error: err instanceof Error ? err.message : String(err) }); - }); + await prisma.scheduledJob + .update({ + where: { id: payload.scheduledJobId }, + data: { lastRun: new Date() }, + }) + .catch((err) => { + logger.error( + `Failed to update lastRun for scheduled job ${payload.scheduledJobId}`, + { error: err instanceof Error ? err.message : String(err) }, + ); + }); } return { ...payload, jobId: existingJob.id }; } @@ -429,12 +569,17 @@ export class JobQueueService { // Update lastRun for the scheduled job if this is a timer-triggered job if (payload.scheduledJobId) { - await prisma.scheduledJob.update({ - where: { id: payload.scheduledJobId }, - data: { lastRun: new Date() }, - }).catch(err => { - logger.error(`Failed to update lastRun for scheduled job ${payload.scheduledJobId}`, { error: err instanceof Error ? err.message : String(err) }); - }); + await prisma.scheduledJob + .update({ + where: { id: payload.scheduledJobId }, + data: { lastRun: new Date() }, + }) + .catch((err) => { + logger.error( + `Failed to update lastRun for scheduled job ${payload.scheduledJobId}`, + { error: err instanceof Error ? err.message : String(err) }, + ); + }); } return { ...payload, jobId: dbJob.id }; @@ -448,7 +593,7 @@ export class JobQueueService { status: string, result?: any, errorMessage?: string, - stackTrace?: string + stackTrace?: string, ): Promise { try { const updateData: any = { @@ -481,7 +626,9 @@ export class JobQueueService { data: updateData, }); } catch (error) { - logger.error('Failed to update job in database', { error: error instanceof Error ? error.message : String(error) }); + logger.error('Failed to update job in database', { + error: error instanceof Error ? error.message : String(error), + }); } } @@ -491,7 +638,7 @@ export class JobQueueService { private async addJob( type: JobType, payload: JobPayload, - options?: JobOptions + options?: JobOptions, ): Promise { // First create the database job record const dbJob = await prisma.job.create({ @@ -524,7 +671,10 @@ export class JobQueueService { /** * Add search indexers job */ - async addSearchJob(requestId: string, audiobook: { id: string; title: string; author: string; asin?: string }): Promise { + async addSearchJob( + requestId: string, + audiobook: { id: string; title: string; author: string; asin?: string }, + ): Promise { return await this.addJob( 'search_indexers', { @@ -533,7 +683,7 @@ export class JobQueueService { } as SearchIndexersPayload, { priority: 10, // High priority for user-initiated requests - } + }, ); } @@ -543,7 +693,7 @@ export class JobQueueService { async addDownloadJob( requestId: string, audiobook: { id: string; title: string; author: string }, - torrent: TorrentResult + torrent: TorrentResult, ): Promise { return await this.addJob( 'download_torrent', @@ -554,7 +704,7 @@ export class JobQueueService { } as DownloadTorrentPayload, { priority: 9, // High priority - download selected torrent - } + }, ); } @@ -569,7 +719,7 @@ export class JobQueueService { delaySeconds: number = 0, lastProgress?: number, stallCount?: number, - pathWaitCount?: number + pathWaitCount?: number, ): Promise { return await this.addJob( 'monitor_download', @@ -585,7 +735,7 @@ export class JobQueueService { { priority: 5, // Medium priority delay: delaySeconds * 1000, // Convert seconds to milliseconds - } + }, ); } @@ -597,7 +747,7 @@ export class JobQueueService { requestId: string, audiobookId: string, downloadPath: string, - targetPath?: string + targetPath?: string, ): Promise { return await this.addJob( 'organize_files', @@ -609,14 +759,18 @@ export class JobQueueService { } as OrganizeFilesPayload, { priority: 8, - } + }, ); } /** * Add Plex scan job */ - async addPlexScanJob(libraryId: string, partial?: boolean, path?: string): Promise { + async addPlexScanJob( + libraryId: string, + partial?: boolean, + path?: string, + ): Promise { return await this.addJob( 'scan_plex', { @@ -626,7 +780,7 @@ export class JobQueueService { } as ScanPlexPayload, { priority: 7, - } + }, ); } @@ -641,7 +795,7 @@ export class JobQueueService { } as PlexRecentlyAddedPayload, { priority: 8, - } + }, ); } @@ -656,7 +810,7 @@ export class JobQueueService { } as MonitorRssFeedsPayload, { priority: 8, - } + }, ); } @@ -671,7 +825,7 @@ export class JobQueueService { } as AudibleRefreshPayload, { priority: 9, - } + }, ); } @@ -686,7 +840,7 @@ export class JobQueueService { } as RetryMissingTorrentsPayload, { priority: 7, - } + }, ); } @@ -701,7 +855,7 @@ export class JobQueueService { } as RetryFailedImportsPayload, { priority: 7, - } + }, ); } @@ -716,14 +870,18 @@ export class JobQueueService { } as CleanupSeededTorrentsPayload, { priority: 10, - } + }, ); } /** * Add sync Goodreads shelves job */ - async addSyncGoodreadsShelvesJob(scheduledJobId?: string, shelfId?: string, maxLookupsPerShelf?: number): Promise { + async addSyncGoodreadsShelvesJob( + scheduledJobId?: string, + shelfId?: string, + maxLookupsPerShelf?: number, + ): Promise { return await this.addJob( 'sync_goodreads_shelves', { @@ -733,7 +891,28 @@ export class JobQueueService { } as SyncGoodreadsShelvesPayload, { priority: 7, - } + }, + ); + } + + /** + * Add sync Hardcover shelves job + */ + async addSyncHardcoverShelvesJob( + scheduledJobId?: string, + shelfId?: string, + maxLookupsPerShelf?: number, + ): Promise { + return await this.addJob( + 'sync_hardcover_shelves', + { + scheduledJobId, + shelfId, + maxLookupsPerShelf, + } as SyncHardcoverShelvesPayload, + { + priority: 7, + }, ); } @@ -747,7 +926,7 @@ export class JobQueueService { async addSearchEbookJob( requestId: string, audiobook: { id: string; title: string; author: string; asin?: string }, - preferredFormat?: string + preferredFormat?: string, ): Promise { return await this.addJob( 'search_ebook', @@ -758,7 +937,7 @@ export class JobQueueService { } as SearchEbookPayload, { priority: 10, // High priority for user-initiated requests - } + }, ); } @@ -770,7 +949,7 @@ export class JobQueueService { downloadHistoryId: string, downloadUrl: string, targetFilename: string, - expectedSize?: number + expectedSize?: number, ): Promise { return await this.addJob( 'start_direct_download', @@ -783,7 +962,7 @@ export class JobQueueService { } as StartDirectDownloadPayload, { priority: 9, // High priority - download selected ebook - } + }, ); } @@ -796,7 +975,7 @@ export class JobQueueService { downloadId: string, targetPath: string, expectedSize?: number, - delaySeconds: number = 0 + delaySeconds: number = 0, ): Promise { return await this.addJob( 'monitor_direct_download', @@ -810,7 +989,7 @@ export class JobQueueService { { priority: 5, // Medium priority delay: delaySeconds * 1000, - } + }, ); } @@ -959,9 +1138,13 @@ export class JobQueueService { author: string, userName: string, message?: string, - requestType?: string + requestType?: string, ): Promise { - logger.info(`Queueing notification: ${event}`, { requestId, title, userName }); + logger.info(`Queueing notification: ${event}`, { + requestId, + title, + userName, + }); return await this.addJob( 'send_notification', { @@ -981,7 +1164,7 @@ export class JobQueueService { } as SendNotificationPayload, { priority: 5, // Medium priority - } + }, ); } @@ -992,7 +1175,7 @@ export class JobQueueService { jobType: string, payload: JobPayload, cronExpression: string, - jobId: string + jobId: string, ): Promise { await this.queue.add(jobType, payload, { repeat: { @@ -1009,7 +1192,7 @@ export class JobQueueService { async removeRepeatableJob( jobType: string, cronExpression: string, - jobId: string + jobId: string, ): Promise { await this.queue.removeRepeatable(jobType, { cron: cronExpression, diff --git a/src/lib/services/scheduler.service.ts b/src/lib/services/scheduler.service.ts index f64fd6a..b4acb8f 100644 --- a/src/lib/services/scheduler.service.ts +++ b/src/lib/services/scheduler.service.ts @@ -10,7 +10,16 @@ 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' | 'sync_goodreads_shelves'; +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' + | 'sync_hardcover_shelves'; export interface ScheduledJob { id: string; @@ -133,6 +142,13 @@ export class SchedulerService { enabled: true, // Enable by default payload: {}, }, + { + name: 'Sync Hardcover Lists', + type: 'sync_hardcover_shelves' as ScheduledJobType, + schedule: '0 */6 * * *', // Every 6 hours + enabled: true, // Enable by default + payload: {}, + }, ]; let created = 0; @@ -149,7 +165,9 @@ export class SchedulerService { data: defaultJob, }); created++; - logger.info(`Created default job: ${defaultJob.name} (enabled: ${defaultJob.enabled})`); + logger.info( + `Created default job: ${defaultJob.name} (enabled: ${defaultJob.enabled})`, + ); } } catch (error) { failed++; @@ -161,7 +179,9 @@ export class SchedulerService { } if (failed > 0) { - logger.warn(`Default jobs: ${created} created, ${failed} failed — failed jobs will be retried on next restart`); + logger.warn( + `Default jobs: ${created} created, ${failed} failed — failed jobs will be retried on next restart`, + ); } else if (created > 0) { logger.info(`Default jobs: ${created} created`); } @@ -191,11 +211,13 @@ export class SchedulerService { job.type, { scheduledJobId: job.id }, job.schedule, - `scheduled-${job.id}` + `scheduled-${job.id}`, ); logger.info(`Job scheduled: ${job.name} (${job.schedule})`); } catch (error) { - logger.error(`Failed to schedule job ${job.name}`, { error: error instanceof Error ? error.message : String(error) }); + logger.error(`Failed to schedule job ${job.name}`, { + error: error instanceof Error ? error.message : String(error), + }); throw error; } } @@ -208,11 +230,13 @@ export class SchedulerService { await this.jobQueue.removeRepeatableJob( job.type, job.schedule, - `scheduled-${job.id}` + `scheduled-${job.id}`, ); logger.info(`Job unscheduled: ${job.name}`); } catch (error) { - logger.error(`Failed to unschedule job ${job.name}`, { error: error instanceof Error ? error.message : String(error) }); + logger.error(`Failed to unschedule job ${job.name}`, { + error: error instanceof Error ? error.message : String(error), + }); // Don't throw - job might not exist in Bull yet } } @@ -264,7 +288,7 @@ export class SchedulerService { */ async updateScheduledJob( id: string, - dto: UpdateScheduledJobDto + dto: UpdateScheduledJobDto, ): Promise { if (dto.schedule) { this.validateCronExpression(dto.schedule); @@ -353,6 +377,9 @@ export class SchedulerService { case 'sync_goodreads_shelves': bullJobId = await this.triggerSyncGoodreadsShelves(job); break; + case 'sync_hardcover_shelves': + bullJobId = await this.triggerSyncHardcoverShelves(job); + break; default: throw new Error(`Unknown job type: ${job.type}`); } @@ -408,7 +435,8 @@ export class SchedulerService { throw new Error(errorMsg); } - libraryId = job.payload?.libraryId || absConfig['audiobookshelf.library_id']; + libraryId = + job.payload?.libraryId || absConfig['audiobookshelf.library_id']; } else { const plexConfig = await configService.getMany([ 'plex_url', @@ -432,15 +460,18 @@ export class SchedulerService { throw new Error(errorMsg); } - libraryId = job.payload?.libraryId || plexConfig.plex_audiobook_library_id; + libraryId = + job.payload?.libraryId || plexConfig.plex_audiobook_library_id; } - logger.info(`Triggering ${backendMode} library scan for library: ${libraryId}`); + logger.info( + `Triggering ${backendMode} library scan for library: ${libraryId}`, + ); return await this.jobQueue.addPlexScanJob( libraryId || '', job.payload?.partial, - job.payload?.path + job.payload?.path, ); } @@ -461,7 +492,6 @@ export class SchedulerService { return await this.jobQueue.addAudibleRefreshJob(job.id); } - /** * Enable a scheduled job */ @@ -493,10 +523,12 @@ export class SchedulerService { await this.triggerJobNow(job.id); // Stagger triggers to avoid connection pool burst on startup - await new Promise(resolve => setTimeout(resolve, 500)); + await new Promise((resolve) => setTimeout(resolve, 500)); } } catch (error) { - logger.error(`Failed to trigger overdue job "${job.name}"`, { error: error instanceof Error ? error.message : String(error) }); + logger.error(`Failed to trigger overdue job "${job.name}"`, { + error: error instanceof Error ? error.message : String(error), + }); } } } @@ -569,13 +601,22 @@ export class SchedulerService { if (dayOfMonth === '*' && month === '*' && dayOfWeek === '*') { const hourNum = parseInt(hour, 10); const minuteNum = parseInt(minute, 10); - if (!isNaN(hourNum) && !isNaN(minuteNum) && hourNum >= 0 && hourNum <= 23 && minuteNum >= 0 && minuteNum <= 59) { + if ( + !isNaN(hourNum) && + !isNaN(minuteNum) && + hourNum >= 0 && + hourNum <= 23 && + minuteNum >= 0 && + minuteNum <= 59 + ) { return 24 * 60 * 60 * 1000; // 24 hours } } // For other patterns, return a conservative default (24 hours) - logger.warn(`Unknown cron pattern "${cronExpression}", defaulting to 24 hours`); + logger.warn( + `Unknown cron pattern "${cronExpression}", defaulting to 24 hours`, + ); return 24 * 60 * 60 * 1000; } @@ -627,6 +668,13 @@ export class SchedulerService { private async triggerSyncGoodreadsShelves(job: any): Promise { return await this.jobQueue.addSyncGoodreadsShelvesJob(job.id); } + + /** + * Trigger Hardcover lists sync + */ + private async triggerSyncHardcoverShelves(job: any): Promise { + return await this.jobQueue.addSyncHardcoverShelvesJob(job.id); + } } // Singleton instance