mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +00:00
Add Hardcover shelf sync & unify book mappings
Introduce Hardcover provider support and consolidate per-provider book mapping tables into a unified BookMapping model. Adds two Prisma migrations (add_hardcover_shelves, unify_book_mappings), new backend services (hardcover-api, shelf-sync-core), and provider-specific sync logic and API routes for hardcover shelves with token/list validation. Frontend: new HardcoverForm component, refactor AddShelfModal to support Hardcover, hook updates, and small UI/accessibility tweaks. Also add documentation for Goodreads and Hardcover sync flows and update tests to cover scheduler/prisma helpers.
This commit is contained in:
@@ -32,6 +32,14 @@
|
|||||||
- **File hash matching for accurate ASIN** → [fixes/file-hash-matching.md](fixes/file-hash-matching.md)
|
- **File hash matching for accurate ASIN** → [fixes/file-hash-matching.md](fixes/file-hash-matching.md)
|
||||||
- **OIDC authentication** → [backend/services/auth.md](backend/services/auth.md)
|
- **OIDC authentication** → [backend/services/auth.md](backend/services/auth.md)
|
||||||
|
|
||||||
|
## Reading Shelves (Goodreads, Hardcover)
|
||||||
|
- **Goodreads shelf sync (RSS feeds)** → [backend/services/goodreads-sync.md](backend/services/goodreads-sync.md)
|
||||||
|
- **Hardcover shelf sync (GraphQL API)** → [backend/services/hardcover-sync.md](backend/services/hardcover-sync.md)
|
||||||
|
- **Shared sync core (Audible lookup, request creation)** → [backend/services/goodreads-sync.md](backend/services/goodreads-sync.md#shared-sync-core)
|
||||||
|
- **Combined shelves API, GenericShelf** → [backend/services/goodreads-sync.md](backend/services/goodreads-sync.md)
|
||||||
|
- **Hook factory (createShelfHooks)** → [backend/services/goodreads-sync.md](backend/services/goodreads-sync.md#hook-factory)
|
||||||
|
- **Adding a new shelf provider** → [backend/services/goodreads-sync.md](backend/services/goodreads-sync.md#adding-a-new-provider)
|
||||||
|
|
||||||
## Audible Integration
|
## Audible Integration
|
||||||
- **Web scraping (popular, new releases)** → [integrations/audible.md](integrations/audible.md)
|
- **Web scraping (popular, new releases)** → [integrations/audible.md](integrations/audible.md)
|
||||||
- **Database caching, real-time matching** → [integrations/audible.md](integrations/audible.md)
|
- **Database caching, real-time matching** → [integrations/audible.md](integrations/audible.md)
|
||||||
@@ -150,3 +158,7 @@
|
|||||||
**"Why do BookDate library books show placeholders?"** → [features/library-thumbnail-cache.md](features/library-thumbnail-cache.md)
|
**"Why do BookDate library books show placeholders?"** → [features/library-thumbnail-cache.md](features/library-thumbnail-cache.md)
|
||||||
**"How does file hash matching work?"** → [fixes/file-hash-matching.md](fixes/file-hash-matching.md)
|
**"How does file hash matching work?"** → [fixes/file-hash-matching.md](fixes/file-hash-matching.md)
|
||||||
**"Why is ABS matching the wrong book?"** → [fixes/file-hash-matching.md](fixes/file-hash-matching.md) (file hash prevents false positives)
|
**"Why is ABS matching the wrong book?"** → [fixes/file-hash-matching.md](fixes/file-hash-matching.md) (file hash prevents false positives)
|
||||||
|
**"How do Goodreads shelves work?"** → [backend/services/goodreads-sync.md](backend/services/goodreads-sync.md)
|
||||||
|
**"How do Hardcover shelves work?"** → [backend/services/hardcover-sync.md](backend/services/hardcover-sync.md)
|
||||||
|
**"How do I add a new shelf provider?"** → [backend/services/goodreads-sync.md](backend/services/goodreads-sync.md#adding-a-new-provider)
|
||||||
|
**"How does the shelf sync core work?"** → [backend/services/goodreads-sync.md](backend/services/goodreads-sync.md#shared-sync-core)
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# Goodreads & Shelf Sync
|
||||||
|
|
||||||
|
**Status:** ✅ Implemented | RSS feed parsing, shared sync core, extensible provider architecture
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
Syncs user-subscribed Goodreads shelves via RSS feeds, resolves books to Audible ASINs, and creates requests. Also documents the shared shelf sync core used by all providers.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Files
|
||||||
|
- `src/lib/services/goodreads-sync.service.ts` — RSS fetch/parse, delegates to shared core
|
||||||
|
- `src/lib/services/shelf-sync-core.service.ts` — Shared sync logic (Audible lookup, cover enrichment, request creation)
|
||||||
|
- `src/lib/utils/shelf-helpers.ts` — Shared `processBooks()` utility for cover URL parsing
|
||||||
|
- `src/lib/hooks/createShelfHooks.ts` — Generic hook factory for shelf CRUD operations
|
||||||
|
- `src/app/api/user/goodreads-shelves/route.ts` — GET (list) + POST (add) routes
|
||||||
|
- `src/app/api/user/goodreads-shelves/[id]/route.ts` — DELETE + PATCH routes
|
||||||
|
- `src/app/api/user/shelves/route.ts` — Combined GET for all providers (GenericShelf shape)
|
||||||
|
- `src/lib/hooks/useGoodreadsShelves.ts` — Frontend hooks (via `createShelfHooks` factory)
|
||||||
|
|
||||||
|
### Database Models
|
||||||
|
- **GoodreadsShelf** — Per-user shelf subscription (`userId`, `rssUrl`, `name`, `lastSyncAt`, `bookCount`, `coverUrls`)
|
||||||
|
- **BookMapping** — Shared table for all providers. Keyed by `provider` + `externalBookId`. Caches Audible ASIN lookups.
|
||||||
|
|
||||||
|
## Goodreads RSS Feed
|
||||||
|
- **Format:** `https://www.goodreads.com/review/list_rss/{userId}?shelf={shelfName}`
|
||||||
|
- **Auth:** None required (public RSS)
|
||||||
|
- **Parsing:** `fast-xml-parser` extracts `item` entries with `book_id`, `title`, `author_name`, `book_image_url`
|
||||||
|
|
||||||
|
## Shared Sync Core
|
||||||
|
|
||||||
|
`shelf-sync-core.service.ts` contains all provider-agnostic sync logic:
|
||||||
|
|
||||||
|
### Interface: `ShelfBook`
|
||||||
|
```typescript
|
||||||
|
{ bookId: string; title: string; author: string; coverUrl?: string }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Function: `processShelfBooks()`
|
||||||
|
Accepts provider-agnostic book list + context, performs:
|
||||||
|
1. **BookMapping lookup** — Check if book already resolved (`provider` + `externalBookId`)
|
||||||
|
2. **Audible search** — Full query (`title author`), fallback with cleaned title (strips parenthetical series info)
|
||||||
|
3. **noMatch retry** — Re-searches after `NO_MATCH_RETRY_DAYS` (7 days)
|
||||||
|
4. **Request creation** — Calls `createRequestForUser()` for matched ASINs
|
||||||
|
5. **Cover enrichment** — Queries `audibleCache` for cached covers, builds `/api/cache/thumbnails/` URLs
|
||||||
|
6. **Shelf metadata update** — Writes `lastSyncAt`, `bookCount`, top 8 books as JSON to `coverUrls`
|
||||||
|
|
||||||
|
### Constants
|
||||||
|
- `DEFAULT_MAX_LOOKUPS_PER_SHELF` = 10 (per scheduled cycle; 0 = unlimited for manual triggers)
|
||||||
|
- `NO_MATCH_RETRY_DAYS` = 7
|
||||||
|
|
||||||
|
### Hook Factory: `createShelfHooks(endpoint)`
|
||||||
|
Returns `{ useList, useAdd, useDelete, useUpdate }` — all with SWR caching, optimistic updates, and automatic revalidation of the combined `/api/user/shelves` endpoint.
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
| Method | Path | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| GET | `/api/user/goodreads-shelves` | List user's Goodreads shelves |
|
||||||
|
| POST | `/api/user/goodreads-shelves` | Add shelf (validates RSS feed, triggers sync) |
|
||||||
|
| DELETE | `/api/user/goodreads-shelves/[id]` | Remove shelf (ownership check) |
|
||||||
|
| PATCH | `/api/user/goodreads-shelves/[id]` | Update RSS URL (triggers re-sync) |
|
||||||
|
| GET | `/api/user/shelves` | Combined endpoint — merges all providers into `GenericShelf` |
|
||||||
|
|
||||||
|
## Adding a New Provider
|
||||||
|
1. Create Prisma shelf model + migration (BookMapping table is already shared)
|
||||||
|
2. Create API client service for the external data source
|
||||||
|
3. Create thin sync service (~50-80 lines) that fetches books and calls `processShelfBooks()`
|
||||||
|
4. Create API routes (or use a generic route handler)
|
||||||
|
5. Create hook file (~40 lines) using `createShelfHooks(endpoint)`
|
||||||
|
6. Add tab in `AddShelfModal` with provider-specific form fields
|
||||||
|
|
||||||
|
## Related
|
||||||
|
- [Hardcover sync](hardcover-sync.md)
|
||||||
|
- [Background jobs](jobs.md)
|
||||||
|
- [Scheduler](scheduler.md)
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# Hardcover Shelf Sync
|
||||||
|
|
||||||
|
**Status:** ✅ Implemented | GraphQL API integration, Audible ASIN resolution, automated request creation
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
Syncs user-subscribed Hardcover lists via their GraphQL API, resolves books to Audible ASINs, and creates audiobook requests automatically.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Files
|
||||||
|
- `src/lib/services/hardcover-api.service.ts` — GraphQL queries, `fetchHardcoverList()`
|
||||||
|
- `src/lib/services/hardcover-sync.service.ts` — Provider-specific orchestration, delegates to shared core
|
||||||
|
- `src/lib/services/shelf-sync-core.service.ts` — Shared sync logic (Audible lookup, cover enrichment, request creation)
|
||||||
|
- `src/app/api/user/hardcover-shelves/route.ts` — GET (list) + POST (add) routes
|
||||||
|
- `src/app/api/user/hardcover-shelves/[id]/route.ts` — DELETE + PATCH routes
|
||||||
|
- `src/lib/hooks/useHardcoverShelves.ts` — Frontend hooks (via `createShelfHooks` factory)
|
||||||
|
|
||||||
|
### Database Models
|
||||||
|
- **HardcoverShelf** — Per-user list subscription (`userId`, `listId`, encrypted `apiToken`, `name`, `lastSyncAt`, `bookCount`, `coverUrls`)
|
||||||
|
- **BookMapping** — Shared across all providers. Keyed by `provider` + `externalBookId`. Caches Audible ASIN resolution (`audibleAsin`, `noMatch`, `lastSearchAt`)
|
||||||
|
|
||||||
|
## Hardcover API
|
||||||
|
|
||||||
|
- **Endpoint:** `https://api.hardcover.app/v1/graphql` (Hasura-based)
|
||||||
|
- **Auth:** Bearer token in Authorization header
|
||||||
|
- **Username type:** `citext` (case-insensitive text) — use `$username: citext!` in GraphQL variables
|
||||||
|
|
||||||
|
### Query Strategies (custom lists)
|
||||||
|
| Input | Strategy | Query root |
|
||||||
|
|---|---|---|
|
||||||
|
| URL with `@username` | Scoped to that user | `users(where: {username: {_eq: $username}}) { lists(...) }` |
|
||||||
|
| Bare slug (no username) | Authenticated user's own list | `me { lists(where: {slug: {_eq: $slug}}) }` |
|
||||||
|
| Numeric ID | Global lookup (IDs are unique) | `lists(where: {id: {_eq: $listId}})` |
|
||||||
|
|
||||||
|
### Status Lists
|
||||||
|
- Prefix: `status-{id}` (e.g., `status-1`)
|
||||||
|
- Query: `me { user_books(where: {status_id: {_eq: $statusId}}) }`
|
||||||
|
- Status IDs: 1=Want to Read, 2=Currently Reading, 3=Read, 4=Did Not Finish
|
||||||
|
|
||||||
|
## Sync Flow
|
||||||
|
1. Fetch shelves from DB (all or specific `shelfId`)
|
||||||
|
2. Decrypt API token (encryption service)
|
||||||
|
3. Fetch books from Hardcover GraphQL API
|
||||||
|
4. Delegate to `processShelfBooks()` in shelf-sync-core (Audible lookup, request creation, cover enrichment)
|
||||||
|
5. Update shelf metadata (`lastSyncAt`, `bookCount`, `coverUrls`)
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
| Method | Path | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| GET | `/api/user/hardcover-shelves` | List user's shelves with book counts/covers |
|
||||||
|
| POST | `/api/user/hardcover-shelves` | Add new shelf (validates via API fetch, encrypts token, triggers sync) |
|
||||||
|
| DELETE | `/api/user/hardcover-shelves/[id]` | Remove shelf (ownership check) |
|
||||||
|
| PATCH | `/api/user/hardcover-shelves/[id]` | Update listId/apiToken (triggers re-sync on change) |
|
||||||
|
|
||||||
|
## Key Details
|
||||||
|
- **Token cleanup:** Strips `Bearer ` prefix if user pastes it
|
||||||
|
- **Duplicate check:** Unique constraint on `(userId, listId)`
|
||||||
|
- **Immediate sync:** POST and PATCH trigger `addSyncShelvesJob()` with unlimited lookups
|
||||||
|
- **Scheduled sync:** Runs via `sync_reading_shelves` job (default: max 10 lookups/shelf/cycle)
|
||||||
|
- **Cover data:** Stores top 8 books as JSON in `coverUrls` field for shelf card display
|
||||||
|
|
||||||
|
## Related
|
||||||
|
- [Shelf sync core (shared logic)](goodreads-sync.md#shared-sync-core)
|
||||||
|
- [Background jobs](jobs.md)
|
||||||
|
- [Scheduler](scheduler.md)
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "hardcover_shelves" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"user_id" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"list_id" TEXT NOT NULL,
|
||||||
|
"api_token" TEXT NOT NULL,
|
||||||
|
"last_sync_at" TIMESTAMP(3),
|
||||||
|
"book_count" INTEGER,
|
||||||
|
"cover_urls" TEXT,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "hardcover_shelves_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "hardcover_book_mappings" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"hardcover_book_id" TEXT NOT NULL,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"author" TEXT NOT NULL,
|
||||||
|
"audible_asin" TEXT,
|
||||||
|
"cover_url" TEXT,
|
||||||
|
"no_match" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"last_search_at" TIMESTAMP(3),
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "hardcover_book_mappings_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "hardcover_shelves_user_id_idx" ON "hardcover_shelves"("user_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "hardcover_shelves_user_id_list_id_key" ON "hardcover_shelves"("user_id", "list_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "hardcover_book_mappings_hardcover_book_id_key" ON "hardcover_book_mappings"("hardcover_book_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "hardcover_book_mappings_hardcover_book_id_idx" ON "hardcover_book_mappings"("hardcover_book_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "hardcover_book_mappings_audible_asin_idx" ON "hardcover_book_mappings"("audible_asin");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "hardcover_shelves" ADD CONSTRAINT "hardcover_shelves_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "book_mappings" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"provider" TEXT NOT NULL,
|
||||||
|
"external_book_id" TEXT NOT NULL,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"author" TEXT NOT NULL,
|
||||||
|
"audible_asin" TEXT,
|
||||||
|
"cover_url" TEXT,
|
||||||
|
"no_match" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"last_search_at" TIMESTAMP(3),
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "book_mappings_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Migrate data from goodreads_book_mappings
|
||||||
|
INSERT INTO "book_mappings" ("id", "provider", "external_book_id", "title", "author", "audible_asin", "cover_url", "no_match", "last_search_at", "created_at", "updated_at")
|
||||||
|
SELECT "id", 'goodreads', "goodreads_book_id", "title", "author", "audible_asin", "cover_url", "no_match", "last_search_at", "created_at", "updated_at"
|
||||||
|
FROM "goodreads_book_mappings";
|
||||||
|
|
||||||
|
-- Migrate data from hardcover_book_mappings
|
||||||
|
INSERT INTO "book_mappings" ("id", "provider", "external_book_id", "title", "author", "audible_asin", "cover_url", "no_match", "last_search_at", "created_at", "updated_at")
|
||||||
|
SELECT "id", 'hardcover', "hardcover_book_id", "title", "author", "audible_asin", "cover_url", "no_match", "last_search_at", "created_at", "updated_at"
|
||||||
|
FROM "hardcover_book_mappings";
|
||||||
|
|
||||||
|
-- DropTable
|
||||||
|
DROP TABLE "goodreads_book_mappings";
|
||||||
|
|
||||||
|
-- DropTable
|
||||||
|
DROP TABLE "hardcover_book_mappings";
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "book_mappings_provider_external_book_id_key" ON "book_mappings"("provider", "external_book_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "book_mappings_provider_external_book_id_idx" ON "book_mappings"("provider", "external_book_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "book_mappings_audible_asin_idx" ON "book_mappings"("audible_asin");
|
||||||
+22
-31
@@ -518,26 +518,34 @@ model GoodreadsShelf {
|
|||||||
@@map("goodreads_shelves")
|
@@map("goodreads_shelves")
|
||||||
}
|
}
|
||||||
|
|
||||||
model GoodreadsBookMapping {
|
// ============================================================================
|
||||||
id String @id @default(uuid())
|
// UNIFIED BOOK MAPPING TABLE
|
||||||
goodreadsBookId String @unique @map("goodreads_book_id")
|
// Global book-to-ASIN mapping cache shared across all shelf providers.
|
||||||
title String
|
// Uses provider + externalBookId composite key for cross-provider dedup.
|
||||||
author String
|
// ============================================================================
|
||||||
audibleAsin String? @map("audible_asin")
|
|
||||||
coverUrl String? @map("cover_url") @db.Text
|
|
||||||
noMatch Boolean @default(false) @map("no_match")
|
|
||||||
lastSearchAt DateTime? @map("last_search_at")
|
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
|
||||||
|
|
||||||
@@index([goodreadsBookId])
|
model BookMapping {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
provider String // "goodreads", "hardcover", etc.
|
||||||
|
externalBookId String @map("external_book_id")
|
||||||
|
title String
|
||||||
|
author String
|
||||||
|
audibleAsin String? @map("audible_asin")
|
||||||
|
coverUrl String? @map("cover_url") @db.Text
|
||||||
|
noMatch Boolean @default(false) @map("no_match")
|
||||||
|
lastSearchAt DateTime? @map("last_search_at")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
@@unique([provider, externalBookId])
|
||||||
|
@@index([provider, externalBookId])
|
||||||
@@index([audibleAsin])
|
@@index([audibleAsin])
|
||||||
@@map("goodreads_book_mappings")
|
@@map("book_mappings")
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// HARDCOVER SYNC TABLES
|
// HARDCOVER SYNC TABLES
|
||||||
// Per-user Hardcover list subscriptions + global book-to-ASIN mapping cache
|
// Per-user Hardcover list subscriptions
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
model HardcoverShelf {
|
model HardcoverShelf {
|
||||||
@@ -560,23 +568,6 @@ model HardcoverShelf {
|
|||||||
@@map("hardcover_shelves")
|
@@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")
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// WORKS TABLE
|
// WORKS TABLE
|
||||||
// Cross-ASIN audiobook identity mapping — links multiple Audible ASINs
|
// Cross-ASIN audiobook identity mapping — links multiple Audible ASINs
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { prisma } from '@/lib/db';
|
|||||||
import { RMABLogger } from '@/lib/utils/logger';
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
import { getJobQueueService } from '@/lib/services/job-queue.service';
|
import { getJobQueueService } from '@/lib/services/job-queue.service';
|
||||||
import { getEncryptionService } from '@/lib/services/encryption.service';
|
import { getEncryptionService } from '@/lib/services/encryption.service';
|
||||||
|
import { fetchHardcoverList } from '@/lib/services/hardcover-api.service';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
const logger = RMABLogger.create('API.HardcoverShelves');
|
const logger = RMABLogger.create('API.HardcoverShelves');
|
||||||
@@ -90,21 +91,50 @@ export async function PATCH(
|
|||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { listId, apiToken } = UpdateHardcoverSchema.parse(body);
|
const { listId, apiToken } = UpdateHardcoverSchema.parse(body);
|
||||||
|
|
||||||
const updateData: any = {};
|
const updateData: { listId?: string; apiToken?: string; lastSyncAt?: null; bookCount?: null; coverUrls?: null } = {};
|
||||||
let needsResync = false;
|
let needsResync = false;
|
||||||
|
|
||||||
if (listId && listId !== shelf.listId) {
|
let cleanedToken: string | undefined;
|
||||||
updateData.listId = listId;
|
|
||||||
needsResync = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (apiToken && apiToken.trim() !== '') {
|
if (apiToken && apiToken.trim() !== '') {
|
||||||
const cleanedToken = apiToken.trim().toLowerCase().startsWith('bearer ')
|
cleanedToken = apiToken.trim().toLowerCase().startsWith('bearer ')
|
||||||
? apiToken.trim().slice(7).trim()
|
? apiToken.trim().slice(7).trim()
|
||||||
: apiToken.trim();
|
: apiToken.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const newListId = (listId && listId !== shelf.listId) ? listId : undefined;
|
||||||
|
|
||||||
|
// Validate token/listId by fetching the list before saving
|
||||||
|
if (cleanedToken || newListId) {
|
||||||
const encryptionService = getEncryptionService();
|
const encryptionService = getEncryptionService();
|
||||||
updateData.apiToken = encryptionService.encrypt(cleanedToken);
|
const tokenToTest = cleanedToken || (() => {
|
||||||
needsResync = true;
|
try {
|
||||||
|
return encryptionService.isEncryptedFormat(shelf.apiToken)
|
||||||
|
? encryptionService.decrypt(shelf.apiToken)
|
||||||
|
: shelf.apiToken;
|
||||||
|
} catch { return shelf.apiToken; }
|
||||||
|
})();
|
||||||
|
const listIdToTest = newListId || shelf.listId;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetchHardcoverList(tokenToTest, listIdToTest);
|
||||||
|
} 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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newListId) {
|
||||||
|
updateData.listId = newListId;
|
||||||
|
needsResync = true;
|
||||||
|
}
|
||||||
|
if (cleanedToken) {
|
||||||
|
updateData.apiToken = encryptionService.encrypt(cleanedToken);
|
||||||
|
needsResync = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we are forcing a resync due to a change, clear metadata
|
// If we are forcing a resync due to a change, clear metadata
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { getJobQueueService } from '@/lib/services/job-queue.service';
|
|||||||
import { getEncryptionService } from '@/lib/services/encryption.service';
|
import { getEncryptionService } from '@/lib/services/encryption.service';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { RMABLogger } from '@/lib/utils/logger';
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
|
import { processBooks } from '@/lib/utils/shelf-helpers';
|
||||||
|
|
||||||
const logger = RMABLogger.create('API.HardcoverShelves');
|
const logger = RMABLogger.create('API.HardcoverShelves');
|
||||||
|
|
||||||
@@ -36,29 +37,7 @@ export async function GET(request: NextRequest) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const shelvesWithMeta = shelves.map((shelf) => {
|
const shelvesWithMeta = shelves.map((shelf) => {
|
||||||
let books: {
|
const books = processBooks(shelf.coverUrls);
|
||||||
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 {
|
return {
|
||||||
id: shelf.id,
|
id: shelf.id,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
|
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||||
import { prisma } from '@/lib/db';
|
import { prisma } from '@/lib/db';
|
||||||
import { RMABLogger } from '@/lib/utils/logger';
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
|
import { processBooks } from '@/lib/utils/shelf-helpers';
|
||||||
|
|
||||||
const logger = RMABLogger.create('API.Shelves');
|
const logger = RMABLogger.create('API.Shelves');
|
||||||
|
|
||||||
@@ -32,33 +33,6 @@ export async function GET(request: NextRequest) {
|
|||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const processBooks = (coverUrls: string | null) => {
|
|
||||||
let books: {
|
|
||||||
coverUrl: string;
|
|
||||||
asin: string | null;
|
|
||||||
title: string;
|
|
||||||
author: string;
|
|
||||||
}[] = [];
|
|
||||||
if (coverUrls) {
|
|
||||||
const parsed = JSON.parse(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 books;
|
|
||||||
};
|
|
||||||
|
|
||||||
const combined = [
|
const combined = [
|
||||||
...goodreads.map((s) => ({
|
...goodreads.map((s) => ({
|
||||||
id: s.id,
|
id: s.id,
|
||||||
|
|||||||
@@ -354,8 +354,9 @@ function ShelfCard({
|
|||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<button
|
<button
|
||||||
onClick={onManage}
|
onClick={onManage}
|
||||||
className="p-2 text-gray-300 hover:text-blue-500 dark:text-gray-600 dark:hover:text-blue-400 transition-all duration-200 rounded-xl hover:bg-blue-50 dark:hover:bg-blue-500/10 opacity-0 group-hover:opacity-100 focus:opacity-100"
|
className="p-2 text-gray-400 hover:text-blue-500 dark:text-gray-500 dark:hover:text-blue-400 transition-all duration-200 rounded-xl hover:bg-blue-50 dark:hover:bg-blue-500/10 opacity-40 hover:opacity-100 focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-blue-500/40 outline-none"
|
||||||
title="Manage shelf"
|
title="Manage shelf"
|
||||||
|
aria-label="Manage shelf"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
className="w-[18px] h-[18px]"
|
className="w-[18px] h-[18px]"
|
||||||
@@ -373,8 +374,9 @@ function ShelfCard({
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={onConfirmDelete}
|
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"
|
className="p-2 text-gray-400 hover:text-red-400 dark:text-gray-500 dark:hover:text-red-400 transition-all duration-200 rounded-xl hover:bg-red-50 dark:hover:bg-red-500/10 opacity-40 hover:opacity-100 focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-red-500/40 outline-none"
|
||||||
title="Remove shelf"
|
title="Remove shelf"
|
||||||
|
aria-label="Remove shelf"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
className="w-[18px] h-[18px]"
|
className="w-[18px] h-[18px]"
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { Input } from './Input';
|
|||||||
import { Button } from './Button';
|
import { Button } from './Button';
|
||||||
import { useAddGoodreadsShelf } from '@/lib/hooks/useGoodreadsShelves';
|
import { useAddGoodreadsShelf } from '@/lib/hooks/useGoodreadsShelves';
|
||||||
import { useAddHardcoverShelf } from '@/lib/hooks/useHardcoverShelves';
|
import { useAddHardcoverShelf } from '@/lib/hooks/useHardcoverShelves';
|
||||||
|
import { HardcoverForm } from './HardcoverForm';
|
||||||
|
|
||||||
interface AddShelfModalProps {
|
interface AddShelfModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -20,9 +21,7 @@ interface AddShelfModalProps {
|
|||||||
const GOODREADS_RSS_PATTERN = /goodreads\.com\/review\/list_rss\//;
|
const GOODREADS_RSS_PATTERN = /goodreads\.com\/review\/list_rss\//;
|
||||||
|
|
||||||
export function AddShelfModal({ isOpen, onClose }: AddShelfModalProps) {
|
export function AddShelfModal({ isOpen, onClose }: AddShelfModalProps) {
|
||||||
const [provider, setProvider] = useState<'goodreads' | 'hardcover'>(
|
const [provider, setProvider] = useState<'goodreads' | 'hardcover'>('goodreads');
|
||||||
'goodreads',
|
|
||||||
);
|
|
||||||
|
|
||||||
// Goodreads State
|
// Goodreads State
|
||||||
const [rssUrl, setRssUrl] = useState('');
|
const [rssUrl, setRssUrl] = useState('');
|
||||||
@@ -30,27 +29,18 @@ export function AddShelfModal({ isOpen, onClose }: AddShelfModalProps) {
|
|||||||
// Hardcover State
|
// Hardcover State
|
||||||
const [apiToken, setApiToken] = useState('');
|
const [apiToken, setApiToken] = useState('');
|
||||||
const [listType, setListType] = useState<'status' | 'custom'>('status');
|
const [listType, setListType] = useState<'status' | 'custom'>('status');
|
||||||
const [statusId, setStatusId] = useState('1'); // 1 = Want to Read
|
const [statusId, setStatusId] = useState('1');
|
||||||
const [customListId, setCustomListId] = useState('');
|
const [customListId, setCustomListId] = useState('');
|
||||||
|
|
||||||
const [validationError, setValidationError] = useState('');
|
const [validationError, setValidationError] = useState('');
|
||||||
const [success, setSuccess] = useState(false);
|
const [success, setSuccess] = useState(false);
|
||||||
const [successMessage, setSuccessMessage] = useState('');
|
const [successMessage, setSuccessMessage] = useState('');
|
||||||
|
|
||||||
const {
|
const { addShelf: addGoodreads, isLoading: isGoodreadsLoading, error: goodreadsError } = useAddGoodreadsShelf();
|
||||||
addShelf: addGoodreads,
|
const { addShelf: addHardcover, isLoading: isHardcoverLoading, error: hardcoverError } = useAddHardcoverShelf();
|
||||||
isLoading: isGoodreadsLoading,
|
|
||||||
error: goodreadsError,
|
|
||||||
} = useAddGoodreadsShelf();
|
|
||||||
const {
|
|
||||||
addShelf: addHardcover,
|
|
||||||
isLoading: isHardcoverLoading,
|
|
||||||
error: hardcoverError,
|
|
||||||
} = useAddHardcoverShelf();
|
|
||||||
|
|
||||||
const isLoading = isGoodreadsLoading || isHardcoverLoading;
|
const isLoading = isGoodreadsLoading || isHardcoverLoading;
|
||||||
const currentError =
|
const currentError = provider === 'goodreads' ? goodreadsError : hardcoverError;
|
||||||
provider === 'goodreads' ? goodreadsError : hardcoverError;
|
|
||||||
|
|
||||||
const validateInput = (): boolean => {
|
const validateInput = (): boolean => {
|
||||||
if (provider === 'goodreads') {
|
if (provider === 'goodreads') {
|
||||||
@@ -59,9 +49,7 @@ export function AddShelfModal({ isOpen, onClose }: AddShelfModalProps) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (!GOODREADS_RSS_PATTERN.test(rssUrl)) {
|
if (!GOODREADS_RSS_PATTERN.test(rssUrl)) {
|
||||||
setValidationError(
|
setValidationError('Must be a Goodreads shelf RSS URL (goodreads.com/review/list_rss/...)');
|
||||||
'Must be a Goodreads shelf RSS URL (goodreads.com/review/list_rss/...)',
|
|
||||||
);
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -88,8 +76,7 @@ export function AddShelfModal({ isOpen, onClose }: AddShelfModalProps) {
|
|||||||
setSuccessMessage(`Added shelf "${shelf.name}" successfully!`);
|
setSuccessMessage(`Added shelf "${shelf.name}" successfully!`);
|
||||||
setRssUrl('');
|
setRssUrl('');
|
||||||
} else {
|
} else {
|
||||||
const finalId =
|
const finalId = listType === 'status' ? `status-${statusId}` : customListId.trim();
|
||||||
listType === 'status' ? `status-${statusId}` : customListId.trim();
|
|
||||||
let cleanedToken = apiToken.trim();
|
let cleanedToken = apiToken.trim();
|
||||||
if (cleanedToken.toLowerCase().startsWith('bearer ')) {
|
if (cleanedToken.toLowerCase().startsWith('bearer ')) {
|
||||||
cleanedToken = cleanedToken.slice(7).trim();
|
cleanedToken = cleanedToken.slice(7).trim();
|
||||||
@@ -124,7 +111,8 @@ export function AddShelfModal({ isOpen, onClose }: AddShelfModalProps) {
|
|||||||
return (
|
return (
|
||||||
<Modal isOpen={isOpen} onClose={handleClose} title="Add Shelf" size="sm">
|
<Modal isOpen={isOpen} onClose={handleClose} title="Add Shelf" size="sm">
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
{/* Provider Selection Tabs */}
|
|
||||||
|
{/* Provider Tabs */}
|
||||||
<div className="flex p-1 bg-gray-100 dark:bg-gray-800 rounded-lg">
|
<div className="flex p-1 bg-gray-100 dark:bg-gray-800 rounded-lg">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -133,10 +121,7 @@ export function AddShelfModal({ isOpen, onClose }: AddShelfModalProps) {
|
|||||||
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm ring-1 ring-gray-200 dark:ring-gray-600'
|
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm ring-1 ring-gray-200 dark:ring-gray-600'
|
||||||
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'
|
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'
|
||||||
}`}
|
}`}
|
||||||
onClick={() => {
|
onClick={() => { setProvider('goodreads'); setValidationError(''); }}
|
||||||
setProvider('goodreads');
|
|
||||||
setValidationError('');
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
Goodreads
|
Goodreads
|
||||||
</button>
|
</button>
|
||||||
@@ -147,97 +132,56 @@ export function AddShelfModal({ isOpen, onClose }: AddShelfModalProps) {
|
|||||||
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm ring-1 ring-gray-200 dark:ring-gray-600'
|
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm ring-1 ring-gray-200 dark:ring-gray-600'
|
||||||
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'
|
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'
|
||||||
}`}
|
}`}
|
||||||
onClick={() => {
|
onClick={() => { setProvider('hardcover'); setValidationError(''); }}
|
||||||
setProvider('hardcover');
|
|
||||||
setValidationError('');
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
Hardcover
|
Hardcover
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Visual header */}
|
{/* Visual Header */}
|
||||||
<div className="flex items-center gap-4 pb-4 border-b border-gray-100 dark:border-gray-700/50">
|
<div className="flex items-center gap-4 pb-4 border-b border-gray-100 dark:border-gray-700/50">
|
||||||
{provider === 'goodreads' ? (
|
{provider === 'goodreads' ? (
|
||||||
<>
|
<>
|
||||||
<div className="w-11 h-11 rounded-xl bg-gradient-to-br from-amber-50 to-orange-50 dark:from-amber-500/10 dark:to-orange-500/10 flex items-center justify-center ring-1 ring-amber-200/50 dark:ring-amber-500/10 flex-shrink-0">
|
<div className="w-11 h-11 rounded-xl bg-gradient-to-br from-amber-50 to-orange-50 dark:from-amber-500/10 dark:to-orange-500/10 flex items-center justify-center ring-1 ring-amber-200/50 dark:ring-amber-500/10 flex-shrink-0">
|
||||||
<img
|
<img src="/goodreads-icon.png" alt="Goodreads" className="w-5 h-5 object-contain" />
|
||||||
src="/goodreads-icon.png"
|
|
||||||
alt="Goodreads"
|
|
||||||
className="w-5 h-5 object-contain"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-300 leading-relaxed">
|
|
||||||
Paste your Goodreads shelf RSS URL. Books will be
|
|
||||||
automatically requested.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-300 leading-relaxed">
|
||||||
|
Paste your Goodreads shelf RSS URL. Books will be automatically requested.
|
||||||
|
</p>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<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">
|
<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">
|
||||||
<img
|
<img src="/hardcover-icon.svg" alt="Hardcover" className="w-6 h-6 object-contain" />
|
||||||
src="/hardcover-icon.svg"
|
|
||||||
alt="Hardcover"
|
|
||||||
className="w-6 h-6 object-contain"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-300 leading-relaxed">
|
|
||||||
Provide your Hardcover API token and select the list you want
|
|
||||||
to sync.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-300 leading-relaxed">
|
||||||
|
Connect a Hardcover reading list and books will be automatically requested as you add them.
|
||||||
|
</p>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Success alert */}
|
{/* Success Alert */}
|
||||||
{success && (
|
{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="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">
|
<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
|
<svg className="w-4 h-4 text-emerald-600 dark:text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
|
||||||
className="w-4 h-4 text-emerald-600 dark:text-emerald-400"
|
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
strokeWidth={2}
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
d="M5 13l4 4L19 7"
|
|
||||||
/>
|
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm font-medium text-emerald-700 dark:text-emerald-300">
|
<p className="text-sm font-medium text-emerald-700 dark:text-emerald-300">{successMessage}</p>
|
||||||
{successMessage}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Error alert */}
|
{/* Error Alert */}
|
||||||
{currentError && (
|
{currentError && (
|
||||||
<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="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">
|
<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
|
<svg className="w-4 h-4 text-red-600 dark:text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
|
||||||
className="w-4 h-4 text-red-600 dark:text-red-400"
|
<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" />
|
||||||
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>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm font-medium text-red-700 dark:text-red-300">
|
<p className="text-sm font-medium text-red-700 dark:text-red-300">{currentError}</p>
|
||||||
{currentError}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -249,113 +193,37 @@ export function AddShelfModal({ isOpen, onClose }: AddShelfModalProps) {
|
|||||||
type="url"
|
type="url"
|
||||||
label="Goodreads RSS URL"
|
label="Goodreads RSS URL"
|
||||||
value={rssUrl}
|
value={rssUrl}
|
||||||
onChange={(e) => {
|
onChange={(e) => { setRssUrl(e.target.value); if (validationError) setValidationError(''); }}
|
||||||
setRssUrl(e.target.value);
|
|
||||||
if (validationError) setValidationError('');
|
|
||||||
}}
|
|
||||||
placeholder="https://www.goodreads.com/review/list_rss/..."
|
placeholder="https://www.goodreads.com/review/list_rss/..."
|
||||||
error={validationError}
|
error={validationError}
|
||||||
disabled={isLoading || success}
|
disabled={isLoading || success}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-2 leading-relaxed">
|
<p className="text-xs text-gray-400 dark:text-gray-500 mt-2 leading-relaxed">
|
||||||
Find it on Goodreads: My Books → select a shelf → RSS
|
Find it on Goodreads: My Books → select a shelf → RSS link at the bottom of the page.
|
||||||
link at the bottom of the page.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<HardcoverForm
|
||||||
<Input
|
apiToken={apiToken}
|
||||||
type="text"
|
setApiToken={setApiToken}
|
||||||
label="API Token"
|
listType={listType}
|
||||||
value={apiToken}
|
setListType={setListType}
|
||||||
onChange={(e) => {
|
statusId={statusId}
|
||||||
setApiToken(e.target.value);
|
setStatusId={setStatusId}
|
||||||
if (validationError) setValidationError('');
|
customListId={customListId}
|
||||||
}}
|
setCustomListId={setCustomListId}
|
||||||
placeholder="eyJhb..."
|
validationError={validationError}
|
||||||
disabled={isLoading || success}
|
setValidationError={setValidationError}
|
||||||
/>
|
isLoading={isLoading}
|
||||||
|
success={success}
|
||||||
<div className="space-y-2">
|
/>
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
|
||||||
List to Sync
|
|
||||||
</label>
|
|
||||||
<div className="flex gap-4">
|
|
||||||
<label className="flex items-center">
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
className="form-radio text-indigo-600"
|
|
||||||
checked={listType === 'status'}
|
|
||||||
onChange={() => setListType('status')}
|
|
||||||
disabled={isLoading || success}
|
|
||||||
/>
|
|
||||||
<span className="ml-2 text-sm text-gray-700 dark:text-gray-300">
|
|
||||||
My Status
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<label className="flex items-center">
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
className="form-radio text-indigo-600"
|
|
||||||
checked={listType === 'custom'}
|
|
||||||
onChange={() => setListType('custom')}
|
|
||||||
disabled={isLoading || success}
|
|
||||||
/>
|
|
||||||
<span className="ml-2 text-sm text-gray-700 dark:text-gray-300">
|
|
||||||
Custom List
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{listType === 'status' ? (
|
|
||||||
<div>
|
|
||||||
<select
|
|
||||||
className="w-full px-3 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm text-gray-900 dark:text-white disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
value={statusId}
|
|
||||||
onChange={(e) => setStatusId(e.target.value)}
|
|
||||||
disabled={isLoading || success}
|
|
||||||
>
|
|
||||||
<option value="1">Want to Read</option>
|
|
||||||
<option value="2">Currently Reading</option>
|
|
||||||
<option value="3">Read</option>
|
|
||||||
<option value="4">Did Not Finish</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
label="List URL or Slug"
|
|
||||||
value={customListId}
|
|
||||||
onChange={(e) => {
|
|
||||||
setCustomListId(e.target.value);
|
|
||||||
if (validationError) setValidationError('');
|
|
||||||
}}
|
|
||||||
placeholder="https://hardcover.app/@username/lists/..."
|
|
||||||
error={validationError}
|
|
||||||
disabled={isLoading || success}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex justify-end gap-3 pt-2">
|
<div className="flex justify-end gap-3 pt-2">
|
||||||
<Button
|
<Button type="button" variant="ghost" size="sm" onClick={handleClose} disabled={isLoading || success}>
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleClose}
|
|
||||||
disabled={isLoading || success}
|
|
||||||
>
|
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button type="submit" variant="primary" size="sm" loading={isLoading} disabled={isLoading || success}>
|
||||||
type="submit"
|
|
||||||
variant="primary"
|
|
||||||
size="sm"
|
|
||||||
loading={isLoading}
|
|
||||||
disabled={isLoading || success}
|
|
||||||
>
|
|
||||||
Add Shelf
|
Add Shelf
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,318 @@
|
|||||||
|
/**
|
||||||
|
* Component: Hardcover Shelf Form
|
||||||
|
* Documentation: documentation/frontend/components.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { Input } from './Input';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Status option definitions
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const STATUS_OPTIONS = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
label: 'Want to Read',
|
||||||
|
description: 'Books saved to read later',
|
||||||
|
icon: (
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.75}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M17.593 3.322c1.1.128 1.907 1.077 1.907 2.185V21L12 17.25 4.5 21V5.507c0-1.108.806-2.057 1.907-2.185a48.507 48.507 0 0 1 11.186 0Z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
label: 'Currently Reading',
|
||||||
|
description: 'Books actively being read',
|
||||||
|
icon: (
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.75}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6.042A8.967 8.967 0 0 0 6 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 0 1 6 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 0 1 6-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0 0 18 18a8.967 8.967 0 0 0-6 2.292m0-14.25v14.25" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
label: 'Read',
|
||||||
|
description: 'Books already finished',
|
||||||
|
icon: (
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.75}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '4',
|
||||||
|
label: 'Did Not Finish',
|
||||||
|
description: 'Books started but set aside',
|
||||||
|
icon: (
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.75}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M18.364 18.364A9 9 0 0 0 5.636 5.636m12.728 12.728A9 9 0 0 1 5.636 5.636m12.728 12.728L5.636 5.636" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface HardcoverFormProps {
|
||||||
|
apiToken: string;
|
||||||
|
setApiToken: (v: string) => void;
|
||||||
|
listType: 'status' | 'custom';
|
||||||
|
setListType: (v: 'status' | 'custom') => void;
|
||||||
|
statusId: string;
|
||||||
|
setStatusId: (v: string) => void;
|
||||||
|
customListId: string;
|
||||||
|
setCustomListId: (v: string) => void;
|
||||||
|
validationError: string;
|
||||||
|
setValidationError: (v: string) => void;
|
||||||
|
isLoading: boolean;
|
||||||
|
success: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function HardcoverForm({
|
||||||
|
apiToken, setApiToken,
|
||||||
|
listType, setListType,
|
||||||
|
statusId, setStatusId,
|
||||||
|
customListId, setCustomListId,
|
||||||
|
validationError, setValidationError,
|
||||||
|
isLoading, success,
|
||||||
|
}: HardcoverFormProps) {
|
||||||
|
const disabled = isLoading || success;
|
||||||
|
const isTokenError = validationError === 'Hardcover API Token is required';
|
||||||
|
const isListError = !isTokenError && !!validationError;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
|
||||||
|
{/* API Token */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-baseline justify-between">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
API Token
|
||||||
|
</label>
|
||||||
|
<a
|
||||||
|
href="https://hardcover.app/account/api"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-xs text-indigo-500 dark:text-indigo-400 hover:text-indigo-600 dark:hover:text-indigo-300 transition-colors flex items-center gap-1 group"
|
||||||
|
>
|
||||||
|
Get your token
|
||||||
|
<svg className="w-3 h-3 opacity-60 group-hover:opacity-100 transition-opacity" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 6H5.25A2.25 2.25 0 0 0 3 8.25v10.5A2.25 2.25 0 0 0 5.25 21h10.5A2.25 2.25 0 0 0 18 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={apiToken}
|
||||||
|
onChange={(e) => {
|
||||||
|
setApiToken(e.target.value);
|
||||||
|
if (isTokenError) setValidationError('');
|
||||||
|
}}
|
||||||
|
placeholder="Paste your Hardcover API token"
|
||||||
|
disabled={disabled}
|
||||||
|
className={[
|
||||||
|
'block w-full rounded-lg border px-4 py-2 text-sm transition-colors',
|
||||||
|
'focus:outline-none focus:ring-2 focus:ring-indigo-500/40 focus:border-indigo-500/60',
|
||||||
|
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||||
|
'bg-white dark:bg-gray-800/60 text-gray-900 dark:text-white',
|
||||||
|
'placeholder-gray-400 dark:placeholder-gray-500',
|
||||||
|
isTokenError
|
||||||
|
? 'border-red-400 dark:border-red-500'
|
||||||
|
: 'border-gray-200 dark:border-gray-700',
|
||||||
|
].join(' ')}
|
||||||
|
/>
|
||||||
|
{isTokenError && (
|
||||||
|
<p className="text-xs text-red-500 dark:text-red-400">{validationError}</p>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-gray-400 dark:text-gray-500 leading-relaxed">
|
||||||
|
Found under{' '}
|
||||||
|
<span className="font-medium text-gray-500 dark:text-gray-400">Settings → API</span>
|
||||||
|
{' '}on hardcover.app. Stored securely and never shared.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<div className="border-t border-gray-100 dark:border-gray-700/60" />
|
||||||
|
|
||||||
|
{/* List Type Selection */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
Which list should we watch?
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-400 dark:text-gray-500 mt-0.5">
|
||||||
|
Choose a reading status or one of your custom lists.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2.5">
|
||||||
|
<ListTypeCard
|
||||||
|
active={listType === 'status'}
|
||||||
|
onClick={() => setListType('status')}
|
||||||
|
disabled={disabled}
|
||||||
|
icon={
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.75}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 0 0 2.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 0 0-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 0 0 .75-.75 2.25 2.25 0 0 0-.1-.664m-5.8 0A2.251 2.251 0 0 1 13.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25Z" />
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
title="Reading Status"
|
||||||
|
subtitle="Want to Read, Reading, Read, etc."
|
||||||
|
/>
|
||||||
|
<ListTypeCard
|
||||||
|
active={listType === 'custom'}
|
||||||
|
onClick={() => setListType('custom')}
|
||||||
|
disabled={disabled}
|
||||||
|
icon={
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.75}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 6.75h12M8.25 12h12m-12 5.25h12M3.75 6.75h.007v.008H3.75V6.75Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0ZM3.75 12h.007v.008H3.75V12Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm-.375 5.25h.007v.008H3.75v-.008Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z" />
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
title="Custom List"
|
||||||
|
subtitle="A list you created on Hardcover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status picker or Custom list input */}
|
||||||
|
{listType === 'status' ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">Status to sync</p>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{STATUS_OPTIONS.map((opt) => (
|
||||||
|
<StatusRow
|
||||||
|
key={opt.id}
|
||||||
|
opt={opt}
|
||||||
|
selected={statusId === opt.id}
|
||||||
|
onSelect={() => setStatusId(opt.id)}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
label="List URL or Slug"
|
||||||
|
value={customListId}
|
||||||
|
onChange={(e) => {
|
||||||
|
setCustomListId(e.target.value);
|
||||||
|
if (isListError) setValidationError('');
|
||||||
|
}}
|
||||||
|
placeholder="https://hardcover.app/@username/lists/..."
|
||||||
|
error={isListError ? validationError : ''}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-400 dark:text-gray-500 leading-relaxed">
|
||||||
|
Paste the list URL from Hardcover, or enter just the slug (e.g.{' '}
|
||||||
|
<code className="font-mono text-gray-500 dark:text-gray-400 bg-gray-100 dark:bg-gray-700/60 px-1 py-0.5 rounded text-[11px]">my-audiobooks</code>
|
||||||
|
) or a numeric ID.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Sub-components
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function ListTypeCard({
|
||||||
|
active, onClick, disabled, icon, title, subtitle,
|
||||||
|
}: {
|
||||||
|
active: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
disabled: boolean;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={disabled}
|
||||||
|
className={[
|
||||||
|
'relative text-left p-3 rounded-xl border-2 transition-all duration-150',
|
||||||
|
'focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2',
|
||||||
|
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||||
|
active
|
||||||
|
? 'border-indigo-500 dark:border-indigo-400 bg-indigo-50/70 dark:bg-indigo-500/[0.08]'
|
||||||
|
: 'border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800/40 hover:border-gray-300 dark:hover:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-800/60',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
{active && (
|
||||||
|
<span className="absolute top-2.5 right-2.5 w-2 h-2 rounded-full bg-indigo-500 dark:bg-indigo-400" />
|
||||||
|
)}
|
||||||
|
<div className={[
|
||||||
|
'w-7 h-7 rounded-lg flex items-center justify-center mb-2',
|
||||||
|
active
|
||||||
|
? 'bg-indigo-100 dark:bg-indigo-500/20 text-indigo-600 dark:text-indigo-400'
|
||||||
|
: 'bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400',
|
||||||
|
].join(' ')}>
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
<p className={`text-sm font-medium leading-tight ${active ? 'text-indigo-700 dark:text-indigo-300' : 'text-gray-700 dark:text-gray-300'}`}>
|
||||||
|
{title}
|
||||||
|
</p>
|
||||||
|
<p className={`text-xs mt-0.5 leading-snug ${active ? 'text-indigo-500/80 dark:text-indigo-400/70' : 'text-gray-400 dark:text-gray-500'}`}>
|
||||||
|
{subtitle}
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusRow({
|
||||||
|
opt, selected, onSelect, disabled,
|
||||||
|
}: {
|
||||||
|
opt: typeof STATUS_OPTIONS[number];
|
||||||
|
selected: boolean;
|
||||||
|
onSelect: () => void;
|
||||||
|
disabled: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onSelect}
|
||||||
|
disabled={disabled}
|
||||||
|
className={[
|
||||||
|
'w-full flex items-center gap-3 px-3 py-2.5 rounded-xl border transition-all duration-150 text-left',
|
||||||
|
'focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1',
|
||||||
|
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||||
|
selected
|
||||||
|
? 'border-indigo-400/70 dark:border-indigo-500/50 bg-indigo-50 dark:bg-indigo-500/[0.08]'
|
||||||
|
: 'border-gray-200 dark:border-gray-700/80 bg-white dark:bg-gray-800/30 hover:border-gray-300 dark:hover:border-gray-600 hover:bg-gray-50/80 dark:hover:bg-gray-800/50',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
<span className={`flex-shrink-0 ${selected ? 'text-indigo-500 dark:text-indigo-400' : 'text-gray-400 dark:text-gray-500'}`}>
|
||||||
|
{opt.icon}
|
||||||
|
</span>
|
||||||
|
<span className="flex-1 min-w-0">
|
||||||
|
<span className={`block text-sm font-medium ${selected ? 'text-indigo-700 dark:text-indigo-300' : 'text-gray-700 dark:text-gray-300'}`}>
|
||||||
|
{opt.label}
|
||||||
|
</span>
|
||||||
|
<span className="block text-xs text-gray-400 dark:text-gray-500 mt-0.5">
|
||||||
|
{opt.description}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
{selected && (
|
||||||
|
<span className="flex-shrink-0">
|
||||||
|
<svg className="w-4 h-4 text-indigo-500 dark:text-indigo-400" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M16.704 4.153a.75.75 0 0 1 .143 1.052l-8 10.5a.75.75 0 0 1-1.127.075l-4.5-4.5a.75.75 0 0 1 1.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 0 1 1.05-.143Z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,3 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* Component: Manage Shelf Modal
|
||||||
|
* Documentation: documentation/frontend/components.md
|
||||||
|
*/
|
||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
@@ -18,8 +23,8 @@ export function ManageShelfModal({ shelf, isOpen, onClose }: ManageShelfModalPro
|
|||||||
const [listId, setListId] = useState(shelf?.type === 'hardcover' ? shelf.sourceId : '');
|
const [listId, setListId] = useState(shelf?.type === 'hardcover' ? shelf.sourceId : '');
|
||||||
const [apiToken, setApiToken] = useState('');
|
const [apiToken, setApiToken] = useState('');
|
||||||
|
|
||||||
const { updateShelf: updateGoodreads, isLoading: isUpdatingGoodreads } = useUpdateGoodreadsShelf();
|
const { updateShelf: updateGoodreads, isLoading: isUpdatingGoodreads, error: goodreadsError } = useUpdateGoodreadsShelf();
|
||||||
const { updateShelf: updateHardcover, isLoading: isUpdatingHardcover } = useUpdateHardcoverShelf();
|
const { updateShelf: updateHardcover, isLoading: isUpdatingHardcover, error: hardcoverError } = useUpdateHardcoverShelf();
|
||||||
|
|
||||||
// Reset form when shelf changes
|
// Reset form when shelf changes
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -33,6 +38,7 @@ export function ManageShelfModal({ shelf, isOpen, onClose }: ManageShelfModalPro
|
|||||||
if (!shelf) return null;
|
if (!shelf) return null;
|
||||||
|
|
||||||
const isUpdating = isUpdatingGoodreads || isUpdatingHardcover;
|
const isUpdating = isUpdatingGoodreads || isUpdatingHardcover;
|
||||||
|
const currentError = shelf.type === 'goodreads' ? goodreadsError : hardcoverError;
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -58,6 +64,17 @@ export function ManageShelfModal({ shelf, isOpen, onClose }: ManageShelfModalPro
|
|||||||
return (
|
return (
|
||||||
<Modal isOpen={isOpen} onClose={onClose} title={`Manage ${shelf.name}`}>
|
<Modal isOpen={isOpen} onClose={onClose} title={`Manage ${shelf.name}`}>
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
{currentError && (
|
||||||
|
<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 text-red-700 dark:text-red-300">{currentError}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-5">
|
<form onSubmit={handleSubmit} className="space-y-5">
|
||||||
{isGoodreads ? (
|
{isGoodreads ? (
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
/**
|
||||||
|
* Component: Shelf Hook Factory
|
||||||
|
* Documentation: documentation/frontend/components.md
|
||||||
|
*
|
||||||
|
* Generic hook factory for shelf CRUD operations. Each provider (Goodreads,
|
||||||
|
* Hardcover, etc.) calls this with its API endpoint to get fully typed hooks
|
||||||
|
* without duplicating the SWR/fetch/mutate boilerplate.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'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;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetcher = (url: string) => fetchWithAuth(url).then((res) => res.json());
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invalidate both the provider-specific endpoint and the combined /api/user/shelves endpoint.
|
||||||
|
*/
|
||||||
|
function revalidate(endpoint: string) {
|
||||||
|
mutate((key) => typeof key === 'string' && key.includes(endpoint));
|
||||||
|
mutate((key) => typeof key === 'string' && key.includes('/api/user/shelves'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a set of hooks for a shelf provider endpoint.
|
||||||
|
*
|
||||||
|
* Returns:
|
||||||
|
* - useList: SWR-based hook to list shelves
|
||||||
|
* - useAdd: Hook returning { addShelf(body), isLoading, error }
|
||||||
|
* - useDelete: Hook returning { deleteShelf(id), isLoading, error }
|
||||||
|
* - useUpdate: Hook returning { updateShelf(id, body), isLoading, error }
|
||||||
|
*/
|
||||||
|
export function createShelfHooks<TShelf>(endpoint: string) {
|
||||||
|
function useList() {
|
||||||
|
const { accessToken } = useAuth();
|
||||||
|
const key = accessToken ? endpoint : null;
|
||||||
|
|
||||||
|
const { data, error, isLoading } = useSWR(key, fetcher, {
|
||||||
|
refreshInterval: 30000,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
shelves: (data?.shelves || []) as TShelf[],
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function useAdd() {
|
||||||
|
const { accessToken } = useAuth();
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const addShelf = async (body: Record<string, unknown>) => {
|
||||||
|
if (!accessToken) throw new Error('Not authenticated');
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetchWithAuth(endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.message || data.error || 'Failed to add shelf');
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidate(endpoint);
|
||||||
|
return data.shelf as TShelf;
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||||
|
setError(message);
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { addShelf, isLoading, error };
|
||||||
|
}
|
||||||
|
|
||||||
|
function useDelete() {
|
||||||
|
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(`${endpoint}/${shelfId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.message || data.error || 'Failed to remove shelf');
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidate(endpoint);
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||||
|
setError(message);
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { deleteShelf, isLoading, error };
|
||||||
|
}
|
||||||
|
|
||||||
|
function useUpdate() {
|
||||||
|
const { accessToken } = useAuth();
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const updateShelf = async (shelfId: string, body: Record<string, unknown>) => {
|
||||||
|
if (!accessToken) throw new Error('Not authenticated');
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetchWithAuth(`${endpoint}/${shelfId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.message || data.error || 'Failed to update shelf');
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidate(endpoint);
|
||||||
|
return data.shelf as TShelf;
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||||
|
setError(message);
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { updateShelf, isLoading, error };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { useList, useAdd, useDelete, useUpdate };
|
||||||
|
}
|
||||||
@@ -5,17 +5,9 @@
|
|||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { createShelfHooks, ShelfBook } from './createShelfHooks';
|
||||||
import useSWR, { mutate } from 'swr';
|
|
||||||
import { useAuth } from '@/contexts/AuthContext';
|
|
||||||
import { fetchWithAuth } from '@/lib/utils/api';
|
|
||||||
|
|
||||||
export interface ShelfBook {
|
export type { ShelfBook };
|
||||||
coverUrl: string;
|
|
||||||
asin: string | null;
|
|
||||||
title: string;
|
|
||||||
author: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GoodreadsShelf {
|
export interface GoodreadsShelf {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -27,150 +19,28 @@ export interface GoodreadsShelf {
|
|||||||
books: ShelfBook[];
|
books: ShelfBook[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const fetcher = (url: string) =>
|
const { useList, useAdd, useDelete, useUpdate } =
|
||||||
fetchWithAuth(url).then((res) => res.json());
|
createShelfHooks<GoodreadsShelf>('/api/user/goodreads-shelves');
|
||||||
|
|
||||||
export function useGoodreadsShelves() {
|
export const useGoodreadsShelves = useList;
|
||||||
const { accessToken } = useAuth();
|
|
||||||
|
|
||||||
const endpoint = accessToken ? '/api/user/goodreads-shelves' : null;
|
|
||||||
|
|
||||||
const { data, error, isLoading } = useSWR(
|
|
||||||
endpoint,
|
|
||||||
fetcher,
|
|
||||||
{ refreshInterval: 30000 }
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
shelves: (data?.shelves || []) as GoodreadsShelf[],
|
|
||||||
isLoading,
|
|
||||||
error,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useAddGoodreadsShelf() {
|
export function useAddGoodreadsShelf() {
|
||||||
const { accessToken } = useAuth();
|
const { addShelf: addGeneric, isLoading, error } = useAdd();
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const addShelf = async (rssUrl: string) => {
|
const addShelf = async (rssUrl: string) => {
|
||||||
if (!accessToken) throw new Error('Not authenticated');
|
return addGeneric({ rssUrl });
|
||||||
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetchWithAuth('/api/user/goodreads-shelves', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ rssUrl }),
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(data.message || data.error || 'Failed to add shelf');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Revalidate shelves list
|
|
||||||
mutate((key) => typeof key === 'string' && key.includes('/api/user/goodreads-shelves'));
|
|
||||||
|
|
||||||
return data.shelf as GoodreadsShelf;
|
|
||||||
} catch (err) {
|
|
||||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
|
||||||
setError(message);
|
|
||||||
throw err;
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return { addShelf, isLoading, error };
|
return { addShelf, isLoading, error };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useDeleteGoodreadsShelf() {
|
export const useDeleteGoodreadsShelf = useDelete;
|
||||||
const { accessToken } = useAuth();
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const deleteShelf = async (shelfId: string) => {
|
|
||||||
if (!accessToken) throw new Error('Not authenticated');
|
|
||||||
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetchWithAuth(`/api/user/goodreads-shelves/${shelfId}`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(data.message || data.error || 'Failed to remove shelf');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Revalidate shelves list
|
|
||||||
mutate((key) => typeof key === 'string' && key.includes('/api/user/goodreads-shelves'));
|
|
||||||
|
|
||||||
return true;
|
|
||||||
} catch (err) {
|
|
||||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
|
||||||
setError(message);
|
|
||||||
throw err;
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return { deleteShelf, isLoading, error };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useUpdateGoodreadsShelf() {
|
export function useUpdateGoodreadsShelf() {
|
||||||
const { accessToken } = useAuth();
|
const { updateShelf: updateGeneric, isLoading, error } = useUpdate();
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const updateShelf = async (shelfId: string, rssUrl: string) => {
|
const updateShelf = async (shelfId: string, rssUrl: string) => {
|
||||||
if (!accessToken) throw new Error('Not authenticated');
|
return updateGeneric(shelfId, { rssUrl });
|
||||||
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetchWithAuth(
|
|
||||||
`/api/user/goodreads-shelves/${shelfId}`,
|
|
||||||
{
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ rssUrl }),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(data.message || data.error || 'Failed to update shelf');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Revalidate shelves list
|
|
||||||
mutate(
|
|
||||||
(key) =>
|
|
||||||
typeof key === 'string' &&
|
|
||||||
key.includes('/api/user/goodreads-shelves'),
|
|
||||||
);
|
|
||||||
mutate(
|
|
||||||
(key) => typeof key === 'string' && key.includes('/api/user/shelves'),
|
|
||||||
);
|
|
||||||
|
|
||||||
return data.shelf as GoodreadsShelf;
|
|
||||||
} catch (err) {
|
|
||||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
|
||||||
setError(message);
|
|
||||||
throw err;
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return { updateShelf, isLoading, error };
|
return { updateShelf, isLoading, error };
|
||||||
|
|||||||
@@ -5,17 +5,9 @@
|
|||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { createShelfHooks, ShelfBook } from './createShelfHooks';
|
||||||
import useSWR, { mutate } from 'swr';
|
|
||||||
import { useAuth } from '@/contexts/AuthContext';
|
|
||||||
import { fetchWithAuth } from '@/lib/utils/api';
|
|
||||||
|
|
||||||
export interface ShelfBook {
|
export type { ShelfBook };
|
||||||
coverUrl: string;
|
|
||||||
asin: string | null;
|
|
||||||
title: string;
|
|
||||||
author: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface HardcoverShelf {
|
export interface HardcoverShelf {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -27,161 +19,31 @@ export interface HardcoverShelf {
|
|||||||
books: ShelfBook[];
|
books: ShelfBook[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const fetcher = (url: string) => fetchWithAuth(url).then((res) => res.json());
|
const { useList, useAdd, useDelete, useUpdate } =
|
||||||
|
createShelfHooks<HardcoverShelf>('/api/user/hardcover-shelves');
|
||||||
|
|
||||||
export function useHardcoverShelves() {
|
export const useHardcoverShelves = useList;
|
||||||
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() {
|
export function useAddHardcoverShelf() {
|
||||||
const { accessToken } = useAuth();
|
const { addShelf: addGeneric, isLoading, error } = useAdd();
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const addShelf = async (apiToken: string, listId: string) => {
|
const addShelf = async (apiToken: string, listId: string) => {
|
||||||
if (!accessToken) throw new Error('Not authenticated');
|
return addGeneric({ apiToken, listId });
|
||||||
|
|
||||||
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 };
|
return { addShelf, isLoading, error };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useDeleteHardcoverShelf() {
|
export const useDeleteHardcoverShelf = useDelete;
|
||||||
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 };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useUpdateHardcoverShelf() {
|
export function useUpdateHardcoverShelf() {
|
||||||
const { accessToken } = useAuth();
|
const { updateShelf: updateGeneric, isLoading, error } = useUpdate();
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const updateShelf = async (
|
const updateShelf = async (
|
||||||
shelfId: string,
|
shelfId: string,
|
||||||
updates: { listId?: string; apiToken?: string },
|
updates: { listId?: string; apiToken?: string },
|
||||||
) => {
|
) => {
|
||||||
if (!accessToken) throw new Error('Not authenticated');
|
return updateGeneric(shelfId, updates);
|
||||||
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetchWithAuth(
|
|
||||||
`/api/user/hardcover-shelves/${shelfId}`,
|
|
||||||
{
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(updates),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(data.message || data.error || 'Failed to update list');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Revalidate shelves list
|
|
||||||
mutate(
|
|
||||||
(key) =>
|
|
||||||
typeof key === 'string' &&
|
|
||||||
key.includes('/api/user/hardcover-shelves'),
|
|
||||||
);
|
|
||||||
mutate(
|
|
||||||
(key) => typeof key === 'string' && key.includes('/api/user/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 { updateShelf, isLoading, error };
|
return { updateShelf, isLoading, error };
|
||||||
|
|||||||
@@ -2,36 +2,29 @@
|
|||||||
* Component: Goodreads Shelf Sync Service
|
* Component: Goodreads Shelf Sync Service
|
||||||
* Documentation: documentation/backend/services/goodreads-sync.md
|
* Documentation: documentation/backend/services/goodreads-sync.md
|
||||||
*
|
*
|
||||||
* Fetches Goodreads shelf RSS feeds, resolves books to Audible ASINs,
|
* Fetches Goodreads shelf RSS feeds and delegates book processing
|
||||||
* and creates requests via the shared request-creator service.
|
* to the shared shelf-sync-core service.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { XMLParser } from 'fast-xml-parser';
|
import { XMLParser } from 'fast-xml-parser';
|
||||||
import { prisma } from '@/lib/db';
|
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';
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
|
import {
|
||||||
|
ShelfBook,
|
||||||
|
ShelfSyncStats,
|
||||||
|
ShelfSyncOptions,
|
||||||
|
createEmptyStats,
|
||||||
|
resolveMaxLookups,
|
||||||
|
processShelfBooks,
|
||||||
|
} from '@/lib/services/shelf-sync-core.service';
|
||||||
|
|
||||||
const logger = RMABLogger.create('GoodreadsSync');
|
const logger = RMABLogger.create('GoodreadsSync');
|
||||||
|
|
||||||
/** Default max Audible lookups per shelf per scheduled sync cycle */
|
|
||||||
const DEFAULT_MAX_LOOKUPS_PER_SHELF = 10;
|
|
||||||
|
|
||||||
/** Days before retrying a noMatch book */
|
|
||||||
const NO_MATCH_RETRY_DAYS = 7;
|
|
||||||
|
|
||||||
interface GoodreadsRssBook {
|
|
||||||
bookId: string;
|
|
||||||
title: string;
|
|
||||||
author: string;
|
|
||||||
coverUrl?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse a Goodreads RSS feed XML into structured book data.
|
* Parse a Goodreads RSS feed XML into structured book data.
|
||||||
*/
|
*/
|
||||||
function parseGoodreadsRss(xml: string): { shelfName: string; books: GoodreadsRssBook[] } {
|
function parseGoodreadsRss(xml: string): { shelfName: string; books: ShelfBook[] } {
|
||||||
const parser = new XMLParser({
|
const parser = new XMLParser({
|
||||||
ignoreAttributes: false,
|
ignoreAttributes: false,
|
||||||
attributeNamePrefix: '@_',
|
attributeNamePrefix: '@_',
|
||||||
@@ -46,65 +39,84 @@ function parseGoodreadsRss(xml: string): { shelfName: string; books: GoodreadsRs
|
|||||||
|
|
||||||
const shelfName = typeof channel.title === 'string' ? channel.title : 'Goodreads Shelf';
|
const shelfName = typeof channel.title === 'string' ? channel.title : 'Goodreads Shelf';
|
||||||
|
|
||||||
// Normalize items to array
|
|
||||||
let items = channel.item;
|
let items = channel.item;
|
||||||
if (!items) return { shelfName, books: [] };
|
if (!items) return { shelfName, books: [] };
|
||||||
if (!Array.isArray(items)) items = [items];
|
if (!Array.isArray(items)) items = [items];
|
||||||
|
|
||||||
const books: GoodreadsRssBook[] = [];
|
const books: ShelfBook[] = [];
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
const bookId = item.book_id?.toString();
|
const bookId = item.book_id?.toString();
|
||||||
if (!bookId) continue;
|
if (!bookId) continue;
|
||||||
|
|
||||||
const title = (item.title || '').toString().trim();
|
const title = (item.title || '').toString().trim();
|
||||||
const authorName = (item.author_name || '').toString().trim();
|
const author = (item.author_name || '').toString().trim();
|
||||||
// Goodreads RSS has book_image_url or book_medium_image_url
|
|
||||||
const coverUrl = (item.book_large_image_url || item.book_medium_image_url || item.book_image_url || '').toString().trim() || undefined;
|
const coverUrl = (item.book_large_image_url || item.book_medium_image_url || item.book_image_url || '').toString().trim() || undefined;
|
||||||
|
|
||||||
if (title && authorName) {
|
if (title && author) {
|
||||||
books.push({ bookId, title, author: authorName, coverUrl });
|
books.push({ bookId, title, author, coverUrl });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { shelfName, books };
|
return { shelfName, books };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Max items Goodreads returns per RSS page */
|
||||||
|
const GOODREADS_PAGE_SIZE = 100;
|
||||||
|
|
||||||
|
/** Safety cap to avoid infinite loops */
|
||||||
|
const MAX_PAGES = 50;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch and validate a Goodreads RSS URL.
|
* Fetch and validate a Goodreads RSS URL.
|
||||||
* Returns the parsed shelf name and books if valid.
|
* Automatically paginates (sort=title, page=1,2,...) when a page returns 100 items.
|
||||||
|
* Deduplicates by bookId across pages.
|
||||||
*/
|
*/
|
||||||
export async function fetchAndValidateRss(rssUrl: string): Promise<{ shelfName: string; books: GoodreadsRssBook[] }> {
|
export async function fetchAndValidateRss(rssUrl: string): Promise<{ shelfName: string; books: ShelfBook[] }> {
|
||||||
const response = await axios.get(rssUrl, { timeout: 15000 });
|
const url = new URL(rssUrl);
|
||||||
return parseGoodreadsRss(response.data);
|
url.searchParams.set('sort', 'title');
|
||||||
|
|
||||||
|
let shelfName = 'Goodreads Shelf';
|
||||||
|
const seenIds = new Set<string>();
|
||||||
|
const allBooks: ShelfBook[] = [];
|
||||||
|
|
||||||
|
for (let page = 1; page <= MAX_PAGES; page++) {
|
||||||
|
url.searchParams.set('page', page.toString());
|
||||||
|
|
||||||
|
const response = await axios.get(url.toString(), { timeout: 15000 });
|
||||||
|
const parsed = parseGoodreadsRss(response.data);
|
||||||
|
|
||||||
|
if (page === 1) {
|
||||||
|
shelfName = parsed.shelfName;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const book of parsed.books) {
|
||||||
|
if (!seenIds.has(book.bookId)) {
|
||||||
|
seenIds.add(book.bookId);
|
||||||
|
allBooks.push(book);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.books.length < GOODREADS_PAGE_SIZE) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { shelfName, books: allBooks };
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GoodreadsSyncStats {
|
// Re-export types that downstream consumers expect
|
||||||
shelvesProcessed: number;
|
export type { ShelfSyncStats as GoodreadsSyncStats };
|
||||||
booksFound: number;
|
export type { ShelfSyncOptions as GoodreadsSyncOptions };
|
||||||
lookupsPerformed: number;
|
|
||||||
requestsCreated: number;
|
|
||||||
errors: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GoodreadsSyncOptions {
|
|
||||||
/** Process only this shelf ID (for immediate single-shelf sync) */
|
|
||||||
shelfId?: string;
|
|
||||||
/** Max Audible lookups per shelf. 0 = unlimited. Default: 10 for scheduled, unlimited for immediate. */
|
|
||||||
maxLookupsPerShelf?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Process Goodreads shelves: fetch RSS, resolve ASINs, create requests.
|
* Process Goodreads shelves: fetch RSS, resolve ASINs, create requests.
|
||||||
* Called from the dedicated sync_goodreads_shelves processor.
|
* Called from the unified sync_reading_shelves processor.
|
||||||
*/
|
*/
|
||||||
export async function processGoodreadsShelves(
|
export async function processGoodreadsShelves(
|
||||||
jobLogger?: ReturnType<typeof RMABLogger.forJob>,
|
jobLogger?: ReturnType<typeof RMABLogger.forJob>,
|
||||||
options: GoodreadsSyncOptions = {}
|
options: ShelfSyncOptions = {}
|
||||||
): Promise<GoodreadsSyncStats> {
|
): Promise<ShelfSyncStats> {
|
||||||
const log = jobLogger || logger;
|
const log = jobLogger || logger;
|
||||||
const stats: GoodreadsSyncStats = { shelvesProcessed: 0, booksFound: 0, lookupsPerformed: 0, requestsCreated: 0, errors: 0 };
|
const stats = createEmptyStats();
|
||||||
|
const maxLookups = resolveMaxLookups(options);
|
||||||
const maxLookups = options.maxLookupsPerShelf ?? DEFAULT_MAX_LOOKUPS_PER_SHELF;
|
|
||||||
|
|
||||||
const whereClause = options.shelfId ? { id: options.shelfId } : {};
|
const whereClause = options.shelfId ? { id: options.shelfId } : {};
|
||||||
const shelves = await prisma.goodreadsShelf.findMany({
|
const shelves = await prisma.goodreadsShelf.findMany({
|
||||||
@@ -121,7 +133,32 @@ export async function processGoodreadsShelves(
|
|||||||
|
|
||||||
for (const shelf of shelves) {
|
for (const shelf of shelves) {
|
||||||
try {
|
try {
|
||||||
await processShelf(shelf, stats, log, maxLookups);
|
log.info(`Fetching RSS for shelf "${shelf.name}" (user: ${shelf.user.plexUsername})`);
|
||||||
|
|
||||||
|
let rssData: { shelfName: string; books: ShelfBook[] };
|
||||||
|
try {
|
||||||
|
rssData = await fetchAndValidateRss(shelf.rssUrl);
|
||||||
|
} catch (error) {
|
||||||
|
log.error(`Failed to fetch RSS for shelf "${shelf.name}": ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||||
|
stats.errors++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info(`Found ${rssData.books.length} books in shelf "${shelf.name}"`);
|
||||||
|
|
||||||
|
const bookData = await processShelfBooks(
|
||||||
|
'goodreads', rssData.books, shelf.user.id, shelf.id, stats, log, maxLookups,
|
||||||
|
);
|
||||||
|
|
||||||
|
await prisma.goodreadsShelf.update({
|
||||||
|
where: { id: shelf.id },
|
||||||
|
data: {
|
||||||
|
lastSyncAt: new Date(),
|
||||||
|
bookCount: rssData.books.length,
|
||||||
|
coverUrls: bookData.length > 0 ? JSON.stringify(bookData) : null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
stats.shelvesProcessed++;
|
stats.shelvesProcessed++;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
stats.errors++;
|
stats.errors++;
|
||||||
@@ -132,238 +169,3 @@ export async function processGoodreadsShelves(
|
|||||||
log.info(`Goodreads sync complete: ${stats.shelvesProcessed} shelves, ${stats.booksFound} books, ${stats.lookupsPerformed} lookups, ${stats.requestsCreated} requests created, ${stats.errors} errors`);
|
log.info(`Goodreads sync complete: ${stats.shelvesProcessed} shelves, ${stats.booksFound} books, ${stats.lookupsPerformed} lookups, ${stats.requestsCreated} requests created, ${stats.errors} errors`);
|
||||||
return stats;
|
return stats;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function processShelf(
|
|
||||||
shelf: { id: string; rssUrl: string; name: string; user: { id: string; plexUsername: string } },
|
|
||||||
stats: GoodreadsSyncStats,
|
|
||||||
log: ReturnType<typeof RMABLogger.forJob> | ReturnType<typeof RMABLogger.create>,
|
|
||||||
maxLookups: number
|
|
||||||
) {
|
|
||||||
log.info(`Fetching RSS for shelf "${shelf.name}" (user: ${shelf.user.plexUsername})`);
|
|
||||||
|
|
||||||
let rssData: { shelfName: string; books: GoodreadsRssBook[] };
|
|
||||||
try {
|
|
||||||
rssData = await fetchAndValidateRss(shelf.rssUrl);
|
|
||||||
} catch (error) {
|
|
||||||
log.error(`Failed to fetch RSS for shelf "${shelf.name}": ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const books = rssData.books;
|
|
||||||
stats.booksFound += books.length;
|
|
||||||
log.info(`Found ${books.length} books in shelf "${shelf.name}"`);
|
|
||||||
|
|
||||||
let lookupsThisCycle = 0;
|
|
||||||
const unlimitedLookups = maxLookups === 0;
|
|
||||||
|
|
||||||
for (const book of books) {
|
|
||||||
// Look up existing mapping
|
|
||||||
let mapping = await prisma.goodreadsBookMapping.findUnique({
|
|
||||||
where: { goodreadsBookId: book.bookId },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!mapping) {
|
|
||||||
// No mapping exists — perform Audible lookup if under cap
|
|
||||||
if (!unlimitedLookups && lookupsThisCycle >= maxLookups) {
|
|
||||||
continue; // Will be resolved in a future cycle
|
|
||||||
}
|
|
||||||
|
|
||||||
mapping = await performAudibleLookup(book, log);
|
|
||||||
lookupsThisCycle++;
|
|
||||||
stats.lookupsPerformed++;
|
|
||||||
|
|
||||||
// If lookup found an ASIN, fall through to create request immediately
|
|
||||||
if (!mapping?.audibleAsin) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mapping exists with noMatch — check if we should retry
|
|
||||||
if (mapping.noMatch) {
|
|
||||||
if (mapping.lastSearchAt) {
|
|
||||||
const daysSinceSearch = (Date.now() - mapping.lastSearchAt.getTime()) / (1000 * 60 * 60 * 24);
|
|
||||||
if (daysSinceSearch >= NO_MATCH_RETRY_DAYS && (unlimitedLookups || lookupsThisCycle < maxLookups)) {
|
|
||||||
log.info(`Retrying Audible lookup for "${book.title}" (${NO_MATCH_RETRY_DAYS}+ days since last search)`);
|
|
||||||
mapping = await performAudibleLookup(book, log, mapping.id);
|
|
||||||
lookupsThisCycle++;
|
|
||||||
stats.lookupsPerformed++;
|
|
||||||
|
|
||||||
// If retry found an ASIN, fall through to create request
|
|
||||||
if (!mapping?.audibleAsin) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
continue; // Still no match, skip
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mapping has ASIN — try to create request
|
|
||||||
if (mapping.audibleAsin) {
|
|
||||||
try {
|
|
||||||
const result = await createRequestForUser(shelf.user.id, {
|
|
||||||
asin: mapping.audibleAsin,
|
|
||||||
title: mapping.title,
|
|
||||||
author: mapping.author,
|
|
||||||
coverArtUrl: mapping.coverUrl || undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
stats.requestsCreated++;
|
|
||||||
log.info(`Created request for "${mapping.title}" by ${mapping.author} (ASIN: ${mapping.audibleAsin})`);
|
|
||||||
}
|
|
||||||
// If not success, it's already available/requested/duplicate — silently skip
|
|
||||||
} catch (error) {
|
|
||||||
log.error(`Failed to create request for "${mapping.title}": ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Collect enriched book data (coverUrl + ASIN) for display
|
|
||||||
const bookIds = books.map(b => b.bookId);
|
|
||||||
const mappings = bookIds.length > 0
|
|
||||||
? await prisma.goodreadsBookMapping.findMany({
|
|
||||||
where: { goodreadsBookId: { in: bookIds } },
|
|
||||||
select: { goodreadsBookId: true, audibleAsin: true, title: true, author: true, coverUrl: true },
|
|
||||||
})
|
|
||||||
: [];
|
|
||||||
const mappingsByBookId = new Map(mappings.map(m => [m.goodreadsBookId, m]));
|
|
||||||
|
|
||||||
// Look up AudibleCache records for high-quality cached cover URLs
|
|
||||||
const matchedAsins = mappings
|
|
||||||
.map(m => m.audibleAsin)
|
|
||||||
.filter((asin): asin is string => !!asin);
|
|
||||||
const cachedCovers = matchedAsins.length > 0
|
|
||||||
? await prisma.audibleCache.findMany({
|
|
||||||
where: { asin: { in: matchedAsins } },
|
|
||||||
select: { asin: true, coverArtUrl: true, cachedCoverPath: true },
|
|
||||||
})
|
|
||||||
: [];
|
|
||||||
const coverByAsin = new Map(
|
|
||||||
cachedCovers
|
|
||||||
.filter(c => c.cachedCoverPath || c.coverArtUrl)
|
|
||||||
.map(c => {
|
|
||||||
let coverUrl = c.coverArtUrl || '';
|
|
||||||
if (c.cachedCoverPath) {
|
|
||||||
const filename = c.cachedCoverPath.split('/').pop();
|
|
||||||
coverUrl = `/api/cache/thumbnails/${filename}`;
|
|
||||||
}
|
|
||||||
return [c.asin, coverUrl] as const;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
const bookData = books
|
|
||||||
.map(b => {
|
|
||||||
const mapping = mappingsByBookId.get(b.bookId);
|
|
||||||
// Prefer cached cover (local proxy) > mapping cover > Goodreads RSS cover
|
|
||||||
const coverUrl = coverByAsin.get(mapping?.audibleAsin || '') || mapping?.coverUrl || b.coverUrl;
|
|
||||||
if (!coverUrl) return null;
|
|
||||||
return {
|
|
||||||
coverUrl,
|
|
||||||
asin: mapping?.audibleAsin || null,
|
|
||||||
title: mapping?.title || b.title,
|
|
||||||
author: mapping?.author || b.author,
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.filter((b): b is NonNullable<typeof b> => b !== null)
|
|
||||||
.slice(0, 8);
|
|
||||||
|
|
||||||
// Update shelf metadata
|
|
||||||
await prisma.goodreadsShelf.update({
|
|
||||||
where: { id: shelf.id },
|
|
||||||
data: {
|
|
||||||
lastSyncAt: new Date(),
|
|
||||||
bookCount: books.length,
|
|
||||||
coverUrls: bookData.length > 0 ? JSON.stringify(bookData) : null,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function performAudibleLookup(
|
|
||||||
book: GoodreadsRssBook,
|
|
||||||
log: ReturnType<typeof RMABLogger.forJob> | ReturnType<typeof RMABLogger.create>,
|
|
||||||
existingMappingId?: string
|
|
||||||
): Promise<any> {
|
|
||||||
const audibleService = getAudibleService();
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Try full Goodreads title first, then fall back to stripped title
|
|
||||||
// (Goodreads titles often include series info like "(Demonica, #2)" that return 0 Audible results)
|
|
||||||
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})`);
|
|
||||||
|
|
||||||
// Use clean Audible/Audnexus metadata instead of Goodreads data
|
|
||||||
// (Goodreads titles contain series info like "(The Empyrean, #1)" that pollute indexer searches)
|
|
||||||
const data = {
|
|
||||||
title: firstResult.title,
|
|
||||||
author: firstResult.author,
|
|
||||||
audibleAsin: firstResult.asin,
|
|
||||||
coverUrl: firstResult.coverArtUrl || book.coverUrl || null,
|
|
||||||
noMatch: false,
|
|
||||||
lastSearchAt: new Date(),
|
|
||||||
};
|
|
||||||
|
|
||||||
if (existingMappingId) {
|
|
||||||
return prisma.goodreadsBookMapping.update({ where: { id: existingMappingId }, data });
|
|
||||||
}
|
|
||||||
return prisma.goodreadsBookMapping.create({
|
|
||||||
data: { goodreadsBookId: book.bookId, ...data },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// No match found
|
|
||||||
log.info(`No Audible match for "${book.title}" by ${book.author}`);
|
|
||||||
|
|
||||||
const noMatchData = {
|
|
||||||
title: book.title,
|
|
||||||
author: book.author,
|
|
||||||
coverUrl: book.coverUrl || null,
|
|
||||||
noMatch: true,
|
|
||||||
lastSearchAt: new Date(),
|
|
||||||
audibleAsin: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (existingMappingId) {
|
|
||||||
return prisma.goodreadsBookMapping.update({ where: { id: existingMappingId }, data: noMatchData });
|
|
||||||
}
|
|
||||||
return prisma.goodreadsBookMapping.create({
|
|
||||||
data: { goodreadsBookId: book.bookId, ...noMatchData },
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
log.error(`Audible lookup failed for "${book.title}": ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
||||||
|
|
||||||
// Still create/update mapping so we don't retry every cycle
|
|
||||||
const errorData = {
|
|
||||||
title: book.title,
|
|
||||||
author: book.author,
|
|
||||||
coverUrl: book.coverUrl || null,
|
|
||||||
noMatch: true,
|
|
||||||
lastSearchAt: new Date(),
|
|
||||||
};
|
|
||||||
|
|
||||||
if (existingMappingId) {
|
|
||||||
return prisma.goodreadsBookMapping.update({ where: { id: existingMappingId }, data: errorData });
|
|
||||||
}
|
|
||||||
return prisma.goodreadsBookMapping.create({
|
|
||||||
data: { goodreadsBookId: book.bookId, ...errorData },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,263 @@
|
|||||||
|
/**
|
||||||
|
* Component: Hardcover API Service
|
||||||
|
* Documentation: documentation/backend/services/hardcover-sync.md
|
||||||
|
*
|
||||||
|
* GraphQL queries and API communication with the Hardcover platform.
|
||||||
|
* Exports fetchHardcoverList for use by the sync orchestration layer.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
const HARDCOVER_API_URL = 'https://api.hardcover.app/v1/graphql';
|
||||||
|
|
||||||
|
export 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[] }> {
|
||||||
|
// Check if it's a status list
|
||||||
|
const isStatus = listIdStr.startsWith('status-');
|
||||||
|
|
||||||
|
if (isStatus) {
|
||||||
|
const statusId = parseInt(listIdStr.replace('status-', ''), 10);
|
||||||
|
const query = `
|
||||||
|
query GetStatusBooks($statusId: Int!) {
|
||||||
|
me {
|
||||||
|
user_books(where: {status_id: {_eq: $statusId}}, limit: 100, order_by: {id: desc}) {
|
||||||
|
book {
|
||||||
|
id
|
||||||
|
title
|
||||||
|
contributions {
|
||||||
|
author {
|
||||||
|
name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cached_image
|
||||||
|
image {
|
||||||
|
url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const response = await axios.post(
|
||||||
|
HARDCOVER_API_URL,
|
||||||
|
{ query, variables: { statusId } },
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${apiToken}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
timeout: 30000,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.data?.errors) {
|
||||||
|
throw new Error(
|
||||||
|
`Hardcover API Error: ${response.data.errors[0]?.message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const userBooks = response.data?.data?.me?.[0]?.user_books || [];
|
||||||
|
let listName = 'Hardcover Status List';
|
||||||
|
|
||||||
|
// Map status numbers to names
|
||||||
|
const statusNames: Record<number, string> = {
|
||||||
|
1: 'Want to Read',
|
||||||
|
2: 'Currently Reading',
|
||||||
|
3: 'Read',
|
||||||
|
4: 'Did Not Finish',
|
||||||
|
};
|
||||||
|
listName = statusNames[statusId] || `Status ${statusId}`;
|
||||||
|
|
||||||
|
const books: HardcoverApiBook[] = [];
|
||||||
|
for (const item of userBooks) {
|
||||||
|
const book = item.book;
|
||||||
|
if (!book || !book.id) continue;
|
||||||
|
|
||||||
|
const authorName =
|
||||||
|
book.contributions?.[0]?.author?.name || 'Unknown Author';
|
||||||
|
const cachedImg = book.cached_image;
|
||||||
|
const coverUrl =
|
||||||
|
(typeof cachedImg === 'string' ? cachedImg : cachedImg?.url) ||
|
||||||
|
book.image?.url ||
|
||||||
|
undefined;
|
||||||
|
|
||||||
|
books.push({
|
||||||
|
bookId: book.id.toString(),
|
||||||
|
title: book.title || 'Unknown Title',
|
||||||
|
author: authorName,
|
||||||
|
coverUrl,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { listName, books };
|
||||||
|
} else {
|
||||||
|
// Custom list query
|
||||||
|
// - URL with @username → query that user's lists by slug
|
||||||
|
// - Bare slug (no username) → query authenticated user's lists via `me`
|
||||||
|
// - Numeric ID → query globally (IDs are unique)
|
||||||
|
const isIntId = /^\d+$/.test(listIdStr);
|
||||||
|
let extractedSlug = listIdStr;
|
||||||
|
let extractedUsername: string | null = null;
|
||||||
|
|
||||||
|
if (!isIntId) {
|
||||||
|
try {
|
||||||
|
if (listIdStr.includes('hardcover.app')) {
|
||||||
|
const url = new URL(
|
||||||
|
listIdStr.startsWith('http') ? listIdStr : `https://${listIdStr}`,
|
||||||
|
);
|
||||||
|
const parts = url.pathname.split('/').filter(Boolean);
|
||||||
|
// URL format: /@username/lists/slug
|
||||||
|
if (parts.length > 0) {
|
||||||
|
extractedSlug = parts[parts.length - 1];
|
||||||
|
}
|
||||||
|
const userPart = parts.find((p) => p.startsWith('@'));
|
||||||
|
if (userPart) {
|
||||||
|
extractedUsername = userPart.slice(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// use extractedSlug as-is
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const listBookFields = `
|
||||||
|
name
|
||||||
|
list_books(limit: 100, order_by: {id: desc}) {
|
||||||
|
book {
|
||||||
|
id title cached_image image { url }
|
||||||
|
contributions { author { name } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Numeric ID: globally unique, query the lists table directly
|
||||||
|
const queryById = `
|
||||||
|
query GetListBooks($listId: Int!) {
|
||||||
|
lists(where: {id: {_eq: $listId}}, limit: 1) {
|
||||||
|
${listBookFields}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Slug with username: query through the users table to scope to that user
|
||||||
|
const queryByUserSlug = `
|
||||||
|
query GetUserListBySlug($username: citext!, $slug: String!) {
|
||||||
|
users(where: {username: {_eq: $username}}, limit: 1) {
|
||||||
|
lists(where: {slug: {_eq: $slug}}, limit: 1) {
|
||||||
|
${listBookFields}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Bare slug (no username): scope to the authenticated user via `me`
|
||||||
|
const queryByMySlug = `
|
||||||
|
query GetMyListBySlug($slug: String!) {
|
||||||
|
me {
|
||||||
|
lists(where: {slug: {_eq: $slug}}, limit: 1) {
|
||||||
|
${listBookFields}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
let activeQuery: string;
|
||||||
|
let variables: Record<string, unknown>;
|
||||||
|
|
||||||
|
if (isIntId) {
|
||||||
|
activeQuery = queryById;
|
||||||
|
variables = { listId: parseInt(listIdStr, 10) };
|
||||||
|
} else if (extractedUsername) {
|
||||||
|
activeQuery = queryByUserSlug;
|
||||||
|
variables = { username: extractedUsername, slug: extractedSlug };
|
||||||
|
} else {
|
||||||
|
activeQuery = queryByMySlug;
|
||||||
|
variables = { slug: extractedSlug };
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await axios.post(
|
||||||
|
HARDCOVER_API_URL,
|
||||||
|
{
|
||||||
|
query: activeQuery,
|
||||||
|
variables,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${apiToken}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
timeout: 30000,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.data?.errors) {
|
||||||
|
throw new Error(
|
||||||
|
`Hardcover API Error: ${response.data.errors[0]?.message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract lists array from the response based on which query was used
|
||||||
|
let listsData: any[];
|
||||||
|
if (isIntId) {
|
||||||
|
listsData = response.data?.data?.lists || [];
|
||||||
|
} else if (extractedUsername) {
|
||||||
|
const users = response.data?.data?.users || [];
|
||||||
|
listsData = users[0]?.lists || [];
|
||||||
|
} else {
|
||||||
|
listsData = response.data?.data?.me?.[0]?.lists || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (listsData.length === 0) {
|
||||||
|
let identifier: string;
|
||||||
|
if (isIntId) {
|
||||||
|
identifier = `ID "${listIdStr}"`;
|
||||||
|
} else if (extractedUsername) {
|
||||||
|
identifier = `slug "${extractedSlug}" for user @${extractedUsername}`;
|
||||||
|
} else {
|
||||||
|
identifier = `slug "${extractedSlug}" in your Hardcover account`;
|
||||||
|
}
|
||||||
|
throw new Error(`Could not find a list with ${identifier}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const listName = listsData[0].name || 'Hardcover List';
|
||||||
|
const listBooks = listsData[0].list_books || [];
|
||||||
|
|
||||||
|
const books: HardcoverApiBook[] = [];
|
||||||
|
for (const item of listBooks) {
|
||||||
|
const book = item.book;
|
||||||
|
if (!book || !book.id) continue;
|
||||||
|
|
||||||
|
const authorName =
|
||||||
|
book.contributions?.[0]?.author?.name || 'Unknown Author';
|
||||||
|
const cachedImg = book.cached_image;
|
||||||
|
const coverUrl =
|
||||||
|
(typeof cachedImg === 'string' ? cachedImg : cachedImg?.url) ||
|
||||||
|
book.image?.url ||
|
||||||
|
undefined;
|
||||||
|
|
||||||
|
books.push({
|
||||||
|
bookId: book.id.toString(),
|
||||||
|
title: book.title || 'Unknown Title',
|
||||||
|
author: authorName,
|
||||||
|
coverUrl,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { listName, books };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,279 +2,42 @@
|
|||||||
* Component: Hardcover Shelf Sync Service
|
* Component: Hardcover Shelf Sync Service
|
||||||
* Documentation: documentation/backend/services/hardcover-sync.md
|
* Documentation: documentation/backend/services/hardcover-sync.md
|
||||||
*
|
*
|
||||||
* Fetches Hardcover books using their GraphQL API, resolves books to Audible ASINs,
|
* Fetches Hardcover lists via GraphQL API and delegates book processing
|
||||||
* and creates requests via the shared request-creator service.
|
* to the shared shelf-sync-core service.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import axios from 'axios';
|
|
||||||
import { prisma } from '@/lib/db';
|
import { prisma } from '@/lib/db';
|
||||||
import { getAudibleService } from '@/lib/integrations/audible.service';
|
|
||||||
import { createRequestForUser } from '@/lib/services/request-creator.service';
|
|
||||||
import { getEncryptionService } from '@/lib/services/encryption.service';
|
import { getEncryptionService } from '@/lib/services/encryption.service';
|
||||||
import { RMABLogger } from '@/lib/utils/logger';
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
|
import { fetchHardcoverList, HardcoverApiBook } from '@/lib/services/hardcover-api.service';
|
||||||
|
import {
|
||||||
|
ShelfSyncStats,
|
||||||
|
ShelfSyncOptions,
|
||||||
|
createEmptyStats,
|
||||||
|
resolveMaxLookups,
|
||||||
|
processShelfBooks,
|
||||||
|
} from '@/lib/services/shelf-sync-core.service';
|
||||||
|
|
||||||
|
export { fetchHardcoverList } from '@/lib/services/hardcover-api.service';
|
||||||
|
export type { HardcoverApiBook } from '@/lib/services/hardcover-api.service';
|
||||||
|
|
||||||
const logger = RMABLogger.create('HardcoverSync');
|
const logger = RMABLogger.create('HardcoverSync');
|
||||||
|
|
||||||
/** Default max Audible lookups per shelf per scheduled sync cycle */
|
// Re-export types that downstream consumers expect
|
||||||
const DEFAULT_MAX_LOOKUPS_PER_SHELF = 10;
|
export type { ShelfSyncStats as HardcoverSyncStats };
|
||||||
|
export type { ShelfSyncOptions as HardcoverSyncOptions };
|
||||||
/** 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.
|
* Process Hardcover shelves: fetch lists via GraphQL, resolve ASINs, create requests.
|
||||||
* This handles both 'status_id' user_books or 'list_id' list_books queries.
|
* Called from the unified sync_reading_shelves processor.
|
||||||
* 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[] }> {
|
|
||||||
// Check if it's a status list
|
|
||||||
const isStatus = listIdStr.startsWith('status-');
|
|
||||||
|
|
||||||
if (isStatus) {
|
|
||||||
const statusId = parseInt(listIdStr.replace('status-', ''), 10);
|
|
||||||
const query = `
|
|
||||||
query GetStatusBooks($statusId: Int!) {
|
|
||||||
me {
|
|
||||||
user_books(where: {status_id: {_eq: $statusId}}, limit: 100, order_by: {id: desc}) {
|
|
||||||
book {
|
|
||||||
id
|
|
||||||
title
|
|
||||||
contributions {
|
|
||||||
author {
|
|
||||||
name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
cached_image
|
|
||||||
image {
|
|
||||||
url
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const response = await axios.post(
|
|
||||||
HARDCOVER_API_URL,
|
|
||||||
{ query, variables: { statusId } },
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${apiToken}`,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
timeout: 30000,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (response.data?.errors) {
|
|
||||||
throw new Error(
|
|
||||||
`Hardcover API Error: ${response.data.errors[0]?.message}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const userBooks = response.data?.data?.me?.[0]?.user_books || [];
|
|
||||||
let listName = 'Hardcover Status List';
|
|
||||||
|
|
||||||
// Map status numbers to names
|
|
||||||
const statusNames: Record<number, string> = {
|
|
||||||
1: 'Want to Read',
|
|
||||||
2: 'Currently Reading',
|
|
||||||
3: 'Read',
|
|
||||||
4: 'Did Not Finish',
|
|
||||||
};
|
|
||||||
listName = statusNames[statusId] || `Status ${statusId}`;
|
|
||||||
|
|
||||||
const books: HardcoverApiBook[] = [];
|
|
||||||
for (const item of userBooks) {
|
|
||||||
const book = item.book;
|
|
||||||
if (!book || !book.id) continue;
|
|
||||||
|
|
||||||
const authorName =
|
|
||||||
book.contributions?.[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 };
|
|
||||||
} else {
|
|
||||||
// Original list_books logic
|
|
||||||
let isUuid = false;
|
|
||||||
let isIntId = false;
|
|
||||||
let extractedSlug = listIdStr;
|
|
||||||
|
|
||||||
if (
|
|
||||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
|
|
||||||
listIdStr,
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
isUuid = true;
|
|
||||||
} else if (/^\d+$/.test(listIdStr)) {
|
|
||||||
isIntId = true;
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
if (listIdStr.includes('hardcover.app')) {
|
|
||||||
const url = new URL(
|
|
||||||
listIdStr.startsWith('http') ? listIdStr : `https://${listIdStr}`,
|
|
||||||
);
|
|
||||||
const parts = url.pathname.split('/').filter(Boolean);
|
|
||||||
if (parts.length > 0) {
|
|
||||||
extractedSlug = parts[parts.length - 1];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// use extractedSlug as-is
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const query = `
|
|
||||||
query GetListBooks($listId: Int!) {
|
|
||||||
list_books(where: {list_id: {_eq: $listId}}, limit: 100, order_by: {id: desc}) {
|
|
||||||
list { name }
|
|
||||||
book {
|
|
||||||
id title cached_image image { url }
|
|
||||||
contributions { author { name } }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const queryUuid = `
|
|
||||||
query GetListBooksUuid($listId: uuid!) {
|
|
||||||
list_books(where: {list_id: {_eq: $listId}}, limit: 100, order_by: {id: desc}) {
|
|
||||||
list { name }
|
|
||||||
book {
|
|
||||||
id title cached_image image { url }
|
|
||||||
contributions { author { name } }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const querySlug = `
|
|
||||||
query GetListBooksBySlug($slug: String!) {
|
|
||||||
lists(where: {slug: {_eq: $slug}}, limit: 1) {
|
|
||||||
name
|
|
||||||
list_books(limit: 100, order_by: {id: desc}) {
|
|
||||||
book {
|
|
||||||
id title cached_image image { url }
|
|
||||||
contributions { author { name } }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const isSlug = !isUuid && !isIntId;
|
|
||||||
const activeQuery = isSlug ? querySlug : isUuid ? queryUuid : query;
|
|
||||||
const variables = isSlug
|
|
||||||
? { slug: extractedSlug }
|
|
||||||
: { listId: isUuid ? listIdStr : parseInt(listIdStr, 10) };
|
|
||||||
|
|
||||||
const response = await axios.post(
|
|
||||||
HARDCOVER_API_URL,
|
|
||||||
{
|
|
||||||
query: activeQuery,
|
|
||||||
variables,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${apiToken}`,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
timeout: 30000,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (response.data?.errors) {
|
|
||||||
throw new Error(
|
|
||||||
`Hardcover API Error: ${response.data.errors[0]?.message}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let listName = 'Hardcover List';
|
|
||||||
let listBooks: any[] = [];
|
|
||||||
|
|
||||||
if (isSlug) {
|
|
||||||
const listsData = response.data?.data?.lists || [];
|
|
||||||
if (listsData.length === 0) {
|
|
||||||
throw new Error(`Could not find a list with slug "${extractedSlug}"`);
|
|
||||||
}
|
|
||||||
listName = listsData[0].name || listName;
|
|
||||||
listBooks = listsData[0].list_books || [];
|
|
||||||
} else {
|
|
||||||
listBooks = response.data?.data?.list_books || [];
|
|
||||||
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;
|
|
||||||
|
|
||||||
const authorName =
|
|
||||||
book.contributions?.[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(
|
export async function processHardcoverShelves(
|
||||||
jobLogger?: ReturnType<typeof RMABLogger.forJob>,
|
jobLogger?: ReturnType<typeof RMABLogger.forJob>,
|
||||||
options: HardcoverSyncOptions = {},
|
options: ShelfSyncOptions = {},
|
||||||
): Promise<HardcoverSyncStats> {
|
): Promise<ShelfSyncStats> {
|
||||||
const log = jobLogger || logger;
|
const log = jobLogger || logger;
|
||||||
const stats: HardcoverSyncStats = {
|
const stats = createEmptyStats();
|
||||||
shelvesProcessed: 0,
|
const maxLookups = resolveMaxLookups(options);
|
||||||
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 whereClause = options.shelfId ? { id: options.shelfId } : {};
|
||||||
const shelves = await prisma.hardcoverShelf.findMany({
|
const shelves = await prisma.hardcoverShelf.findMany({
|
||||||
@@ -297,7 +60,50 @@ export async function processHardcoverShelves(
|
|||||||
|
|
||||||
for (const shelf of shelves) {
|
for (const shelf of shelves) {
|
||||||
try {
|
try {
|
||||||
await processShelf(shelf, stats, log, maxLookups);
|
log.info(`Fetching Hardcover List "${shelf.name}" (user: ${shelf.user.plexUsername})`);
|
||||||
|
|
||||||
|
const encryptionService = getEncryptionService();
|
||||||
|
let decryptedToken = shelf.apiToken;
|
||||||
|
try {
|
||||||
|
if (encryptionService.isEncryptedFormat(shelf.apiToken)) {
|
||||||
|
decryptedToken = encryptionService.decrypt(shelf.apiToken);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
log.error(`Failed to decrypt API token for user ${shelf.user.plexUsername}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let fetchedData: { listName: string; books: HardcoverApiBook[] };
|
||||||
|
try {
|
||||||
|
fetchedData = await fetchHardcoverList(decryptedToken, shelf.listId);
|
||||||
|
} catch (error) {
|
||||||
|
log.error(
|
||||||
|
`Failed to fetch Hardcover list "${shelf.name}": ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||||
|
);
|
||||||
|
stats.errors++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info(`Found ${fetchedData.books.length} books in list "${shelf.name}" (Hardcover API)`);
|
||||||
|
|
||||||
|
const bookData = await processShelfBooks(
|
||||||
|
'hardcover', fetchedData.books, shelf.user.id, shelf.id, stats, log, maxLookups,
|
||||||
|
);
|
||||||
|
|
||||||
|
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: fetchedData.books.length,
|
||||||
|
coverUrls: bookData.length > 0 ? JSON.stringify(bookData) : null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
stats.shelvesProcessed++;
|
stats.shelvesProcessed++;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
stats.errors++;
|
stats.errors++;
|
||||||
@@ -312,287 +118,3 @@ export async function processHardcoverShelves(
|
|||||||
);
|
);
|
||||||
return stats;
|
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})`,
|
|
||||||
);
|
|
||||||
|
|
||||||
const encryptionService = getEncryptionService();
|
|
||||||
let decryptedToken = shelf.apiToken;
|
|
||||||
try {
|
|
||||||
// Check if the token is encrypted (our new storage method format)
|
|
||||||
if (encryptionService.isEncryptedFormat(shelf.apiToken)) {
|
|
||||||
decryptedToken = encryptionService.decrypt(shelf.apiToken);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
log.error(
|
|
||||||
`Failed to decrypt API token for user ${shelf.user.plexUsername}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let fetchedData: { listName: string; books: HardcoverApiBook[] };
|
|
||||||
try {
|
|
||||||
fetchedData = await fetchHardcoverList(decryptedToken, 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 },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,274 @@
|
|||||||
|
/**
|
||||||
|
* Component: Shelf Sync Core Service
|
||||||
|
* Documentation: documentation/backend/services/goodreads-sync.md
|
||||||
|
*
|
||||||
|
* Shared logic for all shelf providers: Audible lookup, noMatch retry,
|
||||||
|
* request creation, cover enrichment, and shelf metadata updates.
|
||||||
|
* Provider-specific services (Goodreads, Hardcover) call into this core.
|
||||||
|
*/
|
||||||
|
|
||||||
|
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';
|
||||||
|
import { BookMapping } from '@/generated/prisma';
|
||||||
|
|
||||||
|
/** 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;
|
||||||
|
|
||||||
|
/** Provider-agnostic book from any shelf source */
|
||||||
|
export interface ShelfBook {
|
||||||
|
bookId: string;
|
||||||
|
title: string;
|
||||||
|
author: string;
|
||||||
|
coverUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sync stats shared across all providers */
|
||||||
|
export interface ShelfSyncStats {
|
||||||
|
shelvesProcessed: number;
|
||||||
|
booksFound: number;
|
||||||
|
lookupsPerformed: number;
|
||||||
|
requestsCreated: number;
|
||||||
|
errors: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Common sync options */
|
||||||
|
export interface ShelfSyncOptions {
|
||||||
|
shelfId?: string;
|
||||||
|
maxLookupsPerShelf?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type LoggerType = ReturnType<typeof RMABLogger.forJob> | ReturnType<typeof RMABLogger.create>;
|
||||||
|
|
||||||
|
export function createEmptyStats(): ShelfSyncStats {
|
||||||
|
return { shelvesProcessed: 0, booksFound: 0, lookupsPerformed: 0, requestsCreated: 0, errors: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeStats(target: ShelfSyncStats, source: ShelfSyncStats): void {
|
||||||
|
target.shelvesProcessed += source.shelvesProcessed;
|
||||||
|
target.booksFound += source.booksFound;
|
||||||
|
target.lookupsPerformed += source.lookupsPerformed;
|
||||||
|
target.requestsCreated += source.requestsCreated;
|
||||||
|
target.errors += source.errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveMaxLookups(options: ShelfSyncOptions): number {
|
||||||
|
return options.maxLookupsPerShelf ?? DEFAULT_MAX_LOOKUPS_PER_SHELF;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process a list of books from any provider: resolve to ASINs, create requests,
|
||||||
|
* enrich covers, and return book data for shelf metadata.
|
||||||
|
*/
|
||||||
|
export async function processShelfBooks(
|
||||||
|
provider: string,
|
||||||
|
books: ShelfBook[],
|
||||||
|
userId: string,
|
||||||
|
shelfId: string,
|
||||||
|
stats: ShelfSyncStats,
|
||||||
|
log: LoggerType,
|
||||||
|
maxLookups: number,
|
||||||
|
): Promise<{ coverUrl: string; asin: string | null; title: string; author: string }[]> {
|
||||||
|
stats.booksFound += books.length;
|
||||||
|
|
||||||
|
let lookupsThisCycle = 0;
|
||||||
|
const unlimitedLookups = maxLookups === 0;
|
||||||
|
|
||||||
|
for (const book of books) {
|
||||||
|
let mapping = await prisma.bookMapping.findUnique({
|
||||||
|
where: { provider_externalBookId: { provider, externalBookId: book.bookId } },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!mapping) {
|
||||||
|
if (!unlimitedLookups && lookupsThisCycle >= maxLookups) continue;
|
||||||
|
|
||||||
|
mapping = await performAudibleLookup(provider, 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(provider, book, log, mapping.id);
|
||||||
|
lookupsThisCycle++;
|
||||||
|
stats.lookupsPerformed++;
|
||||||
|
|
||||||
|
if (!mapping?.audibleAsin) continue;
|
||||||
|
} else {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mapping.audibleAsin) {
|
||||||
|
try {
|
||||||
|
const result = await createRequestForUser(userId, {
|
||||||
|
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'}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return enrichBookCovers(provider, books);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enrich book list with cached cover URLs from AudibleCache.
|
||||||
|
* Returns up to 8 books with the best available cover URL.
|
||||||
|
*/
|
||||||
|
async function enrichBookCovers(
|
||||||
|
provider: string,
|
||||||
|
books: ShelfBook[],
|
||||||
|
): Promise<{ coverUrl: string; asin: string | null; title: string; author: string }[]> {
|
||||||
|
const bookIds = books.map(b => b.bookId);
|
||||||
|
const mappings = bookIds.length > 0
|
||||||
|
? await prisma.bookMapping.findMany({
|
||||||
|
where: { provider, externalBookId: { in: bookIds } },
|
||||||
|
select: { externalBookId: true, audibleAsin: true, title: true, author: true, coverUrl: true },
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
const mappingsByBookId = new Map(mappings.map(m => [m.externalBookId, 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;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search Audible for a book, persist the result to the unified BookMapping table.
|
||||||
|
*/
|
||||||
|
async function performAudibleLookup(
|
||||||
|
provider: string,
|
||||||
|
book: ShelfBook,
|
||||||
|
log: LoggerType,
|
||||||
|
existingMappingId?: string,
|
||||||
|
): Promise<BookMapping | null> {
|
||||||
|
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.bookMapping.update({ where: { id: existingMappingId }, data });
|
||||||
|
}
|
||||||
|
return prisma.bookMapping.create({
|
||||||
|
data: { provider, externalBookId: 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.bookMapping.update({ where: { id: existingMappingId }, data: noMatchData });
|
||||||
|
}
|
||||||
|
return prisma.bookMapping.create({
|
||||||
|
data: { provider, externalBookId: 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.bookMapping.update({ where: { id: existingMappingId }, data: errorData });
|
||||||
|
}
|
||||||
|
return prisma.bookMapping.create({
|
||||||
|
data: { provider, externalBookId: book.bookId, ...errorData },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* Component: Shelf Helpers
|
||||||
|
* Documentation: documentation/frontend/components.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a JSON string of cover/book data into a typed array.
|
||||||
|
* Returns an empty array on parse failure (graceful degradation).
|
||||||
|
*/
|
||||||
|
export function processBooks(
|
||||||
|
coverUrls: string | null,
|
||||||
|
): { coverUrl: string; asin: string | null; title: string; author: string }[] {
|
||||||
|
if (!coverUrls) return [];
|
||||||
|
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(coverUrls);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(parsed)) return [];
|
||||||
|
|
||||||
|
return 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) || '',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -46,7 +46,7 @@ export const createPrismaMock = () => ({
|
|||||||
bookDateRecommendation: createModelMock(),
|
bookDateRecommendation: createModelMock(),
|
||||||
bookDateSwipe: createModelMock(),
|
bookDateSwipe: createModelMock(),
|
||||||
goodreadsShelf: createModelMock(),
|
goodreadsShelf: createModelMock(),
|
||||||
goodreadsBookMapping: createModelMock(),
|
bookMapping: createModelMock(),
|
||||||
hardcoverShelf: createModelMock(),
|
hardcoverShelf: createModelMock(),
|
||||||
work: createModelMock(),
|
work: createModelMock(),
|
||||||
workAsin: createModelMock(),
|
workAsin: createModelMock(),
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ const jobQueueMock = vi.hoisted(() => ({
|
|||||||
addRetryFailedImportsJob: vi.fn(),
|
addRetryFailedImportsJob: vi.fn(),
|
||||||
addCleanupSeededTorrentsJob: vi.fn(),
|
addCleanupSeededTorrentsJob: vi.fn(),
|
||||||
addMonitorRssFeedsJob: vi.fn(),
|
addMonitorRssFeedsJob: vi.fn(),
|
||||||
addMonitorRssFeedsJob: vi.fn(),
|
|
||||||
addSyncShelvesJob: vi.fn(),
|
addSyncShelvesJob: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user