mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-02 20:30:10 +00:00
Hardcover API support
This commit is contained in:
Vendored
+23
@@ -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"
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Add gemini API integration for BookDate
|
||||||
Generated
+6
-6
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "readmeabook",
|
"name": "readmeabook",
|
||||||
"version": "1.0.14",
|
"version": "1.0.15",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "readmeabook",
|
"name": "readmeabook",
|
||||||
"version": "1.0.14",
|
"version": "1.0.15",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@heroicons/react": "^2.2.0",
|
"@heroicons/react": "^2.2.0",
|
||||||
"@prisma/client": "^6.19.0",
|
"@prisma/client": "^6.19.0",
|
||||||
@@ -299,7 +299,7 @@
|
|||||||
"version": "7.27.1",
|
"version": "7.27.1",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
|
||||||
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
|
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
@@ -309,7 +309,7 @@
|
|||||||
"version": "7.28.5",
|
"version": "7.28.5",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
|
||||||
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
|
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
@@ -343,7 +343,7 @@
|
|||||||
"version": "7.28.5",
|
"version": "7.28.5",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz",
|
||||||
"integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==",
|
"integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/types": "^7.28.5"
|
"@babel/types": "^7.28.5"
|
||||||
@@ -403,7 +403,7 @@
|
|||||||
"version": "7.28.5",
|
"version": "7.28.5",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz",
|
||||||
"integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==",
|
"integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/helper-string-parser": "^7.27.1",
|
"@babel/helper-string-parser": "^7.27.1",
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ model User {
|
|||||||
bookDateRecommendations BookDateRecommendation[]
|
bookDateRecommendations BookDateRecommendation[]
|
||||||
bookDateSwipes BookDateSwipe[]
|
bookDateSwipes BookDateSwipe[]
|
||||||
goodreadsShelves GoodreadsShelf[]
|
goodreadsShelves GoodreadsShelf[]
|
||||||
|
hardcoverShelves HardcoverShelf[]
|
||||||
reportedIssues ReportedIssue[] @relation("Reporter")
|
reportedIssues ReportedIssue[] @relation("Reporter")
|
||||||
resolvedIssues ReportedIssue[] @relation("Resolver")
|
resolvedIssues ReportedIssue[] @relation("Resolver")
|
||||||
|
|
||||||
@@ -530,3 +531,45 @@ model GoodreadsBookMapping {
|
|||||||
@@index([audibleAsin])
|
@@index([audibleAsin])
|
||||||
@@map("goodreads_book_mappings")
|
@@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")
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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<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,
|
||||||
|
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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
+81
-16
@@ -12,6 +12,7 @@ import { useAuth } from '@/contexts/AuthContext';
|
|||||||
import { useRequests } from '@/lib/hooks/useRequests';
|
import { useRequests } from '@/lib/hooks/useRequests';
|
||||||
import { cn } from '@/lib/utils/cn';
|
import { cn } from '@/lib/utils/cn';
|
||||||
import { GoodreadsShelvesSection } from '@/components/profile/GoodreadsShelvesSection';
|
import { GoodreadsShelvesSection } from '@/components/profile/GoodreadsShelvesSection';
|
||||||
|
import { HardcoverShelvesSection } from '@/components/profile/HardcoverShelvesSection';
|
||||||
|
|
||||||
const statConfig = [
|
const statConfig = [
|
||||||
{ key: 'total', label: 'Total', color: 'text-gray-900 dark:text-white' },
|
{ 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: 'waiting', label: 'Waiting', color: 'text-amber-500' },
|
||||||
{ key: 'completed', label: 'Complete', color: 'text-emerald-500' },
|
{ key: 'completed', label: 'Complete', color: 'text-emerald-500' },
|
||||||
{ key: 'failed', label: 'Failed', color: 'text-red-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;
|
] as const;
|
||||||
|
|
||||||
type StatKey = (typeof statConfig)[number]['key'];
|
type StatKey = (typeof statConfig)[number]['key'];
|
||||||
@@ -30,25 +35,45 @@ export default function ProfilePage() {
|
|||||||
|
|
||||||
const stats = useMemo(() => {
|
const stats = useMemo(() => {
|
||||||
if (!requests.length) {
|
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 {
|
return {
|
||||||
total: requests.length,
|
total: requests.length,
|
||||||
completed: requests.filter((r: any) => ['available', 'downloaded'].includes(r.status)).length,
|
completed: requests.filter((r: any) =>
|
||||||
active: requests.filter((r: any) => ['pending', 'searching', 'downloading', 'processing'].includes(r.status)).length,
|
['available', 'downloaded'].includes(r.status),
|
||||||
waiting: requests.filter((r: any) => ['awaiting_search', 'awaiting_import'].includes(r.status)).length,
|
).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,
|
failed: requests.filter((r: any) => r.status === 'failed').length,
|
||||||
cancelled: requests.filter((r: any) => r.status === 'cancelled').length,
|
cancelled: requests.filter((r: any) => r.status === 'cancelled').length,
|
||||||
};
|
};
|
||||||
}, [requests]);
|
}, [requests]);
|
||||||
|
|
||||||
const activeDownloads = useMemo(() => {
|
const activeDownloads = useMemo(() => {
|
||||||
return requests.filter((r: any) => ['downloading', 'processing'].includes(r.status));
|
return requests.filter((r: any) =>
|
||||||
|
['downloading', 'processing'].includes(r.status),
|
||||||
|
);
|
||||||
}, [requests]);
|
}, [requests]);
|
||||||
|
|
||||||
const recentRequests = useMemo(() => {
|
const recentRequests = useMemo(() => {
|
||||||
return [...requests]
|
return [...requests]
|
||||||
.sort((a: any, b: any) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
|
.sort(
|
||||||
|
(a: any, b: any) =>
|
||||||
|
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
|
||||||
|
)
|
||||||
.slice(0, 5);
|
.slice(0, 5);
|
||||||
}, [requests]);
|
}, [requests]);
|
||||||
|
|
||||||
@@ -58,8 +83,18 @@ export default function ProfilePage() {
|
|||||||
<Header />
|
<Header />
|
||||||
<main className="container mx-auto px-4 py-20 max-w-5xl text-center">
|
<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">
|
<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}>
|
<svg
|
||||||
<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" />
|
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>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">
|
||||||
@@ -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',
|
'inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold uppercase tracking-wide',
|
||||||
user.role === 'admin'
|
user.role === 'admin'
|
||||||
? 'bg-purple-50 text-purple-600 dark:bg-purple-500/15 dark:text-purple-400'
|
? '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'}
|
{user.role === 'admin' ? 'Administrator' : 'User'}
|
||||||
@@ -128,7 +163,12 @@ export default function ProfilePage() {
|
|||||||
key={stat.key}
|
key={stat.key}
|
||||||
className="py-5 sm:py-6 px-3 text-center bg-white dark:bg-gray-800"
|
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)}>
|
<div
|
||||||
|
className={cn(
|
||||||
|
'text-2xl sm:text-3xl font-bold tabular-nums',
|
||||||
|
stat.color,
|
||||||
|
)}
|
||||||
|
>
|
||||||
{isLoading ? '\u2013' : stats[stat.key as StatKey]}
|
{isLoading ? '\u2013' : stats[stat.key as StatKey]}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs font-medium text-gray-400 dark:text-gray-500 uppercase tracking-wider mt-1.5">
|
<div className="text-xs font-medium text-gray-400 dark:text-gray-500 uppercase tracking-wider mt-1.5">
|
||||||
@@ -142,6 +182,9 @@ export default function ProfilePage() {
|
|||||||
{/* Goodreads Shelves */}
|
{/* Goodreads Shelves */}
|
||||||
<GoodreadsShelvesSection />
|
<GoodreadsShelvesSection />
|
||||||
|
|
||||||
|
{/* Hardcover Lists */}
|
||||||
|
<HardcoverShelvesSection />
|
||||||
|
|
||||||
{/* Active Downloads */}
|
{/* Active Downloads */}
|
||||||
{activeDownloads.length > 0 && (
|
{activeDownloads.length > 0 && (
|
||||||
<section>
|
<section>
|
||||||
@@ -158,7 +201,11 @@ export default function ProfilePage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{activeDownloads.map((request: any) => (
|
{activeDownloads.map((request: any) => (
|
||||||
<RequestCard key={request.id} request={request} showActions={false} />
|
<RequestCard
|
||||||
|
key={request.id}
|
||||||
|
request={request}
|
||||||
|
showActions={false}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -201,7 +248,11 @@ export default function ProfilePage() {
|
|||||||
) : recentRequests.length > 0 ? (
|
) : recentRequests.length > 0 ? (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{recentRequests.map((request: any) => (
|
{recentRequests.map((request: any) => (
|
||||||
<RequestCard key={request.id} request={request} showActions={false} />
|
<RequestCard
|
||||||
|
key={request.id}
|
||||||
|
request={request}
|
||||||
|
showActions={false}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -213,7 +264,11 @@ export default function ProfilePage() {
|
|||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
strokeWidth={1.5}
|
strokeWidth={1.5}
|
||||||
>
|
>
|
||||||
<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" />
|
<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>
|
</svg>
|
||||||
<p className="text-base font-medium text-gray-500 dark:text-gray-400">
|
<p className="text-base font-medium text-gray-500 dark:text-gray-400">
|
||||||
No requests yet
|
No requests yet
|
||||||
@@ -225,8 +280,18 @@ export default function ProfilePage() {
|
|||||||
href="/search"
|
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"
|
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}>
|
<svg
|
||||||
<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" />
|
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>
|
</svg>
|
||||||
Search Audiobooks
|
Search Audiobooks
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -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<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-indigo-50 to-blue-50 dark:from-indigo-500/10 dark:to-blue-500/10 flex items-center justify-center ring-1 ring-indigo-200/50 dark:ring-indigo-500/10">
|
||||||
|
<svg
|
||||||
|
className="w-[18px] h-[18px] text-indigo-600 dark:text-indigo-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">
|
||||||
|
Hardcover Lists
|
||||||
|
</h2>
|
||||||
|
{!isLoading && shelves.length > 0 && (
|
||||||
|
<p className="text-xs text-gray-400 dark:text-gray-500 mt-0.5">
|
||||||
|
{shelves.length} {shelves.length === 1 ? 'list' : 'lists'}{' '}
|
||||||
|
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 List
|
||||||
|
</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)} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<AddHardcoverShelfModal
|
||||||
|
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-indigo-50 to-blue-50 dark:from-indigo-500/10 dark:to-blue-500/10 flex items-center justify-center mb-5 ring-1 ring-indigo-200/50 dark:ring-indigo-500/10">
|
||||||
|
<svg
|
||||||
|
className="w-7 h-7 text-indigo-500 dark:text-indigo-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 Hardcover list and we'll automatically request the audiobook for
|
||||||
|
every book you add.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={onAdd}
|
||||||
|
className="inline-flex items-center gap-2 px-5 py-2.5 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-xl transition-colors shadow-sm"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="w-4 h-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M12 4.5v15m7.5-7.5h-15"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Add Your First List
|
||||||
|
</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: 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 (
|
||||||
|
<div className="group rounded-2xl bg-white dark:bg-gray-800 border border-gray-100 dark:border-gray-700/30 p-6 sm:p-7 transition-all duration-300 hover:shadow-lg hover:shadow-black/[0.04] dark:hover:shadow-black/20 hover:border-gray-200 dark:hover:border-gray-600/40">
|
||||||
|
{/* Top: Shelf info + actions */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex items-start justify-between',
|
||||||
|
(hasCovers || isSyncing) && 'mb-5',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<h3 className="font-semibold text-[15px] text-gray-900 dark:text-white truncate leading-snug">
|
||||||
|
{shelf.name}
|
||||||
|
</h3>
|
||||||
|
<div className="flex items-center gap-2 mt-2">
|
||||||
|
{shelf.bookCount != null && (
|
||||||
|
<span className="inline-flex items-center px-2 py-0.5 rounded-md text-xs font-medium bg-gray-100 dark:bg-gray-700/50 text-gray-500 dark:text-gray-400 tabular-nums">
|
||||||
|
{shelf.bookCount} {shelf.bookCount === 1 ? 'book' : 'books'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="inline-flex items-center gap-1.5 text-xs text-gray-400 dark:text-gray-500">
|
||||||
|
{isSyncing ? (
|
||||||
|
<>
|
||||||
|
<span className="relative flex h-2 w-2">
|
||||||
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" />
|
||||||
|
<span className="relative inline-flex rounded-full h-2 w-2 bg-blue-500" />
|
||||||
|
</span>
|
||||||
|
Syncing…
|
||||||
|
</>
|
||||||
|
) : shelf.lastSyncAt ? (
|
||||||
|
<>
|
||||||
|
<span className="inline-block w-1.5 h-1.5 rounded-full bg-emerald-500" />
|
||||||
|
Synced {formatRelativeTime(shelf.lastSyncAt)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Pending sync'
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Delete action */}
|
||||||
|
<div className="flex-shrink-0 ml-4">
|
||||||
|
{isConfirmingDelete ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={onDelete}
|
||||||
|
disabled={isDeleting}
|
||||||
|
className="px-3 py-1.5 text-xs font-semibold text-white bg-red-500 hover:bg-red-600 rounded-lg transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isDeleting ? 'Removing\u2026' : 'Remove'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onCancelDelete}
|
||||||
|
disabled={isDeleting}
|
||||||
|
className="px-2 py-1.5 text-xs font-medium text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={onConfirmDelete}
|
||||||
|
className="p-2 text-gray-300 hover:text-red-400 dark:text-gray-600 dark:hover:text-red-400 transition-all duration-200 rounded-xl hover:bg-red-50 dark:hover:bg-red-500/10 opacity-0 group-hover:opacity-100 focus:opacity-100"
|
||||||
|
title="Remove list"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Modal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={handleClose}
|
||||||
|
title="Add Hardcover List"
|
||||||
|
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-indigo-50 to-blue-50 dark:from-indigo-500/10 dark:to-blue-500/10 flex items-center justify-center ring-1 ring-indigo-200/50 dark:ring-indigo-500/10 flex-shrink-0">
|
||||||
|
<svg
|
||||||
|
className="w-5 h-5 text-indigo-600 dark:text-indigo-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 className="min-w-0">
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-300 leading-relaxed">
|
||||||
|
Provides your Hardcover API token and the ID of the list you want
|
||||||
|
to 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 className="space-y-3">
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
label="API Token"
|
||||||
|
value={apiToken}
|
||||||
|
onChange={(e) => {
|
||||||
|
setApiToken(e.target.value);
|
||||||
|
if (validationError) setValidationError('');
|
||||||
|
}}
|
||||||
|
placeholder="eyJhb..."
|
||||||
|
disabled={isLoading || success}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
label="List ID or Status ID"
|
||||||
|
value={listId}
|
||||||
|
onChange={(e) => {
|
||||||
|
setListId(e.target.value);
|
||||||
|
if (validationError) setValidationError('');
|
||||||
|
}}
|
||||||
|
placeholder="1234 or uuid"
|
||||||
|
error={validationError}
|
||||||
|
disabled={isLoading || success}
|
||||||
|
/>
|
||||||
|
</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 List
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<string | null>(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<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/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 };
|
||||||
|
}
|
||||||
@@ -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<any> {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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<typeof RMABLogger.forJob>,
|
||||||
|
options: HardcoverSyncOptions = {},
|
||||||
|
): Promise<HardcoverSyncStats> {
|
||||||
|
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<typeof RMABLogger.forJob>
|
||||||
|
| ReturnType<typeof RMABLogger.create>,
|
||||||
|
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<typeof b> => 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<typeof RMABLogger.forJob>
|
||||||
|
| ReturnType<typeof RMABLogger.create>,
|
||||||
|
existingMappingId?: string,
|
||||||
|
): Promise<any> {
|
||||||
|
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 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@ export type JobType =
|
|||||||
| 'cleanup_seeded_torrents'
|
| 'cleanup_seeded_torrents'
|
||||||
| 'monitor_rss_feeds'
|
| 'monitor_rss_feeds'
|
||||||
| 'sync_goodreads_shelves'
|
| 'sync_goodreads_shelves'
|
||||||
|
| 'sync_hardcover_shelves'
|
||||||
| 'send_notification'
|
| 'send_notification'
|
||||||
// Ebook-specific job types
|
// Ebook-specific job types
|
||||||
| 'search_ebook'
|
| 'search_ebook'
|
||||||
@@ -63,8 +64,8 @@ export interface MonitorDownloadPayload extends JobPayload {
|
|||||||
downloadHistoryId: string;
|
downloadHistoryId: string;
|
||||||
downloadClientId: string;
|
downloadClientId: string;
|
||||||
downloadClient: DownloadClientType;
|
downloadClient: DownloadClientType;
|
||||||
lastProgress?: number; // Previous poll's progress (0-100) for stall detection
|
lastProgress?: number; // Previous poll's progress (0-100) for stall detection
|
||||||
stallCount?: number; // Consecutive polls with no progress change (drives backoff)
|
stallCount?: number; // Consecutive polls with no progress change (drives backoff)
|
||||||
pathWaitCount?: number; // Consecutive polls waiting for content_path to relocate to save_path
|
pathWaitCount?: number; // Consecutive polls waiting for content_path to relocate to save_path
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,6 +112,12 @@ export interface SyncGoodreadsShelvesPayload extends JobPayload {
|
|||||||
maxLookupsPerShelf?: number;
|
maxLookupsPerShelf?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SyncHardcoverShelvesPayload extends JobPayload {
|
||||||
|
scheduledJobId?: string;
|
||||||
|
shelfId?: string;
|
||||||
|
maxLookupsPerShelf?: number;
|
||||||
|
}
|
||||||
|
|
||||||
// Ebook-specific payload interfaces
|
// Ebook-specific payload interfaces
|
||||||
export interface SearchEbookPayload extends JobPayload {
|
export interface SearchEbookPayload extends JobPayload {
|
||||||
requestId: string;
|
requestId: string;
|
||||||
@@ -226,13 +233,15 @@ export class JobQueueService {
|
|||||||
'failed',
|
'failed',
|
||||||
null,
|
null,
|
||||||
error.message,
|
error.message,
|
||||||
error.stack
|
error.stack,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Handle permanent failures for specific job types after all retries exhausted
|
// Handle permanent failures for specific job types after all retries exhausted
|
||||||
if (job.name === 'monitor_download' && job.data) {
|
if (job.name === 'monitor_download' && job.data) {
|
||||||
const payload = job.data as MonitorDownloadPayload;
|
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)
|
// Update request status to failed (only happens after all retries exhausted)
|
||||||
try {
|
try {
|
||||||
@@ -240,7 +249,9 @@ export class JobQueueService {
|
|||||||
where: { id: payload.requestId },
|
where: { id: payload.requestId },
|
||||||
data: {
|
data: {
|
||||||
status: 'failed',
|
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(),
|
updatedAt: new Date(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -256,7 +267,12 @@ export class JobQueueService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (updateError) {
|
} 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 {
|
private startProcessors(): void {
|
||||||
// Search indexers processor
|
// Search indexers processor
|
||||||
this.queue.process('search_indexers', 2, async (job: BullJob<SearchIndexersPayload>) => {
|
this.queue.process(
|
||||||
const { processSearchIndexers } = await import('../processors/search-indexers.processor');
|
'search_indexers',
|
||||||
return await processSearchIndexers(job.data);
|
2,
|
||||||
});
|
async (job: BullJob<SearchIndexersPayload>) => {
|
||||||
|
const { processSearchIndexers } =
|
||||||
|
await import('../processors/search-indexers.processor');
|
||||||
|
return await processSearchIndexers(job.data);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Download torrent processor
|
// Download torrent processor
|
||||||
this.queue.process('download_torrent', 2, async (job: BullJob<DownloadTorrentPayload>) => {
|
this.queue.process(
|
||||||
const { processDownloadTorrent } = await import('../processors/download-torrent.processor');
|
'download_torrent',
|
||||||
return await processDownloadTorrent(job.data);
|
2,
|
||||||
});
|
async (job: BullJob<DownloadTorrentPayload>) => {
|
||||||
|
const { processDownloadTorrent } =
|
||||||
|
await import('../processors/download-torrent.processor');
|
||||||
|
return await processDownloadTorrent(job.data);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Monitor download processor
|
// Monitor download processor
|
||||||
this.queue.process('monitor_download', 2, async (job: BullJob<MonitorDownloadPayload>) => {
|
this.queue.process(
|
||||||
const { processMonitorDownload } = await import('../processors/monitor-download.processor');
|
'monitor_download',
|
||||||
return await processMonitorDownload(job.data);
|
2,
|
||||||
});
|
async (job: BullJob<MonitorDownloadPayload>) => {
|
||||||
|
const { processMonitorDownload } =
|
||||||
|
await import('../processors/monitor-download.processor');
|
||||||
|
return await processMonitorDownload(job.data);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Organize files processor
|
// Organize files processor
|
||||||
this.queue.process('organize_files', 2, async (job: BullJob<OrganizeFilesPayload>) => {
|
this.queue.process(
|
||||||
const { processOrganizeFiles } = await import('../processors/organize-files.processor');
|
'organize_files',
|
||||||
return await processOrganizeFiles(job.data);
|
2,
|
||||||
});
|
async (job: BullJob<OrganizeFilesPayload>) => {
|
||||||
|
const { processOrganizeFiles } =
|
||||||
|
await import('../processors/organize-files.processor');
|
||||||
|
return await processOrganizeFiles(job.data);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Scan Plex processor
|
// Scan Plex processor
|
||||||
this.queue.process('scan_plex', 1, async (job: BullJob<ScanPlexPayload>) => {
|
this.queue.process(
|
||||||
const { processScanPlex } = await import('../processors/scan-plex.processor');
|
'scan_plex',
|
||||||
return await processScanPlex(job.data);
|
1,
|
||||||
});
|
async (job: BullJob<ScanPlexPayload>) => {
|
||||||
|
const { processScanPlex } =
|
||||||
|
await import('../processors/scan-plex.processor');
|
||||||
|
return await processScanPlex(job.data);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Scheduled job processors
|
// Scheduled job processors
|
||||||
this.queue.process('plex_library_scan', 1, async (job: BullJob) => {
|
this.queue.process('plex_library_scan', 1, async (job: BullJob) => {
|
||||||
// plex_library_scan is just an alias for scan_plex
|
// plex_library_scan is just an alias for scan_plex
|
||||||
const { processScanPlex } = await import('../processors/scan-plex.processor');
|
const { processScanPlex } =
|
||||||
const payloadWithJobId = await this.ensureJobRecord(job, 'plex_library_scan');
|
await import('../processors/scan-plex.processor');
|
||||||
|
const payloadWithJobId = await this.ensureJobRecord(
|
||||||
|
job,
|
||||||
|
'plex_library_scan',
|
||||||
|
);
|
||||||
return await processScanPlex(payloadWithJobId);
|
return await processScanPlex(payloadWithJobId);
|
||||||
});
|
});
|
||||||
|
|
||||||
this.queue.process('plex_recently_added_check', 1, async (job: BullJob<PlexRecentlyAddedPayload>) => {
|
this.queue.process(
|
||||||
const { processPlexRecentlyAddedCheck } = await import('../processors/plex-recently-added.processor');
|
'plex_recently_added_check',
|
||||||
const payloadWithJobId = await this.ensureJobRecord(job, 'plex_recently_added_check');
|
1,
|
||||||
return await processPlexRecentlyAddedCheck(payloadWithJobId);
|
async (job: BullJob<PlexRecentlyAddedPayload>) => {
|
||||||
});
|
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<MonitorRssFeedsPayload>) => {
|
this.queue.process(
|
||||||
const { processMonitorRssFeeds } = await import('../processors/monitor-rss-feeds.processor');
|
'monitor_rss_feeds',
|
||||||
const payloadWithJobId = await this.ensureJobRecord(job, 'monitor_rss_feeds');
|
1,
|
||||||
return await processMonitorRssFeeds(payloadWithJobId);
|
async (job: BullJob<MonitorRssFeedsPayload>) => {
|
||||||
});
|
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<AudibleRefreshPayload>) => {
|
this.queue.process(
|
||||||
const { processAudibleRefresh } = await import('../processors/audible-refresh.processor');
|
'audible_refresh',
|
||||||
const payloadWithJobId = await this.ensureJobRecord(job, 'audible_refresh');
|
1,
|
||||||
return await processAudibleRefresh(payloadWithJobId);
|
async (job: BullJob<AudibleRefreshPayload>) => {
|
||||||
});
|
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<RetryMissingTorrentsPayload>) => {
|
this.queue.process(
|
||||||
const { processRetryMissingTorrents } = await import('../processors/retry-missing-torrents.processor');
|
'retry_missing_torrents',
|
||||||
const payloadWithJobId = await this.ensureJobRecord(job, 'retry_missing_torrents');
|
1,
|
||||||
return await processRetryMissingTorrents(payloadWithJobId);
|
async (job: BullJob<RetryMissingTorrentsPayload>) => {
|
||||||
});
|
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<RetryFailedImportsPayload>) => {
|
this.queue.process(
|
||||||
const { processRetryFailedImports } = await import('../processors/retry-failed-imports.processor');
|
'retry_failed_imports',
|
||||||
const payloadWithJobId = await this.ensureJobRecord(job, 'retry_failed_imports');
|
1,
|
||||||
return await processRetryFailedImports(payloadWithJobId);
|
async (job: BullJob<RetryFailedImportsPayload>) => {
|
||||||
});
|
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<CleanupSeededTorrentsPayload>) => {
|
this.queue.process(
|
||||||
const { processCleanupSeededTorrents } = await import('../processors/cleanup-seeded-torrents.processor');
|
'cleanup_seeded_torrents',
|
||||||
const payloadWithJobId = await this.ensureJobRecord(job, 'cleanup_seeded_torrents');
|
1,
|
||||||
return await processCleanupSeededTorrents(payloadWithJobId);
|
async (job: BullJob<CleanupSeededTorrentsPayload>) => {
|
||||||
});
|
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<SyncGoodreadsShelvesPayload>) => {
|
this.queue.process(
|
||||||
const { processSyncGoodreadsShelves } = await import('../processors/sync-goodreads-shelves.processor');
|
'sync_goodreads_shelves',
|
||||||
const payloadWithJobId = await this.ensureJobRecord(job, 'sync_goodreads_shelves');
|
1,
|
||||||
return await processSyncGoodreadsShelves(payloadWithJobId);
|
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);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
this.queue.process(
|
||||||
|
'sync_hardcover_shelves',
|
||||||
|
1,
|
||||||
|
async (job: BullJob<SyncHardcoverShelvesPayload>) => {
|
||||||
|
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
|
// Send notification processor
|
||||||
this.queue.process('send_notification', 2, async (job: BullJob<SendNotificationPayload>) => {
|
this.queue.process(
|
||||||
const { processSendNotification } = await import('../processors/send-notification.processor');
|
'send_notification',
|
||||||
return await processSendNotification(job.data);
|
2,
|
||||||
});
|
async (job: BullJob<SendNotificationPayload>) => {
|
||||||
|
const { processSendNotification } =
|
||||||
|
await import('../processors/send-notification.processor');
|
||||||
|
return await processSendNotification(job.data);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Ebook-specific processors
|
// Ebook-specific processors
|
||||||
this.queue.process('search_ebook', 2, async (job: BullJob<SearchEbookPayload>) => {
|
this.queue.process(
|
||||||
const { processSearchEbook } = await import('../processors/search-ebook.processor');
|
'search_ebook',
|
||||||
return await processSearchEbook(job.data);
|
2,
|
||||||
});
|
async (job: BullJob<SearchEbookPayload>) => {
|
||||||
|
const { processSearchEbook } =
|
||||||
|
await import('../processors/search-ebook.processor');
|
||||||
|
return await processSearchEbook(job.data);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
this.queue.process('start_direct_download', 2, async (job: BullJob<StartDirectDownloadPayload>) => {
|
this.queue.process(
|
||||||
const { processStartDirectDownload } = await import('../processors/direct-download.processor');
|
'start_direct_download',
|
||||||
return await processStartDirectDownload(job.data);
|
2,
|
||||||
});
|
async (job: BullJob<StartDirectDownloadPayload>) => {
|
||||||
|
const { processStartDirectDownload } =
|
||||||
|
await import('../processors/direct-download.processor');
|
||||||
|
return await processStartDirectDownload(job.data);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
this.queue.process('monitor_direct_download', 2, async (job: BullJob<MonitorDirectDownloadPayload>) => {
|
this.queue.process(
|
||||||
const { processMonitorDirectDownload } = await import('../processors/direct-download.processor');
|
'monitor_direct_download',
|
||||||
return await processMonitorDirectDownload(job.data);
|
2,
|
||||||
});
|
async (job: BullJob<MonitorDirectDownloadPayload>) => {
|
||||||
|
const { processMonitorDirectDownload } =
|
||||||
|
await import('../processors/direct-download.processor');
|
||||||
|
return await processMonitorDirectDownload(job.data);
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -404,12 +539,17 @@ export class JobQueueService {
|
|||||||
if (existingJob) {
|
if (existingJob) {
|
||||||
// Update lastRun for the scheduled job if this is a timer-triggered job
|
// Update lastRun for the scheduled job if this is a timer-triggered job
|
||||||
if (payload.scheduledJobId) {
|
if (payload.scheduledJobId) {
|
||||||
await prisma.scheduledJob.update({
|
await prisma.scheduledJob
|
||||||
where: { id: payload.scheduledJobId },
|
.update({
|
||||||
data: { lastRun: new Date() },
|
where: { id: payload.scheduledJobId },
|
||||||
}).catch(err => {
|
data: { lastRun: new Date() },
|
||||||
logger.error(`Failed to update lastRun for scheduled job ${payload.scheduledJobId}`, { error: err instanceof Error ? err.message : String(err) });
|
})
|
||||||
});
|
.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 };
|
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
|
// Update lastRun for the scheduled job if this is a timer-triggered job
|
||||||
if (payload.scheduledJobId) {
|
if (payload.scheduledJobId) {
|
||||||
await prisma.scheduledJob.update({
|
await prisma.scheduledJob
|
||||||
where: { id: payload.scheduledJobId },
|
.update({
|
||||||
data: { lastRun: new Date() },
|
where: { id: payload.scheduledJobId },
|
||||||
}).catch(err => {
|
data: { lastRun: new Date() },
|
||||||
logger.error(`Failed to update lastRun for scheduled job ${payload.scheduledJobId}`, { error: err instanceof Error ? err.message : String(err) });
|
})
|
||||||
});
|
.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 };
|
return { ...payload, jobId: dbJob.id };
|
||||||
@@ -448,7 +593,7 @@ export class JobQueueService {
|
|||||||
status: string,
|
status: string,
|
||||||
result?: any,
|
result?: any,
|
||||||
errorMessage?: string,
|
errorMessage?: string,
|
||||||
stackTrace?: string
|
stackTrace?: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const updateData: any = {
|
const updateData: any = {
|
||||||
@@ -481,7 +626,9 @@ export class JobQueueService {
|
|||||||
data: updateData,
|
data: updateData,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} 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(
|
private async addJob(
|
||||||
type: JobType,
|
type: JobType,
|
||||||
payload: JobPayload,
|
payload: JobPayload,
|
||||||
options?: JobOptions
|
options?: JobOptions,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
// First create the database job record
|
// First create the database job record
|
||||||
const dbJob = await prisma.job.create({
|
const dbJob = await prisma.job.create({
|
||||||
@@ -524,7 +671,10 @@ export class JobQueueService {
|
|||||||
/**
|
/**
|
||||||
* Add search indexers job
|
* Add search indexers job
|
||||||
*/
|
*/
|
||||||
async addSearchJob(requestId: string, audiobook: { id: string; title: string; author: string; asin?: string }): Promise<string> {
|
async addSearchJob(
|
||||||
|
requestId: string,
|
||||||
|
audiobook: { id: string; title: string; author: string; asin?: string },
|
||||||
|
): Promise<string> {
|
||||||
return await this.addJob(
|
return await this.addJob(
|
||||||
'search_indexers',
|
'search_indexers',
|
||||||
{
|
{
|
||||||
@@ -533,7 +683,7 @@ export class JobQueueService {
|
|||||||
} as SearchIndexersPayload,
|
} as SearchIndexersPayload,
|
||||||
{
|
{
|
||||||
priority: 10, // High priority for user-initiated requests
|
priority: 10, // High priority for user-initiated requests
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -543,7 +693,7 @@ export class JobQueueService {
|
|||||||
async addDownloadJob(
|
async addDownloadJob(
|
||||||
requestId: string,
|
requestId: string,
|
||||||
audiobook: { id: string; title: string; author: string },
|
audiobook: { id: string; title: string; author: string },
|
||||||
torrent: TorrentResult
|
torrent: TorrentResult,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
return await this.addJob(
|
return await this.addJob(
|
||||||
'download_torrent',
|
'download_torrent',
|
||||||
@@ -554,7 +704,7 @@ export class JobQueueService {
|
|||||||
} as DownloadTorrentPayload,
|
} as DownloadTorrentPayload,
|
||||||
{
|
{
|
||||||
priority: 9, // High priority - download selected torrent
|
priority: 9, // High priority - download selected torrent
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -569,7 +719,7 @@ export class JobQueueService {
|
|||||||
delaySeconds: number = 0,
|
delaySeconds: number = 0,
|
||||||
lastProgress?: number,
|
lastProgress?: number,
|
||||||
stallCount?: number,
|
stallCount?: number,
|
||||||
pathWaitCount?: number
|
pathWaitCount?: number,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
return await this.addJob(
|
return await this.addJob(
|
||||||
'monitor_download',
|
'monitor_download',
|
||||||
@@ -585,7 +735,7 @@ export class JobQueueService {
|
|||||||
{
|
{
|
||||||
priority: 5, // Medium priority
|
priority: 5, // Medium priority
|
||||||
delay: delaySeconds * 1000, // Convert seconds to milliseconds
|
delay: delaySeconds * 1000, // Convert seconds to milliseconds
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -597,7 +747,7 @@ export class JobQueueService {
|
|||||||
requestId: string,
|
requestId: string,
|
||||||
audiobookId: string,
|
audiobookId: string,
|
||||||
downloadPath: string,
|
downloadPath: string,
|
||||||
targetPath?: string
|
targetPath?: string,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
return await this.addJob(
|
return await this.addJob(
|
||||||
'organize_files',
|
'organize_files',
|
||||||
@@ -609,14 +759,18 @@ export class JobQueueService {
|
|||||||
} as OrganizeFilesPayload,
|
} as OrganizeFilesPayload,
|
||||||
{
|
{
|
||||||
priority: 8,
|
priority: 8,
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add Plex scan job
|
* Add Plex scan job
|
||||||
*/
|
*/
|
||||||
async addPlexScanJob(libraryId: string, partial?: boolean, path?: string): Promise<string> {
|
async addPlexScanJob(
|
||||||
|
libraryId: string,
|
||||||
|
partial?: boolean,
|
||||||
|
path?: string,
|
||||||
|
): Promise<string> {
|
||||||
return await this.addJob(
|
return await this.addJob(
|
||||||
'scan_plex',
|
'scan_plex',
|
||||||
{
|
{
|
||||||
@@ -626,7 +780,7 @@ export class JobQueueService {
|
|||||||
} as ScanPlexPayload,
|
} as ScanPlexPayload,
|
||||||
{
|
{
|
||||||
priority: 7,
|
priority: 7,
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -641,7 +795,7 @@ export class JobQueueService {
|
|||||||
} as PlexRecentlyAddedPayload,
|
} as PlexRecentlyAddedPayload,
|
||||||
{
|
{
|
||||||
priority: 8,
|
priority: 8,
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -656,7 +810,7 @@ export class JobQueueService {
|
|||||||
} as MonitorRssFeedsPayload,
|
} as MonitorRssFeedsPayload,
|
||||||
{
|
{
|
||||||
priority: 8,
|
priority: 8,
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -671,7 +825,7 @@ export class JobQueueService {
|
|||||||
} as AudibleRefreshPayload,
|
} as AudibleRefreshPayload,
|
||||||
{
|
{
|
||||||
priority: 9,
|
priority: 9,
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -686,7 +840,7 @@ export class JobQueueService {
|
|||||||
} as RetryMissingTorrentsPayload,
|
} as RetryMissingTorrentsPayload,
|
||||||
{
|
{
|
||||||
priority: 7,
|
priority: 7,
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -701,7 +855,7 @@ export class JobQueueService {
|
|||||||
} as RetryFailedImportsPayload,
|
} as RetryFailedImportsPayload,
|
||||||
{
|
{
|
||||||
priority: 7,
|
priority: 7,
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -716,14 +870,18 @@ export class JobQueueService {
|
|||||||
} as CleanupSeededTorrentsPayload,
|
} as CleanupSeededTorrentsPayload,
|
||||||
{
|
{
|
||||||
priority: 10,
|
priority: 10,
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add sync Goodreads shelves job
|
* Add sync Goodreads shelves job
|
||||||
*/
|
*/
|
||||||
async addSyncGoodreadsShelvesJob(scheduledJobId?: string, shelfId?: string, maxLookupsPerShelf?: number): Promise<string> {
|
async addSyncGoodreadsShelvesJob(
|
||||||
|
scheduledJobId?: string,
|
||||||
|
shelfId?: string,
|
||||||
|
maxLookupsPerShelf?: number,
|
||||||
|
): Promise<string> {
|
||||||
return await this.addJob(
|
return await this.addJob(
|
||||||
'sync_goodreads_shelves',
|
'sync_goodreads_shelves',
|
||||||
{
|
{
|
||||||
@@ -733,7 +891,28 @@ export class JobQueueService {
|
|||||||
} as SyncGoodreadsShelvesPayload,
|
} as SyncGoodreadsShelvesPayload,
|
||||||
{
|
{
|
||||||
priority: 7,
|
priority: 7,
|
||||||
}
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add sync Hardcover shelves job
|
||||||
|
*/
|
||||||
|
async addSyncHardcoverShelvesJob(
|
||||||
|
scheduledJobId?: string,
|
||||||
|
shelfId?: string,
|
||||||
|
maxLookupsPerShelf?: number,
|
||||||
|
): Promise<string> {
|
||||||
|
return await this.addJob(
|
||||||
|
'sync_hardcover_shelves',
|
||||||
|
{
|
||||||
|
scheduledJobId,
|
||||||
|
shelfId,
|
||||||
|
maxLookupsPerShelf,
|
||||||
|
} as SyncHardcoverShelvesPayload,
|
||||||
|
{
|
||||||
|
priority: 7,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -747,7 +926,7 @@ export class JobQueueService {
|
|||||||
async addSearchEbookJob(
|
async addSearchEbookJob(
|
||||||
requestId: string,
|
requestId: string,
|
||||||
audiobook: { id: string; title: string; author: string; asin?: string },
|
audiobook: { id: string; title: string; author: string; asin?: string },
|
||||||
preferredFormat?: string
|
preferredFormat?: string,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
return await this.addJob(
|
return await this.addJob(
|
||||||
'search_ebook',
|
'search_ebook',
|
||||||
@@ -758,7 +937,7 @@ export class JobQueueService {
|
|||||||
} as SearchEbookPayload,
|
} as SearchEbookPayload,
|
||||||
{
|
{
|
||||||
priority: 10, // High priority for user-initiated requests
|
priority: 10, // High priority for user-initiated requests
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -770,7 +949,7 @@ export class JobQueueService {
|
|||||||
downloadHistoryId: string,
|
downloadHistoryId: string,
|
||||||
downloadUrl: string,
|
downloadUrl: string,
|
||||||
targetFilename: string,
|
targetFilename: string,
|
||||||
expectedSize?: number
|
expectedSize?: number,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
return await this.addJob(
|
return await this.addJob(
|
||||||
'start_direct_download',
|
'start_direct_download',
|
||||||
@@ -783,7 +962,7 @@ export class JobQueueService {
|
|||||||
} as StartDirectDownloadPayload,
|
} as StartDirectDownloadPayload,
|
||||||
{
|
{
|
||||||
priority: 9, // High priority - download selected ebook
|
priority: 9, // High priority - download selected ebook
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -796,7 +975,7 @@ export class JobQueueService {
|
|||||||
downloadId: string,
|
downloadId: string,
|
||||||
targetPath: string,
|
targetPath: string,
|
||||||
expectedSize?: number,
|
expectedSize?: number,
|
||||||
delaySeconds: number = 0
|
delaySeconds: number = 0,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
return await this.addJob(
|
return await this.addJob(
|
||||||
'monitor_direct_download',
|
'monitor_direct_download',
|
||||||
@@ -810,7 +989,7 @@ export class JobQueueService {
|
|||||||
{
|
{
|
||||||
priority: 5, // Medium priority
|
priority: 5, // Medium priority
|
||||||
delay: delaySeconds * 1000,
|
delay: delaySeconds * 1000,
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -959,9 +1138,13 @@ export class JobQueueService {
|
|||||||
author: string,
|
author: string,
|
||||||
userName: string,
|
userName: string,
|
||||||
message?: string,
|
message?: string,
|
||||||
requestType?: string
|
requestType?: string,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
logger.info(`Queueing notification: ${event}`, { requestId, title, userName });
|
logger.info(`Queueing notification: ${event}`, {
|
||||||
|
requestId,
|
||||||
|
title,
|
||||||
|
userName,
|
||||||
|
});
|
||||||
return await this.addJob(
|
return await this.addJob(
|
||||||
'send_notification',
|
'send_notification',
|
||||||
{
|
{
|
||||||
@@ -981,7 +1164,7 @@ export class JobQueueService {
|
|||||||
} as SendNotificationPayload,
|
} as SendNotificationPayload,
|
||||||
{
|
{
|
||||||
priority: 5, // Medium priority
|
priority: 5, // Medium priority
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -992,7 +1175,7 @@ export class JobQueueService {
|
|||||||
jobType: string,
|
jobType: string,
|
||||||
payload: JobPayload,
|
payload: JobPayload,
|
||||||
cronExpression: string,
|
cronExpression: string,
|
||||||
jobId: string
|
jobId: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await this.queue.add(jobType, payload, {
|
await this.queue.add(jobType, payload, {
|
||||||
repeat: {
|
repeat: {
|
||||||
@@ -1009,7 +1192,7 @@ export class JobQueueService {
|
|||||||
async removeRepeatableJob(
|
async removeRepeatableJob(
|
||||||
jobType: string,
|
jobType: string,
|
||||||
cronExpression: string,
|
cronExpression: string,
|
||||||
jobId: string
|
jobId: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await this.queue.removeRepeatable(jobType, {
|
await this.queue.removeRepeatable(jobType, {
|
||||||
cron: cronExpression,
|
cron: cronExpression,
|
||||||
|
|||||||
@@ -10,7 +10,16 @@ import { RMABLogger } from '../utils/logger';
|
|||||||
|
|
||||||
const logger = RMABLogger.create('Scheduler');
|
const logger = RMABLogger.create('Scheduler');
|
||||||
|
|
||||||
export type ScheduledJobType = 'plex_library_scan' | 'plex_recently_added_check' | 'audible_refresh' | 'retry_missing_torrents' | 'retry_failed_imports' | 'cleanup_seeded_torrents' | 'monitor_rss_feeds' | '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 {
|
export interface ScheduledJob {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -133,6 +142,13 @@ export class SchedulerService {
|
|||||||
enabled: true, // Enable by default
|
enabled: true, // Enable by default
|
||||||
payload: {},
|
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;
|
let created = 0;
|
||||||
@@ -149,7 +165,9 @@ export class SchedulerService {
|
|||||||
data: defaultJob,
|
data: defaultJob,
|
||||||
});
|
});
|
||||||
created++;
|
created++;
|
||||||
logger.info(`Created default job: ${defaultJob.name} (enabled: ${defaultJob.enabled})`);
|
logger.info(
|
||||||
|
`Created default job: ${defaultJob.name} (enabled: ${defaultJob.enabled})`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
failed++;
|
failed++;
|
||||||
@@ -161,7 +179,9 @@ export class SchedulerService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (failed > 0) {
|
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) {
|
} else if (created > 0) {
|
||||||
logger.info(`Default jobs: ${created} created`);
|
logger.info(`Default jobs: ${created} created`);
|
||||||
}
|
}
|
||||||
@@ -191,11 +211,13 @@ export class SchedulerService {
|
|||||||
job.type,
|
job.type,
|
||||||
{ scheduledJobId: job.id },
|
{ scheduledJobId: job.id },
|
||||||
job.schedule,
|
job.schedule,
|
||||||
`scheduled-${job.id}`
|
`scheduled-${job.id}`,
|
||||||
);
|
);
|
||||||
logger.info(`Job scheduled: ${job.name} (${job.schedule})`);
|
logger.info(`Job scheduled: ${job.name} (${job.schedule})`);
|
||||||
} catch (error) {
|
} 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;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -208,11 +230,13 @@ export class SchedulerService {
|
|||||||
await this.jobQueue.removeRepeatableJob(
|
await this.jobQueue.removeRepeatableJob(
|
||||||
job.type,
|
job.type,
|
||||||
job.schedule,
|
job.schedule,
|
||||||
`scheduled-${job.id}`
|
`scheduled-${job.id}`,
|
||||||
);
|
);
|
||||||
logger.info(`Job unscheduled: ${job.name}`);
|
logger.info(`Job unscheduled: ${job.name}`);
|
||||||
} catch (error) {
|
} 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
|
// Don't throw - job might not exist in Bull yet
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -264,7 +288,7 @@ export class SchedulerService {
|
|||||||
*/
|
*/
|
||||||
async updateScheduledJob(
|
async updateScheduledJob(
|
||||||
id: string,
|
id: string,
|
||||||
dto: UpdateScheduledJobDto
|
dto: UpdateScheduledJobDto,
|
||||||
): Promise<ScheduledJob> {
|
): Promise<ScheduledJob> {
|
||||||
if (dto.schedule) {
|
if (dto.schedule) {
|
||||||
this.validateCronExpression(dto.schedule);
|
this.validateCronExpression(dto.schedule);
|
||||||
@@ -353,6 +377,9 @@ export class SchedulerService {
|
|||||||
case 'sync_goodreads_shelves':
|
case 'sync_goodreads_shelves':
|
||||||
bullJobId = await this.triggerSyncGoodreadsShelves(job);
|
bullJobId = await this.triggerSyncGoodreadsShelves(job);
|
||||||
break;
|
break;
|
||||||
|
case 'sync_hardcover_shelves':
|
||||||
|
bullJobId = await this.triggerSyncHardcoverShelves(job);
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
throw new Error(`Unknown job type: ${job.type}`);
|
throw new Error(`Unknown job type: ${job.type}`);
|
||||||
}
|
}
|
||||||
@@ -408,7 +435,8 @@ export class SchedulerService {
|
|||||||
throw new Error(errorMsg);
|
throw new Error(errorMsg);
|
||||||
}
|
}
|
||||||
|
|
||||||
libraryId = job.payload?.libraryId || absConfig['audiobookshelf.library_id'];
|
libraryId =
|
||||||
|
job.payload?.libraryId || absConfig['audiobookshelf.library_id'];
|
||||||
} else {
|
} else {
|
||||||
const plexConfig = await configService.getMany([
|
const plexConfig = await configService.getMany([
|
||||||
'plex_url',
|
'plex_url',
|
||||||
@@ -432,15 +460,18 @@ export class SchedulerService {
|
|||||||
throw new Error(errorMsg);
|
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(
|
return await this.jobQueue.addPlexScanJob(
|
||||||
libraryId || '',
|
libraryId || '',
|
||||||
job.payload?.partial,
|
job.payload?.partial,
|
||||||
job.payload?.path
|
job.payload?.path,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -461,7 +492,6 @@ export class SchedulerService {
|
|||||||
return await this.jobQueue.addAudibleRefreshJob(job.id);
|
return await this.jobQueue.addAudibleRefreshJob(job.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Enable a scheduled job
|
* Enable a scheduled job
|
||||||
*/
|
*/
|
||||||
@@ -493,10 +523,12 @@ export class SchedulerService {
|
|||||||
await this.triggerJobNow(job.id);
|
await this.triggerJobNow(job.id);
|
||||||
|
|
||||||
// Stagger triggers to avoid connection pool burst on startup
|
// 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) {
|
} 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 === '*') {
|
if (dayOfMonth === '*' && month === '*' && dayOfWeek === '*') {
|
||||||
const hourNum = parseInt(hour, 10);
|
const hourNum = parseInt(hour, 10);
|
||||||
const minuteNum = parseInt(minute, 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
|
return 24 * 60 * 60 * 1000; // 24 hours
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// For other patterns, return a conservative default (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;
|
return 24 * 60 * 60 * 1000;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -627,6 +668,13 @@ export class SchedulerService {
|
|||||||
private async triggerSyncGoodreadsShelves(job: any): Promise<string> {
|
private async triggerSyncGoodreadsShelves(job: any): Promise<string> {
|
||||||
return await this.jobQueue.addSyncGoodreadsShelvesJob(job.id);
|
return await this.jobQueue.addSyncGoodreadsShelvesJob(job.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trigger Hardcover lists sync
|
||||||
|
*/
|
||||||
|
private async triggerSyncHardcoverShelves(job: any): Promise<string> {
|
||||||
|
return await this.jobQueue.addSyncHardcoverShelvesJob(job.id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Singleton instance
|
// Singleton instance
|
||||||
|
|||||||
Reference in New Issue
Block a user