From cc8e106a2b7f124e419cf7e99c04c73d7d48f193 Mon Sep 17 00:00:00 2001 From: kikootwo Date: Thu, 5 Mar 2026 11:30:39 -0500 Subject: [PATCH] Add per-user home sections & unified Audible cache Introduce per-user configurable home page sections and a unified Audible cache/category model. Adds Prisma models (UserHomeSection, AudibleCacheCategory) and migrations to create tables and remove legacy popular/new_release flags; updates schema.prisma accordingly. Add API routes for user home sections, live Audible categories, and category-based audiobook listing, and refactor popular/new-releases/covers routes to read from AudibleCacheCategory. Frontend: new HomeSection component, HomeSectionConfigModal, useHomeSections hook, and homepage changes to render dynamic sections plus image fallback to a placeholder SVG. Also add placeholder_cover.svg and tests for home sections and the audible refresh processor. --- documentation/TABLEOFCONTENTS.md | 4 + documentation/backend/services/scheduler.md | 8 +- documentation/features/home-sections.md | 64 ++++ documentation/integrations/audible.md | 6 +- .../migration.sql | 49 +++ .../migration.sql | 17 + prisma/schema.prisma | 57 ++- public/placeholder_cover.svg | 22 ++ .../components/ReportedIssuesSection.tsx | 24 +- src/app/admin/page.tsx | 24 +- src/app/api/audible/categories/route.ts | 39 ++ .../audiobooks/category/[categoryId]/route.ts | 154 ++++++++ src/app/api/audiobooks/covers/route.ts | 26 +- src/app/api/audiobooks/new-releases/route.ts | 116 +++--- src/app/api/audiobooks/popular/route.ts | 116 +++--- src/app/api/user/home-sections/route.ts | 202 +++++++++++ src/app/login/page.tsx | 1 + src/app/page.tsx | 321 ++++++++-------- src/components/audiobooks/AudiobookCard.tsx | 18 +- .../audiobooks/AudiobookDetailsModal.tsx | 16 +- src/components/bookdate/BookPickerModal.tsx | 2 + .../bookdate/RecommendationCard.tsx | 15 +- src/components/home/HomeSection.tsx | 310 ++++++++++++++++ .../home/HomeSectionConfigModal.tsx | 342 ++++++++++++++++++ .../profile/GoodreadsShelvesSection.tsx | 4 +- .../profile/WatchedListsSection.tsx | 17 +- src/components/requests/RequestCard.tsx | 46 +-- src/components/series/SeriesCard.tsx | 28 +- src/components/series/SeriesDetailCard.tsx | 20 +- src/components/series/SimilarSeriesRow.tsx | 23 +- src/components/ui/UnifiedPagination.tsx | 235 ++++++++---- src/lib/hooks/useHomeSections.ts | 119 ++++++ src/lib/integrations/audible.service.ts | 158 ++++++++ .../processors/audible-refresh.processor.ts | 262 ++++++++------ src/lib/services/thumbnail-cache.service.ts | 22 +- tests/api/audiobooks-browse.routes.test.ts | 28 +- tests/api/home-sections.routes.test.ts | 166 +++++++++ tests/app/home.page.test.tsx | 66 +++- tests/helpers/prisma.ts | 5 + .../audible-refresh.processor.test.ts | 85 ++++- 40 files changed, 2582 insertions(+), 655 deletions(-) create mode 100644 documentation/features/home-sections.md create mode 100644 prisma/migrations/20260306000000_add_home_sections/migration.sql create mode 100644 prisma/migrations/20260307000000_remove_popular_newrelease_flags/migration.sql create mode 100644 public/placeholder_cover.svg create mode 100644 src/app/api/audible/categories/route.ts create mode 100644 src/app/api/audiobooks/category/[categoryId]/route.ts create mode 100644 src/app/api/user/home-sections/route.ts create mode 100644 src/components/home/HomeSection.tsx create mode 100644 src/components/home/HomeSectionConfigModal.tsx create mode 100644 src/lib/hooks/useHomeSections.ts create mode 100644 tests/api/home-sections.routes.test.ts diff --git a/documentation/TABLEOFCONTENTS.md b/documentation/TABLEOFCONTENTS.md index 0d7fb76..2bc77e6 100644 --- a/documentation/TABLEOFCONTENTS.md +++ b/documentation/TABLEOFCONTENTS.md @@ -77,6 +77,7 @@ - **Component catalog (cards, badges, forms)** → [frontend/components.md](frontend/components.md) - **RequestCard, StatusBadge, ProgressBar** → [frontend/components.md](frontend/components.md) - **Pages: home, search, requests, profile** → [frontend/components.md](frontend/components.md) +- **Home page sections (per-user, configurable)** → [features/home-sections.md](features/home-sections.md) ## BookDate (AI Recommendations) - **AI-powered recommendations, swipe interface** → [features/bookdate.md](features/bookdate.md) @@ -150,3 +151,6 @@ **"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) **"Why is ABS matching the wrong book?"** → [fixes/file-hash-matching.md](fixes/file-hash-matching.md) (file hash prevents false positives) +**"How do I customize my home page?"** → [features/home-sections.md](features/home-sections.md) +**"How do Audible categories work?"** → [features/home-sections.md](features/home-sections.md) +**"How do I add category sections to the home page?"** → [features/home-sections.md](features/home-sections.md) diff --git a/documentation/backend/services/scheduler.md b/documentation/backend/services/scheduler.md index 202c2c3..da38edb 100644 --- a/documentation/backend/services/scheduler.md +++ b/documentation/backend/services/scheduler.md @@ -129,10 +129,10 @@ interface ScheduledJob { ## Audible Refresh Processor **Implementation:** -1. Clear previous `isPopular`/`isNewRelease` flags -2. Fetch 200 popular + 200 new releases (multi-page scraping) -3. Download and cache cover thumbnails locally (stored in `/app/cache/thumbnails`) -4. Store/update in DB with category flags, rankings (`popularRank`, `newReleaseRank`), and cached cover paths +1. Fetch 200 popular + 200 new releases (multi-page scraping) +2. Download and cache cover thumbnails locally (stored in `/app/cache/thumbnails`) +3. Wipe and re-populate `AudibleCacheCategory` entries with reserved IDs (`__popular__`, `__new_releases__`) and user-configured category IDs +4. Upsert book metadata in `AudibleCache`, ranked entries in `AudibleCacheCategory` 5. Record sync timestamp (`lastAudibleSync`) 6. Clean up unused thumbnails (removes covers for audiobooks no longer in cache) 7. Perform fuzzy matching (70% threshold) against Plex library diff --git a/documentation/features/home-sections.md b/documentation/features/home-sections.md new file mode 100644 index 0000000..ef75295 --- /dev/null +++ b/documentation/features/home-sections.md @@ -0,0 +1,64 @@ +# Home Page Sections (Per-User Configurable) + +**Status:** Implemented | Per-user home page with configurable sections (popular, new releases, Audible categories) + +## Overview +Users customize their home page by adding/removing/reordering sections. Each section displays audiobooks from a specific source: built-in Popular, New Releases, or scraped Audible categories. + +## Data Models + +**UserHomeSection** (`user_home_sections`): +- `id`, `userId` (FK User), `sectionType` ('popular'|'new_releases'|'category'), `categoryId` (nullable), `categoryName` (nullable), `sortOrder` (int) +- Unique: `(userId, sectionType, categoryId)` +- Default: Popular (0) + New Releases (1) created on first access + +**AudibleCacheCategory** (`audible_cache_categories`): +- `id`, `asin`, `categoryId`, `rank`, `lastSyncedAt` +- Unique: `(asin, categoryId)`, Indexes: `categoryId`, `(categoryId, rank)` + +## API Endpoints + +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| GET | `/api/user/home-sections` | user | Returns sections + nextRefresh | +| PUT | `/api/user/home-sections` | user | Save full config (delete-recreate), max 10 | +| GET | `/api/audible/categories` | user | Live scrape top-level categories | +| GET | `/api/audiobooks/category/[categoryId]` | public | Paginated category books from cache | + +## Refresh Processor (Unified Storage) +- All section data stored in `AudibleCacheCategory` with reserved IDs: `__popular__` and `__new_releases__` for built-in sections +- Popular/new-releases use same wipe-and-populate pattern as user categories +- After built-in sections, queries DISTINCT categoryIds from `UserHomeSection` +- Per section: wipe `AudibleCacheCategory` rows, scrape, upsert `AudibleCache` metadata, insert ranked category entries +- Batch cooldown between sections (10-20s random) +- Constants exported from `audible-refresh.processor.ts`: `POPULAR_CATEGORY_ID`, `NEW_RELEASES_CATEGORY_ID` + +## AudibleService Methods +- `getCategories()`: Scrapes `{baseUrl}/categories`, returns `{id, name}[]` +- `getCategoryBooks(categoryId, limit)`: Scrapes `/search?node={id}&pageSize=50&sort=popularity-rank`, up to 200 results + +## Frontend +- **Hooks:** `useHomeSections()`, `useCategoryAudiobooks()`, `useAudibleCategories()` in `src/lib/hooks/useHomeSections.ts` +- **Config Modal:** `src/components/home/HomeSectionConfigModal.tsx` — drag-and-drop (desktop), up/down arrows (mobile), auto-save with debounce +- **Section Component:** `src/components/home/HomeSection.tsx` — renders individual section with color-coded header +- **Home Page:** `src/app/page.tsx` — dynamic sections from user config, gear icon for customize +- **Pagination:** `src/components/ui/UnifiedPagination.tsx` — updated to support 1-12 dynamic sections + +## Key Decisions +- 10 section limit per user (total) +- Category picker scraped live (no categories table) +- Top-level categories only (v1) +- Wipe-and-re-scrape per category during refresh +- Deduplication of categories across users before scraping +- If category disappears, user sees empty section +- 10-color palette assigned by sort order + +## Files +- Schema: `prisma/schema.prisma` (UserHomeSection, AudibleCacheCategory) +- Migration: `prisma/migrations/20260306000000_add_home_sections/migration.sql` +- Service: `src/lib/integrations/audible.service.ts` (getCategories, getCategoryBooks) +- Processor: `src/lib/processors/audible-refresh.processor.ts` +- API Routes: `src/app/api/user/home-sections/route.ts`, `src/app/api/audible/categories/route.ts`, `src/app/api/audiobooks/category/[categoryId]/route.ts` +- Hooks: `src/lib/hooks/useHomeSections.ts` +- Components: `src/components/home/HomeSectionConfigModal.tsx`, `src/components/home/HomeSection.tsx` +- Tests: `tests/api/home-sections.routes.test.ts`, `tests/processors/audible-refresh.processor.test.ts` diff --git a/documentation/integrations/audible.md b/documentation/integrations/audible.md index 8d6d706..9711695 100644 --- a/documentation/integrations/audible.md +++ b/documentation/integrations/audible.md @@ -128,11 +128,11 @@ Single matching algorithm used everywhere (search, popular, new-releases, jobs). Discovery APIs serve cached data from DB with real-time matching. **Flow:** -1. `audible_refresh` job runs daily → fetches 200 popular + 200 new releases +1. `audible_refresh` job runs daily → fetches 200 popular + 200 new releases + user-configured categories 2. Downloads and caches cover thumbnails locally (reduces Audible load) -3. Stores in DB with flags (`isPopular`, `isNewRelease`) and rankings +3. Stores metadata in `audible_cache`, ranked entries in `audible_cache_categories` with reserved IDs (`__popular__`, `__new_releases__`) and user category IDs 4. Cleans up unused thumbnails after sync -5. API routes query DB → apply real-time matching → return enriched results +5. API routes query `AudibleCacheCategory` by categoryId → join with `AudibleCache` metadata → apply real-time matching → return enriched results 6. Homepage loads instantly (no Audible API hits) ## Thumbnail Caching diff --git a/prisma/migrations/20260306000000_add_home_sections/migration.sql b/prisma/migrations/20260306000000_add_home_sections/migration.sql new file mode 100644 index 0000000..9c7a681 --- /dev/null +++ b/prisma/migrations/20260306000000_add_home_sections/migration.sql @@ -0,0 +1,49 @@ +-- CreateTable +CREATE TABLE "user_home_sections" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "section_type" TEXT NOT NULL, + "category_id" TEXT, + "category_name" TEXT, + "sort_order" INTEGER NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "user_home_sections_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "audible_cache_categories" ( + "id" TEXT NOT NULL, + "asin" TEXT NOT NULL, + "category_id" TEXT NOT NULL, + "rank" INTEGER NOT NULL, + "last_synced_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "audible_cache_categories_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "user_home_sections_user_id_idx" ON "user_home_sections"("user_id"); + +-- CreateIndex +CREATE INDEX "user_home_sections_sort_order_idx" ON "user_home_sections"("sort_order"); + +-- CreateIndex +CREATE UNIQUE INDEX "user_home_sections_user_id_section_type_category_id_key" ON "user_home_sections"("user_id", "section_type", "category_id"); + +-- CreateIndex +CREATE INDEX "audible_cache_categories_category_id_idx" ON "audible_cache_categories"("category_id"); + +-- CreateIndex +CREATE INDEX "audible_cache_categories_asin_idx" ON "audible_cache_categories"("asin"); + +-- CreateIndex +CREATE INDEX "audible_cache_categories_category_id_rank_idx" ON "audible_cache_categories"("category_id", "rank"); + +-- CreateIndex +CREATE UNIQUE INDEX "audible_cache_categories_asin_category_id_key" ON "audible_cache_categories"("asin", "category_id"); + +-- AddForeignKey +ALTER TABLE "user_home_sections" ADD CONSTRAINT "user_home_sections_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20260307000000_remove_popular_newrelease_flags/migration.sql b/prisma/migrations/20260307000000_remove_popular_newrelease_flags/migration.sql new file mode 100644 index 0000000..f5c9326 --- /dev/null +++ b/prisma/migrations/20260307000000_remove_popular_newrelease_flags/migration.sql @@ -0,0 +1,17 @@ +-- DropIndex +DROP INDEX IF EXISTS "audible_cache_is_popular_idx"; + +-- DropIndex +DROP INDEX IF EXISTS "audible_cache_is_new_release_idx"; + +-- DropIndex +DROP INDEX IF EXISTS "audible_cache_popular_rank_idx"; + +-- DropIndex +DROP INDEX IF EXISTS "audible_cache_new_release_rank_idx"; + +-- AlterTable - Remove legacy discovery flag columns (now stored in audible_cache_categories) +ALTER TABLE "audible_cache" DROP COLUMN "is_popular", +DROP COLUMN "is_new_release", +DROP COLUMN "popular_rank", +DROP COLUMN "new_release_rank"; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 174d882..2c3ac6a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -72,6 +72,7 @@ model User { apiTokens ApiToken[] @relation("UserApiTokens") watchedSeries WatchedSeries[] watchedAuthors WatchedAuthor[] + homeSections UserHomeSection[] @@index([plexId]) @@index([role]) @@ -98,12 +99,6 @@ model AudibleCache { rating Decimal? @db.Decimal(3, 2) genres Json @default("[]") - // Discovery categories - isPopular Boolean @default(false) @map("is_popular") - isNewRelease Boolean @default(false) @map("is_new_release") - popularRank Int? @map("popular_rank") - newReleaseRank Int? @map("new_release_rank") - lastSyncedAt DateTime @default(now()) @map("last_synced_at") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") @@ -111,10 +106,6 @@ model AudibleCache { @@index([asin]) @@index([title]) @@index([author]) - @@index([isPopular]) - @@index([isNewRelease]) - @@index([popularRank]) - @@index([newReleaseRank]) @@map("audible_cache") } @@ -647,3 +638,49 @@ model WatchedAuthor { @@index([authorAsin]) @@map("watched_authors") } + +// ============================================================================ +// USER HOME SECTION TABLE +// Per-user configurable home page sections (popular, new_releases, category) +// Documentation: documentation/features/home-sections.md +// ============================================================================ + +model UserHomeSection { + id String @id @default(uuid()) + userId String @map("user_id") + sectionType String @map("section_type") // 'popular' | 'new_releases' | 'category' + categoryId String? @map("category_id") // Audible category node ID (only for type 'category') + categoryName String? @map("category_name") // Display name (only for type 'category') + sortOrder Int @map("sort_order") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + // Relations + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([userId, sectionType, categoryId]) + @@index([userId]) + @@index([sortOrder]) + @@map("user_home_sections") +} + +// ============================================================================ +// AUDIBLE CACHE CATEGORY TABLE +// Join table linking AudibleCache entries to Audible categories with ranking +// Documentation: documentation/features/home-sections.md +// ============================================================================ + +model AudibleCacheCategory { + id String @id @default(uuid()) + asin String + categoryId String @map("category_id") + rank Int + lastSyncedAt DateTime @default(now()) @map("last_synced_at") + createdAt DateTime @default(now()) @map("created_at") + + @@unique([asin, categoryId]) + @@index([categoryId]) + @@index([asin]) + @@index([categoryId, rank]) + @@map("audible_cache_categories") +} diff --git a/public/placeholder_cover.svg b/public/placeholder_cover.svg new file mode 100644 index 0000000..5778281 --- /dev/null +++ b/public/placeholder_cover.svg @@ -0,0 +1,22 @@ + + + + img-coverart + Created with Sketch. + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/app/admin/components/ReportedIssuesSection.tsx b/src/app/admin/components/ReportedIssuesSection.tsx index 975baa9..6245186 100644 --- a/src/app/admin/components/ReportedIssuesSection.tsx +++ b/src/app/admin/components/ReportedIssuesSection.tsx @@ -114,23 +114,13 @@ export function ReportedIssuesSection({ issues }: ReportedIssuesSectionProps) {
{/* Cover Image */}
- {issue.audiobook.coverArtUrl ? ( - {issue.audiobook.title} - ) : ( -
- - - -
- )} + {/* eslint-disable-next-line @next/next/no-img-element */} + {issue.audiobook.title} { (e.target as HTMLImageElement).src = '/placeholder_cover.svg'; }} + />
{/* Info */} diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index a154309..845367c 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -176,23 +176,13 @@ function PendingApprovalSection({ requests }: { requests: PendingApprovalRequest
{/* Cover Image */}
- {request.audiobook.coverArtUrl ? ( - {request.audiobook.title} - ) : ( -
- - - -
- )} + {/* eslint-disable-next-line @next/next/no-img-element */} + {request.audiobook.title} { (e.target as HTMLImageElement).src = '/placeholder_cover.svg'; }} + />
{/* Book Info */} diff --git a/src/app/api/audible/categories/route.ts b/src/app/api/audible/categories/route.ts new file mode 100644 index 0000000..c1b5c15 --- /dev/null +++ b/src/app/api/audible/categories/route.ts @@ -0,0 +1,39 @@ +/** + * Component: Audible Categories API Route + * Documentation: documentation/features/home-sections.md + * + * Live scrape of top-level Audible categories for the home section config modal. + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth'; +import { RMABLogger } from '@/lib/utils/logger'; + +const logger = RMABLogger.create('API.Audible.Categories'); + +/** + * GET /api/audible/categories + * Returns top-level Audible categories scraped live from audible.com/categories + */ +export async function GET(request: NextRequest) { + return requireAuth(request, async (_req: AuthenticatedRequest) => { + try { + const { getAudibleService } = await import('@/lib/integrations/audible.service'); + const audibleService = getAudibleService(); + const categories = await audibleService.getCategories(); + + return NextResponse.json({ + success: true, + categories, + }); + } catch (error) { + logger.error('Failed to fetch categories', { + error: error instanceof Error ? error.message : String(error), + }); + return NextResponse.json( + { error: 'FetchError', message: 'Failed to fetch Audible categories' }, + { status: 500 } + ); + } + }); +} diff --git a/src/app/api/audiobooks/category/[categoryId]/route.ts b/src/app/api/audiobooks/category/[categoryId]/route.ts new file mode 100644 index 0000000..8b4af4b --- /dev/null +++ b/src/app/api/audiobooks/category/[categoryId]/route.ts @@ -0,0 +1,154 @@ +/** + * Component: Category Audiobooks API Route + * Documentation: documentation/features/home-sections.md + * + * Serves audiobooks for a specific Audible category from AudibleCacheCategory, + * with the same enrichment pattern as popular/new-releases routes. + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { prisma } from '@/lib/db'; +import { enrichAudiobooksWithMatches, getAvailableAsins } from '@/lib/utils/audiobook-matcher'; +import { getCurrentUser } from '@/lib/middleware/auth'; +import { RMABLogger } from '@/lib/utils/logger'; + +const logger = RMABLogger.create('API.Audiobooks.Category'); + +/** + * GET /api/audiobooks/category/[categoryId]?page=1&limit=20&hideAvailable=false + */ +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ categoryId: string }> } +) { + try { + const { categoryId } = await params; + const searchParams = request.nextUrl.searchParams; + const page = parseInt(searchParams.get('page') || '1', 10); + const limit = parseInt(searchParams.get('limit') || '20', 10); + const hideAvailable = searchParams.get('hideAvailable') === 'true'; + + if (page < 1 || limit < 1 || limit > 100) { + return NextResponse.json( + { error: 'ValidationError', message: 'Invalid pagination parameters.' }, + { status: 400 } + ); + } + + const skip = (page - 1) * limit; + + // Get excluded ASINs when hideAvailable + let excludedAsins: string[] = []; + if (hideAvailable) { + const availableSet = await getAvailableAsins(); + excludedAsins = [...availableSet]; + } + + // Query AudibleCacheCategory joined with AudibleCache + const whereClause: any = { categoryId }; + if (excludedAsins.length > 0) { + whereClause.asin = { notIn: excludedAsins }; + } + + const [categoryEntries, totalCount] = await Promise.all([ + prisma.audibleCacheCategory.findMany({ + where: whereClause, + orderBy: { rank: 'asc' }, + skip, + take: limit, + select: { asin: true, rank: true }, + }), + prisma.audibleCacheCategory.count({ where: whereClause }), + ]); + + if (totalCount === 0) { + return NextResponse.json({ + success: true, + audiobooks: [], + count: 0, + totalCount: 0, + page, + totalPages: 0, + hasMore: false, + message: 'No audiobooks found for this category. Data may not have been refreshed yet.', + }); + } + + // Fetch full metadata from AudibleCache for these ASINs + const asins = categoryEntries.map((e) => e.asin); + const cacheEntries = await prisma.audibleCache.findMany({ + where: { asin: { in: asins } }, + select: { + asin: true, + title: true, + author: true, + narrator: true, + description: true, + coverArtUrl: true, + cachedCoverPath: true, + durationMinutes: true, + releaseDate: true, + rating: true, + genres: true, + lastSyncedAt: true, + }, + }); + + // Build a map for ordering by rank + const cacheMap = new Map(cacheEntries.map((e) => [e.asin, e])); + + // Transform to matcher input format, preserving rank order + const audibleBooks = categoryEntries + .map((entry) => { + const book = cacheMap.get(entry.asin); + if (!book) return null; + + let coverUrl = book.coverArtUrl || undefined; + if (book.cachedCoverPath) { + const filename = book.cachedCoverPath.split('/').pop(); + coverUrl = `/api/cache/thumbnails/${filename}`; + } + + return { + asin: book.asin, + title: book.title, + author: book.author, + narrator: book.narrator || undefined, + description: book.description || undefined, + coverArtUrl: coverUrl, + durationMinutes: book.durationMinutes || undefined, + releaseDate: book.releaseDate?.toISOString() || undefined, + rating: book.rating ? parseFloat(book.rating.toString()) : undefined, + genres: (book.genres as string[]) || [], + }; + }) + .filter(Boolean) as any[]; + + // Enrich with library matching and request status + const currentUser = getCurrentUser(request); + const userId = currentUser?.sub || undefined; + const enrichedAudiobooks = await enrichAudiobooksWithMatches(audibleBooks, userId); + + const totalPages = Math.ceil(totalCount / limit); + const hasMore = page < totalPages; + + return NextResponse.json({ + success: true, + audiobooks: enrichedAudiobooks, + count: enrichedAudiobooks.length, + totalCount, + page, + totalPages, + hasMore, + lastSync: cacheEntries[0]?.lastSyncedAt?.toISOString() || null, + }); + } catch (error) { + logger.error('Failed to get category audiobooks', { + error: error instanceof Error ? error.message : String(error), + }); + return NextResponse.json( + { error: 'FetchError', message: 'Failed to fetch category audiobooks' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/audiobooks/covers/route.ts b/src/app/api/audiobooks/covers/route.ts index bbd6b5f..7f2a635 100644 --- a/src/app/api/audiobooks/covers/route.ts +++ b/src/app/api/audiobooks/covers/route.ts @@ -2,12 +2,14 @@ * Component: Audiobook Covers API Route * Documentation: documentation/frontend/pages/login.md * - * Serves random popular audiobook covers for login page floating animations + * Serves random popular audiobook covers for login page floating animations. + * Queries AudibleCacheCategory with '__popular__' categoryId for cover sources. */ import { NextResponse } from 'next/server'; import { prisma } from '@/lib/db'; import { RMABLogger } from '@/lib/utils/logger'; +import { POPULAR_CATEGORY_ID } from '@/lib/processors/audible-refresh.processor'; const logger = RMABLogger.create('API.Audiobooks.Covers'); @@ -20,18 +22,22 @@ const logger = RMABLogger.create('API.Audiobooks.Covers'); */ export async function GET() { try { - // Fetch all popular audiobooks with covers (up to 200) + // Get popular ASINs from category table (up to 200) + const categoryEntries = await prisma.audibleCacheCategory.findMany({ + where: { categoryId: POPULAR_CATEGORY_ID }, + orderBy: { rank: 'asc' }, + take: 200, + select: { asin: true }, + }); + + const asins = categoryEntries.map((e) => e.asin); + + // Fetch cover data from AudibleCache for popular ASINs with cached covers const audiobooks = await prisma.audibleCache.findMany({ where: { - isPopular: true, - cachedCoverPath: { - not: null, - }, + asin: { in: asins }, + cachedCoverPath: { not: null }, }, - orderBy: { - popularRank: 'asc', - }, - take: 200, select: { asin: true, title: true, diff --git a/src/app/api/audiobooks/new-releases/route.ts b/src/app/api/audiobooks/new-releases/route.ts index 5bec85d..cfd3545 100644 --- a/src/app/api/audiobooks/new-releases/route.ts +++ b/src/app/api/audiobooks/new-releases/route.ts @@ -2,7 +2,8 @@ * Component: New Releases API Route * Documentation: documentation/integrations/audible.md * - * Serves new release audiobooks from audible_cache with real-time Plex matching + * Serves new release audiobooks from AudibleCacheCategory with real-time library matching. + * New releases are stored with categoryId '__new_releases__' in the unified category table. */ import { NextRequest, NextResponse } from 'next/server'; @@ -10,12 +11,13 @@ import { prisma } from '@/lib/db'; import { enrichAudiobooksWithMatches, getAvailableAsins } from '@/lib/utils/audiobook-matcher'; import { getCurrentUser } from '@/lib/middleware/auth'; import { RMABLogger } from '@/lib/utils/logger'; +import { NEW_RELEASES_CATEGORY_ID } from '@/lib/processors/audible-refresh.processor'; const logger = RMABLogger.create('API.Audiobooks.NewReleases'); /** * GET /api/audiobooks/new-releases?page=1&limit=20 - * Get new release audiobooks from audible_cache with pagination + * Get new release audiobooks from AudibleCacheCategory with pagination * * Real-time matching against plex_library determines availability. */ @@ -46,39 +48,21 @@ export async function GET(request: NextRequest) { excludedAsins = [...availableSet]; } - const whereClause = { - isNewRelease: true, - ...(excludedAsins.length > 0 ? { asin: { notIn: excludedAsins } } : {}), - }; + const whereClause: any = { categoryId: NEW_RELEASES_CATEGORY_ID }; + if (excludedAsins.length > 0) { + whereClause.asin = { notIn: excludedAsins }; + } - // Query audible_cache for new release audiobooks - const [audiobooks, totalCount] = await Promise.all([ - prisma.audibleCache.findMany({ + // Query AudibleCacheCategory for new release audiobooks + const [categoryEntries, totalCount] = await Promise.all([ + prisma.audibleCacheCategory.findMany({ where: whereClause, - orderBy: { - newReleaseRank: 'asc', - }, + orderBy: { rank: 'asc' }, skip, take: limit, - select: { - id: true, - asin: true, - title: true, - author: true, - narrator: true, - description: true, - coverArtUrl: true, - cachedCoverPath: true, - durationMinutes: true, - releaseDate: true, - rating: true, - genres: true, - lastSyncedAt: true, - }, - }), - prisma.audibleCache.count({ - where: whereClause, + select: { asin: true, rank: true }, }), + prisma.audibleCacheCategory.count({ where: whereClause }), ]); // If no data found, return helpful message @@ -95,30 +79,56 @@ export async function GET(request: NextRequest) { }); } - // Transform to matcher input format (uses ASIN as required field) - // Use cached cover path when available, otherwise fall back to coverArtUrl - const audibleBooks = audiobooks.map((book) => { - // Convert cached path to API URL if it exists - let coverUrl = book.coverArtUrl || undefined; - if (book.cachedCoverPath) { - const filename = book.cachedCoverPath.split('/').pop(); - coverUrl = `/api/cache/thumbnails/${filename}`; - } - - return { - asin: book.asin, - title: book.title, - author: book.author, - narrator: book.narrator || undefined, - description: book.description || undefined, - coverArtUrl: coverUrl, - durationMinutes: book.durationMinutes || undefined, - releaseDate: book.releaseDate?.toISOString() || undefined, - rating: book.rating ? parseFloat(book.rating.toString()) : undefined, - genres: (book.genres as string[]) || [], - }; + // Fetch full metadata from AudibleCache for these ASINs + const asins = categoryEntries.map((e) => e.asin); + const cacheEntries = await prisma.audibleCache.findMany({ + where: { asin: { in: asins } }, + select: { + asin: true, + title: true, + author: true, + narrator: true, + description: true, + coverArtUrl: true, + cachedCoverPath: true, + durationMinutes: true, + releaseDate: true, + rating: true, + genres: true, + lastSyncedAt: true, + }, }); + // Build a map for ordering by rank + const cacheMap = new Map(cacheEntries.map((e) => [e.asin, e])); + + // Transform to matcher input format, preserving rank order + const audibleBooks = categoryEntries + .map((entry) => { + const book = cacheMap.get(entry.asin); + if (!book) return null; + + let coverUrl = book.coverArtUrl || undefined; + if (book.cachedCoverPath) { + const filename = book.cachedCoverPath.split('/').pop(); + coverUrl = `/api/cache/thumbnails/${filename}`; + } + + return { + asin: book.asin, + title: book.title, + author: book.author, + narrator: book.narrator || undefined, + description: book.description || undefined, + coverArtUrl: coverUrl, + durationMinutes: book.durationMinutes || undefined, + releaseDate: book.releaseDate?.toISOString() || undefined, + rating: book.rating ? parseFloat(book.rating.toString()) : undefined, + genres: (book.genres as string[]) || [], + }; + }) + .filter(Boolean) as any[]; + // Get current user (optional - for request status enrichment) const currentUser = getCurrentUser(request); const userId = currentUser?.sub || undefined; @@ -137,7 +147,7 @@ export async function GET(request: NextRequest) { page, totalPages, hasMore, - lastSync: audiobooks[0]?.lastSyncedAt?.toISOString() || null, + lastSync: cacheEntries[0]?.lastSyncedAt?.toISOString() || null, }); } catch (error) { logger.error('Failed to get new releases', { error: error instanceof Error ? error.message : String(error) }); diff --git a/src/app/api/audiobooks/popular/route.ts b/src/app/api/audiobooks/popular/route.ts index 8c46913..3683053 100644 --- a/src/app/api/audiobooks/popular/route.ts +++ b/src/app/api/audiobooks/popular/route.ts @@ -2,7 +2,8 @@ * Component: Popular Audiobooks API Route * Documentation: documentation/integrations/audible.md * - * Serves popular audiobooks from audible_cache with real-time Plex matching + * Serves popular audiobooks from AudibleCacheCategory with real-time library matching. + * Popular books are stored with categoryId '__popular__' in the unified category table. */ import { NextRequest, NextResponse } from 'next/server'; @@ -10,12 +11,13 @@ import { prisma } from '@/lib/db'; import { enrichAudiobooksWithMatches, getAvailableAsins } from '@/lib/utils/audiobook-matcher'; import { getCurrentUser } from '@/lib/middleware/auth'; import { RMABLogger } from '@/lib/utils/logger'; +import { POPULAR_CATEGORY_ID } from '@/lib/processors/audible-refresh.processor'; const logger = RMABLogger.create('API.Audiobooks.Popular'); /** * GET /api/audiobooks/popular?page=1&limit=20 - * Get popular audiobooks from audible_cache with pagination + * Get popular audiobooks from AudibleCacheCategory with pagination * * Real-time matching against plex_library determines availability. */ @@ -46,39 +48,21 @@ export async function GET(request: NextRequest) { excludedAsins = [...availableSet]; } - const whereClause = { - isPopular: true, - ...(excludedAsins.length > 0 ? { asin: { notIn: excludedAsins } } : {}), - }; + const whereClause: any = { categoryId: POPULAR_CATEGORY_ID }; + if (excludedAsins.length > 0) { + whereClause.asin = { notIn: excludedAsins }; + } - // Query audible_cache for popular audiobooks - const [audiobooks, totalCount] = await Promise.all([ - prisma.audibleCache.findMany({ + // Query AudibleCacheCategory for popular audiobooks + const [categoryEntries, totalCount] = await Promise.all([ + prisma.audibleCacheCategory.findMany({ where: whereClause, - orderBy: { - popularRank: 'asc', - }, + orderBy: { rank: 'asc' }, skip, take: limit, - select: { - id: true, - asin: true, - title: true, - author: true, - narrator: true, - description: true, - coverArtUrl: true, - cachedCoverPath: true, - durationMinutes: true, - releaseDate: true, - rating: true, - genres: true, - lastSyncedAt: true, - }, - }), - prisma.audibleCache.count({ - where: whereClause, + select: { asin: true, rank: true }, }), + prisma.audibleCacheCategory.count({ where: whereClause }), ]); // If no data found, return helpful message @@ -95,30 +79,56 @@ export async function GET(request: NextRequest) { }); } - // Transform to matcher input format (uses ASIN as required field) - // Use cached cover path when available, otherwise fall back to coverArtUrl - const audibleBooks = audiobooks.map((book) => { - // Convert cached path to API URL if it exists - let coverUrl = book.coverArtUrl || undefined; - if (book.cachedCoverPath) { - const filename = book.cachedCoverPath.split('/').pop(); - coverUrl = `/api/cache/thumbnails/${filename}`; - } - - return { - asin: book.asin, - title: book.title, - author: book.author, - narrator: book.narrator || undefined, - description: book.description || undefined, - coverArtUrl: coverUrl, - durationMinutes: book.durationMinutes || undefined, - releaseDate: book.releaseDate?.toISOString() || undefined, - rating: book.rating ? parseFloat(book.rating.toString()) : undefined, - genres: (book.genres as string[]) || [], - }; + // Fetch full metadata from AudibleCache for these ASINs + const asins = categoryEntries.map((e) => e.asin); + const cacheEntries = await prisma.audibleCache.findMany({ + where: { asin: { in: asins } }, + select: { + asin: true, + title: true, + author: true, + narrator: true, + description: true, + coverArtUrl: true, + cachedCoverPath: true, + durationMinutes: true, + releaseDate: true, + rating: true, + genres: true, + lastSyncedAt: true, + }, }); + // Build a map for ordering by rank + const cacheMap = new Map(cacheEntries.map((e) => [e.asin, e])); + + // Transform to matcher input format, preserving rank order + const audibleBooks = categoryEntries + .map((entry) => { + const book = cacheMap.get(entry.asin); + if (!book) return null; + + let coverUrl = book.coverArtUrl || undefined; + if (book.cachedCoverPath) { + const filename = book.cachedCoverPath.split('/').pop(); + coverUrl = `/api/cache/thumbnails/${filename}`; + } + + return { + asin: book.asin, + title: book.title, + author: book.author, + narrator: book.narrator || undefined, + description: book.description || undefined, + coverArtUrl: coverUrl, + durationMinutes: book.durationMinutes || undefined, + releaseDate: book.releaseDate?.toISOString() || undefined, + rating: book.rating ? parseFloat(book.rating.toString()) : undefined, + genres: (book.genres as string[]) || [], + }; + }) + .filter(Boolean) as any[]; + // Get current user (optional - for request status enrichment) const currentUser = getCurrentUser(request); const userId = currentUser?.sub || undefined; @@ -137,7 +147,7 @@ export async function GET(request: NextRequest) { page, totalPages, hasMore, - lastSync: audiobooks[0]?.lastSyncedAt?.toISOString() || null, + lastSync: cacheEntries[0]?.lastSyncedAt?.toISOString() || null, }); } catch (error) { logger.error('Failed to get popular audiobooks', { error: error instanceof Error ? error.message : String(error) }); diff --git a/src/app/api/user/home-sections/route.ts b/src/app/api/user/home-sections/route.ts new file mode 100644 index 0000000..af172fb --- /dev/null +++ b/src/app/api/user/home-sections/route.ts @@ -0,0 +1,202 @@ +/** + * Component: User Home Sections API Route + * Documentation: documentation/features/home-sections.md + * + * Per-user configurable home page sections. + * GET returns sections + next refresh time. + * PUT saves full section config (delete-and-recreate in transaction). + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth'; +import { prisma } from '@/lib/db'; +import { z } from 'zod'; +import { RMABLogger } from '@/lib/utils/logger'; + +const logger = RMABLogger.create('API.User.HomeSections'); + +const MAX_SECTIONS = 10; + +const VALID_SECTION_TYPES = ['popular', 'new_releases', 'category'] as const; + +const SectionSchema = z.object({ + sectionType: z.enum(VALID_SECTION_TYPES), + categoryId: z.string().optional().nullable(), + categoryName: z.string().optional().nullable(), + sortOrder: z.number().int().min(0), +}); + +const PutBodySchema = z.object({ + sections: z.array(SectionSchema).max(MAX_SECTIONS), +}); + +/** + * Create default home sections for a new user (Popular + New Releases). + */ +async function ensureDefaultSections(userId: string) { + const existing = await prisma.userHomeSection.findMany({ + where: { userId }, + select: { id: true }, + take: 1, + }); + + if (existing.length > 0) return; + + await prisma.userHomeSection.createMany({ + data: [ + { userId, sectionType: 'popular', sortOrder: 0 }, + { userId, sectionType: 'new_releases', sortOrder: 1 }, + ], + }); +} + +/** + * GET /api/user/home-sections + * Returns the user's configured home sections + next scheduled refresh time. + */ +export async function GET(request: NextRequest) { + return requireAuth(request, async (req: AuthenticatedRequest) => { + try { + if (!req.user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + await ensureDefaultSections(req.user.id); + + const sections = await prisma.userHomeSection.findMany({ + where: { userId: req.user.id }, + orderBy: { sortOrder: 'asc' }, + }); + + // Get next refresh time from scheduled jobs + let nextRefresh: string | null = null; + try { + const scheduledJob = await prisma.scheduledJob.findFirst({ + where: { type: 'audible_refresh', enabled: true }, + select: { nextRun: true }, + }); + nextRefresh = scheduledJob?.nextRun?.toISOString() || null; + } catch { + // Non-critical — just omit nextRefresh + } + + return NextResponse.json({ + success: true, + sections: sections.map((s) => ({ + id: s.id, + sectionType: s.sectionType, + categoryId: s.categoryId, + categoryName: s.categoryName, + sortOrder: s.sortOrder, + })), + nextRefresh, + }); + } catch (error) { + logger.error('Failed to get home sections', { + error: error instanceof Error ? error.message : String(error), + }); + return NextResponse.json( + { error: 'FetchError', message: 'Failed to fetch home sections' }, + { status: 500 } + ); + } + }); +} + +/** + * PUT /api/user/home-sections + * Replaces all home sections for the user (delete-and-recreate in transaction). + * Validates: max 10 sections, no duplicate sections, category sections need categoryId. + */ +export async function PUT(request: NextRequest) { + return requireAuth(request, async (req: AuthenticatedRequest) => { + try { + if (!req.user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const body = await req.json(); + const { sections } = PutBodySchema.parse(body); + + // Validate category sections have categoryId + for (const section of sections) { + if (section.sectionType === 'category' && !section.categoryId) { + return NextResponse.json( + { error: 'ValidationError', message: 'Category sections require a categoryId' }, + { status: 400 } + ); + } + } + + // Check for duplicate section types (only one popular, one new_releases, unique categories) + const seen = new Set(); + for (const section of sections) { + const key = + section.sectionType === 'category' + ? `category:${section.categoryId}` + : section.sectionType; + if (seen.has(key)) { + return NextResponse.json( + { error: 'ValidationError', message: `Duplicate section: ${key}` }, + { status: 400 } + ); + } + seen.add(key); + } + + const userId = req.user.id; + + // Delete-and-recreate in a transaction + await prisma.$transaction(async (tx) => { + await tx.userHomeSection.deleteMany({ where: { userId } }); + + if (sections.length > 0) { + await tx.userHomeSection.createMany({ + data: sections.map((s, idx) => ({ + userId, + sectionType: s.sectionType, + categoryId: s.sectionType === 'category' ? s.categoryId : null, + categoryName: s.sectionType === 'category' ? s.categoryName : null, + sortOrder: idx, + })), + }); + } + }); + + // Return the saved sections + const saved = await prisma.userHomeSection.findMany({ + where: { userId }, + orderBy: { sortOrder: 'asc' }, + }); + + logger.info(`User ${userId} updated home sections (${saved.length} sections)`); + + return NextResponse.json({ + success: true, + sections: saved.map((s) => ({ + id: s.id, + sectionType: s.sectionType, + categoryId: s.categoryId, + categoryName: s.categoryName, + sortOrder: s.sortOrder, + })), + }); + } catch (error) { + logger.error('Failed to save home sections', { + error: error instanceof Error ? error.message : String(error), + }); + + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: 'ValidationError', details: error.errors }, + { status: 400 } + ); + } + + return NextResponse.json( + { error: 'SaveError', message: 'Failed to save home sections' }, + { status: 500 } + ); + } + }); +} diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index da1d286..762d658 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -486,6 +486,7 @@ function LoginContent() { quality={70} priority={index < 10} loading={index < 10 ? 'eager' : 'lazy'} + onError={(e) => { (e.target as HTMLImageElement).src = '/placeholder_cover.svg'; }} />
diff --git a/src/app/page.tsx b/src/app/page.tsx index da71faa..54d9aa0 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,208 +1,189 @@ /** - * Component: Homepage - Audiobook Discovery - * Documentation: documentation/frontend/components.md + * Component: Homepage - Audiobook Discovery (Dynamic Sections) + * Documentation: documentation/features/home-sections.md */ 'use client'; -import { useState, useRef, useEffect } from 'react'; +import { useState, useRef, useEffect, useCallback, createRef } from 'react'; import { Header } from '@/components/layout/Header'; -import { AudiobookGrid } from '@/components/audiobooks/AudiobookGrid'; -import { useAudiobooks } from '@/lib/hooks/useAudiobooks'; import { ProtectedRoute } from '@/components/auth/ProtectedRoute'; -import { UnifiedPagination } from '@/components/ui/UnifiedPagination'; -import { SectionToolbar } from '@/components/ui/SectionToolbar'; +import { UnifiedPagination, PaginationSection } from '@/components/ui/UnifiedPagination'; +import { HomeSection, SECTION_DOT_COLORS } from '@/components/home/HomeSection'; +import { HomeSectionConfigModal } from '@/components/home/HomeSectionConfigModal'; +import { useHomeSections } from '@/lib/hooks/useHomeSections'; import { usePreferences } from '@/contexts/PreferencesContext'; +import { Cog6ToothIcon } from '@heroicons/react/24/outline'; + +function getSectionTitle(sectionType: string, categoryName?: string | null): string { + if (sectionType === 'popular') return 'Popular Audiobooks'; + if (sectionType === 'new_releases') return 'New Releases'; + return categoryName || 'Category'; +} export default function HomePage() { - const [popularPage, setPopularPage] = useState(1); - const [newReleasesPage, setNewReleasesPage] = useState(1); + const { sections, nextRefresh, isLoading: sectionsLoading, saveSections } = useHomeSections(); const { cardSize, setCardSize, squareCovers, setSquareCovers, hideAvailable, setHideAvailable } = usePreferences(); - // Refs for auto-scrolling to section tops - const popularSectionRef = useRef(null); - const newReleasesSectionRef = useRef(null); + // Per-section pagination state + const [pages, setPages] = useState>({}); + const [totalPagesMap, setTotalPagesMap] = useState>({}); + const [configOpen, setConfigOpen] = useState(false); + const footerRef = useRef(null); - const { - audiobooks: popular, - isLoading: loadingPopular, - totalPages: popularTotalPages, - message: popularMessage, - } = useAudiobooks('popular', 20, popularPage, hideAvailable); + // Create stable refs for each section + const sectionRefsMap = useRef>>(new Map()); - const { - audiobooks: newReleases, - isLoading: loadingNewReleases, - totalPages: newReleasesTotalPages, - message: newReleasesMessage, - } = useAudiobooks('new-releases', 20, newReleasesPage, hideAvailable); + const getSectionKey = (s: { sectionType: string; categoryId: string | null }) => + s.sectionType === 'category' ? `category:${s.categoryId}` : s.sectionType; - // Reset to page 1 when hideAvailable changes (total pages may differ) + // Ensure refs exist for current sections + sections.forEach((s) => { + const key = getSectionKey(s); + if (!sectionRefsMap.current.has(key)) { + sectionRefsMap.current.set(key, createRef()); + } + }); + + // Reset pages and totalPages when hideAvailable changes useEffect(() => { - setPopularPage(1); - setNewReleasesPage(1); + setPages({}); + setTotalPagesMap({}); }, [hideAvailable]); - // Handle page changes with auto-scroll to section top - const handlePopularPageChange = (page: number) => { - setPopularPage(page); - popularSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }); - }; + const getPage = (key: string) => pages[key] || 1; + const setPage = useCallback((key: string, page: number) => { + setPages((prev) => ({ ...prev, [key]: page })); + }, []); + const handleTotalPagesChange = useCallback((key: string, totalPages: number) => { + setTotalPagesMap((prev) => { + if (prev[key] === totalPages) return prev; + return { ...prev, [key]: totalPages }; + }); + }, []); - const handleNewReleasesPageChange = (page: number) => { - setNewReleasesPage(page); - newReleasesSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }); - }; + // Build pagination sections for the floating pill + const paginationSections: PaginationSection[] = sections.map((s, i) => { + const key = getSectionKey(s); + const ref = sectionRefsMap.current.get(key)!; + return { + label: getSectionTitle(s.sectionType, s.categoryName), + accentColor: SECTION_DOT_COLORS[i % SECTION_DOT_COLORS.length], + currentPage: getPage(key), + totalPages: totalPagesMap[key] || 1, + onPageChange: (page: number) => { + setPage(key, page); + ref.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }, + sectionRef: ref, + onScrollToSection: () => + ref.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }), + }; + }); return (
-
- {/* Popular Audiobooks Section */} -
- {/* Sticky Section Header */} -
-
-
-
-

- Popular Audiobooks -

- + {/* Loading state */} + {sectionsLoading && ( +
+
+
+ )} + + {/* Empty state */} + {!sectionsLoading && sections.length === 0 && ( +
+

+ No sections configured. Click Customize to add sections to your home page. +

+ +
+ )} + + {/* Dynamic sections */} + {!sectionsLoading && + sections.map((section, index) => { + const key = getSectionKey(section); + const ref = sectionRefsMap.current.get(key)!; + + return ( + { + setPage(key, page); + ref.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }} + sectionRef={ref} + cardSize={cardSize} + squareCovers={squareCovers} hideAvailable={hideAvailable} onToggleHideAvailable={setHideAvailable} - squareCovers={squareCovers} onToggleSquareCovers={setSquareCovers} - cardSize={cardSize} onCardSizeChange={setCardSize} + onConfigOpen={index === 0 ? () => setConfigOpen(true) : undefined} + onTotalPagesChange={(tp) => handleTotalPagesChange(key, tp)} + nextRefresh={nextRefresh} /> -
+ ); + })} + + {/* Call to Action */} +
+

+ Can't find what you're looking for? +

+

+ Use our search to find any audiobook from Audible +

+ + Search Audiobooks + +
+
+ + {/* Footer */} +
+
+
+

ReadMeABook - Audiobook Library Management System

+
- {/* Section Content */} -
- {popularMessage && !loadingPopular && popular.length === 0 ? ( -
-

- No popular audiobooks found -

-

- {popularMessage} -

-
- ) : ( - - )} -
- + {/* Unified Pagination — dynamic sections */} + {paginationSections.length > 0 && ( + + )} - {/* New Releases Section */} -
- {/* Sticky Section Header */} -
-
-
-
-

- New Releases -

- -
-
-
- - {/* Section Content */} -
- {newReleasesMessage && !loadingNewReleases && newReleases.length === 0 ? ( -
-

- No new releases found -

-

- {newReleasesMessage} -

-
- ) : ( - - )} -
-
- - {/* Call to Action */} -
-

- Can't find what you're looking for? -

-

- Use our search to find any audiobook from Audible -

- - Search Audiobooks - -
- - - {/* Footer */} -
-
-
-

ReadMeABook - Audiobook Library Management System

-
-
-
- - {/* Unified Pagination — single context-aware pill for both sections */} - - popularSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }), - }, - { - label: 'New Releases', - accentColor: 'bg-emerald-500', - currentPage: newReleasesPage, - totalPages: newReleasesTotalPages, - onPageChange: handleNewReleasesPageChange, - sectionRef: newReleasesSectionRef, - onScrollToSection: () => - newReleasesSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }), - }, - ]} - /> + {/* Config Modal */} + setConfigOpen(false)} + sections={sections} + onSave={saveSections} + />
); diff --git a/src/components/audiobooks/AudiobookCard.tsx b/src/components/audiobooks/AudiobookCard.tsx index 0afb173..f77048e 100644 --- a/src/components/audiobooks/AudiobookCard.tsx +++ b/src/components/audiobooks/AudiobookCard.tsx @@ -46,6 +46,8 @@ const getStatusConfig = (audiobook: Audiobook) => { return null; }; +const PLACEHOLDER_COVER = '/placeholder_cover.svg'; + export function AudiobookCard({ audiobook, onRequestSuccess, @@ -57,6 +59,7 @@ export function AudiobookCard({ const [error, setError] = useState(null); const [showModal, setShowModal] = useState(false); const [localRequestStatus, setLocalRequestStatus] = useState(undefined); + const [coverError, setCoverError] = useState(false); // Build a display-only audiobook with the local status override const displayAudiobook = localRequestStatus !== undefined @@ -113,20 +116,23 @@ export function AudiobookCard({ `} > {/* Cover Art */} - {audiobook.coverArtUrl ? ( + {audiobook.coverArtUrl && !coverError ? ( setCoverError(true)} /> ) : ( -
- - - -
+ )} {/* Hover Overlay with Actions - Desktop Only diff --git a/src/components/audiobooks/AudiobookDetailsModal.tsx b/src/components/audiobooks/AudiobookDetailsModal.tsx index c6c577b..44e6e66 100644 --- a/src/components/audiobooks/AudiobookDetailsModal.tsx +++ b/src/components/audiobooks/AudiobookDetailsModal.tsx @@ -96,6 +96,7 @@ export function AudiobookDetailsModal({ const [asinCopied, setAsinCopied] = useState(false); const [localRequestStatus, setLocalRequestStatus] = useState(requestStatus ?? null); const [isDownloading, setIsDownloading] = useState(false); + const [coverError, setCoverError] = useState(false); // Sync local status when the prop changes (e.g. page data refreshes) useEffect(() => { @@ -287,7 +288,7 @@ export function AudiobookDetailsModal({ ${squareCovers ? 'w-40 sm:w-44 lg:w-52 aspect-square' : 'w-32 sm:w-40 lg:w-48 aspect-[2/3]'} ${status.type === 'available' ? 'ring-2 ring-emerald-400/60' : ''} `}> - {audiobook.coverArtUrl ? ( + {audiobook.coverArtUrl && !coverError ? ( setCoverError(true)} /> ) : ( -
- - - -
+ )} {/* Rating Badge */} diff --git a/src/components/bookdate/BookPickerModal.tsx b/src/components/bookdate/BookPickerModal.tsx index 0d47eb3..043834d 100644 --- a/src/components/bookdate/BookPickerModal.tsx +++ b/src/components/bookdate/BookPickerModal.tsx @@ -250,10 +250,12 @@ export function BookPickerModal({ {/* Cover Image or Text Placeholder */}
{book.coverUrl ? ( + /* eslint-disable-next-line @next/next/no-img-element */ {book.title} { (e.target as HTMLImageElement).src = '/placeholder_cover.svg'; }} /> ) : (
diff --git a/src/components/bookdate/RecommendationCard.tsx b/src/components/bookdate/RecommendationCard.tsx index 83a2ad1..cd02581 100644 --- a/src/components/bookdate/RecommendationCard.tsx +++ b/src/components/bookdate/RecommendationCard.tsx @@ -27,6 +27,7 @@ export function RecommendationCard({ isDraggable = true, }: RecommendationCardProps) { const [showToast, setShowToast] = useState(false); + const [coverError, setCoverError] = useState(false); const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 }); const [isDragging, setIsDragging] = useState(false); @@ -227,7 +228,7 @@ export function RecommendationCard({ {/* Cover image - smaller on mobile to fit all content */}
- {recommendation.coverUrl ? ( + {recommendation.coverUrl && !coverError ? ( {recommendation.title} setCoverError(true)} /> ) : ( -
- 📚 -
+ {recommendation.title} )}
diff --git a/src/components/home/HomeSection.tsx b/src/components/home/HomeSection.tsx new file mode 100644 index 0000000..c1981fa --- /dev/null +++ b/src/components/home/HomeSection.tsx @@ -0,0 +1,310 @@ +/** + * Component: Home Section — renders a single audiobook discovery section + * Documentation: documentation/features/home-sections.md + * + * Handles popular, new_releases, and category section types with unified rendering. + */ + +'use client'; + +import React, { useEffect } from 'react'; +import { AudiobookGrid } from '@/components/audiobooks/AudiobookGrid'; +import { SectionToolbar } from '@/components/ui/SectionToolbar'; +import { useAudiobooks } from '@/lib/hooks/useAudiobooks'; +import { useCategoryAudiobooks } from '@/lib/hooks/useHomeSections'; +import { Cog6ToothIcon, ClockIcon } from '@heroicons/react/24/outline'; + +const SECTION_COLORS = [ + 'from-blue-500 to-indigo-500', + 'from-emerald-500 to-teal-500', + 'from-violet-500 to-purple-500', + 'from-amber-500 to-orange-500', + 'from-rose-500 to-pink-500', + 'from-cyan-500 to-sky-500', + 'from-fuchsia-500 to-pink-500', + 'from-lime-500 to-green-500', + 'from-orange-500 to-red-500', + 'from-teal-500 to-emerald-500', +]; + +export const SECTION_DOT_COLORS = [ + 'bg-blue-500', 'bg-emerald-500', 'bg-violet-500', 'bg-amber-500', 'bg-rose-500', + 'bg-cyan-500', 'bg-fuchsia-500', 'bg-lime-500', 'bg-orange-500', 'bg-teal-500', +]; + +function getSectionTitle(sectionType: string, categoryName?: string | null): string { + if (sectionType === 'popular') return 'Popular Audiobooks'; + if (sectionType === 'new_releases') return 'New Releases'; + return categoryName || 'Category'; +} + +/** + * Formats a nextRefresh ISO timestamp into a friendly, readable string. + * Examples: "today at 6:00 PM", "tomorrow at 2:00 AM", "Saturday at 9:00 AM" + */ +function formatNextRefresh(isoString: string): string { + const refreshDate = new Date(isoString); + const now = new Date(); + + const refreshMidnight = new Date(refreshDate); + refreshMidnight.setHours(0, 0, 0, 0); + + const todayMidnight = new Date(now); + todayMidnight.setHours(0, 0, 0, 0); + + const tomorrowMidnight = new Date(todayMidnight); + tomorrowMidnight.setDate(tomorrowMidnight.getDate() + 1); + + const dayAfterMidnight = new Date(tomorrowMidnight); + dayAfterMidnight.setDate(dayAfterMidnight.getDate() + 1); + + const timeStr = refreshDate.toLocaleTimeString(undefined, { + hour: 'numeric', + minute: '2-digit', + hour12: true, + }); + + if (refreshMidnight.getTime() === todayMidnight.getTime()) { + return `today at ${timeStr}`; + } + if (refreshMidnight.getTime() === tomorrowMidnight.getTime()) { + return `tomorrow at ${timeStr}`; + } + if (refreshMidnight.getTime() < dayAfterMidnight.getTime()) { + const dayName = refreshDate.toLocaleDateString(undefined, { weekday: 'long' }); + return `${dayName} at ${timeStr}`; + } + + const dateStr = refreshDate.toLocaleDateString(undefined, { + weekday: 'long', + month: 'long', + day: 'numeric', + }); + return `${dateStr} at ${timeStr}`; +} + +interface HomeSectionProps { + sectionType: 'popular' | 'new_releases' | 'category'; + categoryId: string | null; + categoryName: string | null; + colorIndex: number; + page: number; + onPageChange: (page: number) => void; + sectionRef: React.RefObject; + cardSize: number; + squareCovers: boolean; + hideAvailable: boolean; + onToggleHideAvailable: (v: boolean) => void; + onToggleSquareCovers: (v: boolean) => void; + onCardSizeChange: (v: number) => void; + onConfigOpen?: () => void; + onTotalPagesChange?: (totalPages: number) => void; + nextRefresh: string | null; +} + +function PopularOrNewSection({ + type, + page, + hideAvailable, + onTotalPagesChange, + ...renderProps +}: { + type: 'popular' | 'new-releases'; + page: number; + hideAvailable: boolean; + onTotalPagesChange?: (totalPages: number) => void; +} & RenderSectionProps) { + const { audiobooks, isLoading, totalPages, message } = useAudiobooks(type, 20, page, hideAvailable); + + useEffect(() => { + onTotalPagesChange?.(totalPages); + }, [totalPages, onTotalPagesChange]); + + return ( + + ); +} + +function CategorySection({ + categoryId, + page, + hideAvailable, + onTotalPagesChange, + ...renderProps +}: { + categoryId: string; + page: number; + hideAvailable: boolean; + onTotalPagesChange?: (totalPages: number) => void; +} & RenderSectionProps) { + const { audiobooks, isLoading, totalPages, message } = useCategoryAudiobooks( + categoryId, + 20, + page, + hideAvailable + ); + + useEffect(() => { + onTotalPagesChange?.(totalPages); + }, [totalPages, onTotalPagesChange]); + + return ( + + ); +} + +interface RenderSectionProps { + cardSize: number; + squareCovers: boolean; + nextRefresh?: string | null; +} + +function CategoryEmptyState({ nextRefresh }: { nextRefresh?: string | null }) { + const refreshLabel = nextRefresh ? formatNextRefresh(nextRefresh) : null; + + return ( +
+
+ +
+

+ No audiobooks yet +

+

+ {refreshLabel + ? <>This section will fill in after the next data refresh, scheduled for {refreshLabel}. + : 'This section will fill in after the next scheduled data refresh.'} +

+
+ ); +} + +function RenderSection({ + audiobooks, + isLoading, + totalPages, + message, + cardSize, + squareCovers, + nextRefresh, +}: RenderSectionProps & { + audiobooks: any[]; + isLoading: boolean; + totalPages: number; + message: string | null; +}) { + if (message && !isLoading && audiobooks.length === 0) { + return ; + } + + return ( + + ); +} + +export function HomeSection({ + sectionType, + categoryId, + categoryName, + colorIndex, + page, + onPageChange, + sectionRef, + cardSize, + squareCovers, + hideAvailable, + onToggleHideAvailable, + onToggleSquareCovers, + onCardSizeChange, + onConfigOpen, + onTotalPagesChange, + nextRefresh, +}: HomeSectionProps) { + const gradient = SECTION_COLORS[colorIndex % SECTION_COLORS.length]; + const title = getSectionTitle(sectionType, categoryName); + + const renderProps: RenderSectionProps = { cardSize, squareCovers, nextRefresh }; + + return ( +
+ {/* Sticky Section Header */} +
+
+
+
+

+ {title} +

+ + {onConfigOpen && ( + + )} +
+
+
+ + {/* Section Content */} +
+ {sectionType === 'popular' && ( + + )} + {sectionType === 'new_releases' && ( + + )} + {sectionType === 'category' && categoryId && ( + + )} +
+
+ ); +} diff --git a/src/components/home/HomeSectionConfigModal.tsx b/src/components/home/HomeSectionConfigModal.tsx new file mode 100644 index 0000000..2072897 --- /dev/null +++ b/src/components/home/HomeSectionConfigModal.tsx @@ -0,0 +1,342 @@ +/** + * Component: Home Section Configuration Modal + * Documentation: documentation/features/home-sections.md + * + * Allows users to add/remove/reorder home page sections. + * Drag-and-drop on desktop, up/down arrows on mobile. Auto-save with debounce. + */ + +'use client'; + +import React, { useState, useEffect, useCallback, useRef } from 'react'; +import { + XMarkIcon, + PlusIcon, + TrashIcon, + ChevronUpIcon, + ChevronDownIcon, + Bars3Icon, +} from '@heroicons/react/24/outline'; +import type { HomeSection, AudibleCategory } from '@/lib/hooks/useHomeSections'; +import { authenticatedFetcher } from '@/lib/utils/api'; + +const MAX_SECTIONS = 10; + +const SECTION_COLORS = [ + 'bg-blue-500', 'bg-emerald-500', 'bg-violet-500', 'bg-amber-500', 'bg-rose-500', + 'bg-cyan-500', 'bg-fuchsia-500', 'bg-lime-500', 'bg-orange-500', 'bg-teal-500', +]; + +function getSectionLabel(section: { sectionType: string; categoryName?: string | null }) { + if (section.sectionType === 'popular') return 'Popular Audiobooks'; + if (section.sectionType === 'new_releases') return 'New Releases'; + return section.categoryName || 'Category'; +} + +interface Props { + isOpen: boolean; + onClose: () => void; + sections: HomeSection[]; + onSave: (sections: Omit[]) => Promise; +} + +export function HomeSectionConfigModal({ isOpen, onClose, sections, onSave }: Props) { + const [localSections, setLocalSections] = useState[]>([]); + const [categories, setCategories] = useState([]); + const [loadingCategories, setLoadingCategories] = useState(false); + const [showCategoryPicker, setShowCategoryPicker] = useState(false); + const [saving, setSaving] = useState(false); + const [dirty, setDirty] = useState(false); + const debounceRef = useRef | null>(null); + const [dragIndex, setDragIndex] = useState(null); + + // Sync from prop when modal opens + useEffect(() => { + if (isOpen) { + setLocalSections( + sections.map((s) => ({ + sectionType: s.sectionType, + categoryId: s.categoryId, + categoryName: s.categoryName, + sortOrder: s.sortOrder, + })) + ); + setDirty(false); + setShowCategoryPicker(false); + } + }, [isOpen, sections]); + + // Auto-save with debounce + useEffect(() => { + if (!dirty) return; + if (debounceRef.current) clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(async () => { + setSaving(true); + try { + await onSave(localSections.map((s, i) => ({ ...s, sortOrder: i }))); + } catch { + // Silently fail — user will see stale state + } + setSaving(false); + setDirty(false); + }, 800); + + return () => { + if (debounceRef.current) clearTimeout(debounceRef.current); + }; + }, [dirty, localSections, onSave]); + + // Fetch categories when picker opens + const loadCategories = useCallback(async () => { + if (categories.length > 0) { + setShowCategoryPicker(true); + return; + } + setLoadingCategories(true); + try { + const data = await authenticatedFetcher('/api/audible/categories'); + setCategories(data.categories || []); + } catch { + setCategories([]); + } + setLoadingCategories(false); + setShowCategoryPicker(true); + }, [categories.length]); + + const addCategory = useCallback( + (cat: AudibleCategory) => { + if (localSections.length >= MAX_SECTIONS) return; + // Prevent duplicate + if (localSections.some((s) => s.sectionType === 'category' && s.categoryId === cat.id)) return; + + setLocalSections((prev) => [ + ...prev, + { + sectionType: 'category' as const, + categoryId: cat.id, + categoryName: cat.name, + sortOrder: prev.length, + }, + ]); + setDirty(true); + setShowCategoryPicker(false); + }, + [localSections] + ); + + const addBuiltIn = useCallback( + (type: 'popular' | 'new_releases') => { + if (localSections.length >= MAX_SECTIONS) return; + if (localSections.some((s) => s.sectionType === type)) return; + + setLocalSections((prev) => [ + ...prev, + { sectionType: type, categoryId: null, categoryName: null, sortOrder: prev.length }, + ]); + setDirty(true); + }, + [localSections] + ); + + const removeSection = useCallback((index: number) => { + setLocalSections((prev) => prev.filter((_, i) => i !== index)); + setDirty(true); + }, []); + + const moveSection = useCallback((from: number, to: number) => { + setLocalSections((prev) => { + const next = [...prev]; + const [item] = next.splice(from, 1); + next.splice(to, 0, item); + return next; + }); + setDirty(true); + }, []); + + // Drag handlers + const handleDragStart = (index: number) => { + setDragIndex(index); + }; + + const handleDragOver = (e: React.DragEvent, index: number) => { + e.preventDefault(); + if (dragIndex === null || dragIndex === index) return; + moveSection(dragIndex, index); + setDragIndex(index); + }; + + const handleDragEnd = () => { + setDragIndex(null); + }; + + if (!isOpen) return null; + + const hasPopular = localSections.some((s) => s.sectionType === 'popular'); + const hasNewReleases = localSections.some((s) => s.sectionType === 'new_releases'); + const existingCategoryIds = new Set( + localSections.filter((s) => s.sectionType === 'category').map((s) => s.categoryId) + ); + + return ( +
+ {/* Backdrop */} +
+ + {/* Modal */} +
+ {/* Header */} +
+
+

+ Customize Home +

+

+ {localSections.length}/{MAX_SECTIONS} sections + {saving && ( + Saving... + )} +

+
+ +
+ + {/* Section list */} +
+ {localSections.length === 0 && ( +
+

No sections configured.

+

Add sections below to customize your home page.

+
+ )} + + {localSections.map((section, index) => ( +
handleDragStart(index)} + onDragOver={(e) => handleDragOver(e, index)} + onDragEnd={handleDragEnd} + className={` + flex items-center gap-3 px-3 py-2.5 rounded-xl border transition-all duration-200 + ${dragIndex === index + ? 'border-blue-400 dark:border-blue-500 bg-blue-50 dark:bg-blue-900/20 shadow-md scale-[1.02]' + : 'border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50 hover:border-gray-300 dark:hover:border-gray-600' + } + `} + > + {/* Drag handle */} +
+ +
+ + {/* Color dot */} +
+ + {/* Label */} + + {getSectionLabel(section)} + + + {/* Mobile reorder arrows */} +
+ + +
+ + {/* Remove */} + +
+ ))} +
+ + {/* Add section controls */} +
+ {/* Built-in section buttons */} +
+ {!hasPopular && ( + + )} + {!hasNewReleases && ( + + )} + +
+ + {/* Category picker */} + {showCategoryPicker && ( +
+ {categories.length === 0 ? ( +
No categories found.
+ ) : ( + categories + .filter((c) => !existingCategoryIds.has(c.id)) + .map((cat) => ( + + )) + )} + +
+ )} +
+
+
+ ); +} diff --git a/src/components/profile/GoodreadsShelvesSection.tsx b/src/components/profile/GoodreadsShelvesSection.tsx index 0b8d5e5..df61352 100644 --- a/src/components/profile/GoodreadsShelvesSection.tsx +++ b/src/components/profile/GoodreadsShelvesSection.tsx @@ -333,12 +333,14 @@ function CoverStack({ onClick={() => book.asin && onBookClick(book.asin)} title={book.asin ? `${book.title}${book.author ? ` by ${book.author}` : ''}` : undefined} > + {/* eslint-disable-next-line @next/next/no-img-element */} { (e.target as HTMLImageElement).src = '/placeholder_cover.svg'; }} />
))} diff --git a/src/components/profile/WatchedListsSection.tsx b/src/components/profile/WatchedListsSection.tsx index 48b16e2..efe5fbf 100644 --- a/src/components/profile/WatchedListsSection.tsx +++ b/src/components/profile/WatchedListsSection.tsx @@ -101,15 +101,14 @@ function WatchedSeriesCard({ {/* Cover */} diff --git a/src/components/requests/RequestCard.tsx b/src/components/requests/RequestCard.tsx index d54ad21..1c0784e 100644 --- a/src/components/requests/RequestCard.tsx +++ b/src/components/requests/RequestCard.tsx @@ -44,6 +44,7 @@ export function RequestCard({ request, showActions = true }: RequestCardProps) { const { squareCovers } = usePreferences(); const [showError, setShowError] = React.useState(false); const [showDetailsModal, setShowDetailsModal] = React.useState(false); + const [coverError, setCoverError] = React.useState(false); const requestType = request.type || 'audiobook'; const isEbook = requestType === 'ebook'; @@ -98,41 +99,34 @@ export function RequestCard({ request, showActions = true }: RequestCardProps) { tabIndex={request.audiobook.audibleAsin ? 0 : undefined} onKeyDown={(e) => e.key === 'Enter' && request.audiobook.audibleAsin && setShowDetailsModal(true)} > - {request.audiobook.coverArtUrl ? ( + {request.audiobook.coverArtUrl && !coverError ? ( {request.audiobook.title} setCoverError(true)} /> - ) : ( + ) : isEbook ? (
- {isEbook ? ( - - - - ) : ( - - - - )} + + +
+ ) : ( + {request.audiobook.title} )}
diff --git a/src/components/series/SeriesCard.tsx b/src/components/series/SeriesCard.tsx index fc5614c..af6e1dc 100644 --- a/src/components/series/SeriesCard.tsx +++ b/src/components/series/SeriesCard.tsx @@ -9,7 +9,7 @@ 'use client'; -import React from 'react'; +import React, { useState } from 'react'; import Image from 'next/image'; import Link from 'next/link'; import { SeriesSummary } from '@/lib/hooks/useSeries'; @@ -20,6 +20,7 @@ interface SeriesCardProps { } export function SeriesCard({ series, squareCovers = false }: SeriesCardProps) { + const [coverError, setCoverError] = useState(false); const visibleTags = series.tags.slice(0, 2); const hasTags = visibleTags.length > 0; const hasRating = series.rating != null && series.rating > 0; @@ -42,30 +43,23 @@ export function SeriesCard({ series, squareCovers = false }: SeriesCardProps) { `} > {/* Cover Art or Fallback */} - {series.coverArtUrl ? ( + {series.coverArtUrl && !coverError ? ( setCoverError(true)} /> ) : ( -
- - - -
+ )} {/* Top-row badges — Rating (left) + Book count (right) */} diff --git a/src/components/series/SeriesDetailCard.tsx b/src/components/series/SeriesDetailCard.tsx index 158b304..89b8b92 100644 --- a/src/components/series/SeriesDetailCard.tsx +++ b/src/components/series/SeriesDetailCard.tsx @@ -8,11 +8,13 @@ 'use client'; -import React, { useState } from 'react'; +import React, { useState, useCallback } from 'react'; import Image from 'next/image'; import { SeriesDetail } from '@/lib/hooks/useSeries'; import { WatchSeriesButton } from '@/components/ui/WatchButton'; +const PLACEHOLDER_COVER = '/placeholder_cover.svg'; + interface SeriesDetailCardProps { series: SeriesDetail; squareCovers?: boolean; @@ -20,6 +22,7 @@ interface SeriesDetailCardProps { export function SeriesDetailCard({ series, squareCovers = false }: SeriesDetailCardProps) { const [expanded, setExpanded] = useState(false); + const [coverError, setCoverError] = useState(false); const hasLongDescription = (series.description?.length || 0) > 300; return ( @@ -27,7 +30,7 @@ export function SeriesDetailCard({ series, squareCovers = false }: SeriesDetailC {/* Rectangular Cover */}
- {series.books[0]?.coverArtUrl ? ( + {series.books[0]?.coverArtUrl && !coverError ? ( {series.title} setCoverError(true)} /> ) : ( -
- - - -
+ {series.title} )}
diff --git a/src/components/series/SimilarSeriesRow.tsx b/src/components/series/SimilarSeriesRow.tsx index 6b60152..6f0fb56 100644 --- a/src/components/series/SimilarSeriesRow.tsx +++ b/src/components/series/SimilarSeriesRow.tsx @@ -97,21 +97,14 @@ export function SimilarSeriesRow({ series, currentSeriesTitle, squareCovers = fa > {/* Cover */}
- {s.coverArtUrl ? ( - - ) : ( -
- - {s.title.charAt(0).toUpperCase()} - -
- )} + { (e.target as HTMLImageElement).src = '/placeholder_cover.svg'; }} + />
{/* Title */} diff --git a/src/components/ui/UnifiedPagination.tsx b/src/components/ui/UnifiedPagination.tsx index b44230b..6fd0b07 100644 --- a/src/components/ui/UnifiedPagination.tsx +++ b/src/components/ui/UnifiedPagination.tsx @@ -2,10 +2,9 @@ * Component: Unified Pagination — context-aware floating paginator * Documentation: documentation/frontend/components.md * - * Replaces two overlapping StickyPagination instances with a single pill - * that automatically tracks which section dominates the viewport and shows - * controls for that section. Transitions smoothly when the dominant section - * changes. Includes a two-dot section indicator for manual switching. + * A single floating pill that automatically tracks which section dominates + * the viewport and shows pagination controls for that section. + * Supports 1-12 sections dynamically with dot indicators for manual switching. */ 'use client'; @@ -28,7 +27,7 @@ export interface PaginationSection { } interface UnifiedPaginationProps { - sections: [PaginationSection, PaginationSection]; + sections: PaginationSection[]; footerRef?: React.RefObject; } @@ -91,34 +90,152 @@ function PageJump({ currentPage, totalPages, onPageChange }: PageJumpProps) { ); } +// --------------------------------------------------------------------------- +// Section indicator dots — scales gracefully from 2-12 sections +// --------------------------------------------------------------------------- + +interface SectionDotsProps { + sections: PaginationSection[]; + activeIndex: number; +} + +/** + * For 2-4 sections: simple vertical dot column (original behavior, unchanged). + * For 5+ sections: iOS-style compressed window of 5 visible dots. + * - Center slot = active section (full height, accent color) + * - ±1 slots = neighboring sections (medium) + * - ±2 slots = far neighbors (tiny, fade indicator) + * Dots beyond the window are hidden entirely. The window slides as activeIndex changes. + */ +function SectionDots({ sections, activeIndex }: SectionDotsProps) { + const count = sections.length; + + // ---- Few sections: simple column ---- + if (count <= 4) { + return ( +
+ {sections.map((section, idx) => { + const isActive = idx === activeIndex; + return ( +
+ ); + } + + // ---- Many sections: windowed 5-slot strip ---- + // The window is always 5 slots wide; we clamp it so it doesn't fall off edges. + const WINDOW = 5; + const half = Math.floor(WINDOW / 2); // 2 + + // Ideal window start: center the active dot + let windowStart = activeIndex - half; + // Clamp so window stays within [0, count - WINDOW] + windowStart = Math.max(0, Math.min(windowStart, count - WINDOW)); + const windowEnd = windowStart + WINDOW - 1; // inclusive + + // Distance from active within the window (for size calculation) + // slots: [windowStart, windowStart+1, ..., windowEnd] + const slots = Array.from({ length: WINDOW }, (_, i) => windowStart + i); + + // Sizes: index 0 (dist 2 from active) → 2.5px, dist 1 → 4px, dist 0 (active) → 6px + const heightForDist = [16, 10, 7, 5, 3]; // px — dist 0..4 (we only use 0-2) + + // Whether we need overflow arrows (dots hidden beyond window edges) + const hasHiddenLeft = windowStart > 0; + const hasHiddenRight = windowEnd < count - 1; + + return ( +
+ {/* Top fade indicator */} + {hasHiddenLeft && ( + + {sections.length > 1 && ( + <> + - {/* Divider */} -
+ {/* Divider */} +
+ + )} {/* Label + controls — cross-fades on section switch */}
{/* Section label — hidden on small screens */} - + {active.label} diff --git a/src/lib/hooks/useHomeSections.ts b/src/lib/hooks/useHomeSections.ts new file mode 100644 index 0000000..9567a71 --- /dev/null +++ b/src/lib/hooks/useHomeSections.ts @@ -0,0 +1,119 @@ +/** + * Component: Home Sections Hook + * Documentation: documentation/features/home-sections.md + * + * Manages user home section configuration (CRUD) and category fetching. + */ + +'use client'; + +import useSWR, { mutate as globalMutate } from 'swr'; +import { authenticatedFetcher } from '@/lib/utils/api'; +import { useCallback, useRef } from 'react'; + +export interface HomeSection { + id: string; + sectionType: 'popular' | 'new_releases' | 'category'; + categoryId: string | null; + categoryName: string | null; + sortOrder: number; +} + +export interface HomeSectionsResponse { + success: boolean; + sections: HomeSection[]; + nextRefresh: string | null; +} + +export interface AudibleCategory { + id: string; + name: string; +} + +const HOME_SECTIONS_KEY = '/api/user/home-sections'; + +/** + * Hook to fetch and manage user home sections. + */ +export function useHomeSections() { + const { data, error, isLoading, mutate } = useSWR( + HOME_SECTIONS_KEY, + authenticatedFetcher, + { + revalidateOnFocus: false, + dedupingInterval: 30000, + } + ); + + const saveSections = useCallback( + async (sections: Omit[]) => { + const { fetchJSON } = await import('@/lib/utils/api'); + const result = await fetchJSON(HOME_SECTIONS_KEY, { + method: 'PUT', + body: JSON.stringify({ sections }), + }); + // Update local cache + mutate(result, false); + return result; + }, + [mutate] + ); + + return { + sections: data?.sections || [], + nextRefresh: data?.nextRefresh || null, + isLoading, + error, + saveSections, + mutate, + }; +} + +/** + * Hook to fetch Audible categories (live scrape, for config modal). + */ +export function useAudibleCategories() { + const { data, error, isLoading } = useSWR<{ success: boolean; categories: AudibleCategory[] }>( + null, // Don't fetch automatically — use fetchCategories + authenticatedFetcher, + { revalidateOnFocus: false } + ); + + return { + categories: data?.categories || [], + isLoading, + error, + }; +} + +/** + * Hook to fetch category audiobooks (same pattern as useAudiobooks). + */ +export function useCategoryAudiobooks( + categoryId: string | null, + limit: number = 20, + page: number = 1, + hideAvailable: boolean = false +) { + const hideParam = hideAvailable ? '&hideAvailable=true' : ''; + const endpoint = categoryId + ? `/api/audiobooks/category/${categoryId}?page=${page}&limit=${limit}${hideParam}` + : null; + + const { data, error, isLoading } = useSWR(endpoint, authenticatedFetcher, { + revalidateOnFocus: false, + revalidateOnReconnect: false, + dedupingInterval: 60000, + }); + + return { + audiobooks: data?.audiobooks || [], + totalCount: data?.totalCount || 0, + totalPages: data?.totalPages || 0, + currentPage: data?.page || page, + hasMore: data?.hasMore || false, + message: data?.message || null, + isLoading, + error, + }; +} diff --git a/src/lib/integrations/audible.service.ts b/src/lib/integrations/audible.service.ts index de32076..bc1bd90 100644 --- a/src/lib/integrations/audible.service.ts +++ b/src/lib/integrations/audible.service.ts @@ -256,6 +256,15 @@ export class AudibleService { throw error; } + // Don't retry on deterministic 500 errors (e.g. "Release date is in the future") + if (status === 500) { + const message = error.response?.data?.message || ''; + if (message.includes('Release date is in the future')) { + logger.info(` External API returned non-retryable error: ${message}`); + throw error; + } + } + // Don't retry on last attempt if (attempt === maxRetries) { break; @@ -1172,6 +1181,155 @@ export class AudibleService { } } + /** + * Get top-level categories from Audible's categories page. + * Scrapes {baseUrl}/categories and returns {id, name}[] for top-level nodes. + */ + async getCategories(): Promise<{ id: string; name: string }[]> { + await this.initialize(); + + logger.info('Fetching Audible categories...'); + + try { + const { data: response } = await this.fetchWithRetry('/categories', { + params: { ipRedirectOverride: 'true' }, + }); + + const $ = cheerio.load(response.data); + const categories: { id: string; name: string }[] = []; + + // Top-level category links are in the main categories grid + // They follow the pattern /cat/{name}/{nodeId} + $('a[href*="/cat/"]').each((_index, element) => { + const $el = $(element); + const href = $el.attr('href') || ''; + const match = href.match(/\/cat\/[^\/]+\/(\d+)/); + if (!match) return; + + const id = match[1]; + const name = $el.text().trim(); + + if (name && !categories.some((c) => c.id === id)) { + categories.push({ id, name }); + } + }); + + logger.info(`Found ${categories.length} top-level categories`); + return categories; + } catch (error) { + logger.error('Failed to fetch categories', { + error: error instanceof Error ? error.message : String(error), + }); + return []; + } + } + + /** + * Get audiobooks for a specific category using Audible search with node parameter. + * Scrapes {baseUrl}/search?node={categoryId}&pageSize=50, up to `limit` results. + */ + async getCategoryBooks(categoryId: string, limit: number = 200): Promise { + await this.initialize(); + + logger.info(`Fetching category books for node ${categoryId} (limit: ${limit})...`); + + const audiobooks: AudibleAudiobook[] = []; + let page = 1; + const maxPages = Math.ceil(limit / AUDIBLE_PAGE_SIZE); + + this.pacer.reset(); + + while (audiobooks.length < limit && page <= maxPages) { + try { + const { data: response, meta } = await this.fetchWithRetry('/search', { + params: { + ipRedirectOverride: 'true', + node: categoryId, + pageSize: AUDIBLE_PAGE_SIZE, + sort: 'popularity-rank', + ...(page > 1 ? { page } : {}), + }, + }); + + const $ = cheerio.load(response.data); + let foundOnPage = 0; + + // Parse search results — same selectors as search() + $('.s-result-item, .productListItem').each((_index, element) => { + if (audiobooks.length >= limit) return false; + const $el = $(element); + + const asin = + $el.find('li').attr('data-asin') || + $el.find('a').attr('href')?.match(/\/(?:pd|ac)\/[^\/]+\/([A-Z0-9]{10})/)?.[1] || + ''; + if (!asin || audiobooks.some((b) => b.asin === asin)) return; + + const title = + $el.find('h2').first().text().trim() || + $el.find('h3 a').text().trim() || + $el.find('.bc-heading a').text().trim(); + + const authorLink = $el.find('a[href*="/author/"]').first(); + const authorText = + authorLink.text().trim() || + $el.find('.authorLabel').text().trim(); + const authorHref = authorLink.attr('href') || ''; + const authorAsinMatch = authorHref.match(/\/author\/[^\/]+\/([A-Z0-9]{10})/); + + const narratorText = + $el.find('a[href*="searchNarrator="]').first().text().trim() || + $el.find('.narratorLabel').text().trim(); + + const coverArtUrl = $el.find('img').attr('src') || ''; + + const langConfig = this.getLangConfig(); + const runtimeText = + $el.find('.runtimeLabel').text().trim() || + $el.find(buildContainsSelector('span', langConfig.scraping.lengthLabels)).text().trim(); + const durationMinutes = this.parseRuntime(runtimeText); + + const ratingText = + $el.find('.ratingsLabel').text().trim() || + $el.find('.a-icon-star span').first().text().trim(); + const rating = ratingText ? parseFloat(ratingText.split(' ')[0]) : undefined; + + audiobooks.push({ + asin, + title, + author: stripPrefixes(authorText, langConfig.scraping.authorPrefixes), + authorAsin: authorAsinMatch?.[1] || undefined, + narrator: stripPrefixes(narratorText, langConfig.scraping.narratorPrefixes), + coverArtUrl: coverArtUrl.replace(/\._.*_\./, '._SL500_.'), + durationMinutes, + rating, + }); + + foundOnPage++; + }); + + logger.info(`Category ${categoryId}: found ${foundOnPage} books on page ${page}`); + + if (foundOnPage < AUDIBLE_PAGE_SIZE / 2) break; + + page++; + + if (page <= maxPages && audiobooks.length < limit) { + await this.delay(this.pacer.reportPageResult(meta)); + } + } catch (error) { + logger.error(`Failed to fetch category ${categoryId} page ${page}`, { + error: error instanceof Error ? error.message : String(error), + collectedSoFar: audiobooks.length, + }); + break; + } + } + + logger.info(`Category ${categoryId}: collected ${audiobooks.length} books across ${page - 1} pages`); + return audiobooks; + } + /** * Add delay between requests to respect rate limits */ diff --git a/src/lib/processors/audible-refresh.processor.ts b/src/lib/processors/audible-refresh.processor.ts index 817d831..0c15bee 100644 --- a/src/lib/processors/audible-refresh.processor.ts +++ b/src/lib/processors/audible-refresh.processor.ts @@ -2,12 +2,18 @@ * Component: Audible Refresh Processor * Documentation: documentation/backend/services/scheduler.md * - * Fetches popular and new release audiobooks from Audible and caches them + * Fetches popular, new release, and category audiobooks from Audible and caches them. + * All section data is stored uniformly in AudibleCacheCategory with reserved IDs + * '__popular__' and '__new_releases__' for built-in sections. */ import { prisma } from '../db'; import { RMABLogger } from '../utils/logger'; +/** Reserved category IDs for built-in home sections */ +export const POPULAR_CATEGORY_ID = '__popular__'; +export const NEW_RELEASES_CATEGORY_ID = '__new_releases__'; + export interface AudibleRefreshPayload { jobId?: string; scheduledJobId?: string; @@ -25,22 +31,7 @@ export async function processAudibleRefresh(payload: AudibleRefreshPayload): Pro const thumbnailCache = getThumbnailCacheService(); try { - // Clear previous popular/new-release flags for fresh data - await prisma.audibleCache.updateMany({ - where: { - OR: [ - { isPopular: true }, - { isNewRelease: true }, - ], - }, - data: { - isPopular: false, - isNewRelease: false, - popularRank: null, - newReleaseRank: null, - }, - }); - logger.info('Cleared previous popular/new-release flags in audible_cache'); + const syncTime = new Date(); // Fetch popular and new releases - 200 items each const popular = await audibleService.getPopularAudiobooks(200); @@ -54,113 +45,63 @@ export async function processAudibleRefresh(payload: AudibleRefreshPayload): Pro logger.info(`Fetched ${popular.length} popular, ${newReleases.length} new releases from Audible`); - // Persist to audible_cache - let popularSaved = 0; - let newReleasesSaved = 0; - const syncTime = new Date(); + // Persist popular audiobooks via AudibleCacheCategory + const popularSaved = await persistSectionBooks( + popular, POPULAR_CATEGORY_ID, syncTime, thumbnailCache, logger, 'popular audiobook' + ); - for (let i = 0; i < popular.length; i++) { - const audiobook = popular[i]; - try { - // Cache thumbnail if coverArtUrl exists - let cachedCoverPath: string | null = null; - if (audiobook.coverArtUrl) { - cachedCoverPath = await thumbnailCache.cacheThumbnail(audiobook.asin, audiobook.coverArtUrl); + // Persist new releases via AudibleCacheCategory + const newReleasesSaved = await persistSectionBooks( + newReleases, NEW_RELEASES_CATEGORY_ID, syncTime, thumbnailCache, logger, 'new release' + ); + + logger.info(`Saved ${popularSaved} popular and ${newReleasesSaved} new releases`); + + // --- Category scraping --- + // Query distinct categoryIds from all users' home sections + let categoriesSynced = 0; + try { + const categorySections = await prisma.userHomeSection.findMany({ + where: { sectionType: 'category', categoryId: { not: null } }, + select: { categoryId: true }, + distinct: ['categoryId'], + }); + + const categoryIds = categorySections + .map((s) => s.categoryId) + .filter((id): id is string => id !== null); + + if (categoryIds.length > 0) { + logger.info(`Refreshing ${categoryIds.length} user-configured categories...`); + + for (const catId of categoryIds) { + try { + // Batch cooldown between categories + const catCooldownMs = 10000 + Math.floor(Math.random() * 10000); + logger.info(`Category cooldown: waiting ${Math.round(catCooldownMs / 1000)}s before category ${catId}...`); + await new Promise(resolve => setTimeout(resolve, catCooldownMs)); + + // Scrape category books + const books = await audibleService.getCategoryBooks(catId, 200); + logger.info(`Category ${catId}: fetched ${books.length} books`); + + const saved = await persistSectionBooks( + books, catId, syncTime, thumbnailCache, logger, 'category book' + ); + + categoriesSynced++; + logger.info(`Category ${catId}: saved ${saved} entries`); + } catch (error) { + logger.error(`Failed to refresh category ${catId}: ${error instanceof Error ? error.message : 'Unknown error'}`); + } } - await prisma.audibleCache.upsert({ - where: { asin: audiobook.asin }, - create: { - asin: audiobook.asin, - title: audiobook.title, - author: audiobook.author, - narrator: audiobook.narrator, - description: audiobook.description, - coverArtUrl: audiobook.coverArtUrl, - cachedCoverPath: cachedCoverPath, - durationMinutes: audiobook.durationMinutes, - releaseDate: audiobook.releaseDate ? new Date(audiobook.releaseDate) : null, - rating: audiobook.rating ? audiobook.rating : null, - genres: audiobook.genres || [], - isPopular: true, - popularRank: i + 1, - lastSyncedAt: syncTime, - }, - update: { - title: audiobook.title, - author: audiobook.author, - narrator: audiobook.narrator, - description: audiobook.description, - coverArtUrl: audiobook.coverArtUrl, - cachedCoverPath: cachedCoverPath, - durationMinutes: audiobook.durationMinutes, - releaseDate: audiobook.releaseDate ? new Date(audiobook.releaseDate) : null, - rating: audiobook.rating ? audiobook.rating : null, - genres: audiobook.genres || [], - isPopular: true, - popularRank: i + 1, - lastSyncedAt: syncTime, - }, - }); - - popularSaved++; - } catch (error) { - logger.error(`Failed to save popular audiobook ${audiobook.title}: ${error instanceof Error ? error.message : 'Unknown error'}`); + logger.info(`Category refresh complete: ${categoriesSynced}/${categoryIds.length} categories synced`); } + } catch (error) { + logger.error(`Category refresh failed: ${error instanceof Error ? error.message : 'Unknown error'}`); } - for (let i = 0; i < newReleases.length; i++) { - const audiobook = newReleases[i]; - try { - // Cache thumbnail if coverArtUrl exists - let cachedCoverPath: string | null = null; - if (audiobook.coverArtUrl) { - cachedCoverPath = await thumbnailCache.cacheThumbnail(audiobook.asin, audiobook.coverArtUrl); - } - - await prisma.audibleCache.upsert({ - where: { asin: audiobook.asin }, - create: { - asin: audiobook.asin, - title: audiobook.title, - author: audiobook.author, - narrator: audiobook.narrator, - description: audiobook.description, - coverArtUrl: audiobook.coverArtUrl, - cachedCoverPath: cachedCoverPath, - durationMinutes: audiobook.durationMinutes, - releaseDate: audiobook.releaseDate ? new Date(audiobook.releaseDate) : null, - rating: audiobook.rating ? audiobook.rating : null, - genres: audiobook.genres || [], - isNewRelease: true, - newReleaseRank: i + 1, - lastSyncedAt: syncTime, - }, - update: { - title: audiobook.title, - author: audiobook.author, - narrator: audiobook.narrator, - description: audiobook.description, - coverArtUrl: audiobook.coverArtUrl, - cachedCoverPath: cachedCoverPath, - durationMinutes: audiobook.durationMinutes, - releaseDate: audiobook.releaseDate ? new Date(audiobook.releaseDate) : null, - rating: audiobook.rating ? audiobook.rating : null, - genres: audiobook.genres || [], - isNewRelease: true, - newReleaseRank: i + 1, - lastSyncedAt: syncTime, - }, - }); - - newReleasesSaved++; - } catch (error) { - logger.error(`Failed to save new release ${audiobook.title}: ${error instanceof Error ? error.message : 'Unknown error'}`); - } - } - - logger.info(`Saved ${popularSaved} popular and ${newReleasesSaved} new releases to audible_cache`); - // Cleanup unused thumbnails logger.info('Cleaning up unused thumbnails...'); const allActiveAsins = await prisma.audibleCache.findMany({ @@ -175,6 +116,7 @@ export async function processAudibleRefresh(payload: AudibleRefreshPayload): Pro message: 'Audible refresh completed', popularSaved, newReleasesSaved, + categoriesSynced, thumbnailsDeleted: deletedCount, }; } catch (error) { @@ -182,3 +124,87 @@ export async function processAudibleRefresh(payload: AudibleRefreshPayload): Pro throw error; } } + +/** + * Wipe previous entries for a category, upsert book metadata into AudibleCache, + * and insert ranked entries into AudibleCacheCategory. + * Returns the number of books successfully saved. + */ +async function persistSectionBooks( + books: any[], + categoryId: string, + syncTime: Date, + thumbnailCache: { cacheThumbnail: (asin: string, url: string) => Promise }, + logger: ReturnType, + labelForErrors: string, +): Promise { + // Wipe previous entries for this section + logger.info(`Clearing previous data for ${categoryId}...`); + await prisma.audibleCacheCategory.deleteMany({ + where: { categoryId }, + }); + logger.info(`Cleared previous entries for ${categoryId}, saving ${books.length} books...`); + + let saved = 0; + for (let i = 0; i < books.length; i++) { + const book = books[i]; + try { + // Cache thumbnail if coverArtUrl exists + let cachedCoverPath: string | null = null; + if (book.coverArtUrl) { + cachedCoverPath = await thumbnailCache.cacheThumbnail(book.asin, book.coverArtUrl); + if (!cachedCoverPath) { + logger.warn(`Cover cache failed for "${book.title}" (${book.asin}) - falling back to remote URL`); + } + } + + // Upsert book metadata into AudibleCache + await prisma.audibleCache.upsert({ + where: { asin: book.asin }, + create: { + asin: book.asin, + title: book.title, + author: book.author, + narrator: book.narrator, + description: book.description, + coverArtUrl: book.coverArtUrl, + cachedCoverPath, + durationMinutes: book.durationMinutes, + releaseDate: book.releaseDate ? new Date(book.releaseDate) : null, + rating: book.rating ? book.rating : null, + genres: book.genres || [], + lastSyncedAt: syncTime, + }, + update: { + title: book.title, + author: book.author, + narrator: book.narrator, + description: book.description, + coverArtUrl: book.coverArtUrl, + cachedCoverPath, + durationMinutes: book.durationMinutes, + releaseDate: book.releaseDate ? new Date(book.releaseDate) : null, + rating: book.rating ? book.rating : null, + genres: book.genres || [], + lastSyncedAt: syncTime, + }, + }); + + // Insert ranked entry into AudibleCacheCategory + await prisma.audibleCacheCategory.create({ + data: { + asin: book.asin, + categoryId, + rank: i + 1, + lastSyncedAt: syncTime, + }, + }); + + saved++; + } catch (error) { + logger.error(`Failed to save ${labelForErrors} ${book.title}: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + } + + return saved; +} diff --git a/src/lib/services/thumbnail-cache.service.ts b/src/lib/services/thumbnail-cache.service.ts index 8673768..7bfb4b7 100644 --- a/src/lib/services/thumbnail-cache.service.ts +++ b/src/lib/services/thumbnail-cache.service.ts @@ -24,7 +24,7 @@ export class ThumbnailCacheService { try { await fs.mkdir(CACHE_DIR, { recursive: true }); } catch (error) { - logger.error('Failed to create cache directory', { error: error instanceof Error ? error.message : String(error) }); + logger.error(`Failed to create cache directory: ${error instanceof Error ? error.message : String(error)}`); throw error; } } @@ -36,7 +36,7 @@ export class ThumbnailCacheService { try { await fs.mkdir(LIBRARY_CACHE_DIR, { recursive: true }); } catch (error) { - logger.error('Failed to create library cache directory', { error: error instanceof Error ? error.message : String(error) }); + logger.error(`Failed to create library cache directory: ${error instanceof Error ? error.message : String(error)}`); throw error; } } @@ -127,8 +127,8 @@ export class ThumbnailCacheService { logger.info(`Cached thumbnail for ${asin}: ${filePath}`); return filePath; } catch (error) { - // Log error but don't throw - we'll fall back to the original URL - logger.error(`Failed to cache thumbnail for ${asin}`, { error: error instanceof Error ? error.message : String(error) }); + // Log warning but don't throw - we'll fall back to the original URL + logger.warn(`Failed to cache thumbnail for ${asin}: ${error instanceof Error ? error.message : String(error)} - will use remote URL`); return null; } } @@ -203,10 +203,8 @@ export class ThumbnailCacheService { logger.info(`Cached library thumbnail for ${plexGuid}: ${filePath}`); return filePath; } catch (error) { - // Log error but don't throw - graceful degradation - logger.warn(`Failed to cache library thumbnail for ${plexGuid}`, { - error: error instanceof Error ? error.message : String(error), - }); + // Log warning but don't throw - graceful degradation + logger.warn(`Failed to cache library thumbnail for ${plexGuid}: ${error instanceof Error ? error.message : String(error)}`); return null; } } @@ -227,7 +225,7 @@ export class ThumbnailCacheService { logger.info(`Deleted thumbnail: ${filePath}`); } } catch (error) { - logger.error(`Failed to delete thumbnail for ${asin}`, { error: error instanceof Error ? error.message : String(error) }); + logger.error(`Failed to delete thumbnail for ${asin}: ${error instanceof Error ? error.message : String(error)}`); } } @@ -258,7 +256,7 @@ export class ThumbnailCacheService { logger.info(`Cleanup complete: ${deletedCount} thumbnails deleted`); return deletedCount; } catch (error) { - logger.error('Failed to cleanup thumbnails', { error: error instanceof Error ? error.message : String(error) }); + logger.error(`Failed to cleanup thumbnails: ${error instanceof Error ? error.message : String(error)}`); return 0; } } @@ -299,9 +297,7 @@ export class ThumbnailCacheService { logger.info(`Library cleanup complete: ${deletedCount} thumbnails deleted`); return deletedCount; } catch (error) { - logger.error('Failed to cleanup library thumbnails', { - error: error instanceof Error ? error.message : String(error), - }); + logger.error(`Failed to cleanup library thumbnails: ${error instanceof Error ? error.message : String(error)}`); return 0; } } diff --git a/tests/api/audiobooks-browse.routes.test.ts b/tests/api/audiobooks-browse.routes.test.ts index 9ffe21c..f4f5e24 100644 --- a/tests/api/audiobooks-browse.routes.test.ts +++ b/tests/api/audiobooks-browse.routes.test.ts @@ -68,6 +68,12 @@ describe('Audiobooks browse routes', () => { }); it('returns popular audiobooks with cached cover URLs', async () => { + // Mock AudibleCacheCategory query (popular route now queries category table) + prismaMock.audibleCacheCategory.findMany.mockResolvedValueOnce([ + { asin: 'ASIN', rank: 1 }, + ]); + prismaMock.audibleCacheCategory.count.mockResolvedValueOnce(1); + // Mock AudibleCache metadata fetch prismaMock.audibleCache.findMany.mockResolvedValueOnce([ { asin: 'ASIN', @@ -84,7 +90,6 @@ describe('Audiobooks browse routes', () => { lastSyncedAt: new Date(), }, ]); - prismaMock.audibleCache.count.mockResolvedValueOnce(1); enrichMock.mockResolvedValueOnce([{ asin: 'ASIN', coverArtUrl: '/api/cache/thumbnails/asin.jpg' }]); const { GET } = await import('@/app/api/audiobooks/popular/route'); @@ -106,8 +111,9 @@ describe('Audiobooks browse routes', () => { }); it('returns new release audiobooks', async () => { - prismaMock.audibleCache.findMany.mockResolvedValueOnce([]); - prismaMock.audibleCache.count.mockResolvedValueOnce(0); + // Mock AudibleCacheCategory query (new-releases route now queries category table) + prismaMock.audibleCacheCategory.findMany.mockResolvedValueOnce([]); + prismaMock.audibleCacheCategory.count.mockResolvedValueOnce(0); const { GET } = await import('@/app/api/audiobooks/new-releases/route'); const response = await GET({ nextUrl: new URL('http://app/api/audiobooks/new-releases?page=1&limit=1') } as any); @@ -118,6 +124,12 @@ describe('Audiobooks browse routes', () => { }); it('enriches new releases and uses cached cover URLs', async () => { + // Mock AudibleCacheCategory query + prismaMock.audibleCacheCategory.findMany.mockResolvedValueOnce([ + { asin: 'ASIN', rank: 1 }, + ]); + prismaMock.audibleCacheCategory.count.mockResolvedValueOnce(1); + // Mock AudibleCache metadata fetch prismaMock.audibleCache.findMany.mockResolvedValueOnce([ { asin: 'ASIN', @@ -134,7 +146,6 @@ describe('Audiobooks browse routes', () => { lastSyncedAt: new Date('2024-01-02'), }, ]); - prismaMock.audibleCache.count.mockResolvedValueOnce(1); currentUserMock.mockReturnValue({ sub: 'user-1' }); enrichMock.mockResolvedValueOnce([{ asin: 'ASIN', available: true }]); @@ -155,7 +166,7 @@ describe('Audiobooks browse routes', () => { }); it('returns 500 when new releases query fails', async () => { - prismaMock.audibleCache.findMany.mockRejectedValueOnce(new Error('db down')); + prismaMock.audibleCacheCategory.findMany.mockRejectedValueOnce(new Error('db down')); const { GET } = await import('@/app/api/audiobooks/new-releases/route'); const response = await GET({ nextUrl: new URL('http://app/api/audiobooks/new-releases?page=1&limit=1') } as any); @@ -209,6 +220,11 @@ describe('Audiobooks browse routes', () => { }); it('returns cached covers for login', async () => { + // Mock AudibleCacheCategory query (covers route now queries category table) + prismaMock.audibleCacheCategory.findMany.mockResolvedValueOnce([ + { asin: 'ASIN' }, + ]); + // Mock AudibleCache metadata fetch prismaMock.audibleCache.findMany.mockResolvedValueOnce([ { asin: 'ASIN', title: 'Title', author: 'Author', cachedCoverPath: '/tmp/asin.jpg', coverArtUrl: null }, ]); @@ -221,5 +237,3 @@ describe('Audiobooks browse routes', () => { expect(payload.covers[0].coverUrl).toBe('/api/cache/thumbnails/asin.jpg'); }); }); - - diff --git a/tests/api/home-sections.routes.test.ts b/tests/api/home-sections.routes.test.ts new file mode 100644 index 0000000..22ce3e7 --- /dev/null +++ b/tests/api/home-sections.routes.test.ts @@ -0,0 +1,166 @@ +/** + * Component: Home Sections API Route Tests + * Documentation: documentation/features/home-sections.md + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createPrismaMock } from '../helpers/prisma'; + +const prismaMock = createPrismaMock(); + +vi.mock('@/lib/db', () => ({ + prisma: prismaMock, +})); + +vi.mock('@/lib/middleware/auth', () => ({ + requireAuth: vi.fn((_req: any, handler: any) => { + const mockReq = { + user: { id: 'user-1', sub: 'user-1', role: 'user' }, + json: async () => (globalThis as any).__testBody || {}, + }; + return handler(mockReq); + }), + getCurrentUser: vi.fn(() => ({ sub: 'user-1' })), +})); + +vi.mock('@/lib/utils/logger', () => ({ + RMABLogger: { create: () => ({ info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() }) }, +})); + +describe('GET /api/user/home-sections', () => { + beforeEach(() => { + vi.clearAllMocks(); + // Re-apply default mock implementations after clearAllMocks + prismaMock.userHomeSection.createMany.mockResolvedValue({ count: 0 }); + prismaMock.userHomeSection.deleteMany.mockResolvedValue({ count: 0 }); + prismaMock.$transaction.mockImplementation(async (fn: any) => fn(prismaMock)); + }); + + it('returns default sections for new user', async () => { + // ensureDefaultSections check: no existing sections + prismaMock.userHomeSection.findMany + .mockResolvedValueOnce([]) // ensureDefaultSections + .mockResolvedValueOnce([ // actual fetch after defaults created + { id: '1', sectionType: 'popular', categoryId: null, categoryName: null, sortOrder: 0 }, + { id: '2', sectionType: 'new_releases', categoryId: null, categoryName: null, sortOrder: 1 }, + ]); + prismaMock.scheduledJob.findFirst.mockResolvedValueOnce(null); + + const { GET } = await import('@/app/api/user/home-sections/route'); + const request = new Request('http://localhost/api/user/home-sections'); + const response = await GET(request as any); + const data = await response.json(); + + expect(data.success).toBe(true); + expect(data.sections).toHaveLength(2); + expect(data.sections[0].sectionType).toBe('popular'); + expect(data.sections[1].sectionType).toBe('new_releases'); + }); + + it('returns existing sections without creating defaults', async () => { + prismaMock.userHomeSection.findMany + .mockResolvedValueOnce([{ id: '1' }]) // has existing + .mockResolvedValueOnce([ + { id: '1', sectionType: 'category', categoryId: '123', categoryName: 'Sci-Fi', sortOrder: 0 }, + ]); + prismaMock.scheduledJob.findFirst.mockResolvedValueOnce({ + nextRun: new Date('2026-03-05T00:00:00Z'), + }); + + const { GET } = await import('@/app/api/user/home-sections/route'); + const request = new Request('http://localhost/api/user/home-sections'); + const response = await GET(request as any); + const data = await response.json(); + + expect(data.success).toBe(true); + expect(data.sections).toHaveLength(1); + expect(data.sections[0].categoryName).toBe('Sci-Fi'); + expect(data.nextRefresh).toBe('2026-03-05T00:00:00.000Z'); + expect(prismaMock.userHomeSection.createMany).not.toHaveBeenCalled(); + }); +}); + +describe('PUT /api/user/home-sections', () => { + beforeEach(() => { + vi.clearAllMocks(); + prismaMock.userHomeSection.createMany.mockResolvedValue({ count: 0 }); + prismaMock.userHomeSection.deleteMany.mockResolvedValue({ count: 0 }); + prismaMock.$transaction.mockImplementation(async (fn: any) => fn(prismaMock)); + }); + + it('saves new section configuration', async () => { + (globalThis as any).__testBody = { + sections: [ + { sectionType: 'new_releases', sortOrder: 0 }, + { sectionType: 'popular', sortOrder: 1 }, + { sectionType: 'category', categoryId: '123', categoryName: 'Sci-Fi', sortOrder: 2 }, + ], + }; + + prismaMock.userHomeSection.findMany.mockResolvedValueOnce([ + { id: '1', sectionType: 'new_releases', categoryId: null, categoryName: null, sortOrder: 0 }, + { id: '2', sectionType: 'popular', categoryId: null, categoryName: null, sortOrder: 1 }, + { id: '3', sectionType: 'category', categoryId: '123', categoryName: 'Sci-Fi', sortOrder: 2 }, + ]); + + const { PUT } = await import('@/app/api/user/home-sections/route'); + const request = new Request('http://localhost/api/user/home-sections', { method: 'PUT' }); + const response = await PUT(request as any); + const data = await response.json(); + + expect(data.success).toBe(true); + expect(data.sections).toHaveLength(3); + expect(prismaMock.userHomeSection.deleteMany).toHaveBeenCalledWith({ + where: { userId: 'user-1' }, + }); + expect(prismaMock.userHomeSection.createMany).toHaveBeenCalled(); + }); + + it('rejects more than 10 sections', async () => { + (globalThis as any).__testBody = { + sections: Array.from({ length: 11 }, (_, i) => ({ + sectionType: 'category', + categoryId: `cat-${i}`, + categoryName: `Cat ${i}`, + sortOrder: i, + })), + }; + + const { PUT } = await import('@/app/api/user/home-sections/route'); + const request = new Request('http://localhost/api/user/home-sections', { method: 'PUT' }); + const response = await PUT(request as any); + + expect(response.status).toBe(400); + }); + + it('rejects duplicate sections', async () => { + (globalThis as any).__testBody = { + sections: [ + { sectionType: 'popular', sortOrder: 0 }, + { sectionType: 'popular', sortOrder: 1 }, + ], + }; + + const { PUT } = await import('@/app/api/user/home-sections/route'); + const request = new Request('http://localhost/api/user/home-sections', { method: 'PUT' }); + const response = await PUT(request as any); + + expect(response.status).toBe(400); + const data = await response.json(); + expect(data.message).toContain('Duplicate'); + }); + + it('rejects category section without categoryId', async () => { + (globalThis as any).__testBody = { + sections: [{ sectionType: 'category', sortOrder: 0 }], + }; + + const { PUT } = await import('@/app/api/user/home-sections/route'); + const request = new Request('http://localhost/api/user/home-sections', { method: 'PUT' }); + const response = await PUT(request as any); + + expect(response.status).toBe(400); + const data = await response.json(); + expect(data.message).toContain('categoryId'); + }); +}); diff --git a/tests/app/home.page.test.tsx b/tests/app/home.page.test.tsx index 6a009e4..2bfbc72 100644 --- a/tests/app/home.page.test.tsx +++ b/tests/app/home.page.test.tsx @@ -1,6 +1,6 @@ /** * Component: Home Page Tests - * Documentation: documentation/frontend/components.md + * Documentation: documentation/features/home-sections.md */ // @vitest-environment jsdom @@ -12,15 +12,26 @@ import { resetMockAuthState } from '../helpers/mock-auth'; import { resetMockRouter } from '../helpers/mock-next-navigation'; const useAudiobooksMock = vi.hoisted(() => vi.fn()); +const useCategoryAudiobooksMock = vi.hoisted(() => vi.fn()); +const useHomeSectionsMock = vi.hoisted(() => vi.fn()); const usePreferencesMock = vi.hoisted(() => ({ cardSize: 5, setCardSize: vi.fn(), + squareCovers: false, + setSquareCovers: vi.fn(), + hideAvailable: false, + setHideAvailable: vi.fn(), })); vi.mock('@/lib/hooks/useAudiobooks', () => ({ useAudiobooks: useAudiobooksMock, })); +vi.mock('@/lib/hooks/useHomeSections', () => ({ + useHomeSections: useHomeSectionsMock, + useCategoryAudiobooks: useCategoryAudiobooksMock, +})); + vi.mock('@/contexts/PreferencesContext', () => ({ usePreferences: () => usePreferencesMock, })); @@ -71,9 +82,25 @@ describe('HomePage', () => { resetMockAuthState(); resetMockRouter(); useAudiobooksMock.mockReset(); + useCategoryAudiobooksMock.mockReset(); + useHomeSectionsMock.mockReset(); usePreferencesMock.cardSize = 5; usePreferencesMock.setCardSize.mockReset(); + usePreferencesMock.hideAvailable = false; vi.resetModules(); + + // Default: return popular + new_releases sections + useHomeSectionsMock.mockReturnValue({ + sections: [ + { id: '1', sectionType: 'popular', categoryId: null, categoryName: null, sortOrder: 0 }, + { id: '2', sectionType: 'new_releases', categoryId: null, categoryName: null, sortOrder: 1 }, + ], + isLoading: false, + nextRefresh: null, + saveSections: vi.fn(), + mutate: vi.fn(), + error: null, + }); }); it('renders empty state messaging for popular audiobooks', async () => { @@ -97,28 +124,39 @@ describe('HomePage', () => { const { default: HomePage } = await import('@/app/page'); render(); - expect(screen.getByText('No popular audiobooks found')).toBeInTheDocument(); - expect(screen.getByText('Nothing here')).toBeInTheDocument(); + expect(screen.getByText('No audiobooks yet')).toBeInTheDocument(); + // Raw API message is intentionally not shown; friendly empty state is rendered instead + expect(screen.queryByText('Nothing here')).not.toBeInTheDocument(); expect(screen.getByText('New Release')).toBeInTheDocument(); }); - it('updates pagination when the sticky controls request a new page', async () => { - useAudiobooksMock.mockImplementation((category: string, _limit: number, page: number) => { - return { - audiobooks: [{ asin: `${category}-${page}`, title: `${category}-${page}`, author: 'Author' }], - isLoading: false, - totalPages: 3, - message: null, - }; + it('renders customize button', async () => { + useAudiobooksMock.mockReturnValue({ + audiobooks: [], + isLoading: false, + totalPages: 0, + message: null, }); const { default: HomePage } = await import('@/app/page'); render(); - fireEvent.click(screen.getByRole('button', { name: 'Popular Audiobooks next' })); + expect(screen.getByLabelText('Customize home page')).toBeInTheDocument(); + }); - await waitFor(() => { - expect(useAudiobooksMock).toHaveBeenCalledWith('popular', 20, 2, undefined); + it('renders empty state when no sections configured', async () => { + useHomeSectionsMock.mockReturnValue({ + sections: [], + isLoading: false, + nextRefresh: null, + saveSections: vi.fn(), + mutate: vi.fn(), + error: null, }); + + const { default: HomePage } = await import('@/app/page'); + render(); + + expect(screen.getByText(/No sections configured/)).toBeInTheDocument(); }); }); diff --git a/tests/helpers/prisma.ts b/tests/helpers/prisma.ts index 8b6debb..388480c 100644 --- a/tests/helpers/prisma.ts +++ b/tests/helpers/prisma.ts @@ -10,6 +10,7 @@ type PrismaModelMock = { findFirst: ReturnType; findUnique: ReturnType; create: ReturnType; + createMany: ReturnType; update: ReturnType; updateMany: ReturnType; upsert: ReturnType; @@ -23,6 +24,7 @@ const createModelMock = (): PrismaModelMock => ({ findFirst: vi.fn(), findUnique: vi.fn(), create: vi.fn(() => Promise.resolve({})), + createMany: vi.fn(() => Promise.resolve({ count: 0 })), update: vi.fn(() => Promise.resolve({})), updateMany: vi.fn(() => Promise.resolve({})), upsert: vi.fn(() => Promise.resolve({})), @@ -52,6 +54,9 @@ export const createPrismaMock = () => ({ workAsin: createModelMock(), watchedSeries: createModelMock(), watchedAuthor: createModelMock(), + userHomeSection: createModelMock(), + audibleCacheCategory: createModelMock(), $queryRaw: vi.fn(), + $transaction: vi.fn(), $disconnect: vi.fn(), }); diff --git a/tests/processors/audible-refresh.processor.test.ts b/tests/processors/audible-refresh.processor.test.ts index 3deb01b..bdfd7b3 100644 --- a/tests/processors/audible-refresh.processor.test.ts +++ b/tests/processors/audible-refresh.processor.test.ts @@ -10,6 +10,7 @@ const prismaMock = createPrismaMock(); const audibleServiceMock = vi.hoisted(() => ({ getPopularAudiobooks: vi.fn(), getNewReleases: vi.fn(), + getCategoryBooks: vi.fn(), })); const thumbnailCacheMock = vi.hoisted(() => ({ cacheThumbnail: vi.fn(), @@ -45,7 +46,7 @@ describe('processAudibleRefresh', () => { global.setTimeout = origSetTimeout; }); - it('refreshes popular and new releases, caching thumbnails', async () => { + it('refreshes popular and new releases via AudibleCacheCategory', async () => { const popular = [ { asin: 'ASIN-1', @@ -91,8 +92,12 @@ describe('processAudibleRefresh', () => { audibleServiceMock.getNewReleases.mockResolvedValue(newReleases); thumbnailCacheMock.cacheThumbnail.mockResolvedValue('cached/path.jpg'); thumbnailCacheMock.cleanupUnusedThumbnails.mockResolvedValue(2); - prismaMock.audibleCache.updateMany.mockResolvedValue({ count: 1 }); prismaMock.audibleCache.upsert.mockResolvedValue({}); + prismaMock.audibleCacheCategory.deleteMany.mockResolvedValue({ count: 0 }); + prismaMock.audibleCacheCategory.create.mockResolvedValue({}); + // No user-configured categories + prismaMock.userHomeSection.findMany.mockResolvedValue([]); + prismaMock.audibleCache.findMany.mockResolvedValue([ { asin: 'ASIN-1' }, { asin: 'ASIN-2' }, @@ -105,8 +110,32 @@ describe('processAudibleRefresh', () => { expect(result.success).toBe(true); expect(result.popularSaved).toBe(2); expect(result.newReleasesSaved).toBe(1); - expect(prismaMock.audibleCache.updateMany).toHaveBeenCalled(); + expect(result.categoriesSynced).toBe(0); + + // Should wipe old entries for __popular__ and __new_releases__ + expect(prismaMock.audibleCacheCategory.deleteMany).toHaveBeenCalledWith({ + where: { categoryId: '__popular__' }, + }); + expect(prismaMock.audibleCacheCategory.deleteMany).toHaveBeenCalledWith({ + where: { categoryId: '__new_releases__' }, + }); + + // 3 metadata upserts (2 popular + 1 new release) expect(prismaMock.audibleCache.upsert).toHaveBeenCalledTimes(3); + + // 3 category entries created (2 popular + 1 new release) + expect(prismaMock.audibleCacheCategory.create).toHaveBeenCalledTimes(3); + expect(prismaMock.audibleCacheCategory.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ asin: 'ASIN-1', categoryId: '__popular__', rank: 1 }), + }); + expect(prismaMock.audibleCacheCategory.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ asin: 'ASIN-2', categoryId: '__popular__', rank: 2 }), + }); + expect(prismaMock.audibleCacheCategory.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ asin: 'ASIN-3', categoryId: '__new_releases__', rank: 1 }), + }); + + // Thumbnail caching still works expect(thumbnailCacheMock.cacheThumbnail).toHaveBeenCalledWith('ASIN-1', 'http://image/1'); expect(thumbnailCacheMock.cacheThumbnail).toHaveBeenCalledWith('ASIN-3', 'http://image/3'); expect(thumbnailCacheMock.cleanupUnusedThumbnails).toHaveBeenCalled(); @@ -115,8 +144,56 @@ describe('processAudibleRefresh', () => { expect(Array.from(activeSet).sort()).toEqual(['ASIN-1', 'ASIN-2', 'ASIN-3']); }); + it('scrapes user-configured categories after popular/new-releases', async () => { + audibleServiceMock.getPopularAudiobooks.mockResolvedValue([]); + audibleServiceMock.getNewReleases.mockResolvedValue([]); + thumbnailCacheMock.cacheThumbnail.mockResolvedValue('cached/cat.jpg'); + thumbnailCacheMock.cleanupUnusedThumbnails.mockResolvedValue(0); + prismaMock.audibleCacheCategory.deleteMany.mockResolvedValue({ count: 0 }); + prismaMock.audibleCacheCategory.create.mockResolvedValue({}); + + // User has one category section + prismaMock.userHomeSection.findMany.mockResolvedValue([ + { categoryId: 'node-42' }, + ]); + + // getCategoryBooks returns 2 books + audibleServiceMock.getCategoryBooks.mockResolvedValue([ + { asin: 'CAT-1', title: 'Cat Book 1', author: 'Author', coverArtUrl: 'http://img/c1' }, + { asin: 'CAT-2', title: 'Cat Book 2', author: 'Author', coverArtUrl: null }, + ]); + + prismaMock.audibleCache.upsert.mockResolvedValue({}); + prismaMock.audibleCache.findMany.mockResolvedValue([]); + + const { processAudibleRefresh } = await import('@/lib/processors/audible-refresh.processor'); + const result = await processAudibleRefresh({ jobId: 'job-cat' }); + + expect(result.categoriesSynced).toBe(1); + expect(audibleServiceMock.getCategoryBooks).toHaveBeenCalledWith('node-42', 200); + + // Should wipe entries for __popular__, __new_releases__, and node-42 + expect(prismaMock.audibleCacheCategory.deleteMany).toHaveBeenCalledWith({ + where: { categoryId: '__popular__' }, + }); + expect(prismaMock.audibleCacheCategory.deleteMany).toHaveBeenCalledWith({ + where: { categoryId: '__new_releases__' }, + }); + expect(prismaMock.audibleCacheCategory.deleteMany).toHaveBeenCalledWith({ + where: { categoryId: 'node-42' }, + }); + + // 2 category book creates (for node-42) — popular/new-releases had 0 books + expect(prismaMock.audibleCacheCategory.create).toHaveBeenCalledTimes(2); + expect(prismaMock.audibleCache.upsert).toHaveBeenCalledTimes(2); + }); + it('rethrows fatal errors', async () => { - prismaMock.audibleCache.updateMany.mockRejectedValue(new Error('DB down')); + // Mock audible service to return data so we reach the DB calls + audibleServiceMock.getPopularAudiobooks.mockResolvedValue([]); + audibleServiceMock.getNewReleases.mockResolvedValue([]); + // First DB call is now audibleCacheCategory.deleteMany (for __popular__) + prismaMock.audibleCacheCategory.deleteMany.mockRejectedValue(new Error('DB down')); const { processAudibleRefresh } = await import('@/lib/processors/audible-refresh.processor'); await expect(processAudibleRefresh({ jobId: 'job-2' })).rejects.toThrow('DB down');