Compare commits

...

11 Commits

Author SHA1 Message Date
kikootwo 01b59fae9d Bump package version to 1.1.3
Update package.json version from 1.1.2 to 1.1.3. No other changes in this diff; version increment for the next release/patch.
2026-03-05 17:14:45 -05:00
kikootwo 137e2b5607 Propagate and use customSearchTerms for ebooks
Persist and apply customSearchTerms across ebook workflows and searches. Updated admin search-terms PATCH to enqueue addSearchEbookJob for ebook requests. Included customSearchTerms when creating ebook request records in audiobooks/[asin]/fetch-ebook, audiobooks/[asin]/select-ebook and requests/[id]/fetch-ebook. Reworked requests/[id]/select-ebook to handle being passed either an audiobook or ebook request (resolve parent audiobook, reuse existing ebook request if present) and to propagate parent.customSearchTerms when creating new ebook requests. Modified search-ebook.processor to read customSearchTerms from the request record, use it as the effective search title (with logging), and pass the modified audiobook title into Anna's Archive and indexer searches so custom terms are honored.
2026-03-05 17:14:26 -05:00
kikootwo f09931f352 Bump package version to 1.1.2
Update package.json version from 1.1.1 to 1.1.2 to mark a new patch release.
2026-03-05 16:46:09 -05:00
kikootwo 5b4aa3fa15 Add data-migration tracking; prevent subtitle dedup
Track and run run-once SQL data migrations: entrypoint now checks _data_migrations before executing each prisma data-migration file, records successful runs, and skips already-applied scripts. Adds a Prisma DataMigration model mapped to _data_migrations and a new reset-works-table.sql migration to clear work tables for a dedup rebuild. Also improves dedup logic: extractSubtitle and subtitle-compatibility checks are added so series entries like "Series: Book A" vs "Series: Book B" are not collapsed, with accompanying unit tests for extraction and behavior.
2026-03-05 16:45:56 -05:00
kikootwo 3e2221ad5b Bump package version to 1.1.1
Update package.json version from 1.1.0 to 1.1.1 to reflect a patch release.
2026-03-05 15:03:29 -05:00
kikootwo 859a331012 Run data migrations; use search title for ranking
Add an entrypoint step to execute idempotent SQL data migrations (prisma db execute) from prisma/data-migrations/*.sql so fixes that prisma db push doesn't handle are applied on startup. Add normalize-local-usernames.sql to normalize local users' plex_username and plex_id to lowercase. Update interactive search and search-indexers processor to prefer the user-provided/custom search title (searchTitle / effectiveSearchTitle) when ranking torrents and adjust debug logs to show the ranking title alongside the audiobook title/author for clearer diagnostics.
2026-03-05 15:02:59 -05:00
kikootwo c35bec9f89 Bump package.json version to 1.1.0
Update package.json version from 1.0.16 to 1.1.0 to reflect the new release version.
2026-03-05 12:20:41 -05:00
kikootwo 09e1a0db3a Use .gl for Anna's Archive; add manual-import test
Replace default Anna's Archive base URL from https://annas-archive.li to https://annas-archive.gl across docs, UI components, API routes, processors, services, and tests. Add comprehensive tests for the admin manual-import API route and enhance the manual-import route to fetch missing ASIN details from Audnexus and create audiobook records with proper error handling and logging. Update related test expectations and FlareSolverr test usages to reflect the new default URL.
2026-03-05 12:20:00 -05:00
kikootwo 832a8ad00b Merge branch 'main' of https://github.com/kikootwo/ReadMeABook 2026-03-05 11:31:49 -05:00
kikootwo cc8e106a2b 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.
2026-03-05 11:30:39 -05:00
kikootwo 079a337f1c Merge pull request #128 from kikootwo/feature/hardover-shelves
Feature/hardover shelves
2026-03-04 23:55:51 -05:00
76 changed files with 3130 additions and 734 deletions
+23
View File
@@ -403,6 +403,29 @@ echo "🔄 Running Prisma migrations..."
cd /app
su - node -c "cd /app && DATABASE_URL='$DATABASE_URL' npx prisma db push --skip-generate --accept-data-loss" || echo "⚠️ Migrations may have failed, continuing..."
# Run data migrations (run-once SQL scripts tracked in _data_migrations table)
echo "🔄 Running data migrations..."
for sql_file in /app/prisma/data-migrations/*.sql; do
if [ -f "$sql_file" ]; then
migration_name=$(basename "$sql_file")
already_run=$(psql "$DATABASE_URL" -tA -c "SELECT 1 FROM _data_migrations WHERE name = '$migration_name' LIMIT 1;")
if [ "$already_run" = "1" ]; then
echo " Skipping $migration_name (already executed)"
continue
fi
echo " Running $migration_name..."
if su - node -c "cd /app && DATABASE_URL='$DATABASE_URL' npx prisma db execute --schema prisma/schema.prisma --file '$sql_file'"; then
psql "$DATABASE_URL" -c "INSERT INTO _data_migrations (name) VALUES ('$migration_name');"
echo "$migration_name completed"
else
echo "⚠️ Data migration $migration_name failed, will retry on next start"
fi
fi
done
# Stop internal PostgreSQL (supervisord will restart it via wrapper)
if [ "$USE_EXTERNAL_POSTGRES" = "false" ]; then
echo "🔧 Stopping temporary PostgreSQL instance..."
+4
View File
@@ -85,6 +85,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)
@@ -158,6 +159,9 @@
**"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)
**"How do Goodreads shelves work?"** → [backend/services/goodreads-sync.md](backend/services/goodreads-sync.md)
**"How do Hardcover shelves work?"** → [backend/services/hardcover-sync.md](backend/services/hardcover-sync.md)
**"How do I add a new shelf provider?"** → [backend/services/goodreads-sync.md](backend/services/goodreads-sync.md#adding-a-new-provider)
+4 -4
View File
@@ -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
+64
View File
@@ -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`
+3 -3
View File
@@ -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
+5 -5
View File
@@ -51,7 +51,7 @@ Ebooks are first-class citizens in RMAB, with their own request type, tracking,
| Key | Default | Description |
|-----|---------|-------------|
| `ebook_annas_archive_enabled` | `false` | Enable Anna's Archive downloads |
| `ebook_sidecar_base_url` | `https://annas-archive.li` | Base URL for mirror |
| `ebook_sidecar_base_url` | `https://annas-archive.gl` | Base URL for mirror |
| `ebook_sidecar_flaresolverr_url` | `` (empty) | FlareSolverr proxy URL (optional) |
#### Section 2: Indexer Search
@@ -180,18 +180,18 @@ Configure URL in Admin Settings → E-book Sidecar: `http://localhost:8191`
### Method 1: ASIN Search (exact match)
```
Search: https://annas-archive.li/search?ext=epub&lang=en&q="asin:B09TWSRMCB"
Search: https://annas-archive.gl/search?ext=epub&lang=en&q="asin:B09TWSRMCB"
MD5 Page: https://annas-archive.li/md5/[md5]
MD5 Page: https://annas-archive.gl/md5/[md5]
Slow Download: https://annas-archive.li/slow_download/[md5]/0/5
Slow Download: https://annas-archive.gl/slow_download/[md5]/0/5
File Server: http://[server]/path/to/file.epub
```
### Method 2: Title + Author (fallback)
```
Search: https://annas-archive.li/search?q=Title+Author&ext=epub&lang=en
Search: https://annas-archive.gl/search?q=Title+Author&ext=epub&lang=en
↓ (Same flow from MD5 page)
```
+2 -2
View File
@@ -81,7 +81,7 @@ src/app/admin/settings/
1. **Anna's Archive Section**
- Enable toggle for Anna's Archive downloads
- Base URL (default: `https://annas-archive.li`)
- Base URL (default: `https://annas-archive.gl`)
- FlareSolverr URL (optional, for Cloudflare bypass)
2. **Indexer Search Section**
@@ -101,7 +101,7 @@ src/app/admin/settings/
| `ebook_sidecar_preferred_format` | `epub` | Preferred format |
| `ebook_auto_grab_enabled` | `true` | Auto-create ebook requests after audiobook downloads |
| `ebook_kindle_fix_enabled` | `false` | Apply Kindle compatibility fixes to EPUB files |
| `ebook_sidecar_base_url` | `https://annas-archive.li` | Anna's Archive mirror |
| `ebook_sidecar_base_url` | `https://annas-archive.gl` | Anna's Archive mirror |
| `ebook_sidecar_flaresolverr_url` | `` | FlareSolverr URL |
**Behavior:**
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "readmeabook",
"version": "1.0.16",
"version": "1.1.3",
"private": true,
"scripts": {
"dev": "next dev",
@@ -0,0 +1,7 @@
-- Normalize existing local usernames to lowercase (idempotent - safe to run multiple times)
-- Only affects local auth users, not Plex/OIDC users
UPDATE users SET plex_username = LOWER(plex_username)
WHERE auth_provider = 'local' AND deleted_at IS NULL AND plex_username != LOWER(plex_username);
UPDATE users SET plex_id = 'local-' || LOWER(SUBSTRING(plex_id FROM 7))
WHERE plex_id LIKE 'local-%' AND plex_id NOT LIKE 'local-%-deleted-%' AND plex_id != LOWER(plex_id);
@@ -0,0 +1,7 @@
-- Reset works table to fix incorrect dedup groupings (v1.1.2)
-- Books with "Series: Title" naming (e.g. "Eden's Gate: The Reborn" vs
-- "Eden's Gate: The Spartan") were incorrectly merged into the same work
-- because subtitle stripping collapsed them to the same base title.
-- The works table auto-rebuilds from dedup logic as users browse.
DELETE FROM work_asins;
DELETE FROM works;
@@ -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;
@@ -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";
+59 -10
View File
@@ -73,6 +73,7 @@ model User {
apiTokens ApiToken[] @relation("UserApiTokens")
watchedSeries WatchedSeries[]
watchedAuthors WatchedAuthor[]
homeSections UserHomeSection[]
@@index([plexId])
@@index([role])
@@ -99,12 +100,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")
@@ -112,10 +107,6 @@ model AudibleCache {
@@index([asin])
@@index([title])
@@index([author])
@@index([isPopular])
@@index([isNewRelease])
@@index([popularRank])
@@index([newReleaseRank])
@@map("audible_cache")
}
@@ -681,3 +672,61 @@ 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")
}
// ============================================================================
// DATA MIGRATION TRACKING
// Tracks which data migration SQL scripts have been executed (run-once).
// ============================================================================
model DataMigration {
name String @id
executedAt DateTime @default(now()) @map("executed_at")
@@map("_data_migrations")
}
+22
View File
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg width="500px" height="500px" viewBox="0 0 500 500" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Generator: Sketch 41.2 (35397) - http://www.bohemiancoding.com/sketch -->
<title>img-coverart</title>
<desc>Created with Sketch.</desc>
<defs>
<rect id="path-1" x="0" y="0" width="500" height="500"></rect>
</defs>
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="Account-details:-membership-asin-doc" transform="translate(-87.000000, -867.000000)">
<g id="Group" transform="translate(65.000000, 780.000000)">
<g id="img-coverart" transform="translate(22.000000, 87.000000)">
<mask id="mask-2" fill="white">
<use xlink:href="#path-1"></use>
</mask>
<use id="mask" fill="#BBBBBB" xlink:href="#path-1"></use>
<path d="M251.314605,307.191176 L126.315789,229.090627 L126.315789,250.186562 L251.314605,328.289474 L376.315789,250.186562 L376.315789,229.090627 L251.314605,307.191176 Z M300.338486,257.198698 L318.743522,245.697622 L318.757695,245.697622 C304.238718,223.902504 279.436447,209.540923 251.277279,209.540923 C223.146464,209.540923 198.363093,223.878883 183.839389,245.643293 L183.952782,245.655104 C184.933157,244.762229 185.930063,243.885889 186.955321,243.03317 C222.033803,213.960416 272.668324,220.342816 300.338486,257.198698 Z M214.370819,264.53208 C220.980666,259.892912 228.629944,257.226098 236.796575,257.226098 C250.228874,257.226098 262.283922,264.413975 270.556862,275.811119 L288.30989,264.716324 L288.319343,264.716324 C280.157438,253.040453 266.61174,245.39669 251.277753,245.39669 C236.026448,245.39669 222.549266,252.95778 214.370819,264.53208 Z M166.789394,213.901363 C218.255462,173.164548 291.088955,184.079823 329.878678,238.171964 L330.136173,238.568797 L349.186133,226.701596 C328.31953,194.777784 292.263042,173.684211 251.278701,173.684211 C210.866048,173.684211 174.47174,194.572281 153.416152,226.633095 C157.283311,222.56083 162.29621,217.458689 166.789394,213.901363 Z" id="icn-audible" fill="#FFFFFF" mask="url(#mask-2)"></path>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

@@ -163,7 +163,7 @@ function getInitialParams(): {
};
}
export function RecentRequestsTable({ ebookSidecarEnabled = false, annasArchiveBaseUrl = 'https://annas-archive.li' }: RecentRequestsTableProps) {
export function RecentRequestsTable({ ebookSidecarEnabled = false, annasArchiveBaseUrl = 'https://annas-archive.gl' }: RecentRequestsTableProps) {
const toast = useToast();
// Get initial filter state from URL (only evaluated once due to lazy init)
@@ -114,23 +114,13 @@ export function ReportedIssuesSection({ issues }: ReportedIssuesSectionProps) {
<div className="flex gap-3">
{/* Cover Image */}
<div className="flex-shrink-0">
{issue.audiobook.coverArtUrl ? (
<img
src={issue.audiobook.coverArtUrl}
alt={issue.audiobook.title}
className="w-16 h-16 rounded object-cover"
/>
) : (
<div className="w-16 h-16 rounded bg-gray-200 dark:bg-gray-700 flex items-center justify-center">
<svg
className="w-8 h-8 text-gray-400 dark:text-gray-600"
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M9 4.804A7.968 7.968 0 005.5 4c-1.255 0-2.443.29-3.5.804v10A7.969 7.969 0 015.5 14c1.669 0 3.218.51 4.5 1.385A7.962 7.962 0 0114.5 14c1.255 0 2.443.29 3.5.804v-10A7.968 7.968 0 0014.5 4c-1.255 0-2.443.29-3.5.804V12a1 1 0 11-2 0V4.804z" />
</svg>
</div>
)}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={issue.audiobook.coverArtUrl || '/placeholder_cover.svg'}
alt={issue.audiobook.title}
className="w-16 h-16 rounded object-cover"
onError={(e) => { (e.target as HTMLImageElement).src = '/placeholder_cover.svg'; }}
/>
</div>
{/* Info */}
@@ -47,7 +47,7 @@ export function RequestActionsDropdown({
onFetchEbook,
onSearchTermsUpdated,
ebookSidecarEnabled = false,
annasArchiveBaseUrl = 'https://annas-archive.li',
annasArchiveBaseUrl = 'https://annas-archive.gl',
isLoading = false,
}: RequestActionsDropdownProps) {
const [isOpen, setIsOpen] = useState(false);
+7 -17
View File
@@ -176,23 +176,13 @@ function PendingApprovalSection({ requests }: { requests: PendingApprovalRequest
<div className="flex gap-3">
{/* Cover Image */}
<div className="flex-shrink-0">
{request.audiobook.coverArtUrl ? (
<img
src={request.audiobook.coverArtUrl}
alt={request.audiobook.title}
className="w-16 h-16 rounded object-cover"
/>
) : (
<div className="w-16 h-16 rounded bg-gray-200 dark:bg-gray-700 flex items-center justify-center">
<svg
className="w-8 h-8 text-gray-400 dark:text-gray-600"
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M9 4.804A7.968 7.968 0 005.5 4c-1.255 0-2.443.29-3.5.804v10A7.969 7.969 0 015.5 14c1.669 0 3.218.51 4.5 1.385A7.962 7.962 0 0114.5 14c1.255 0 2.443.29 3.5.804v-10A7.968 7.968 0 0014.5 4c-1.255 0-2.443.29-3.5.804V12a1 1 0 11-2 0V4.804z" />
</svg>
</div>
)}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={request.audiobook.coverArtUrl || '/placeholder_cover.svg'}
alt={request.audiobook.title}
className="w-16 h-16 rounded object-cover"
onError={(e) => { (e.target as HTMLImageElement).src = '/placeholder_cover.svg'; }}
/>
</div>
{/* Book Info */}
@@ -90,9 +90,9 @@ export function EbookTab({ ebook, onChange, onSuccess, onError, markAsSaved }: E
</label>
<Input
type="text"
value={ebook.baseUrl || 'https://annas-archive.li'}
value={ebook.baseUrl || 'https://annas-archive.gl'}
onChange={(e) => updateEbook('baseUrl', e.target.value)}
placeholder="https://annas-archive.li"
placeholder="https://annas-archive.gl"
className="font-mono"
/>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
@@ -53,7 +53,7 @@ export function useEbookSettings({ ebook, onChange, onSuccess, onError, markAsSa
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
url: ebook.flaresolverrUrl,
baseUrl: ebook.baseUrl || 'https://annas-archive.li',
baseUrl: ebook.baseUrl || 'https://annas-archive.gl',
}),
});
@@ -83,7 +83,7 @@ export function useEbookSettings({ ebook, onChange, onSuccess, onError, markAsSa
annasArchiveEnabled: ebook.annasArchiveEnabled || false,
indexerSearchEnabled: ebook.indexerSearchEnabled || false,
format: ebook.preferredFormat || 'epub',
baseUrl: ebook.baseUrl || 'https://annas-archive.li',
baseUrl: ebook.baseUrl || 'https://annas-archive.gl',
flaresolverrUrl: ebook.flaresolverrUrl || '',
autoGrabEnabled: ebook.autoGrabEnabled ?? true,
kindleFixEnabled: ebook.kindleFixEnabled ?? false,
+36 -4
View File
@@ -155,10 +155,42 @@ export async function POST(request: NextRequest) {
audiobookId = newBook.id;
logger.info(`Created audiobook record from cache for ASIN ${asin}: ${newBook.id}`);
} else {
return NextResponse.json(
{ error: 'Audiobook not found for the given ASIN' },
{ status: 404 }
);
// Not in DB — fetch live from Audnexus and create a record
try {
const audibleService = getAudibleService();
const liveData = await audibleService.getAudiobookDetails(asin);
if (liveData) {
const newBook = await prisma.audiobook.create({
data: {
audibleAsin: asin,
title: liveData.title,
author: liveData.author,
coverArtUrl: liveData.coverArtUrl,
narrator: liveData.narrator,
series: liveData.series,
seriesPart: liveData.seriesPart,
seriesAsin: liveData.seriesAsin,
year: liveData.releaseDate
? new Date(liveData.releaseDate).getFullYear() || undefined
: undefined,
status: 'pending',
},
});
audiobookId = newBook.id;
logger.info(`Created audiobook record from Audnexus for ASIN ${asin}: ${newBook.id}`);
} else {
return NextResponse.json(
{ error: 'Audiobook not found for the given ASIN' },
{ status: 404 }
);
}
} catch (audnexusError) {
logger.error(`Failed to fetch ASIN ${asin} from Audnexus: ${audnexusError instanceof Error ? audnexusError.message : String(audnexusError)}`);
return NextResponse.json(
{ error: 'Audiobook not found for the given ASIN' },
{ status: 404 }
);
}
}
}
}
@@ -100,15 +100,21 @@ export async function PATCH(
},
});
// Queue search job
// Queue search job based on request type
const { getJobQueueService } = await import('@/lib/services/job-queue.service');
const jobQueue = getJobQueueService();
await jobQueue.addSearchJob(id, {
const audiobookData = {
id: existingRequest.audiobook.id,
title: existingRequest.audiobook.title,
author: existingRequest.audiobook.author,
asin: existingRequest.audiobook.audibleAsin || undefined,
});
};
if (existingRequest.type === 'ebook') {
await jobQueue.addSearchEbookJob(id, audiobookData);
} else {
await jobQueue.addSearchJob(id, audiobookData);
}
searchTriggered = true;
logger.info(`Search triggered for request ${id} with custom terms`, { requestId: id });
+1 -1
View File
@@ -78,7 +78,7 @@ export async function PUT(request: NextRequest) {
// Anna's Archive specific settings
{
key: 'ebook_sidecar_base_url',
value: baseUrl || 'https://annas-archive.li',
value: baseUrl || 'https://annas-archive.gl',
category: 'ebook',
description: 'Base URL for Anna\'s Archive',
},
+1 -1
View File
@@ -138,7 +138,7 @@ export async function GET(request: NextRequest) {
(configMap.get('ebook_annas_archive_enabled') === undefined && configMap.get('ebook_sidecar_enabled') === 'true'),
indexerSearchEnabled: configMap.get('ebook_indexer_search_enabled') === 'true',
// Anna's Archive specific settings
baseUrl: configMap.get('ebook_sidecar_base_url') || 'https://annas-archive.li',
baseUrl: configMap.get('ebook_sidecar_base_url') || 'https://annas-archive.gl',
flaresolverrUrl: configMap.get('ebook_sidecar_flaresolverr_url') || '',
// General settings
preferredFormat: configMap.get('ebook_sidecar_preferred_format') || 'epub',
+39
View File
@@ -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 }
);
}
});
}
@@ -260,6 +260,7 @@ export async function POST(
parentRequestId: availableRequest?.id || null, // Link to parent if exists
status: 'awaiting_approval',
progress: 0,
customSearchTerms: availableRequest?.customSearchTerms || null,
},
});
@@ -292,6 +293,7 @@ export async function POST(
parentRequestId: availableRequest?.id || null,
status: 'pending',
progress: 0,
customSearchTerms: availableRequest?.customSearchTerms || null,
},
});
@@ -227,7 +227,7 @@ export async function POST(
const isAnnasArchiveEnabled = annasArchiveEnabled === 'true';
const isIndexerSearchEnabled = indexerSearchEnabled === 'true';
const format = preferredFormat || 'epub';
const annasBaseUrl = baseUrl || 'https://annas-archive.li';
const annasBaseUrl = baseUrl || 'https://annas-archive.gl';
// Get language code from Audible region config
const region = await configService.getAudibleRegion() as AudibleRegion;
@@ -252,6 +252,7 @@ export async function POST(
status: 'awaiting_approval',
progress: 0,
selectedTorrent: selectedEbook as any,
customSearchTerms: availableRequest?.customSearchTerms || null,
},
});
logger.info(`Created ebook request ${ebookRequest.id}, awaiting approval`);
@@ -296,6 +297,7 @@ export async function POST(
parentRequestId: availableRequest?.id || null,
status: 'searching',
progress: 0,
customSearchTerms: availableRequest?.customSearchTerms || null,
},
});
logger.info(`Created new ebook request ${ebookRequest.id}`);
@@ -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 }
);
}
}
+16 -10
View File
@@ -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,
+63 -53
View File
@@ -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) });
+63 -53
View File
@@ -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) });
@@ -123,6 +123,7 @@ export async function POST(
parentRequestId,
status: 'pending',
progress: 0,
customSearchTerms: parentRequest.customSearchTerms,
},
});
@@ -136,7 +136,7 @@ export async function POST(
const isAnnasArchiveEnabled = annasArchiveEnabled === 'true';
const isIndexerSearchEnabled = indexerSearchEnabled === 'true';
const format = preferredFormat || 'epub';
const annasBaseUrl = baseUrl || 'https://annas-archive.li';
const annasBaseUrl = baseUrl || 'https://annas-archive.gl';
// Get language code from Audible region config
const region = await configService.getAudibleRegion() as AudibleRegion;
@@ -196,10 +196,10 @@ export async function POST(
const langConfig = getLanguageForRegion(region);
// Rank torrents using the ranking algorithm with indexer priorities and flag configs
// Always use the audiobook's title/author for ranking (not custom search query)
// Use searchTitle for ranking so custom search terms and search bar overrides are respected
// requireAuthor: false - interactive mode, show all results for user decision
const rankedResults = rankTorrents(results, {
title: requestRecord.audiobook.title,
title: searchTitle,
author: requestRecord.audiobook.author,
durationMinutes,
}, {
@@ -218,7 +218,7 @@ export async function POST(
const top3 = rankedResults.slice(0, 3);
if (top3.length > 0) {
logger.debug('==================== RANKING DEBUG ====================');
logger.debug('Search parameters', { searchTitle, requestedTitle: requestRecord.audiobook.title, requestedAuthor: requestRecord.audiobook.author });
logger.debug('Search parameters', { searchTitle, rankingTitle: searchTitle, audiobookTitle: requestRecord.audiobook.title, requestedAuthor: requestRecord.audiobook.author });
logger.debug(`Top ${top3.length} results (out of ${rankedResults.length} total)`);
logger.debug('--------------------------------------------------------');
top3.forEach((result, index) => {
+31 -12
View File
@@ -52,17 +52,32 @@ export async function POST(
return NextResponse.json({ error: 'Ebook source not specified' }, { status: 400 });
}
// Get the parent audiobook request
const parentRequest = await prisma.request.findUnique({
// Get the request - could be an audiobook request or an existing ebook request
const foundRequest = await prisma.request.findUnique({
where: { id: parentRequestId },
include: { audiobook: true },
});
if (!parentRequest) {
if (!foundRequest) {
return NextResponse.json({ error: 'Request not found' }, { status: 404 });
}
if (parentRequest.type !== 'audiobook') {
// If this is an ebook request, find the parent audiobook request
let parentRequest;
if (foundRequest.type === 'ebook') {
if (!foundRequest.parentRequestId) {
return NextResponse.json({ error: 'Ebook request has no parent audiobook request' }, { status: 400 });
}
parentRequest = await prisma.request.findUnique({
where: { id: foundRequest.parentRequestId },
include: { audiobook: true },
});
if (!parentRequest) {
return NextResponse.json({ error: 'Parent audiobook request not found' }, { status: 404 });
}
} else if (foundRequest.type === 'audiobook') {
parentRequest = foundRequest;
} else {
return NextResponse.json({ error: 'Can only select ebooks for audiobook requests' }, { status: 400 });
}
@@ -74,13 +89,16 @@ export async function POST(
}
// Check for existing ebook request
let ebookRequest = await prisma.request.findFirst({
where: {
parentRequestId,
type: 'ebook',
deletedAt: null,
},
});
// If we were given an ebook request ID directly, use that; otherwise search by parent
let ebookRequest = foundRequest.type === 'ebook'
? foundRequest
: await prisma.request.findFirst({
where: {
parentRequestId: parentRequest.id,
type: 'ebook',
deletedAt: null,
},
});
if (ebookRequest && !['failed', 'awaiting_search', 'pending'].includes(ebookRequest.status)) {
return NextResponse.json({
@@ -109,9 +127,10 @@ export async function POST(
userId: parentRequest.userId,
audiobookId: parentRequest.audiobookId,
type: 'ebook',
parentRequestId,
parentRequestId: parentRequest.id,
status: 'searching',
progress: 0,
customSearchTerms: parentRequest.customSearchTerms,
},
});
logger.info(`Created new ebook request ${ebookRequest.id}`);
+202
View File
@@ -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<string>();
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 }
);
}
});
}
+1
View File
@@ -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'; }}
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/40 to-transparent" />
</div>
+151 -170
View File
@@ -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<HTMLElement>(null);
const newReleasesSectionRef = useRef<HTMLElement>(null);
// Per-section pagination state
const [pages, setPages] = useState<Record<string, number>>({});
const [totalPagesMap, setTotalPagesMap] = useState<Record<string, number>>({});
const [configOpen, setConfigOpen] = useState(false);
const footerRef = useRef<HTMLElement>(null);
const {
audiobooks: popular,
isLoading: loadingPopular,
totalPages: popularTotalPages,
message: popularMessage,
} = useAudiobooks('popular', 20, popularPage, hideAvailable);
// Create stable refs for each section
const sectionRefsMap = useRef<Map<string, React.RefObject<HTMLElement | null>>>(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<HTMLElement>());
}
});
// 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 (
<ProtectedRoute>
<div className="min-h-screen">
<Header />
<main className="container mx-auto px-4 py-6 sm:py-8 max-w-7xl space-y-8 sm:space-y-12">
{/* Popular Audiobooks Section */}
<section ref={popularSectionRef} className="relative">
{/* Sticky Section Header */}
<div className="sticky top-14 sm:top-16 z-30 mb-4 sm:mb-6">
<div className="bg-white/90 dark:bg-gray-800/90 backdrop-blur-md rounded-2xl px-4 sm:px-6 py-3 border border-gray-200/50 dark:border-gray-700/50 shadow-sm">
<div className="flex items-center gap-3">
<div className="w-1 h-6 bg-gradient-to-b from-blue-500 to-purple-500 rounded-full" />
<h2 className="text-xl sm:text-2xl md:text-3xl font-bold text-gray-900 dark:text-gray-100 truncate">
Popular Audiobooks
</h2>
<SectionToolbar
<main className="container mx-auto px-4 py-6 sm:py-8 max-w-7xl space-y-8 sm:space-y-12">
{/* Loading state */}
{sectionsLoading && (
<div className="flex justify-center py-20">
<div className="animate-spin h-8 w-8 border-2 border-blue-500 border-t-transparent rounded-full" />
</div>
)}
{/* Empty state */}
{!sectionsLoading && sections.length === 0 && (
<div className="text-center py-20">
<p className="text-gray-500 dark:text-gray-400 mb-4">
No sections configured. Click Customize to add sections to your home page.
</p>
<button
onClick={() => setConfigOpen(true)}
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 transition-colors"
>
<Cog6ToothIcon className="w-4 h-4 mr-2" />
Customize Home
</button>
</div>
)}
{/* Dynamic sections */}
{!sectionsLoading &&
sections.map((section, index) => {
const key = getSectionKey(section);
const ref = sectionRefsMap.current.get(key)!;
return (
<HomeSection
key={key}
sectionType={section.sectionType as 'popular' | 'new_releases' | 'category'}
categoryId={section.categoryId}
categoryName={section.categoryName}
colorIndex={index}
page={getPage(key)}
onPageChange={(page) => {
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}
/>
</div>
);
})}
{/* Call to Action */}
<section className="bg-gradient-to-br from-blue-50 to-indigo-50 dark:from-blue-900/20 dark:to-indigo-900/20 rounded-2xl p-6 sm:p-8 text-center border border-blue-200/50 dark:border-blue-800/50 shadow-sm">
<h3 className="text-xl sm:text-2xl font-bold text-gray-900 dark:text-gray-100 mb-2">
Can't find what you're looking for?
</h3>
<p className="text-gray-600 dark:text-gray-400 mb-4">
Use our search to find any audiobook from Audible
</p>
<a
href="/search"
className="inline-flex items-center px-6 py-3 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 transition-colors shadow-md hover:shadow-lg"
>
Search Audiobooks
</a>
</section>
</main>
{/* Footer */}
<footer ref={footerRef} className="bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 mt-16">
<div className="container mx-auto px-4 py-6 max-w-7xl">
<div className="text-center text-sm text-gray-600 dark:text-gray-400">
<p>ReadMeABook - Audiobook Library Management System</p>
</div>
</div>
</footer>
{/* Section Content */}
<div className="bg-white/40 dark:bg-gray-800/40 backdrop-blur-sm rounded-2xl p-4 sm:p-6 border border-gray-200/50 dark:border-gray-700/50 shadow-sm">
{popularMessage && !loadingPopular && popular.length === 0 ? (
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-6 text-center">
<p className="text-yellow-800 dark:text-yellow-200 mb-2 font-medium">
No popular audiobooks found
</p>
<p className="text-yellow-700 dark:text-yellow-300 text-sm">
{popularMessage}
</p>
</div>
) : (
<AudiobookGrid
audiobooks={popular}
isLoading={loadingPopular}
emptyMessage="No popular audiobooks available"
cardSize={cardSize}
squareCovers={squareCovers}
/>
)}
</div>
</section>
{/* Unified Pagination — dynamic sections */}
{paginationSections.length > 0 && (
<UnifiedPagination
footerRef={footerRef}
sections={paginationSections}
/>
)}
{/* New Releases Section */}
<section ref={newReleasesSectionRef} className="relative">
{/* Sticky Section Header */}
<div className="sticky top-14 sm:top-16 z-30 mb-4 sm:mb-6">
<div className="bg-white/90 dark:bg-gray-800/90 backdrop-blur-md rounded-2xl px-4 sm:px-6 py-3 border border-gray-200/50 dark:border-gray-700/50 shadow-sm">
<div className="flex items-center gap-3">
<div className="w-1 h-6 bg-gradient-to-b from-emerald-500 to-teal-500 rounded-full" />
<h2 className="text-xl sm:text-2xl md:text-3xl font-bold text-gray-900 dark:text-gray-100 truncate">
New Releases
</h2>
<SectionToolbar
hideAvailable={hideAvailable}
onToggleHideAvailable={setHideAvailable}
squareCovers={squareCovers}
onToggleSquareCovers={setSquareCovers}
cardSize={cardSize}
onCardSizeChange={setCardSize}
/>
</div>
</div>
</div>
{/* Section Content */}
<div className="bg-white/40 dark:bg-gray-800/40 backdrop-blur-sm rounded-2xl p-4 sm:p-6 border border-gray-200/50 dark:border-gray-700/50 shadow-sm">
{newReleasesMessage && !loadingNewReleases && newReleases.length === 0 ? (
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-6 text-center">
<p className="text-yellow-800 dark:text-yellow-200 mb-2 font-medium">
No new releases found
</p>
<p className="text-yellow-700 dark:text-yellow-300 text-sm">
{newReleasesMessage}
</p>
</div>
) : (
<AudiobookGrid
audiobooks={newReleases}
isLoading={loadingNewReleases}
emptyMessage="No new releases available"
cardSize={cardSize}
squareCovers={squareCovers}
/>
)}
</div>
</section>
{/* Call to Action */}
<section className="bg-gradient-to-br from-blue-50 to-indigo-50 dark:from-blue-900/20 dark:to-indigo-900/20 rounded-2xl p-6 sm:p-8 text-center border border-blue-200/50 dark:border-blue-800/50 shadow-sm">
<h3 className="text-xl sm:text-2xl font-bold text-gray-900 dark:text-gray-100 mb-2">
Can't find what you're looking for?
</h3>
<p className="text-gray-600 dark:text-gray-400 mb-4">
Use our search to find any audiobook from Audible
</p>
<a
href="/search"
className="inline-flex items-center px-6 py-3 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 transition-colors shadow-md hover:shadow-lg"
>
Search Audiobooks
</a>
</section>
</main>
{/* Footer */}
<footer ref={footerRef} className="bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 mt-16">
<div className="container mx-auto px-4 py-6 max-w-7xl">
<div className="text-center text-sm text-gray-600 dark:text-gray-400">
<p>ReadMeABook - Audiobook Library Management System</p>
</div>
</div>
</footer>
{/* Unified Pagination — single context-aware pill for both sections */}
<UnifiedPagination
footerRef={footerRef}
sections={[
{
label: 'Popular Audiobooks',
accentColor: 'bg-blue-500',
currentPage: popularPage,
totalPages: popularTotalPages,
onPageChange: handlePopularPageChange,
sectionRef: popularSectionRef,
onScrollToSection: () =>
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 */}
<HomeSectionConfigModal
isOpen={configOpen}
onClose={() => setConfigOpen(false)}
sections={sections}
onSave={saveSections}
/>
</div>
</ProtectedRoute>
);
+12 -6
View File
@@ -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<string | null>(null);
const [showModal, setShowModal] = useState(false);
const [localRequestStatus, setLocalRequestStatus] = useState<string | undefined>(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 ? (
<Image
src={audiobook.coverArtUrl}
alt=""
fill
className="object-cover"
sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 20vw"
onError={() => setCoverError(true)}
/>
) : (
<div className="absolute inset-0 bg-gradient-to-br from-gray-200 to-gray-300 dark:from-gray-700 dark:to-gray-800 flex items-center justify-center">
<svg className="w-12 h-12 text-gray-400 dark:text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3" />
</svg>
</div>
<Image
src={PLACEHOLDER_COVER}
alt=""
fill
className="object-cover"
sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 20vw"
/>
)}
{/* Hover Overlay with Actions - Desktop Only
@@ -96,6 +96,7 @@ export function AudiobookDetailsModal({
const [asinCopied, setAsinCopied] = useState(false);
const [localRequestStatus, setLocalRequestStatus] = useState<string | null>(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 ? (
<Image
src={audiobook.coverArtUrl}
alt=""
@@ -295,13 +296,16 @@ export function AudiobookDetailsModal({
className="object-cover"
sizes="200px"
priority
onError={() => setCoverError(true)}
/>
) : (
<div className="absolute inset-0 bg-gradient-to-br from-gray-200 to-gray-300 dark:from-gray-700 dark:to-gray-800 flex items-center justify-center">
<svg className="w-12 h-12 text-gray-400 dark:text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3" />
</svg>
</div>
<Image
src="/placeholder_cover.svg"
alt=""
fill
className="object-cover"
sizes="200px"
/>
)}
{/* Rating Badge */}
@@ -250,10 +250,12 @@ export function BookPickerModal({
{/* Cover Image or Text Placeholder */}
<div className="absolute inset-0 bg-gradient-to-br from-blue-50 to-blue-100 dark:from-gray-700 dark:to-gray-600">
{book.coverUrl ? (
/* eslint-disable-next-line @next/next/no-img-element */
<img
src={book.coverUrl}
alt={book.title}
className="w-full h-full object-cover"
onError={(e) => { (e.target as HTMLImageElement).src = '/placeholder_cover.svg'; }}
/>
) : (
<div className="w-full h-full flex flex-col items-center justify-center p-3">
+11 -4
View File
@@ -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 */}
<div className="w-full relative bg-gray-200 dark:bg-gray-700 flex-shrink-0" style={{ maxHeight: 'min(25vh, 300px)' }}>
{recommendation.coverUrl ? (
{recommendation.coverUrl && !coverError ? (
<Image
src={recommendation.coverUrl}
alt={recommendation.title}
@@ -236,11 +237,17 @@ export function RecommendationCard({
className="object-contain w-full h-auto"
style={{ maxHeight: 'min(25vh, 300px)' }}
unoptimized
onError={() => setCoverError(true)}
/>
) : (
<div className="w-full h-48 flex items-center justify-center">
<span className="text-6xl">📚</span>
</div>
<Image
src="/placeholder_cover.svg"
alt={recommendation.title}
width={400}
height={400}
className="object-contain w-full h-auto"
style={{ maxHeight: 'min(25vh, 300px)' }}
/>
)}
</div>
+310
View File
@@ -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<HTMLElement | null>;
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 (
<RenderSection
audiobooks={audiobooks}
isLoading={isLoading}
totalPages={totalPages}
message={message}
{...renderProps}
/>
);
}
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 (
<RenderSection
audiobooks={audiobooks}
isLoading={isLoading}
totalPages={totalPages}
message={message}
{...renderProps}
/>
);
}
interface RenderSectionProps {
cardSize: number;
squareCovers: boolean;
nextRefresh?: string | null;
}
function CategoryEmptyState({ nextRefresh }: { nextRefresh?: string | null }) {
const refreshLabel = nextRefresh ? formatNextRefresh(nextRefresh) : null;
return (
<div className="flex flex-col items-center justify-center py-14 px-6 text-center">
<div className="flex items-center justify-center w-11 h-11 rounded-full bg-gray-100 dark:bg-gray-700/60 mb-4">
<ClockIcon className="w-5 h-5 text-gray-400 dark:text-gray-500" />
</div>
<p className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
No audiobooks yet
</p>
<p className="text-sm text-gray-400 dark:text-gray-500 max-w-xs leading-relaxed">
{refreshLabel
? <>This section will fill in after the next data refresh, scheduled for <span className="text-gray-500 dark:text-gray-400">{refreshLabel}</span>.</>
: 'This section will fill in after the next scheduled data refresh.'}
</p>
</div>
);
}
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 <CategoryEmptyState nextRefresh={nextRefresh} />;
}
return (
<AudiobookGrid
audiobooks={audiobooks}
isLoading={isLoading}
emptyMessage="No audiobooks available"
cardSize={cardSize}
squareCovers={squareCovers}
/>
);
}
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 (
<section ref={sectionRef} className="relative">
{/* Sticky Section Header */}
<div className="sticky top-14 sm:top-16 z-30 mb-4 sm:mb-6">
<div className="bg-white/90 dark:bg-gray-800/90 backdrop-blur-md rounded-2xl px-4 sm:px-6 py-3 border border-gray-200/50 dark:border-gray-700/50 shadow-sm">
<div className="flex items-center gap-3">
<div className={`w-1 h-6 bg-gradient-to-b ${gradient} rounded-full`} />
<h2 className="text-xl sm:text-2xl md:text-3xl font-bold text-gray-900 dark:text-gray-100 truncate">
{title}
</h2>
<SectionToolbar
hideAvailable={hideAvailable}
onToggleHideAvailable={onToggleHideAvailable}
squareCovers={squareCovers}
onToggleSquareCovers={onToggleSquareCovers}
cardSize={cardSize}
onCardSizeChange={onCardSizeChange}
/>
{onConfigOpen && (
<button
onClick={onConfigOpen}
className="p-1.5 rounded-lg text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700/50 transition-colors"
aria-label="Customize home page"
title="Customize sections"
>
<Cog6ToothIcon className="w-4 h-4" />
</button>
)}
</div>
</div>
</div>
{/* Section Content */}
<div className="bg-white/40 dark:bg-gray-800/40 backdrop-blur-sm rounded-2xl p-4 sm:p-6 border border-gray-200/50 dark:border-gray-700/50 shadow-sm">
{sectionType === 'popular' && (
<PopularOrNewSection
type="popular"
page={page}
hideAvailable={hideAvailable}
onTotalPagesChange={onTotalPagesChange}
{...renderProps}
/>
)}
{sectionType === 'new_releases' && (
<PopularOrNewSection
type="new-releases"
page={page}
hideAvailable={hideAvailable}
onTotalPagesChange={onTotalPagesChange}
{...renderProps}
/>
)}
{sectionType === 'category' && categoryId && (
<CategorySection
categoryId={categoryId}
page={page}
hideAvailable={hideAvailable}
onTotalPagesChange={onTotalPagesChange}
{...renderProps}
/>
)}
</div>
</section>
);
}
@@ -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<HomeSection, 'id'>[]) => Promise<unknown>;
}
export function HomeSectionConfigModal({ isOpen, onClose, sections, onSave }: Props) {
const [localSections, setLocalSections] = useState<Omit<HomeSection, 'id'>[]>([]);
const [categories, setCategories] = useState<AudibleCategory[]>([]);
const [loadingCategories, setLoadingCategories] = useState(false);
const [showCategoryPicker, setShowCategoryPicker] = useState(false);
const [saving, setSaving] = useState(false);
const [dirty, setDirty] = useState(false);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [dragIndex, setDragIndex] = useState<number | null>(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 (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={onClose} />
{/* Modal */}
<div className="relative bg-white dark:bg-gray-900 rounded-2xl shadow-2xl w-full max-w-lg mx-4 max-h-[85vh] flex flex-col overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
Customize Home
</h2>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
{localSections.length}/{MAX_SECTIONS} sections
{saving && (
<span className="ml-2 text-blue-500 dark:text-blue-400">Saving...</span>
)}
</p>
</div>
<button
onClick={onClose}
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
aria-label="Close"
>
<XMarkIcon className="w-5 h-5 text-gray-500" />
</button>
</div>
{/* Section list */}
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-2">
{localSections.length === 0 && (
<div className="text-center text-gray-400 dark:text-gray-500 py-8">
<p className="text-sm">No sections configured.</p>
<p className="text-xs mt-1">Add sections below to customize your home page.</p>
</div>
)}
{localSections.map((section, index) => (
<div
key={`${section.sectionType}-${section.categoryId || index}`}
draggable
onDragStart={() => 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 */}
<div className="cursor-grab active:cursor-grabbing text-gray-400 dark:text-gray-500 hidden sm:block">
<Bars3Icon className="w-4 h-4" />
</div>
{/* Color dot */}
<div className={`w-2 h-2 rounded-full flex-shrink-0 ${SECTION_COLORS[index % SECTION_COLORS.length]}`} />
{/* Label */}
<span className="flex-1 text-sm font-medium text-gray-800 dark:text-gray-200 truncate">
{getSectionLabel(section)}
</span>
{/* Mobile reorder arrows */}
<div className="flex sm:hidden gap-0.5">
<button
onClick={() => index > 0 && moveSection(index, index - 1)}
disabled={index === 0}
className="p-1 rounded text-gray-400 hover:text-gray-600 disabled:opacity-25"
aria-label="Move up"
>
<ChevronUpIcon className="w-4 h-4" />
</button>
<button
onClick={() => index < localSections.length - 1 && moveSection(index, index + 1)}
disabled={index === localSections.length - 1}
className="p-1 rounded text-gray-400 hover:text-gray-600 disabled:opacity-25"
aria-label="Move down"
>
<ChevronDownIcon className="w-4 h-4" />
</button>
</div>
{/* Remove */}
<button
onClick={() => removeSection(index)}
className="p-1 rounded-lg text-gray-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
aria-label={`Remove ${getSectionLabel(section)}`}
>
<TrashIcon className="w-4 h-4" />
</button>
</div>
))}
</div>
{/* Add section controls */}
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 space-y-3">
{/* Built-in section buttons */}
<div className="flex gap-2 flex-wrap">
{!hasPopular && (
<button
onClick={() => addBuiltIn('popular')}
disabled={localSections.length >= MAX_SECTIONS}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/20 rounded-lg hover:bg-blue-100 dark:hover:bg-blue-900/40 transition-colors disabled:opacity-50"
>
<PlusIcon className="w-3.5 h-3.5" />
Popular
</button>
)}
{!hasNewReleases && (
<button
onClick={() => addBuiltIn('new_releases')}
disabled={localSections.length >= MAX_SECTIONS}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-900/20 rounded-lg hover:bg-emerald-100 dark:hover:bg-emerald-900/40 transition-colors disabled:opacity-50"
>
<PlusIcon className="w-3.5 h-3.5" />
New Releases
</button>
)}
<button
onClick={loadCategories}
disabled={localSections.length >= MAX_SECTIONS || loadingCategories}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-violet-600 dark:text-violet-400 bg-violet-50 dark:bg-violet-900/20 rounded-lg hover:bg-violet-100 dark:hover:bg-violet-900/40 transition-colors disabled:opacity-50"
>
<PlusIcon className="w-3.5 h-3.5" />
{loadingCategories ? 'Loading...' : 'Category'}
</button>
</div>
{/* Category picker */}
{showCategoryPicker && (
<div className="max-h-48 overflow-y-auto rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800">
{categories.length === 0 ? (
<div className="px-4 py-3 text-sm text-gray-500">No categories found.</div>
) : (
categories
.filter((c) => !existingCategoryIds.has(c.id))
.map((cat) => (
<button
key={cat.id}
onClick={() => addCategory(cat)}
className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors border-b border-gray-100 dark:border-gray-700/50 last:border-0"
>
{cat.name}
</button>
))
)}
<button
onClick={() => setShowCategoryPicker(false)}
className="w-full px-4 py-2 text-xs text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
>
Cancel
</button>
</div>
)}
</div>
</div>
</div>
);
}
+3 -1
View File
@@ -467,12 +467,14 @@ function CoverStack({
: undefined
}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={book.coverUrl}
src={book.coverUrl || '/placeholder_cover.svg'}
alt=""
className="w-full h-full object-cover"
loading="lazy"
draggable={false}
onError={(e) => { (e.target as HTMLImageElement).src = '/placeholder_cover.svg'; }}
/>
</div>
))}
@@ -101,15 +101,14 @@ function WatchedSeriesCard({
{/* Cover */}
<button onClick={onNavigate} className="flex-shrink-0">
<div className={`relative w-14 ${squareCovers ? 'aspect-square' : 'aspect-[2/3]'} rounded-lg overflow-hidden bg-gradient-to-br from-emerald-100 to-teal-200 dark:from-emerald-900 dark:to-teal-900`}>
{item.coverArtUrl ? (
<Image src={item.coverArtUrl} alt={item.seriesTitle} fill className="object-cover" sizes="56px" />
) : (
<div className="absolute inset-0 flex items-center justify-center">
<svg className="w-6 h-6 text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25" />
</svg>
</div>
)}
<Image
src={item.coverArtUrl || '/placeholder_cover.svg'}
alt={item.seriesTitle}
fill
className="object-cover"
sizes="56px"
onError={(e) => { (e.target as HTMLImageElement).src = '/placeholder_cover.svg'; }}
/>
</div>
</button>
+20 -26
View File
@@ -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 ? (
<Image
src={request.audiobook.coverArtUrl}
alt={request.audiobook.title}
fill
className="object-cover"
sizes="96px"
onError={() => setCoverError(true)}
/>
) : (
) : isEbook ? (
<div className="w-full h-full flex items-center justify-center">
{isEbook ? (
<svg
className="w-12 h-12"
style={{ color: '#f16f19' }}
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M6 2c-1.1 0-1.99.9-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6H6zm7 7V3.5L18.5 9H13z" />
</svg>
) : (
<svg
className="w-12 h-12 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"
/>
</svg>
)}
<svg
className="w-12 h-12"
style={{ color: '#f16f19' }}
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M6 2c-1.1 0-1.99.9-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6H6zm7 7V3.5L18.5 9H13z" />
</svg>
</div>
) : (
<Image
src="/placeholder_cover.svg"
alt={request.audiobook.title}
fill
className="object-cover"
sizes="96px"
/>
)}
</div>
</div>
+11 -17
View File
@@ -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 ? (
<Image
src={series.coverArtUrl}
alt=""
fill
className="object-cover"
sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 20vw"
onError={() => setCoverError(true)}
/>
) : (
<div className="absolute inset-0 bg-gradient-to-br from-emerald-600 to-teal-800 dark:from-emerald-700 dark:to-teal-900 flex items-center justify-center">
<svg
className="w-1/3 h-1/3 text-white/40"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.2}
d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25"
/>
</svg>
</div>
<Image
src="/placeholder_cover.svg"
alt=""
fill
className="object-cover"
sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 20vw"
/>
)}
{/* Top-row badges — Rating (left) + Book count (right) */}
+13 -7
View File
@@ -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 */}
<div className="flex-shrink-0">
<div className={`relative w-36 sm:w-44 lg:w-52 ${squareCovers ? 'aspect-square' : 'aspect-[2/3]'} rounded-xl overflow-hidden shadow-xl shadow-black/20 dark:shadow-black/40`}>
{series.books[0]?.coverArtUrl ? (
{series.books[0]?.coverArtUrl && !coverError ? (
<Image
src={series.books[0].coverArtUrl}
alt={series.title}
@@ -35,13 +38,16 @@ export function SeriesDetailCard({ series, squareCovers = false }: SeriesDetailC
className="object-cover"
sizes="(max-width: 640px) 144px, (max-width: 1024px) 176px, 208px"
priority
onError={() => setCoverError(true)}
/>
) : (
<div className="absolute inset-0 bg-gradient-to-br from-emerald-100 to-teal-200 dark:from-emerald-900 dark:to-teal-900 flex items-center justify-center">
<svg className="w-1/3 h-1/3 text-emerald-400 dark:text-emerald-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25" />
</svg>
</div>
<Image
src={PLACEHOLDER_COVER}
alt={series.title}
fill
className="object-cover"
sizes="(max-width: 640px) 144px, (max-width: 1024px) 176px, 208px"
/>
)}
</div>
</div>
+8 -15
View File
@@ -97,21 +97,14 @@ export function SimilarSeriesRow({ series, currentSeriesTitle, squareCovers = fa
>
{/* Cover */}
<div className={`relative w-20 ${squareCovers ? 'h-20 sm:w-24 sm:h-24' : 'h-[120px] sm:w-24 sm:h-36'} rounded-lg overflow-hidden shadow-md shadow-black/15 dark:shadow-black/30 group-hover/card:shadow-lg group-hover/card:scale-[1.04] group-hover/card:-translate-y-0.5 transition-all duration-300`}>
{s.coverArtUrl ? (
<Image
src={s.coverArtUrl}
alt=""
fill
className="object-cover"
sizes="96px"
/>
) : (
<div className="absolute inset-0 bg-gradient-to-br from-emerald-100 to-teal-200 dark:from-emerald-900 dark:to-teal-900 flex items-center justify-center">
<span className="text-lg font-bold text-emerald-400 dark:text-emerald-300">
{s.title.charAt(0).toUpperCase()}
</span>
</div>
)}
<Image
src={s.coverArtUrl || '/placeholder_cover.svg'}
alt=""
fill
className="object-cover"
sizes="96px"
onError={(e) => { (e.target as HTMLImageElement).src = '/placeholder_cover.svg'; }}
/>
</div>
{/* Title */}
+164 -71
View File
@@ -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<HTMLElement | null>;
}
@@ -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 (
<div className="flex flex-col gap-1 pl-2 pr-3">
{sections.map((section, idx) => {
const isActive = idx === activeIndex;
return (
<button
key={`${section.label}-${idx}`}
onClick={() => { if (!isActive) section.onScrollToSection(); }}
disabled={isActive}
title={section.label}
aria-label={`Switch to ${section.label}`}
className={`
w-1.5 rounded-full transition-all duration-300 ease-out
${isActive
? `${section.accentColor} h-4 opacity-100`
: 'bg-gray-300 dark:bg-gray-600 h-1.5 opacity-60 hover:opacity-90 hover:scale-110 cursor-pointer'
}
`}
/>
);
})}
</div>
);
}
// ---- 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 (
<div className="flex flex-col items-center gap-0.5 pl-2 pr-3">
{/* Top fade indicator */}
{hasHiddenLeft && (
<div
className="w-0.5 rounded-full bg-gray-300 dark:bg-gray-600 opacity-30 flex-shrink-0"
style={{ height: '3px' }}
aria-hidden="true"
/>
)}
{slots.map((sectionIdx) => {
const section = sections[sectionIdx];
const isActive = sectionIdx === activeIndex;
const dist = Math.abs(sectionIdx - activeIndex);
const h = heightForDist[Math.min(dist, heightForDist.length - 1)];
// Active dot gets the section's accent color.
// Inactive dots: the farther they are, the more faded.
const opacityMap = [1, 0.55, 0.3];
const opacity = opacityMap[Math.min(dist, opacityMap.length - 1)];
return (
<button
key={`${section.label}-${sectionIdx}`}
onClick={() => { if (!isActive) section.onScrollToSection(); }}
disabled={isActive}
title={section.label}
aria-label={`Switch to ${section.label}`}
style={{ height: `${h}px`, opacity }}
className={`
w-1.5 rounded-full flex-shrink-0
transition-all duration-300 ease-out
${isActive
? `${section.accentColor} cursor-default`
: 'bg-gray-400 dark:bg-gray-500 hover:opacity-90 cursor-pointer'
}
`}
/>
);
})}
{/* Bottom fade indicator */}
{hasHiddenRight && (
<div
className="w-0.5 rounded-full bg-gray-300 dark:bg-gray-600 opacity-30 flex-shrink-0"
style={{ height: '3px' }}
aria-hidden="true"
/>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Main component
// ---------------------------------------------------------------------------
export function UnifiedPagination({ sections, footerRef }: UnifiedPaginationProps) {
// Index of the currently dominant section (0 or 1)
const [activeIndex, setActiveIndex] = useState<0 | 1>(0);
// Whether the label+controls area is mid-transition (drives opacity fade)
const [activeIndex, setActiveIndex] = useState(0);
const [isTransitioning, setIsTransitioning] = useState(false);
const [footerVisible, setFooterVisible] = useState(false);
// Per-section raw intersection ratios [0,1]
const ratiosRef = useRef<[number, number]>([0, 0]);
// Whether each section has any meaningful intersection
const [sectionVisible, setSectionVisible] = useState<[boolean, boolean]>([false, false]);
const ratiosRef = useRef<number[]>(sections.map(() => 0));
const [anySectionVisible, setAnySectionVisible] = useState(false);
const transitionTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Determine if the pill should be shown at all:
// - at least one section is meaningfully visible
// - footer is not visible
// - the active section has >1 page
const activeSectionHasPages = sections[activeIndex].totalPages > 1;
const eitherSectionVisible = sectionVisible[0] || sectionVisible[1];
const shouldShow = eitherSectionVisible && !footerVisible && activeSectionHasPages;
// Keep ratios array length in sync with sections
useEffect(() => {
ratiosRef.current = sections.map((_, i) => ratiosRef.current[i] || 0);
}, [sections.length]);
const activeSectionHasPages = sections[activeIndex]?.totalPages > 1;
const shouldShow = anySectionVisible && !footerVisible && activeSectionHasPages && sections.length > 0;
// ------------------------------------------------------------------
// Track which section each instance belongs to via intersection ratio
// Intersection observers for all sections
// ------------------------------------------------------------------
useEffect(() => {
const observers: IntersectionObserver[] = [];
@@ -128,38 +245,31 @@ export function UnifiedPagination({ sections, footerRef }: UnifiedPaginationProp
const observer = new IntersectionObserver(
([entry]) => {
ratiosRef.current[idx as 0 | 1] = entry.intersectionRatio;
const isVisible = entry.isIntersecting && entry.intersectionRatio > 0.05;
ratiosRef.current[idx] = entry.intersectionRatio;
const anyVisible = ratiosRef.current.some((r) => r > 0.05);
setAnySectionVisible(anyVisible);
setSectionVisible((prev) => {
const next: [boolean, boolean] = [...prev] as [boolean, boolean];
next[idx as 0 | 1] = isVisible;
return next;
});
// Determine dominant section (whichever has more viewport coverage)
const [r0, r1] = ratiosRef.current;
const dominant: 0 | 1 = r0 >= r1 ? 0 : 1;
// Find dominant section
let maxRatio = -1;
let dominant = 0;
for (let i = 0; i < ratiosRef.current.length; i++) {
if (ratiosRef.current[i] > maxRatio) {
maxRatio = ratiosRef.current[i];
dominant = i;
}
}
setActiveIndex((current) => {
if (current !== dominant) {
// Trigger cross-fade transition
setIsTransitioning(true);
if (transitionTimerRef.current) {
clearTimeout(transitionTimerRef.current);
}
transitionTimerRef.current = setTimeout(() => {
setIsTransitioning(false);
}, 320);
if (transitionTimerRef.current) clearTimeout(transitionTimerRef.current);
transitionTimerRef.current = setTimeout(() => setIsTransitioning(false), 320);
return dominant;
}
return current;
});
},
{
// Dense threshold array gives us smooth ratio tracking
threshold: Array.from({ length: 21 }, (_, i) => i / 20),
rootMargin: '-60px 0px -80px 0px',
}
@@ -173,8 +283,9 @@ export function UnifiedPagination({ sections, footerRef }: UnifiedPaginationProp
observers.forEach((o) => o.disconnect());
if (transitionTimerRef.current) clearTimeout(transitionTimerRef.current);
};
// Re-run when section refs change
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sections[0].sectionRef, sections[1].sectionRef]);
}, [sections.map((s) => s.sectionRef.current).join(',')]);
// ------------------------------------------------------------------
// Footer observer
@@ -190,9 +301,10 @@ export function UnifiedPagination({ sections, footerRef }: UnifiedPaginationProp
}, [footerRef]);
// ------------------------------------------------------------------
// Derived values for the currently active section
// Derived values
// ------------------------------------------------------------------
const active = sections[activeIndex];
if (!active) return null;
const handlePrev = () => {
if (active.currentPage > 1) active.onPageChange(active.currentPage - 1);
@@ -231,32 +343,14 @@ export function UnifiedPagination({ sections, footerRef }: UnifiedPaginationProp
"
>
{/* Section selector dots — left side */}
<div className="flex flex-col gap-1 pl-2 pr-3">
{sections.map((section, idx) => {
const isActive = idx === activeIndex;
return (
<button
key={section.label}
onClick={() => {
if (!isActive) section.onScrollToSection();
}}
disabled={isActive}
title={section.label}
aria-label={`Switch to ${section.label}`}
className={`
w-1.5 rounded-full transition-all duration-300 ease-out
${isActive
? `${section.accentColor} h-4 opacity-100`
: 'bg-gray-300 dark:bg-gray-600 h-1.5 opacity-60 hover:opacity-90 hover:scale-110 cursor-pointer'
}
`}
/>
);
})}
</div>
{sections.length > 1 && (
<>
<SectionDots sections={sections} activeIndex={activeIndex} />
{/* Divider */}
<div className="w-px h-6 bg-gray-200 dark:bg-white/10 mr-3 flex-shrink-0" />
{/* Divider */}
<div className="w-px h-6 bg-gray-200 dark:bg-white/10 mr-3 flex-shrink-0" />
</>
)}
{/* Label + controls — cross-fades on section switch */}
<div
@@ -265,11 +359,10 @@ export function UnifiedPagination({ sections, footerRef }: UnifiedPaginationProp
transition-opacity duration-200 ease-in-out
${isTransitioning ? 'opacity-0' : 'opacity-100'}
`}
// key forces full remount on switch so input state resets cleanly
key={activeIndex}
>
{/* Section label — hidden on small screens */}
<span className="hidden sm:block text-xs font-medium text-gray-500 dark:text-gray-400 whitespace-nowrap pr-1 select-none">
<span className="hidden sm:block text-xs font-medium text-gray-500 dark:text-gray-400 whitespace-nowrap pr-1 select-none max-w-[120px] truncate">
{active.label}
</span>
+119
View File
@@ -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<HomeSectionsResponse>(
HOME_SECTIONS_KEY,
authenticatedFetcher,
{
revalidateOnFocus: false,
dedupingInterval: 30000,
}
);
const saveSections = useCallback(
async (sections: Omit<HomeSection, 'id'>[]) => {
const { fetchJSON } = await import('@/lib/utils/api');
const result = await fetchJSON<HomeSectionsResponse>(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,
};
}
+158
View File
@@ -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<AudibleAudiobook[]> {
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
*/
+144 -118
View File
@@ -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<string | null> },
logger: ReturnType<typeof RMABLogger.forJob>,
labelForErrors: string,
): Promise<number> {
// 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;
}
@@ -79,7 +79,7 @@ export async function processStartDirectDownload(payload: StartDirectDownloadPay
// Get download configuration
const configService = getConfigService();
const downloadsDir = await configService.get('download_dir') || '/downloads';
const baseUrl = await configService.get('ebook_sidecar_base_url') || 'https://annas-archive.li';
const baseUrl = await configService.get('ebook_sidecar_base_url') || 'https://annas-archive.gl';
const preferredFormat = await configService.get('ebook_sidecar_preferred_format') || 'epub';
const flaresolverrUrl = await configService.get('ebook_sidecar_flaresolverr_url') || undefined;
+14 -5
View File
@@ -36,16 +36,25 @@ export async function processSearchEbook(payload: SearchEbookPayload): Promise<a
logger.info(`Processing ebook request ${requestId} for "${audiobook.title}"`);
try {
// Update request status to searching
await prisma.request.update({
// Update request status to searching and fetch custom search terms
const requestRecord = await prisma.request.update({
where: { id: requestId },
data: {
status: 'searching',
searchAttempts: { increment: 1 },
updatedAt: new Date(),
},
select: { customSearchTerms: true },
});
// Use custom search terms if set, otherwise use audiobook title
const effectiveSearchTitle = requestRecord?.customSearchTerms || audiobook.title;
const searchAudiobook = { ...audiobook, title: effectiveSearchTitle };
if (requestRecord?.customSearchTerms) {
logger.info(`Using custom search terms: "${effectiveSearchTitle}" (original: "${audiobook.title}")`);
}
// Get ebook configuration
const configService = getConfigService();
const preferredFormat = payloadFormat || await configService.get('ebook_sidecar_preferred_format') || 'epub';
@@ -62,7 +71,7 @@ export async function processSearchEbook(payload: SearchEbookPayload): Promise<a
// ========== STEP 1: Try Anna's Archive (if enabled) ==========
if (annasArchiveEnabled) {
logger.info(`Searching Anna's Archive...`);
annasArchiveResult = await searchAnnasArchive(audiobook, preferredFormat, logger);
annasArchiveResult = await searchAnnasArchive(searchAudiobook, preferredFormat, logger);
if (annasArchiveResult) {
logger.info(`Found ebook via Anna's Archive (score: ${annasArchiveResult.score})`);
@@ -74,7 +83,7 @@ export async function processSearchEbook(payload: SearchEbookPayload): Promise<a
// ========== STEP 2: Try Indexer Search (if enabled and no Anna's Archive result) ==========
if (!annasArchiveResult && indexerSearchEnabled) {
logger.info(`Searching indexers...`);
indexerResult = await searchIndexers(requestId, audiobook, preferredFormat, logger);
indexerResult = await searchIndexers(requestId, searchAudiobook, preferredFormat, logger);
if (indexerResult) {
logger.info(`Found ebook via indexer search (score: ${indexerResult.finalScore.toFixed(1)})`);
@@ -150,7 +159,7 @@ async function searchAnnasArchive(
logger: RMABLogger
): Promise<EbookSearchResult | null> {
const configService = getConfigService();
const baseUrl = await configService.get('ebook_sidecar_base_url') || 'https://annas-archive.li';
const baseUrl = await configService.get('ebook_sidecar_base_url') || 'https://annas-archive.gl';
const flaresolverrUrl = await configService.get('ebook_sidecar_flaresolverr_url') || undefined;
// Get language code from Audible region config
@@ -166,9 +166,10 @@ export async function processSearchIndexers(payload: SearchIndexersPayload): Pro
// Rank results with indexer priorities and flag configs
// Note: rankTorrents now filters out results < 20 MB internally
// Use effectiveSearchTitle so custom search terms are respected for ranking
// requireAuthor: true (default) - strict filtering for automatic selection
const rankedResults = ranker.rankTorrents(searchResults, {
title: audiobook.title,
title: effectiveSearchTitle,
author: audiobook.author,
durationMinutes,
}, {
@@ -228,7 +229,7 @@ export async function processSearchIndexers(payload: SearchIndexersPayload): Pro
// Log top 3 results with detailed breakdown
const top3 = filteredResults.slice(0, 3);
logger.info(`==================== RANKING DEBUG ====================`);
logger.info(`Requested Title: "${audiobook.title}"`);
logger.info(`Ranking Title: "${effectiveSearchTitle}"${effectiveSearchTitle !== audiobook.title ? ` (audiobook: "${audiobook.title}")` : ''}`);
logger.info(`Requested Author: "${audiobook.author}"`);
logger.info(`Top ${top3.length} results (out of ${filteredResults.length} above threshold):`);
logger.info(`--------------------------------------------------------`);
+2 -2
View File
@@ -128,7 +128,7 @@ async function fetchHtml(
*/
export async function testFlareSolverrConnection(
flaresolverrUrl: string,
baseUrl: string = 'https://annas-archive.li'
baseUrl: string = 'https://annas-archive.gl'
): Promise<{ success: boolean; message: string; responseTime?: number }> {
const startTime = Date.now();
@@ -168,7 +168,7 @@ export async function downloadEbook(
author: string,
targetDir: string,
preferredFormat: string = 'epub',
baseUrl: string = 'https://annas-archive.li',
baseUrl: string = 'https://annas-archive.gl',
logger?: RMABLogger,
flaresolverrUrl?: string,
languageCode: string = 'en'
+9 -13
View File
@@ -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;
}
}
+48 -6
View File
@@ -19,7 +19,7 @@ import type { AudibleAudiobook } from '../integrations/audible.service';
/** Patterns in parentheses or brackets to strip (edition markers, format labels) */
const EDITION_PAREN_RE = /[([][^)\]]*?(?:unabridged|abridged|edition|remaster(?:ed)?|anniversary|complete|original|version|narrat(?:ed|or)?|audio(?:book)?|full cast|dramatiz(?:ed|ation))[^)\]]*[)\]]/gi;
/** Trailing subtitle after colon or long dash */
/** Trailing subtitle after colon or long dash (used for extraction, not blind stripping) */
const SUBTITLE_RE = /\s*[:]\s+.+$/;
const LONG_DASH_SUBTITLE_RE = /\s+[-\u2013\u2014]\s+.+$/;
@@ -44,6 +44,44 @@ export function normalizeTitle(title: string): string {
return t.replace(/\s+/g, ' ').trim();
}
/**
* Extract the subtitle portion from a title (part after colon or long dash).
* Returns empty string if no subtitle found.
* Used to prevent false dedup of series books like "Series: Book A" vs "Series: Book B".
*/
export function extractSubtitle(title: string): string {
let t = title.toLowerCase();
// Remove parenthesized/bracketed edition markers first (same as normalizeTitle)
t = t.replace(EDITION_PAREN_RE, '');
// Remove trailing descriptors
t = t.replace(TRAILING_DESCRIPTOR_RE, '');
t = t.replace(/\s+/g, ' ').trim();
// Try colon subtitle
const colonMatch = t.match(/\s*[:]\s+(.+)$/);
if (colonMatch) return colonMatch[1].trim();
// Try long dash subtitle
const dashMatch = t.match(/\s+[-\u2013\u2014]\s+(.+)$/);
if (dashMatch) return dashMatch[1].trim();
return '';
}
/**
* Check if two titles' subtitles are compatible for dedup purposes.
* - Both have no subtitle compatible
* - One has a subtitle, other doesn't compatible (re-listing with/without subtitle)
* - Both have the SAME subtitle compatible
* - Both have DIFFERENT subtitles NOT compatible (different books, e.g. series entries)
*/
function areSubtitlesCompatible(titleA: string, titleB: string): boolean {
const subA = extractSubtitle(titleA);
const subB = extractSubtitle(titleB);
if (!subA || !subB) return true; // one or both missing → compatible
return subA === subB;
}
/** Normalize narrator for comparison. Sorts individual names so order doesn't matter. */
function normalizeNarrator(narrator?: string): string {
const raw = (narrator || '').toLowerCase().trim();
@@ -152,16 +190,20 @@ export function deduplicateAndCollectGroups(books: AudibleAudiobook[]): Deduplic
continue;
}
// Within a title+narrator group, further split by duration compatibility.
// Build sub-groups where all members are duration-compatible with the
// representative (first member). A book joins the first compatible sub-group.
// Within a title+narrator group, further split by duration AND subtitle
// compatibility. Build sub-groups where all members are compatible with
// the representative (first member). A book joins the first compatible sub-group.
// This prevents false dedup of series entries like "Series: Book A" vs "Series: Book B".
const subGroups: AudibleAudiobook[][] = [];
for (const book of group) {
let placed = false;
for (const sg of subGroups) {
// Check compatibility against the representative (first member)
if (areDurationsCompatible(sg[0].durationMinutes, book.durationMinutes)) {
// Check both duration and subtitle compatibility against the representative
if (
areDurationsCompatible(sg[0].durationMinutes, book.durationMinutes) &&
areSubtitlesCompatible(sg[0].title, book.title)
) {
sg.push(book);
placed = true;
break;
@@ -0,0 +1,258 @@
/**
* Component: Admin Manual Import API Route Tests
* Documentation: documentation/features/manual-import.md
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { createPrismaMock } from '../helpers/prisma';
let authRequest: any;
let requestBody: any;
const prismaMock = createPrismaMock();
const requireAuthMock = vi.hoisted(() => vi.fn());
const requireAdminMock = vi.hoisted(() => vi.fn());
const jobQueueMock = vi.hoisted(() => ({
addOrganizeJob: vi.fn(() => Promise.resolve()),
}));
const audibleServiceMock = vi.hoisted(() => ({
getAudiobookDetails: vi.fn(),
}));
// fs mock
const fsMock = vi.hoisted(() => ({
stat: vi.fn(),
readdir: vi.fn(),
}));
vi.mock('@/lib/db', () => ({
prisma: prismaMock,
}));
vi.mock('@/lib/middleware/auth', () => ({
requireAuth: requireAuthMock,
requireAdmin: requireAdminMock,
AuthenticatedRequest: {},
}));
vi.mock('@/lib/services/job-queue.service', () => ({
getJobQueueService: () => jobQueueMock,
}));
vi.mock('@/lib/integrations/audible.service', () => ({
getAudibleService: () => audibleServiceMock,
}));
vi.mock('fs/promises', () => fsMock);
vi.mock('path', async () => {
const actual = await vi.importActual<typeof import('path')>('path');
return {
...actual,
default: actual,
resolve: (...args: string[]) => actual.posix.resolve(...args),
extname: actual.posix.extname,
};
});
describe('Admin manual-import route', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.resetModules();
authRequest = { user: { id: 'admin-1', role: 'admin' } };
requestBody = { asin: 'B00TEST0001', folderPath: '/bookdrop/author/title' };
requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest));
requireAdminMock.mockImplementation((_req: any, handler: any) => handler());
// Default: download_dir and media_dir not configured, bookdrop exists
prismaMock.configuration.findUnique.mockResolvedValue(null);
fsMock.stat.mockImplementation(async (p: string) => {
if (p === '/bookdrop') return { isDirectory: () => true };
if (p === '/bookdrop/author/title') return { isDirectory: () => true };
throw new Error('ENOENT');
});
fsMock.readdir.mockResolvedValue([
{ name: 'chapter1.m4b', isFile: () => true },
]);
});
it('creates audiobook from Audnexus when ASIN is not in DB or cache', async () => {
// Neither audiobook nor audibleCache has this ASIN
prismaMock.audiobook.findFirst.mockResolvedValueOnce(null);
prismaMock.audibleCache.findUnique.mockResolvedValueOnce(null);
// Audnexus returns live data
audibleServiceMock.getAudiobookDetails.mockResolvedValueOnce({
asin: 'B00TEST0001',
title: 'Live Title',
author: 'Live Author',
coverArtUrl: 'https://example.com/cover.jpg',
narrator: 'Live Narrator',
series: 'Test Series',
seriesPart: '1',
seriesAsin: 'SERIES0001',
releaseDate: '2024-01-15',
});
// audiobook.create returns the new record
prismaMock.audiobook.create.mockResolvedValueOnce({
id: 'ab-new',
audibleAsin: 'B00TEST0001',
title: 'Live Title',
author: 'Live Author',
status: 'pending',
});
// audiobook.findUnique for the verification step
prismaMock.audiobook.findUnique.mockResolvedValueOnce({
id: 'ab-new',
audibleAsin: 'B00TEST0001',
title: 'Live Title',
author: 'Live Author',
status: 'pending',
});
// No existing request
prismaMock.request.findFirst.mockResolvedValueOnce(null);
prismaMock.request.create.mockResolvedValueOnce({ id: 'req-new' });
const { POST } = await import('@/app/api/admin/manual-import/route');
const request = {
json: vi.fn().mockResolvedValue(requestBody),
nextUrl: new URL('http://localhost/api/admin/manual-import'),
};
const response = await POST(request as any);
const payload = await response.json();
expect(response.status).toBe(200);
expect(payload.success).toBe(true);
expect(audibleServiceMock.getAudiobookDetails).toHaveBeenCalledWith('B00TEST0001');
expect(prismaMock.audiobook.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
audibleAsin: 'B00TEST0001',
title: 'Live Title',
author: 'Live Author',
}),
})
);
});
it('returns 404 when ASIN is not in DB, cache, or Audnexus', async () => {
prismaMock.audiobook.findFirst.mockResolvedValueOnce(null);
prismaMock.audibleCache.findUnique.mockResolvedValueOnce(null);
audibleServiceMock.getAudiobookDetails.mockResolvedValueOnce(null);
const { POST } = await import('@/app/api/admin/manual-import/route');
const request = {
json: vi.fn().mockResolvedValue(requestBody),
nextUrl: new URL('http://localhost/api/admin/manual-import'),
};
const response = await POST(request as any);
const payload = await response.json();
expect(response.status).toBe(404);
expect(payload.error).toBe('Audiobook not found for the given ASIN');
});
it('returns 404 when Audnexus lookup throws an error', async () => {
prismaMock.audiobook.findFirst.mockResolvedValueOnce(null);
prismaMock.audibleCache.findUnique.mockResolvedValueOnce(null);
audibleServiceMock.getAudiobookDetails.mockRejectedValueOnce(new Error('Network timeout'));
const { POST } = await import('@/app/api/admin/manual-import/route');
const request = {
json: vi.fn().mockResolvedValue(requestBody),
nextUrl: new URL('http://localhost/api/admin/manual-import'),
};
const response = await POST(request as any);
const payload = await response.json();
expect(response.status).toBe(404);
expect(payload.error).toBe('Audiobook not found for the given ASIN');
});
it('uses existing audiobook record when ASIN is in DB', async () => {
prismaMock.audiobook.findFirst.mockResolvedValueOnce({
id: 'ab-existing',
audibleAsin: 'B00TEST0001',
});
prismaMock.audiobook.findUnique.mockResolvedValueOnce({
id: 'ab-existing',
audibleAsin: 'B00TEST0001',
title: 'Existing Title',
author: 'Existing Author',
status: 'pending',
});
prismaMock.request.findFirst.mockResolvedValueOnce(null);
prismaMock.request.create.mockResolvedValueOnce({ id: 'req-1' });
const { POST } = await import('@/app/api/admin/manual-import/route');
const request = {
json: vi.fn().mockResolvedValue(requestBody),
nextUrl: new URL('http://localhost/api/admin/manual-import'),
};
const response = await POST(request as any);
const payload = await response.json();
expect(response.status).toBe(200);
expect(payload.success).toBe(true);
// Should NOT have queried audibleCache for ASIN resolution
expect(prismaMock.audibleCache.findUnique).not.toHaveBeenCalled();
});
it('uses audibleCache when ASIN is not in audiobook table but is cached', async () => {
prismaMock.audiobook.findFirst.mockResolvedValueOnce(null);
prismaMock.audibleCache.findUnique.mockResolvedValueOnce({
asin: 'B00TEST0001',
title: 'Cached Title',
author: 'Cached Author',
coverArtUrl: 'https://example.com/cached.jpg',
narrator: 'Cached Narrator',
});
// audiobook.create from cache
prismaMock.audiobook.create.mockResolvedValueOnce({
id: 'ab-from-cache',
audibleAsin: 'B00TEST0001',
title: 'Cached Title',
author: 'Cached Author',
status: 'pending',
});
prismaMock.audiobook.findUnique.mockResolvedValueOnce({
id: 'ab-from-cache',
audibleAsin: 'B00TEST0001',
title: 'Cached Title',
author: 'Cached Author',
status: 'pending',
});
prismaMock.request.findFirst.mockResolvedValueOnce(null);
prismaMock.request.create.mockResolvedValueOnce({ id: 'req-2' });
const { POST } = await import('@/app/api/admin/manual-import/route');
const request = {
json: vi.fn().mockResolvedValue(requestBody),
nextUrl: new URL('http://localhost/api/admin/manual-import'),
};
const response = await POST(request as any);
const payload = await response.json();
expect(response.status).toBe(200);
expect(payload.success).toBe(true);
// audiobook.create should have used cache data, not Audnexus
expect(prismaMock.audiobook.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
title: 'Cached Title',
author: 'Cached Author',
}),
})
);
});
});
+1 -1
View File
@@ -355,7 +355,7 @@ describe('Admin settings core routes', () => {
it('updates ebook settings', async () => {
const request = {
json: vi.fn().mockResolvedValue({ enabled: true, format: 'epub', baseUrl: 'https://annas-archive.li' }),
json: vi.fn().mockResolvedValue({ enabled: true, format: 'epub', baseUrl: 'https://annas-archive.gl' }),
};
const { PUT } = await import('@/app/api/admin/settings/ebook/route');
@@ -348,14 +348,14 @@ describe('Admin settings test routes', () => {
it('tests FlareSolverr connection', async () => {
testFlareSolverrMock.mockResolvedValueOnce({ success: true });
const request = { json: vi.fn().mockResolvedValue({ url: 'http://flare', baseUrl: 'https://annas-archive.li' }) };
const request = { json: vi.fn().mockResolvedValue({ url: 'http://flare', baseUrl: 'https://annas-archive.gl' }) };
const { POST } = await import('@/app/api/admin/settings/ebook/test-flaresolverr/route');
const response = await POST(request as any);
const payload = await response.json();
expect(payload.success).toBe(true);
expect(testFlareSolverrMock).toHaveBeenCalledWith('http://flare', 'https://annas-archive.li');
expect(testFlareSolverrMock).toHaveBeenCalledWith('http://flare', 'https://annas-archive.gl');
});
it('rejects FlareSolverr test when URL is missing', async () => {
@@ -382,7 +382,7 @@ describe('Admin settings test routes', () => {
it('returns error when FlareSolverr test throws', async () => {
testFlareSolverrMock.mockRejectedValueOnce(new Error('flare down'));
const request = { json: vi.fn().mockResolvedValue({ url: 'http://flare', baseUrl: 'https://annas-archive.li' }) };
const request = { json: vi.fn().mockResolvedValue({ url: 'http://flare', baseUrl: 'https://annas-archive.gl' }) };
const { POST } = await import('@/app/api/admin/settings/ebook/test-flaresolverr/route');
const response = await POST(request as any);
+21 -7
View File
@@ -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');
});
});
+166
View File
@@ -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');
});
});
@@ -87,7 +87,7 @@ describe('RequestActionsDropdown', () => {
author: 'Author',
status: 'downloaded',
type: 'ebook',
torrentUrl: JSON.stringify(['https://annas-archive.li/slow_download/abc123def456abc123def456abc123de/0/5']),
torrentUrl: JSON.stringify(['https://annas-archive.gl/slow_download/abc123def456abc123def456abc123de/0/5']),
}}
onManualSearch={vi.fn().mockResolvedValue(undefined)}
onCancel={vi.fn().mockResolvedValue(undefined)}
@@ -28,7 +28,7 @@ const renderHook = <T,>(hook: () => T) => {
const baseEbook = {
enabled: true,
preferredFormat: 'epub',
baseUrl: 'https://annas-archive.li',
baseUrl: 'https://annas-archive.gl',
flaresolverrUrl: 'http://flare',
};
@@ -93,7 +93,7 @@ describe('useEbookSettings', () => {
expect(result.current.flaresolverrTestResult?.success).toBe(true);
// Verify baseUrl is included in the request body
const callBody = JSON.parse(fetchWithAuthMock.mock.calls[0][1].body);
expect(callBody.baseUrl).toBe('https://annas-archive.li');
expect(callBody.baseUrl).toBe('https://annas-archive.gl');
expect(callBody.url).toBe('http://flare');
});
+52 -14
View File
@@ -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(<HomePage />);
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(<HomePage />);
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(<HomePage />);
expect(screen.getByText(/No sections configured/)).toBeInTheDocument();
});
});
@@ -115,7 +115,7 @@ describe('IndexersTab - Auto-load Indexers on Tab Activation', () => {
ebook: {
enabled: false,
preferredFormat: 'epub',
baseUrl: 'https://annas-archive.li',
baseUrl: 'https://annas-archive.gl',
flaresolverrUrl: '',
},
};
+5
View File
@@ -10,6 +10,7 @@ type PrismaModelMock = {
findFirst: ReturnType<typeof vi.fn>;
findUnique: ReturnType<typeof vi.fn>;
create: ReturnType<typeof vi.fn>;
createMany: ReturnType<typeof vi.fn>;
update: ReturnType<typeof vi.fn>;
updateMany: ReturnType<typeof vi.fn>;
upsert: ReturnType<typeof vi.fn>;
@@ -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({})),
@@ -53,6 +55,9 @@ export const createPrismaMock = () => ({
workAsin: createModelMock(),
watchedSeries: createModelMock(),
watchedAuthor: createModelMock(),
userHomeSection: createModelMock(),
audibleCacheCategory: createModelMock(),
$queryRaw: vi.fn(),
$transaction: vi.fn(),
$disconnect: vi.fn(),
});
@@ -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');
@@ -63,7 +63,7 @@ describe('processStartDirectDownload', () => {
vi.clearAllMocks();
configServiceMock.get.mockImplementation(async (key: string) => {
if (key === 'downloads_dir') return '/downloads';
if (key === 'ebook_sidecar_base_url') return 'https://annas-archive.li';
if (key === 'ebook_sidecar_base_url') return 'https://annas-archive.gl';
if (key === 'ebook_sidecar_preferred_format') return 'epub';
return null;
});
@@ -238,7 +238,7 @@ describe('processStartDirectDownload', () => {
configServiceMock.get.mockImplementation(async (key: string) => {
if (key === 'downloads_dir') return '/downloads';
if (key === 'ebook_sidecar_base_url') return 'https://annas-archive.li';
if (key === 'ebook_sidecar_base_url') return 'https://annas-archive.gl';
if (key === 'ebook_sidecar_preferred_format') return 'epub';
if (key === 'ebook_sidecar_flaresolverr_url') return 'http://flaresolverr:8191';
return null;
@@ -286,7 +286,7 @@ describe('processStartDirectDownload', () => {
expect(ebookScraperMock.extractDownloadUrl).toHaveBeenCalledWith(
'https://slow.example.com/book',
'https://annas-archive.li',
'https://annas-archive.gl',
'epub',
expect.anything(),
'http://flaresolverr:8191'
@@ -43,7 +43,7 @@ describe('processSearchEbook', () => {
configServiceMock.getAudibleRegion.mockResolvedValue('us');
configServiceMock.get.mockImplementation(async (key: string) => {
if (key === 'ebook_sidecar_preferred_format') return 'epub';
if (key === 'ebook_sidecar_base_url') return 'https://annas-archive.li';
if (key === 'ebook_sidecar_base_url') return 'https://annas-archive.gl';
if (key === 'ebook_annas_archive_enabled') return 'true';
if (key === 'ebook_indexer_search_enabled') return 'false';
return null;
@@ -79,7 +79,7 @@ describe('processSearchEbook', () => {
expect(ebookScraperMock.searchByAsin).toHaveBeenCalledWith(
'B001ASIN',
'epub',
'https://annas-archive.li',
'https://annas-archive.gl',
expect.anything(),
undefined,
'en'
@@ -124,7 +124,7 @@ describe('processSearchEbook', () => {
'Another Book',
'Another Author',
'epub',
'https://annas-archive.li',
'https://annas-archive.gl',
expect.anything(),
undefined,
'en'
@@ -229,7 +229,7 @@ describe('processSearchEbook', () => {
configServiceMock.get.mockImplementation(async (key: string) => {
if (key === 'ebook_sidecar_preferred_format') return 'epub';
if (key === 'ebook_sidecar_base_url') return 'https://annas-archive.li';
if (key === 'ebook_sidecar_base_url') return 'https://annas-archive.gl';
if (key === 'ebook_sidecar_flaresolverr_url') return 'http://flaresolverr:8191';
if (key === 'ebook_annas_archive_enabled') return 'true';
if (key === 'ebook_indexer_search_enabled') return 'false';
@@ -255,7 +255,7 @@ describe('processSearchEbook', () => {
expect(ebookScraperMock.searchByAsin).toHaveBeenCalledWith(
'B006ASIN',
'epub',
'https://annas-archive.li',
'https://annas-archive.gl',
expect.anything(),
'http://flaresolverr:8191',
'en'
+7 -7
View File
@@ -63,7 +63,7 @@ describe('E-book sidecar', () => {
},
});
const result = await testFlareSolverrConnection('http://flare', 'https://annas-archive.li');
const result = await testFlareSolverrConnection('http://flare', 'https://annas-archive.gl');
expect(result.success).toBe(true);
expect(result.responseTime).toBeTypeOf('number');
@@ -95,7 +95,7 @@ describe('E-book sidecar', () => {
},
});
const result = await testFlareSolverrConnection('http://flare', 'https://annas-archive.li');
const result = await testFlareSolverrConnection('http://flare', 'https://annas-archive.gl');
expect(result.success).toBe(false);
});
@@ -103,7 +103,7 @@ describe('E-book sidecar', () => {
it('returns error details when FlareSolverr request fails', async () => {
axiosMock.post.mockRejectedValue(new Error('flare down'));
const result = await testFlareSolverrConnection('http://flare', 'https://annas-archive.li');
const result = await testFlareSolverrConnection('http://flare', 'https://annas-archive.gl');
expect(result.success).toBe(false);
expect(result.message).toContain('flare down');
@@ -117,7 +117,7 @@ describe('E-book sidecar', () => {
},
});
const result = await testFlareSolverrConnection('http://flare', 'https://annas-archive.li');
const result = await testFlareSolverrConnection('http://flare', 'https://annas-archive.gl');
expect(result.success).toBe(false);
expect(result.message).toContain('FlareSolverr error');
@@ -132,7 +132,7 @@ describe('E-book sidecar', () => {
},
});
const result = await testFlareSolverrConnection('http://flare', 'https://annas-archive.li');
const result = await testFlareSolverrConnection('http://flare', 'https://annas-archive.gl');
expect(result.success).toBe(false);
expect(result.message).toContain('FlareSolverr returned HTTP 403');
@@ -221,7 +221,7 @@ describe('E-book sidecar', () => {
throw new Error(`Unexpected URL: ${url}`);
});
const promise = downloadEbook('ASIN-NO', 'Title', 'Author', '/downloads', 'pdf', 'https://annas-archive.li', undefined, 'http://flare');
const promise = downloadEbook('ASIN-NO', 'Title', 'Author', '/downloads', 'pdf', 'https://annas-archive.gl', undefined, 'http://flare');
await vi.runAllTimersAsync();
const result = await promise;
@@ -417,7 +417,7 @@ describe('E-book sidecar', () => {
'Author',
'/downloads',
'epub',
'https://annas-archive.li',
'https://annas-archive.gl',
undefined,
'http://flare'
);
@@ -8,6 +8,7 @@ import {
deduplicateAudiobooks,
deduplicateAndCollectGroups,
normalizeTitle,
extractSubtitle,
areDurationsCompatible,
} from '@/lib/utils/deduplicate-audiobooks';
import type { AudibleAudiobook } from '@/lib/integrations/audible.service';
@@ -92,6 +93,32 @@ describe('normalizeTitle', () => {
});
});
// ---------------------------------------------------------------------------
// extractSubtitle
// ---------------------------------------------------------------------------
describe('extractSubtitle', () => {
it('extracts subtitle after colon', () => {
expect(extractSubtitle('Eden\'s Gate: The Reborn')).toBe('the reborn');
});
it('extracts subtitle after long dash', () => {
expect(extractSubtitle('Eden\'s Gate \u2014 The Reborn')).toBe('the reborn');
});
it('returns empty for title without subtitle', () => {
expect(extractSubtitle('The Black Prism')).toBe('');
});
it('strips edition markers before extracting', () => {
expect(extractSubtitle('The Hobbit (Unabridged): Extended')).toBe('extended');
});
it('returns empty string for empty input', () => {
expect(extractSubtitle('')).toBe('');
});
});
// ---------------------------------------------------------------------------
// areDurationsCompatible
// ---------------------------------------------------------------------------
@@ -302,6 +329,27 @@ describe('deduplicateAudiobooks', () => {
expect(deduplicateAudiobooks(books)).toHaveLength(1);
});
it('does NOT collapse series entries with different subtitles (Eden\'s Gate bug)', () => {
// Series format: "Series Name: Book Title" — different books, NOT duplicates
const books = [
makeBook({ asin: 'A1', title: 'Eden\'s Gate: The Reborn', author: 'Edward Brody', narrator: 'Pavi Proczko', durationMinutes: 510 }),
makeBook({ asin: 'A2', title: 'Eden\'s Gate: The Spartan', author: 'Edward Brody', narrator: 'Pavi Proczko', durationMinutes: 540 }),
makeBook({ asin: 'A3', title: 'Eden\'s Gate: The Sapper', author: 'Edward Brody', narrator: 'Pavi Proczko', durationMinutes: 600 }),
];
const result = deduplicateAudiobooks(books);
expect(result).toHaveLength(3); // All 3 are different books!
});
it('still collapses when one has subtitle and other does not', () => {
// Same book re-listed: "The Black Prism: Lightbringer, Book 1" vs "The Black Prism"
const books = [
makeBook({ asin: 'A1', title: 'The Black Prism: Lightbringer, Book 1', author: 'Brent Weeks', narrator: 'Simon Vance', durationMinutes: 1260 }),
makeBook({ asin: 'A2', title: 'The Black Prism', author: 'Brent Weeks', narrator: 'Simon Vance', durationMinutes: 1262 }),
];
const result = deduplicateAudiobooks(books);
expect(result).toHaveLength(1);
});
it('does not collapse empty-narrator with named narrator', () => {
const books = [
makeBook({ asin: 'A1', title: 'Test Book', author: 'Auth', narrator: undefined, durationMinutes: 300 }),