Merge branch 'main' into feature/bulk-import-folder-fallback

Resolves conflicts in src/lib/integrations/audible.service.ts.

main switched the ASIN-detail fallback from HTML scraping to the JSON
catalog API (fetchAudibleDetailsFromApi), removing scrapeAudibleDetails.
The PR's lookupAsinFast was a fail-fast variant of the same pattern that
getAudiobookDetails now performs (Audnexus -> catalog API), so it's
redundant.

- Drop the lookupAsinFast method (delete entire HEAD-side conflict block)
- Take main's fetchAudibleDetailsFromApi verbatim (the scrapeAudibleDetails
  maxRetries parameterization is moot)
- In bulk-import scan route, swap lookupAsinFast for getAudiobookDetails

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
kikootwo
2026-05-14 16:14:25 -04:00
36 changed files with 3952 additions and 1532 deletions
+3
View File
@@ -45,6 +45,8 @@
- **Web scraping (popular, new releases)** → [integrations/audible.md](integrations/audible.md)
- **Database caching, real-time matching** → [integrations/audible.md](integrations/audible.md)
- **Book covers API for login page** → [frontend/pages/login.md](frontend/pages/login.md)
- **Dedup & works table (cross-ASIN identity)** → [integrations/audible.md](integrations/audible.md#dedup--works-table)
- **Multi-narrator capture in HTML scrapers** → [integrations/audible.md](integrations/audible.md#narrator-capture-in-html-scrapers)
## E-book Support (First-Class)
- **First-class ebook requests, separate tracking** → [integrations/ebook-sidecar.md](integrations/ebook-sidecar.md)
@@ -144,6 +146,7 @@
**"How do I delete requests?"** → [admin-features/request-deletion.md](admin-features/request-deletion.md)
**"How do I approve/deny user requests?"** → [admin-features/request-approval.md](admin-features/request-approval.md)
**"How do I enable auto-approve for requests?"** → [admin-features/request-approval.md](admin-features/request-approval.md)
**"How does the admin book info modal work?"** → [admin-features/request-approval.md](admin-features/request-approval.md#ui-features), [frontend/components.md](frontend/components.md#component-apis)
**"How do I customize audiobook folder organization?"** → [settings-pages.md](settings-pages.md#audiobook-organization-template), [phase3/file-organization.md](phase3/file-organization.md#target-structure)
**"How do I deploy?"** → [deployment/docker.md](deployment/docker.md) (multi-container), [deployment/unified.md](deployment/unified.md) (all-in-one)
**"How do I use the unified container?"** → [deployment/unified.md](deployment/unified.md)
@@ -259,8 +259,11 @@ Update user (includes autoApproveRequests field)
- Title and author
- User avatar and username
- Request timestamp (relative: "2 hours ago")
- Info button (ⓘ, top-right corner) — opens AudiobookDetailsModal for full book details
- Approve button (green, checkmark icon)
- Search button (blue, magnifier icon) — opens InteractiveTorrentSearchModal
- Deny button (red, X icon)
- **Info modal:** `AudiobookDetailsModal` rendered with `adminActions` prop containing Approve/Search/Deny buttons, allowing admin to review full book details (cover, description, series, genres, narrator, etc.) without leaving the approval workflow
- Auto-refreshes every 10 seconds (SWR)
- Loading states on buttons during approval/denial
- Success/error toast notifications
@@ -7,7 +7,7 @@ Sends notifications for audiobook request events (pending approval, approved, av
## Key Details
- **Backends:** Apprise (API), Discord (webhooks), ntfy (API), Pushover (API)
- **Events:** request_pending_approval, request_approved, request_available, request_error, issue_reported
- **Events:** request_pending_approval, request_approved, request_grabbed, request_available, request_error, issue_reported
- **Encryption:** AES-256-GCM for sensitive config (webhook URLs, API keys, notification URLs)
- **Delivery:** Async via Bull job queue (priority 5)
- **Failure Handling:** Non-blocking, Promise.allSettled (one backend fails, others succeed)
@@ -33,11 +33,14 @@ model NotificationBackend {
|-------|---------|------------------------|
| request_pending_approval | User creates request | Request needs admin approval |
| request_approved | Admin approves OR auto-approval | Request approved (manual or auto) |
| request_grabbed | Torrent/NZB added to download client | Download handed off to configured download client (title resolves by type) — **opt-in: existing backends do not auto-subscribe; enable in Settings** |
| request_available | Plex/ABS scan or ebook download completes | Request available (title resolves by type) |
| request_error | Download/import fails | Request failed at any stage |
| issue_reported | User reports issue | User reports problem with available audiobook |
**Dynamic Titles:** Events can define `titleByRequestType` in `notification-events.ts` for type-specific titles.
- `request_grabbed` + `requestType: 'audiobook'` → "Audiobook Grabbed"
- `request_grabbed` + `requestType: 'ebook'` → "Ebook Grabbed"
- `request_available` + `requestType: 'audiobook'` → "Audiobook Available"
- `request_available` + `requestType: 'ebook'` → "Ebook Available"
- `request_available` + no requestType → "Request Available" (fallback)
@@ -66,6 +69,11 @@ model NotificationBackend {
- Approve (with or without pre-selected torrent): After job triggered → request_approved
- Deny: No notification
**Download Grabbed (processor: download-torrent)**
- After `client.addDownload()` succeeds and `DownloadHistory` record created → request_grabbed
- `message` field: `"${torrent.title} via ${indexer} (${clientType})"`
- `requestType`: from `request.type` (audiobook/ebook)
**Audiobook Available (processors: scan-plex, plex-recently-added)**
- After `status: 'available'` update → request_available (requestType: 'audiobook')
- Includes user info in query (plexUsername)
+2 -1
View File
@@ -30,7 +30,7 @@ src/components/
**Audiobooks**
- **AudiobookCard** ✅ - Cover, title, author, narrator, duration, request button, clickable to open details modal. Shows "Requested by [username]" when someone else has requested the book, "Requested" when current user has requested it
- **AudiobookGrid** - Responsive grid (1/2/3/4 cols)
- **AudiobookDetailsModal** ✅ - Full-screen modal with comprehensive metadata (description, genres, rating, release date, narrator, request functionality). Shows requesting user's name when applicable
- **AudiobookDetailsModal** ✅ - Full-screen modal with comprehensive metadata (description, genres, rating, release date, narrator, language, format, publisher, request functionality). Shows requesting user's name when applicable
**Requests**
- **RequestCard** ✅ - Cover, title, author, status badge, progress bar, timestamps, action buttons (cancel, manual search, interactive search)
@@ -113,6 +113,7 @@ interface AudiobookDetailsModalProps {
requestStatus?: string | null;
isAvailable?: boolean;
requestedByUsername?: string | null;
adminActions?: React.ReactNode; // Optional admin buttons (Approve/Search/Deny) rendered as second row in action bar
}
interface RequestCardProps {
+204 -130
View File
@@ -1,104 +1,131 @@
# Audible Integration
**Status:** Implemented (Audnexus API + Web Scraping)
**Status:** Implemented | Hybrid — curated HTML for discovery refresh + Audible JSON catalog API for user-facing real-time + Audnexus for per-ASIN details
Audiobook metadata from Audnexus API (primary) and Audible.com scraping (fallback) for discovery, search, and detail pages.
## Overview
## Detail Page Strategy
Audiobook metadata for discovery, search, and detail pages. Split by access pattern:
**Primary: Audnexus API**
- Endpoint: `https://api.audnex.us/books/{asin}`
- Structured JSON response (no parsing needed)
- Provides: title, authors, narrators, description, duration, rating, genres, cover art
- Free, no API key required
- ~95% success rate for popular audiobooks
- **Nightly discovery refresh** (popular / new releases / category lists) — scraped from Audible's **curated HTML storefronts** (`www.audible.<tld>/adblbestsellers`, `/newreleases`, `/search?node=<id>`). The HTML pages reflect Audible's own editorial picks.
- **User-facing real-time** (search, author books, categories listing, per-ASIN details) — Audible's unauthenticated public **JSON catalog API** (`api.audible.<tld>/1.0/catalog/*`).
- **Per-ASIN detail lookups** — Audnexus (`api.audnex.us/books/{asin}`) primary; catalog API used as fallback when Audnexus returns 404.
**Fallback: Audible Scraping**
- Used when Audnexus returns 404
- Parse Audible HTML with Cheerio
- Multiple selector strategies with promotional text filtering
- Extract JSON-LD structured data when available
## Architecture
- **Curated HTML (refresh job only):** the three methods called solely by `audible-refresh.processor.ts` (`getPopularAudiobooks`, `getNewReleases`, `getCategoryBooks`) scrape Audible's storefront HTML to inherit editorial curation. Beefed-up retry/backoff knobs (12 retries, 3-min jittered cap) handle 503 storms patiently on the nightly job without slowing healthy users.
- **JSON catalog API (real-time):** `search`, `searchByAuthorAsin`, `getCategories` (categories listing), and `fetchAudibleDetailsFromApi` (per-ASIN fallback). Same endpoint used by the official Audible mobile apps. No authentication, no API key, no user credentials, no special headers.
- **Audnexus (per-ASIN):** `getAudiobookDetails` and `getRuntime` prefer Audnexus, with catalog API fallback for `getAudiobookDetails`.
- **`www.audible.<tld>`:** Used by HTML refresh scraping, by `audible-series.ts`, and by `getBaseUrl()` for "View on Audible" link generation.
## Data Sources
### Nightly refresh (HTML — `htmlClient`, baseURL `www.audible.<tld>`)
| Operation | Endpoint | Key params |
|---|---|---|
| Popular | `/adblbestsellers` | `pageSize=50`, `page=<n>` (omitted on first page) |
| New releases | `/newreleases` | `pageSize=50`, `page=<n>` (omitted on first page) |
| Category books | `/search` | `node=<categoryId>&pageSize=50&sort=popularity-rank&page=<n>` |
Parsed via cheerio. Selectors: `.productListItem` (popular/new releases), `.s-result-item, .productListItem` (categories).
### Real-time (JSON catalog API — `apiClient`, baseURL `api.audible.<tld>`)
| Operation | Endpoint | Key params |
|---|---|---|
| Search | `/1.0/catalog/products` | `keywords=<q>` |
| Author books | `/1.0/catalog/products` | `author=<name>` (name, NOT ASIN) |
| Categories listing | `/1.0/catalog/categories` | (none) |
| Single product | `/1.0/catalog/products/{asin}` | — |
| Audnexus (per-ASIN) | `https://api.audnex.us/books/{asin}` | `region={audnexusParam}` |
All `products` endpoints share:
- `num_results` — max **50** (service constant `AUDIBLE_PAGE_SIZE = 50`)
- `page`**0-indexed at the API** (service public interface is 1-indexed; the service subtracts 1 at the call site). See Gotchas.
- `response_groups=<CATALOG_RESPONSE_GROUPS>`
## `response_groups` Constant
`CATALOG_RESPONSE_GROUPS = 'contributors,product_desc,product_attrs,product_extended_attrs,media,rating,series,category_ladders,product_details'`
Populates every `AudibleAudiobook` field. Covered:
- `contributors` → authors (with ASINs), narrators
- `product_desc``publisher_summary`, `merchandising_summary`
- `product_attrs` / `product_extended_attrs` / `product_details` → title, release_date, language, runtime_length_min
- `media``product_images` (cover URLs, uses `500` variant)
- `rating``overall_distribution.display_stars`
- `series` → array of `{asin, title, sequence}`
- `category_ladders` → genre names (deduped, capped at 5)
## Gotchas
- **Catalog API cannot filter preorders or surface curated bestsellers.** The API's `BestSellers` sort is a right-now velocity rank that spikes on launch-day promos and preorder windows; the `-ReleaseDate` sort returns 100% future preorders. There is no server-side `release_time`, `released-only`, `customer_rights`, or alternate sort (`Reviewed`, `MostListened`, etc.) — every plausible variant was tested and silently ignored. This is why the nightly refresh job uses the curated HTML storefront pages instead.
- **`author=` takes a name, not an ASIN.** The catalog API has no ASIN-based author param. `searchByAuthorAsin()` queries by name, then filters client-side: keeps only products where `products[].authors[].asin === authorAsin`. Preserves ASIN-authoritative author identity. Also filters by `product.language` via `isAcceptedLanguage()` for the configured region.
- **Invalid ASIN returns HTTP 200 with stub body.** `/1.0/catalog/products/{asin}` responds 200 with `{product: {asin: INPUT}}` and no other fields. `fetchAudibleDetailsFromApi()` detects this via missing `product.title` and returns `null`.
- **`publisher_summary` is HTML.** Service strips tags via inline `stripHtml()` helper (regex-based, no cheerio) before populating `description`. Falls back to `merchandising_summary` (plain text) if `publisher_summary` missing.
- **Series is an array.** `products[].series[]` — a book may belong to multiple series. Service picks the first entry with non-empty `sequence`, else the first entry. `sequence` is cleaned by extracting first `/\d+(?:\.\d+)?/` match for numeric ordering.
- **Stub `product_images`:** cover URL reads from `product_images['500']`; missing keys fall back to `undefined`.
- **`page` is 0-indexed (catalog API only).** Despite the default value appearing to be 1, the API returns items `(page * num_results)` through `((page + 1) * num_results - 1)`. So `page=1` fetches items 51100, not 150. All catalog-API service methods accept a 1-indexed `page` and subtract 1 at the axios call. The symptom of getting this wrong is silent: queries whose `total_results ≤ num_results` return an empty `products` array while `total_results` is populated (e.g. author searches for small catalogues). HTML paths use Audible's native 1-indexed `page` query param and omit it on the first page.
## Rate Limiting & Resilience
- **Real-time JSON API paths:** 503s are uncommon. `fetchWithRetry()` uses jittered exponential backoff, 5 retries, retries on 503/429/5xx. API responses include `Cache-Control: private, max-age=1800`.
- **Nightly HTML refresh paths:** 503s are more likely (HTML storefront is more rate-sensitive). Same `fetchWithRetry()`, but with `HTML_MAX_RETRIES=12` and `HTML_MAX_BACKOFF_MS=180_000` (3-minute cap on jittered backoff). Healthy refreshes still complete fast (per-page success on attempt 0); users hit by sustained 503 storms grind through patiently rather than abandoning the refresh.
- **`AdaptivePacer`** — inter-page delay 24 s baseline, scales up multiplicatively under retry pressure, with a 4560 s circuit-breaker cooldown after 3 consecutive retry-pages.
- **Per-batch cooldowns** in `audible-refresh.processor.ts` — 1530 s between popular/new-releases, 1020 s between categories.
## Region Configuration
**Status:** Implemented
**Status:** Implemented
Configurable Audible region for accurate metadata matching across different international Audible stores.
Configurable Audible region for accurate metadata matching across international stores.
**Supported Regions:**
- United States (`us`) - `audible.com` (default, English)
- Canada (`ca`) - `audible.ca` (English)
- United Kingdom (`uk`) - `audible.co.uk` (English)
- Australia (`au`) - `audible.com.au` (English)
- India (`in`) - `audible.in` (English)
- Germany (`de`) - `audible.de` (non-English)
- Spain (`es`) - `audible.es` (non-English)
- French (`fr`) - `audible.fr` (non-English)
**`isEnglish` Flag:**
- Each region has `isEnglish: boolean` in `AudibleRegionConfig`
- Non-English regions (`isEnglish: false`) display an amber warning in all region dropdowns (setup wizard + admin settings)
- Warning text: "Many features such as search, discovery, and metadata matching are not yet fully supported for non-English regions."
- Dropdown options for non-English regions show `*` suffix (e.g., "Germany *")
| Code | Name | HTML baseUrl | apiBaseUrl | isEnglish |
|---|---|---|---|---|
| `us` | United States | `https://www.audible.com` | `https://api.audible.com` | true (default) |
| `ca` | Canada | `https://www.audible.ca` | `https://api.audible.ca` | true |
| `uk` | United Kingdom | `https://www.audible.co.uk` | `https://api.audible.co.uk` | true |
| `au` | Australia | `https://www.audible.com.au` | `https://api.audible.com.au` | true |
| `in` | India | `https://www.audible.in` | `https://api.audible.in` | true |
| `de` | Germany | `https://www.audible.de` | `https://api.audible.de` | false |
| `es` | Spain | `https://www.audible.es` | `https://api.audible.es` | false |
| `fr` | France | `https://www.audible.fr` | `https://api.audible.fr` | false |
**Why Regions Matter:**
- Each Audible region uses different ASINs for the same audiobook
- Metadata engines (Audnexus/Audible Agent) in Plex/Audiobookshelf must match RMAB's region
- Mismatched regions cause poor search results and failed metadata matching
**`AudibleRegionConfig` fields:** `code`, `name`, `baseUrl`, `apiBaseUrl`, `audnexusParam`, `language`.
**`isEnglish` flag:**
- Non-English regions show amber warning in region dropdowns (setup wizard + admin settings): "Many features such as search, discovery, and metadata matching are not yet fully supported for non-English regions."
- Dropdown options for non-English regions show `*` suffix.
**Why regions matter:**
- Each Audible region uses different ASINs for the same audiobook.
- Metadata engines (Audnexus / Audible Agent) in Plex / Audiobookshelf must match RMAB's region.
**Configuration:**
- Key: `audible.region` (stored in database)
- Default: `us`
- Set during: Setup wizard (Backend Selection step) or Admin Settings (Library tab)
- Help text instructs users to match their metadata engine region
- Auto-detection: Service checks config before each request and re-initializes if region changed.
- Cache clearing: Region change clears ConfigService cache and AudibleService state.
- Automatic refresh: Region change triggers `audible_refresh` job.
**Implementation:**
- `AudibleService` loads region from config on initialization
- Dynamically builds base URL: `AUDIBLE_REGIONS[region].baseUrl`
- Audnexus API calls include region parameter: `?region={code}`
- IP redirect prevention: `?ipRedirectOverride=true` on all Audible requests (region only)
- **Locale enforcement:** `?language=english` query parameter on all Audible requests (forces English content regardless of server IP geolocation)
- Configuration service helper: `getAudibleRegion()` returns configured region
- **Auto-detection of region changes**: Service checks config before each request and re-initializes if region changed
- **Cache clearing**: When region changes, ConfigService cache and AudibleService initialization are cleared
- **Automatic refresh**: Changing region automatically triggers `audible_refresh` job to fetch new data
**Per-region HTTP clients (on init):**
- `apiClient``baseURL=apiBaseUrl`, `Accept: application/json`, `User-Agent: ReadMeABook/1.0`, no language/ipRedirect params. Used for the real-time JSON catalog operations (search, author books, categories listing, per-ASIN details fallback).
- `htmlClient``baseURL=baseUrl`, rotating browser headers (`pickUserAgent` + `getBrowserHeaders`), default params `ipRedirectOverride=true` + `language=<audibleLocaleParam>`. Used by the nightly discovery refresh (`/adblbestsellers`, `/newreleases`, `/search?node=...`), by `audible-series.ts`, and by `getBaseUrl()`-based link generation.
- Audnexus calls include `region=<audnexusParam>`.
**Files:**
- Types: `src/lib/types/audible.ts`
- Service: `src/lib/integrations/audible.service.ts`
- Series (HTML): `src/lib/integrations/audible-series.ts`
- Config: `src/lib/services/config.service.ts`
- API: `src/app/api/admin/settings/audible/route.ts`
## Discovery Strategy (Popular/New/Search)
- Parse Audible HTML with Cheerio
- Multi-page scraping (20 items/page)
- Rate limit: max 10 req/min, 1.5s delay between pages
- Cache results in database (24hr TTL)
## Data Sources
URLs dynamically built based on configured region:
1. **Best Sellers:** `{baseUrl}/adblbestsellers`
2. **New Releases:** `{baseUrl}/newreleases`
3. **Search:** `{baseUrl}/search?keywords={query}&ipRedirectOverride=true`
4. **Detail Page:** `{baseUrl}/pd/{asin}?ipRedirectOverride=true`
5. **Audnexus API:** `https://api.audnex.us/books/{asin}?region={code}`
Where `{baseUrl}` is determined by configured region (e.g., `https://www.audible.co.uk` for UK).
## Metadata Extracted
- ASIN (Audible ID)
- Title, author, narrator
- Duration (minutes), release date, rating
- Description, cover art URL
- Genres/categories
## Unified Matching (`audiobook-matcher.ts`)
**Status:** Production Ready (ASIN-Only Matching)
**Status:** Production Ready (ASIN-Only Matching)
Single matching algorithm used everywhere (search, popular, new-releases, jobs).
@@ -112,50 +139,80 @@ Single matching algorithm used everywhere (search, popular, new-releases, jobs).
- `findPlexMatch()`: ASIN (field) → ASIN (GUID) → null
- `matchAudiobook()`: ASIN → ISBN → null
**Benefits:**
- Real-time matching at query time (not pre-matched)
- 100% confidence matches only (eliminates false positives)
- O(1) indexed lookups (faster than fuzzy matching)
- Solves race condition with Audiobookshelf ASIN population
- Used by all APIs for consistency
**Note:** Fuzzy matching (70% threshold) is preserved in `ranking-algorithm.ts` for Prowlarr torrent ranking. Library availability checks require exact ASIN matches only.
**Note:** Fuzzy matching (70% threshold) is preserved in `ranking-algorithm.ts` for Prowlarr torrent ranking, where it's needed to score multiple release candidates. Library availability checks require exact ASIN matches only.
## Dedup & Works Table
**Status:** ✅ Implemented | Two-pass dedup on every discovery view + cross-batch identity via works table
Discovery views (search, author books, series detail) collapse duplicate Audible listings for the same recording (publisher re-listings, regional re-issues, full-cast vs single-narrator productions) into a single card. Two passes run in sequence:
1. **Local pass — `deduplicateAndCollectGroups()`** (`src/lib/utils/deduplicate-audiobooks.ts`)
- Stateless, in-memory. Keys books by normalized title + sorted narrator set + duration (±max(5%, 10 min) tolerance), with subtitle compatibility to keep distinct series entries separate.
- Picks a canonical representative per group by `metadataScore()` (cover + rating + duration + description + narrator + release date + genres).
- Emits `DedupGroup[]` describing every multi-ASIN collapse → handed to `persistDedupGroups()` for the works table.
2. **Works pass — `collapseByExistingWorks()`** (`src/lib/services/works.service.ts`)
- Async DB lookup. Reads `work_asins` for every ASIN in the local-passed list and collapses any books sharing a `workId` to one representative (same `metadataScore()` ranking).
- Catches duplicates the local pass misses: source-metadata divergence (e.g. HTML scraper captured different narrators), cross-page splits (paginated series), or non-matching field shapes.
- Degrades gracefully — returns the input unchanged on DB failure (view still renders).
### Works Table Schema
- `Work { id, title, author }` — one row per logical book
- `WorkAsin { id, workId, asin, narrator?, durationMinutes?, isCanonical, source, createdAt }` — many ASINs per Work
### Population Layers
- **Layer 1 (auto):** `persistDedupGroups()` writes whenever the local pass finds a duplicate. Merges across pre-existing works when a new group spans them.
- **Layer 2 (seed):** `seedAsin()` writes a single-ASIN work at request creation time, ensuring every requested ASIN has an entry to grow from.
### Read Paths
- **`collapseByExistingWorks()`** — view-level collapse (this section).
- **`getSiblingAsins()`** — library availability matching (`audiobook-matcher.ts`), request-creation duplicate prevention (`request-creator.service.ts`), ignored-audiobook expansion. Returns sibling ASINs grouped by input ASIN.
### Narrator Capture in HTML Scrapers
- HTML scrapers (`audible-series.ts`, the two `parse*Items` parsers in `audible.service.ts`) capture **all** narrator anchors via `extractAllNarrators()` (`src/lib/utils/extract-narrator.ts`). Multi-narrator productions render each name as its own `<a href="?searchNarrator=...">` link; capturing only the first (prior bug) made co-narrated audiobooks fail to dedup. Order is not significant — `normalizeNarrator()` sorts before comparison.
### Wired Routes
- `src/app/api/audiobooks/search/route.ts`
- `src/app/api/authors/[asin]/books/route.ts`
- `src/app/api/series/[asin]/route.ts`
Watched-list background jobs (`watched-lists.service.ts`) run the local pass only — they don't render a view, and the downstream `request-creator.service.ts` already does sibling-aware dedup at request creation time.
## Database-First Approach
**Status:** Implemented
**Status:** Implemented
Discovery APIs serve cached data from DB with real-time matching.
**Flow:**
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 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 `AudibleCacheCategory` by categoryId → join with `AudibleCache` metadata → apply real-time matching → return enriched results
6. Homepage loads instantly (no Audible API hits)
1. `audible_refresh` cron runs daily → fetches 200 popular + 200 new releases + user-configured categories by scraping Audible's curated HTML storefronts (`/adblbestsellers`, `/newreleases`, `/search?node=<id>&sort=popularity-rank`).
2. Downloads and caches cover thumbnails locally.
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 `AudibleCacheCategory` by categoryId → join with `AudibleCache` metadata → apply real-time matching → return enriched results.
6. Homepage loads instantly (no Audible HTTP hits at request time).
## Thumbnail Caching
**Status:** Implemented
**Status:** Implemented
Cover images cached locally to reduce external requests and improve performance.
Cover images cached locally to reduce external requests.
**Features:**
- Downloads covers during `audible_refresh` job
- Stores in `/app/cache/thumbnails` (Docker volume)
- Serves via `/api/cache/thumbnails/[filename]`
- Auto-cleanup of unused thumbnails
- Falls back to original URL if cache fails
- 24-hour browser cache headers
- Downloads covers during `audible_refresh` job.
- Stores in `/app/cache/thumbnails` (Docker volume).
- Serves via `/api/cache/thumbnails/[filename]`.
- Auto-cleanup of unused thumbnails.
- Falls back to original URL if cache fails.
- 24-hour browser cache headers.
- Filename: `{asin}.{ext}` (e.g. `B08G9PRS1K.jpg`).
**Implementation:**
**Files:**
- Service: `src/lib/services/thumbnail-cache.service.ts`
- API Route: `src/app/api/cache/thumbnails/[filename]/route.ts`
- Storage: Docker volume `cache` mounted at `/app/cache`
- Filename: `{asin}.{ext}` (e.g., `B08G9PRS1K.jpg`)
**API Endpoints:**
## App-Level API Endpoints
**GET /api/audiobooks/popular?page=1&limit=20**
**GET /api/audiobooks/new-releases?page=1&limit=20**
@@ -182,6 +239,7 @@ interface AudibleAudiobook {
asin: string;
title: string;
author: string;
authorAsin?: string;
narrator?: string;
description?: string;
coverArtUrl?: string;
@@ -189,6 +247,12 @@ interface AudibleAudiobook {
releaseDate?: string;
rating?: number;
genres?: string[];
series?: string;
seriesPart?: string;
seriesAsin?: string;
language?: string;
formatType?: string;
publisherName?: string;
}
interface EnrichedAudibleAudiobook extends AudibleAudiobook {
@@ -197,48 +261,58 @@ interface EnrichedAudibleAudiobook extends AudibleAudiobook {
plexGuid: string | null;
dbId: string;
}
interface AudibleSearchResult {
query: string;
results: AudibleAudiobook[];
totalResults: number;
page: number;
hasMore: boolean;
}
interface AuthorBooksResult {
books: AudibleAudiobook[];
hasMore: boolean;
page: number;
totalResults: number;
}
```
## Tech Stack
- axios (HTTP)
- cheerio (HTML parsing)
- Redis (caching, optional)
- Database (PostgreSQL)
- string-similarity (matching)
- `axios` (HTTP, two clients: `apiClient` for JSON catalog API, `htmlClient` for HTML refresh + series scraping)
- `cheerio` (HTML parsing for refresh job and `audible-series.ts`)
- Audnexus API (per-ASIN details, primary)
- PostgreSQL (`audible_cache`, `audible_cache_categories`)
## Fixed Issues
**Search returning empty results (2026-01-07)**
- **Problem:** Audible changed HTML structure for search results from `.productListItem` to `.s-result-item`
- **Impact:** All search queries returned 0 results
- **Fix:** Updated `search()` method to support both `.s-result-item` (current) and `.productListItem` (legacy)
- **Selectors updated:**
- Main: `.s-result-item, .productListItem`
- Title: `h2` (new) or `h3 a` (legacy)
- Author: `a[href*="/author/"]` (new) or `.authorLabel` (legacy)
- Narrator: `a[href*="searchNarrator="]` (new) or `.narratorLabel` (legacy)
- Runtime: `span:contains("Length:")` (new) or `.runtimeLabel` (legacy)
- Rating: `.a-icon-star span` (new) or `.ratingsLabel` (legacy)
- **Location:** `src/lib/integrations/audible.service.ts:235`
**Series-page duplicates not collapsing across user views (2026-05-14)**
- **Problem:** Two re-listings of the same audiobook (same title, same narrator set, same duration, different ASINs) showed as two cards on series detail pages, even after the works table had already linked them via search-page dedup.
- **Root cause (two-part):** (1) HTML scrapers used `$el.find('a[href*="searchNarrator="]').first()` for multi-narrator productions, capturing only the first co-narrator. So two listings of the same recording landed in `deduplicateAndCollectGroups` with mismatched single-narrator strings and never merged. (2) `deduplicateAndCollectGroups` was stateless — it wrote to the works table but never read it back, so even when one path (e.g. search) successfully merged two ASINs and persisted the Work, every other path (series, author books) re-derived the dedup decision from scratch and split them again.
- **Fix:** (1) New `extractAllNarrators()` helper (`src/lib/utils/extract-narrator.ts`) captures every `searchNarrator=` anchor and joins them; all three HTML scrapers route through it. (2) New `collapseByExistingWorks()` consults the works table after the local pass and collapses any remaining books sharing a `workId`. Wired into the three user-facing discovery routes (search / author books / series detail). Skipped for watched-list background jobs — those feed `request-creator.service.ts` which already does sibling-aware dedup.
- **Location:** `src/lib/utils/extract-narrator.ts` (new); `src/lib/integrations/audible-series.ts` (parseSeriesBooks); `src/lib/integrations/audible.service.ts` (parseProductListItems + parseSearchResultItems); `src/lib/utils/deduplicate-audiobooks.ts` (`metadataScore` exported); `src/lib/services/works.service.ts` (`collapseByExistingWorks` added); three API routes updated.
**Some audiobooks missing from search results (2026-01-07)**
- **Problem:** ASIN extraction only matched `/pd/` URLs but some audiobooks use `/ac/` URLs
- **Impact:** Books like "Beatitude" by DJ Krimmer (ASIN: B0DVH7XL36) were skipped
- **Fix:** Updated ASIN regex to match both `/pd/` and `/ac/` URL patterns: `/\/(?:pd|ac)\/[^\/]+\/([A-Z0-9]{10})/`
- **Location:** `src/lib/integrations/audible.service.ts:75, 161, 240`
- **Affects:** `getPopularAudiobooks()`, `getNewReleases()`, `search()` methods
**Discovery refresh reverted to curated HTML scraping (2026-05-14)**
- **Problem:** After switching all catalog ops to the JSON catalog API in `f564d0a`, the nightly discovery refresh (Popular / New Releases / user-configured Categories) started serving junk: New Releases became 100% preorders out to 2027, and Popular was dominated by launch-day no-name shovelware.
- **Root cause:** `products_sort_by=BestSellers` is a right-now sales velocity rank that spikes on launch promos and preorder windows; `-ReleaseDate` returns all catalog items in date order with no released-only filter. The catalog API exposes no server-side filter to exclude preorders or sort by established popularity (verified by exhaustively testing `release_time`, `availability_status`, `customer_rights`, `Reviewed`/`MostListened`/`SalesRank` sorts — all silently ignored or rejected). Doing the curation client-side would have made RMAB the editorial curator, which Audible's storefront pages already do well.
- **Fix:** Hybrid architecture — the three refresh-only methods (`getPopularAudiobooks`, `getNewReleases`, `getCategoryBooks`) went back to scraping Audible's curated HTML storefronts (`/adblbestsellers`, `/newreleases`, `/search?node=<id>&sort=popularity-rank`). All user-facing real-time paths (search, author books, categories listing, per-ASIN details) stayed on the JSON catalog API. To keep the higher-503-risk HTML traffic resilient on the unattended nightly job, `fetchWithRetry()` accepts an optional `maxBackoffMs` cap and HTML callers use `HTML_MAX_RETRIES=12` + `HTML_MAX_BACKOFF_MS=180_000` (3-min cap). Healthy users finish quickly; 503-blocked users grind through patiently.
- **Location:** `src/lib/integrations/audible.service.ts` (three methods + two private parsers `parseProductListItems` / `parseSearchResultItems`); `src/lib/utils/scrape-resilience.ts` (`jitteredBackoff` cap parameter).
**Audiobookshelf metadata matching not respecting configured region (2026-01-28)**
- **Problem:** `triggerABSItemMatch()` hardcoded `'audible'` provider (audible.com) instead of respecting user's configured Audible region
- **Impact:** Users with non-US regions (CA, UK, AU, IN) had incorrect metadata matching in Audiobookshelf, causing wrong ASINs and poor search results
- **Fix:** Added `mapRegionToABSProvider()` to convert RMAB region codes to AudiobookShelf provider values. US → `'audible'`, others → `'audible.{region}'` (e.g., `'audible.ca'`, `'audible.uk'`)
- **Problem:** `triggerABSItemMatch()` hardcoded `'audible'` provider (audible.com) instead of respecting user's configured Audible region.
- **Impact:** Users with non-US regions (CA, UK, AU, IN) had incorrect metadata matching in Audiobookshelf, causing wrong ASINs.
- **Fix:** Added `mapRegionToABSProvider()` to convert RMAB region codes to Audiobookshelf provider values. US → `'audible'`, others → `'audible.{region}'` (e.g. `'audible.ca'`, `'audible.uk'`).
- **Location:** `src/lib/services/audiobookshelf/api.ts:14, 147`
- **Affects:** All Audiobookshelf metadata matching operations
**Non-English locale pages served to users outside US (2026-02-05)**
- **Problem:** Audible uses IP geolocation to serve locale-specific pages (e.g., Spanish content for Dominican Republic IPs). `ipRedirectOverride=true` only prevents region redirects (audible.com → audible.co.uk), NOT language/locale changes.
- **Impact:** Users self-hosting from non-English-speaking countries got non-English bestsellers/new releases on their homepage.
- **Fix:** Added `language=english` query parameter to all Audible requests via axios default params. Audible respects this parameter and serves English content regardless of IP geolocation. Fails gracefully for regions where English isn't available.
- **Location:** `src/lib/integrations/audible.service.ts``initialize()` (axios default params)
- **Affects:** All Audible scraping: popular, new releases, search, detail pages
- **Problem:** Audible uses IP geolocation to serve locale-specific pages. `ipRedirectOverride=true` only prevents region redirects, NOT language/locale changes.
- **Impact:** Users self-hosting from non-English-speaking countries got non-English content on HTML-scraped surfaces.
- **Fix:** Added `language=<audibleLocaleParam>` default param on `htmlClient` (axios default params). Still in effect for the remaining HTML path (`audible-series.ts`). **Not applied to `apiClient`** — the catalog JSON API is region-bound via `apiBaseUrl` and does not require the language param.
- **Location:** `src/lib/integrations/audible.service.ts``initialize()` (htmlClient params)
## Related
- [Audiobookshelf Integration](./audiobookshelf.md)
- [Plex Integration](./plex.md)
- [Ranking Algorithm](../phase3/ranking-algorithm.md)
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "readmeabook",
"version": "1.1.7",
"version": "1.1.8",
"private": true,
"scripts": {
"dev": "next dev",
+118 -44
View File
@@ -14,8 +14,10 @@ import { RecentRequestsTable } from './components/RecentRequestsTable';
import { ToastProvider, useToast } from '@/components/ui/Toast';
import { ReportedIssuesSection } from './components/ReportedIssuesSection';
import { InteractiveTorrentSearchModal } from '@/components/requests/InteractiveTorrentSearchModal';
import { AudiobookDetailsModal } from '@/components/audiobooks/AudiobookDetailsModal';
import { BulkImportWizard } from '@/components/admin/BulkImportWizard';
import { TorrentResult } from '@/lib/utils/ranking-algorithm';
import { InformationCircleIcon } from '@heroicons/react/24/outline';
import { formatDistanceToNow } from 'date-fns';
import { useState } from 'react';
@@ -56,15 +58,78 @@ function formatTorrentSize(bytes: number): string {
return gb >= 1 ? `${gb.toFixed(1)} GB` : `${mb.toFixed(0)} MB`;
}
function LoadingSpinner() {
return (
<svg className="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
);
}
interface ApprovalActionButtonsProps {
isLoading: boolean;
onApprove: () => void;
onSearch: () => void;
onDeny: () => void;
}
function ApprovalActionButtons({ isLoading, onApprove, onSearch, onDeny }: ApprovalActionButtonsProps) {
return (
<>
<button
onClick={onApprove}
disabled={isLoading}
className="flex-1 inline-flex items-center justify-center gap-1.5 px-3 py-2 bg-green-600 hover:bg-green-700 disabled:bg-green-400 disabled:cursor-not-allowed text-white text-sm font-medium rounded-lg transition-colors"
>
{isLoading ? <LoadingSpinner /> : (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
)}
<span>Approve</span>
</button>
<button
onClick={onSearch}
disabled={isLoading}
className="flex-1 inline-flex items-center justify-center gap-1.5 px-3 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 disabled:cursor-not-allowed text-white text-sm font-medium rounded-lg transition-colors"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<span>Search</span>
</button>
<button
onClick={onDeny}
disabled={isLoading}
className="flex-1 inline-flex items-center justify-center gap-1.5 px-3 py-2 bg-red-600 hover:bg-red-700 disabled:bg-red-400 disabled:cursor-not-allowed text-white text-sm font-medium rounded-lg transition-colors"
>
{isLoading ? <LoadingSpinner /> : (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
)}
<span>Deny</span>
</button>
</>
);
}
function PendingApprovalSection({ requests }: { requests: PendingApprovalRequest[] }) {
const toast = useToast();
const [loadingStates, setLoadingStates] = useState<Record<string, boolean>>({});
const [searchModalRequestId, setSearchModalRequestId] = useState<string | null>(null);
const [detailsAsin, setDetailsAsin] = useState<string | null>(null);
const [detailsRequestId, setDetailsRequestId] = useState<string | null>(null);
const searchModalRequest = searchModalRequestId
? requests.find((r) => r.id === searchModalRequestId)
: null;
const detailsRequest = detailsRequestId
? requests.find((r) => r.id === detailsRequestId)
: null;
const handleApproveRequest = async (requestId: string) => {
setLoadingStates((prev) => ({ ...prev, [requestId]: true }));
@@ -125,13 +190,6 @@ function PendingApprovalSection({ requests }: { requests: PendingApprovalRequest
await mutate('/api/admin/metrics');
};
const LoadingSpinner = () => (
<svg className="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
);
return (
<div className="mb-8">
{/* Section Header */}
@@ -170,8 +228,23 @@ function PendingApprovalSection({ requests }: { requests: PendingApprovalRequest
return (
<div
key={request.id}
className="bg-white dark:bg-gray-800 border-2 border-amber-200 dark:border-amber-800 rounded-lg shadow-sm hover:shadow-md transition-shadow overflow-hidden"
className="relative bg-white dark:bg-gray-800 border-2 border-amber-200 dark:border-amber-800 rounded-lg shadow-sm hover:shadow-md transition-shadow overflow-hidden"
>
{/* Info Button — opens AudiobookDetailsModal */}
{request.audiobook.audibleAsin && (
<button
onClick={() => {
setDetailsAsin(request.audiobook.audibleAsin);
setDetailsRequestId(request.id);
}}
className="absolute top-2 right-2 z-10 p-1 text-gray-400 hover:text-blue-500 dark:hover:text-blue-400 transition-colors rounded-full hover:bg-gray-100 dark:hover:bg-gray-700"
title="View book details"
aria-label="View book details"
>
<InformationCircleIcon className="w-5 h-5" />
</button>
)}
{/* Card Content */}
<div className="p-4">
<div className="flex gap-3">
@@ -314,42 +387,12 @@ function PendingApprovalSection({ requests }: { requests: PendingApprovalRequest
{/* Action Buttons */}
<div className="border-t border-amber-200 dark:border-amber-800 bg-gray-50 dark:bg-gray-900/50 px-4 py-3 flex gap-2">
<button
onClick={() => handleApproveRequest(request.id)}
disabled={isLoading}
className="flex-1 inline-flex items-center justify-center gap-1.5 px-3 py-2 bg-green-600 hover:bg-green-700 disabled:bg-green-400 disabled:cursor-not-allowed text-white text-sm font-medium rounded-lg transition-colors"
>
{isLoading ? <LoadingSpinner /> : (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
)}
<span>Approve</span>
</button>
<button
onClick={() => setSearchModalRequestId(request.id)}
disabled={isLoading}
className="flex-1 inline-flex items-center justify-center gap-1.5 px-3 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 disabled:cursor-not-allowed text-white text-sm font-medium rounded-lg transition-colors"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<span>Search</span>
</button>
<button
onClick={() => handleDenyRequest(request.id)}
disabled={isLoading}
className="flex-1 inline-flex items-center justify-center gap-1.5 px-3 py-2 bg-red-600 hover:bg-red-700 disabled:bg-red-400 disabled:cursor-not-allowed text-white text-sm font-medium rounded-lg transition-colors"
>
{isLoading ? <LoadingSpinner /> : (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
)}
<span>Deny</span>
</button>
<ApprovalActionButtons
isLoading={isLoading}
onApprove={() => handleApproveRequest(request.id)}
onSearch={() => setSearchModalRequestId(request.id)}
onDeny={() => handleDenyRequest(request.id)}
/>
</div>
</div>
);
@@ -375,6 +418,37 @@ function PendingApprovalSection({ requests }: { requests: PendingApprovalRequest
}}
/>
)}
{/* Book Details Modal — opened via info button on each approval card */}
{detailsAsin && detailsRequestId && (
<AudiobookDetailsModal
asin={detailsAsin}
isOpen={true}
onClose={() => { setDetailsAsin(null); setDetailsRequestId(null); }}
requestStatus="awaiting_approval"
requestedByUsername={detailsRequest?.user.plexUsername ?? null}
adminActions={
<ApprovalActionButtons
isLoading={loadingStates[detailsRequestId] || false}
onApprove={async () => {
await handleApproveRequest(detailsRequestId);
setDetailsAsin(null);
setDetailsRequestId(null);
}}
onSearch={() => {
setSearchModalRequestId(detailsRequestId);
setDetailsAsin(null);
setDetailsRequestId(null);
}}
onDeny={async () => {
await handleDenyRequest(detailsRequestId);
setDetailsAsin(null);
setDetailsRequestId(null);
}}
/>
}
/>
)}
</div>
);
}
+1 -1
View File
@@ -164,7 +164,7 @@ export async function POST(request: NextRequest) {
// keyword text search. Fall back to text search if the lookup fails.
if (book.extractedAsin) {
try {
const asinResult = await audibleService.lookupAsinFast(book.extractedAsin);
const asinResult = await audibleService.getAudiobookDetails(book.extractedAsin);
if (asinResult) {
match = asinResult;
}
+7 -4
View File
@@ -7,7 +7,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { getAudibleService } from '@/lib/integrations/audible.service';
import { enrichAudiobooksWithMatches } from '@/lib/utils/audiobook-matcher';
import { deduplicateAndCollectGroups } from '@/lib/utils/deduplicate-audiobooks';
import { persistDedupGroups } from '@/lib/services/works.service';
import { persistDedupGroups, collapseByExistingWorks } from '@/lib/services/works.service';
import { getCurrentUser } from '@/lib/middleware/auth';
import { RMABLogger } from '@/lib/utils/logger';
import { annotateWithIgnoreStatus } from '@/lib/utils/ignored-audiobooks';
@@ -41,16 +41,19 @@ export async function GET(request: NextRequest) {
const currentUser = getCurrentUser(request);
const userId = currentUser?.sub || undefined;
// Deduplicate before enrichment to avoid wasted DB queries on duplicate entries
// Two-pass dedup: local title/narrator/duration matching first, then collapse
// any remaining duplicates that the works table already knows are the same book
// (handles cases where source metadata diverges across paths or pages).
const { books: dedupedResults, groups } = deduplicateAndCollectGroups(results.results);
// Fire-and-forget: persist dedup groups to works table for cross-ASIN matching
if (groups.length > 0) {
persistDedupGroups(groups).catch(() => {});
}
const collapsedResults = await collapseByExistingWorks(dedupedResults);
// Enrich search results with availability and request status information
const enrichedResults = await enrichAudiobooksWithMatches(dedupedResults, userId);
const enrichedResults = await enrichAudiobooksWithMatches(collapsedResults, userId);
// Annotate with per-user ignore status
const annotatedResults = await annotateWithIgnoreStatus(enrichedResults, userId);
+7 -4
View File
@@ -7,7 +7,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { getAudibleService } from '@/lib/integrations/audible.service';
import { enrichAudiobooksWithMatches } from '@/lib/utils/audiobook-matcher';
import { deduplicateAndCollectGroups } from '@/lib/utils/deduplicate-audiobooks';
import { persistDedupGroups } from '@/lib/services/works.service';
import { persistDedupGroups, collapseByExistingWorks } from '@/lib/services/works.service';
import { getCurrentUser } from '@/lib/middleware/auth';
import { RMABLogger } from '@/lib/utils/logger';
import { annotateWithIgnoreStatus } from '@/lib/utils/ignored-audiobooks';
@@ -56,17 +56,20 @@ export async function GET(
const audibleService = getAudibleService();
const result = await audibleService.searchByAuthorAsin(authorName.trim(), asin, page);
// Deduplicate before enrichment to avoid wasted DB queries on duplicate entries
// Two-pass dedup: local title/narrator/duration matching first, then collapse
// any remaining duplicates that the works table already knows are the same book
// (handles cases where source metadata diverges across paths or pages).
const { books: dedupedBooks, groups } = deduplicateAndCollectGroups(result.books);
// Fire-and-forget: persist dedup groups to works table for cross-ASIN matching
if (groups.length > 0) {
persistDedupGroups(groups).catch(() => {});
}
const collapsedBooks = await collapseByExistingWorks(dedupedBooks);
// Enrich with library availability and request status
const userId = currentUser.sub || undefined;
const enrichedBooks = await enrichAudiobooksWithMatches(dedupedBooks, userId);
const enrichedBooks = await enrichAudiobooksWithMatches(collapsedBooks, userId);
// Annotate with per-user ignore status
const annotatedBooks = await annotateWithIgnoreStatus(enrichedBooks, userId);
+7 -4
View File
@@ -9,7 +9,7 @@ import { RMABLogger } from '@/lib/utils/logger';
import { scrapeSeriesPage } from '@/lib/integrations/audible-series';
import { enrichAudiobooksWithMatches } from '@/lib/utils/audiobook-matcher';
import { deduplicateAndCollectGroups } from '@/lib/utils/deduplicate-audiobooks';
import { persistDedupGroups } from '@/lib/services/works.service';
import { persistDedupGroups, collapseByExistingWorks } from '@/lib/services/works.service';
import { annotateWithIgnoreStatus } from '@/lib/utils/ignored-audiobooks';
const logger = RMABLogger.create('API.Series.Detail');
@@ -52,17 +52,20 @@ export async function GET(
);
}
// Deduplicate before enrichment to avoid wasted DB queries on duplicate entries
// Two-pass dedup: local title/narrator/duration matching first, then collapse
// any remaining duplicates that the works table already knows are the same book
// (handles cases where source metadata diverges across paths or pages).
const { books: dedupedBooks, groups } = deduplicateAndCollectGroups(detail.books);
// Fire-and-forget: persist dedup groups to works table for cross-ASIN matching
if (groups.length > 0) {
persistDedupGroups(groups).catch(() => {});
}
const collapsedBooks = await collapseByExistingWorks(dedupedBooks);
// Enrich books with library availability and request status
const userId = currentUser.sub || undefined;
const enrichedBooks = await enrichAudiobooksWithMatches(dedupedBooks, userId);
const enrichedBooks = await enrichAudiobooksWithMatches(collapsedBooks, userId);
// Annotate with per-user ignore status
const annotatedBooks = await annotateWithIgnoreStatus(enrichedBooks, userId);
+932
View File
@@ -0,0 +1,932 @@
/**
* Component: Path Mapping Helper
* Documentation: documentation/deployment/volume-mapping.md
*
* Public, unprotected page that guides users through configuring
* Docker volume mappings for their download clients and RMAB.
* Purely client-side — no API calls, no real data access.
*/
'use client';
import { useState, useMemo } from 'react';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import {
CLIENT_DISPLAY_NAMES,
CLIENT_PROTOCOL_MAP,
type DownloadClientType,
} from '@/lib/interfaces/download-client.interface';
// =========================================================================
// TYPES
// =========================================================================
interface ClientConfig {
type: DownloadClientType;
/** The path inside the download client container where completed downloads land */
savePath: string;
/** The volume mapping from the client's docker-compose (host:container) — host side */
hostPath: string;
/** The volume mapping from the client's docker-compose (host:container) — container side */
containerMountPath: string;
/** Whether this client needs remote path mapping */
remotePathMapping: boolean;
/** The path as seen by the remote download client (for remote path mapping) */
remotePath: string;
}
type Step = 'clients' | 'save-paths' | 'host-paths' | 'results';
const STEPS: { key: Step; title: string }[] = [
{ key: 'clients', title: 'Clients' },
{ key: 'save-paths', title: 'Save Paths' },
{ key: 'host-paths', title: 'Volume Mapping' },
{ key: 'results', title: 'Results' },
];
const ALL_CLIENTS: DownloadClientType[] = ['qbittorrent', 'transmission', 'deluge', 'sabnzbd', 'nzbget'];
const DEFAULT_SAVE_PATHS: Record<DownloadClientType, string> = {
qbittorrent: '/downloads',
transmission: '/downloads/complete',
deluge: '/downloads',
sabnzbd: '/downloads/complete',
nzbget: '/downloads/completed',
};
// =========================================================================
// UTILITY FUNCTIONS
// =========================================================================
/**
* Find the longest common path prefix across multiple paths.
* Only meaningful when there are multiple DIFFERENT paths.
*/
function findCommonRoot(paths: string[]): string {
if (paths.length === 0) return '';
if (paths.length === 1) return paths[0];
const unique = [...new Set(paths)];
if (unique.length === 1) return unique[0];
// Split each path into segments
const segmentArrays = unique.map((p) => p.replace(/\/+$/, '').split('/').filter(Boolean));
const minLength = Math.min(...segmentArrays.map((s) => s.length));
const commonSegments: string[] = [];
for (let i = 0; i < minLength; i++) {
const segment = segmentArrays[0][i];
if (segmentArrays.every((s) => s[i] === segment)) {
commonSegments.push(segment);
} else {
break;
}
}
if (commonSegments.length === 0) return '/';
return '/' + commonSegments.join('/');
}
/**
* Get the relative path from a root to a full path.
* Returns empty string if they're the same.
*/
function getRelativePath(root: string, fullPath: string): string {
const normalizedRoot = root.replace(/\/+$/, '');
const normalizedFull = fullPath.replace(/\/+$/, '');
if (normalizedRoot === normalizedFull) return '';
if (normalizedFull.startsWith(normalizedRoot + '/')) {
return normalizedFull.slice(normalizedRoot.length + 1);
}
// Shouldn't happen if common root is correct, but fallback
return normalizedFull;
}
/**
* Find the common root of the host paths to build the RMAB volume mapping.
* Maps from the host path hierarchy to the container path hierarchy.
*/
function findHostCommonRoot(configs: ClientConfig[]): string {
const hostPaths = configs.map((c) => c.hostPath);
if (hostPaths.length === 0) return '';
if (hostPaths.length === 1) return hostPaths[0];
const unique = [...new Set(hostPaths)];
if (unique.length === 1) return unique[0];
return findCommonRoot(hostPaths);
}
// =========================================================================
// COMPONENTS
// =========================================================================
function StepIndicator({ currentStep }: { currentStep: Step }) {
const currentIndex = STEPS.findIndex((s) => s.key === currentStep);
return (
<div className="flex items-center justify-between py-4">
{STEPS.map((step, index) => (
<div key={step.key} className="flex items-center flex-1">
<div className="flex flex-col items-center flex-1">
<div
className={`
w-10 h-10 rounded-full flex items-center justify-center font-semibold text-sm
${
index < currentIndex
? 'bg-green-500 text-white'
: index === currentIndex
? 'bg-blue-600 text-white'
: 'bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-400'
}
`}
>
{index < currentIndex ? (
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
) : (
index + 1
)}
</div>
<span
className={`
text-xs mt-2 text-center whitespace-nowrap
${
index === currentIndex
? 'text-blue-600 dark:text-blue-400 font-medium'
: 'text-gray-600 dark:text-gray-400'
}
`}
>
{step.title}
</span>
</div>
{index < STEPS.length - 1 && (
<div
className={`
h-1 flex-1 mx-1 rounded
${index < currentIndex ? 'bg-green-500' : 'bg-gray-200 dark:bg-gray-700'}
`}
/>
)}
</div>
))}
</div>
);
}
function InfoBox({ children }: { children: React.ReactNode }) {
return (
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4 border border-blue-200 dark:border-blue-800">
<div className="flex gap-3">
<svg
className="w-6 h-6 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
clipRule="evenodd"
/>
</svg>
<div className="text-sm text-blue-800 dark:text-blue-200">{children}</div>
</div>
</div>
);
}
function WarningBox({ children }: { children: React.ReactNode }) {
return (
<div className="bg-amber-50 dark:bg-amber-900/20 rounded-lg p-4 border border-amber-200 dark:border-amber-800">
<div className="flex gap-3">
<svg
className="w-6 h-6 text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
clipRule="evenodd"
/>
</svg>
<div className="text-sm text-amber-800 dark:text-amber-200">{children}</div>
</div>
</div>
);
}
function CodeBlock({ children, label, onCopy }: { children: string; label?: string; onCopy?: () => void }) {
const [copied, setCopied] = useState(false);
const handleCopy = () => {
navigator.clipboard.writeText(children);
setCopied(true);
onCopy?.();
setTimeout(() => setCopied(false), 2000);
};
return (
<div className="relative">
{label && (
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">{label}</div>
)}
<div className="bg-gray-900 dark:bg-gray-950 rounded-lg p-4 font-mono text-sm text-gray-100 overflow-x-auto">
<pre className="whitespace-pre">{children}</pre>
</div>
<button
onClick={handleCopy}
className="absolute top-2 right-2 px-2 py-1 text-xs rounded bg-gray-700 hover:bg-gray-600 text-gray-300 transition-colors"
style={label ? { top: '1.75rem' } : undefined}
>
{copied ? 'Copied!' : 'Copy'}
</button>
</div>
);
}
// =========================================================================
// STEP COMPONENTS
// =========================================================================
function ClientSelectionStep({
selectedClients,
onToggle,
onNext,
}: {
selectedClients: Set<DownloadClientType>;
onToggle: (client: DownloadClientType) => void;
onNext: () => void;
}) {
return (
<div className="space-y-6">
<div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
Which download clients do you use?
</h2>
<p className="text-gray-600 dark:text-gray-400 mt-2">
Select all the download clients you have configured or plan to use with ReadMeABook.
</p>
</div>
<div className="space-y-3">
{ALL_CLIENTS.map((client) => {
const protocol = CLIENT_PROTOCOL_MAP[client];
const isSelected = selectedClients.has(client);
return (
<button
key={client}
onClick={() => onToggle(client)}
className={`
w-full flex items-center gap-4 p-4 rounded-lg border-2 transition-all text-left
${
isSelected
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
}
`}
>
<div
className={`
w-6 h-6 rounded border-2 flex items-center justify-center flex-shrink-0
${
isSelected
? 'border-blue-500 bg-blue-500'
: 'border-gray-300 dark:border-gray-600'
}
`}
>
{isSelected && (
<svg className="w-4 h-4 text-white" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
)}
</div>
<div className="flex-1">
<div className="font-medium text-gray-900 dark:text-gray-100">
{CLIENT_DISPLAY_NAMES[client]}
</div>
<div className="text-sm text-gray-500 dark:text-gray-400 capitalize">
{protocol} client
</div>
</div>
</button>
);
})}
</div>
<div className="flex justify-end pt-4">
<Button onClick={onNext} disabled={selectedClients.size === 0}>
Next
</Button>
</div>
</div>
);
}
function SavePathsStep({
configs,
onUpdateConfig,
onNext,
onBack,
}: {
configs: ClientConfig[];
onUpdateConfig: (type: DownloadClientType, field: keyof ClientConfig, value: string) => void;
onNext: () => void;
onBack: () => void;
}) {
const allFilled = configs.every((c) => c.savePath.trim() !== '');
return (
<div className="space-y-6">
<div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
Download client save paths
</h2>
<p className="text-gray-600 dark:text-gray-400 mt-2">
For each client, enter the path <strong>inside that client&apos;s container</strong> where
completed downloads are saved. This is the path you see in the client&apos;s own settings
(e.g., qBittorrent Web UI &rarr; Options &rarr; Downloads &rarr; Default Save Path).
</p>
</div>
<InfoBox>
<p>
<strong>This is the container path, not the host path.</strong> For example, if your
qBittorrent docker-compose has <code className="bg-blue-100 dark:bg-blue-800 px-1 rounded">-
/mnt/data/torrents:/downloads</code>, and qBittorrent is configured to save
to <code className="bg-blue-100 dark:bg-blue-800 px-1 rounded">/downloads</code>, then
enter <code className="bg-blue-100 dark:bg-blue-800 px-1 rounded">/downloads</code> here.
</p>
</InfoBox>
<div className="space-y-4">
{configs.map((config) => (
<div key={config.type} className="bg-gray-50 dark:bg-gray-800/50 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-2 mb-3">
<span className="font-medium text-gray-900 dark:text-gray-100">
{CLIENT_DISPLAY_NAMES[config.type]}
</span>
<span className="text-xs px-2 py-0.5 rounded-full bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-400 capitalize">
{CLIENT_PROTOCOL_MAP[config.type]}
</span>
</div>
<Input
placeholder={DEFAULT_SAVE_PATHS[config.type]}
value={config.savePath}
onChange={(e) => onUpdateConfig(config.type, 'savePath', e.target.value)}
className="font-mono"
helperText={`Default: ${DEFAULT_SAVE_PATHS[config.type]}`}
/>
</div>
))}
</div>
<div className="flex justify-between pt-4">
<Button onClick={onBack} variant="outline">
Back
</Button>
<Button onClick={onNext} disabled={!allFilled}>
Next
</Button>
</div>
</div>
);
}
function HostPathsStep({
configs,
onUpdateConfig,
onNext,
onBack,
}: {
configs: ClientConfig[];
onUpdateConfig: (type: DownloadClientType, field: keyof ClientConfig, value: string | boolean) => void;
onNext: () => void;
onBack: () => void;
}) {
const allFilled = configs.every(
(c) => c.hostPath.trim() !== '' && c.containerMountPath.trim() !== '' && (!c.remotePathMapping || c.remotePath.trim() !== '')
);
return (
<div className="space-y-6">
<div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
Docker volume mappings
</h2>
<p className="text-gray-600 dark:text-gray-400 mt-2">
For each client, enter the volume mapping from <strong>that client&apos;s</strong> docker-compose
file. This tells us where on your host machine the downloads actually end up.
</p>
</div>
<InfoBox>
<p>
A Docker volume mapping looks like <code className="bg-blue-100 dark:bg-blue-800 px-1 rounded">/host/path:/container/path</code> in
your docker-compose.yml. We need both sides so we know how to map RMAB to the same files.
</p>
</InfoBox>
<div className="space-y-6">
{configs.map((config) => (
<div key={config.type} className="bg-gray-50 dark:bg-gray-800/50 rounded-lg p-5 border border-gray-200 dark:border-gray-700 space-y-4">
<div className="flex items-center gap-2">
<span className="font-medium text-gray-900 dark:text-gray-100">
{CLIENT_DISPLAY_NAMES[config.type]}
</span>
<span className="text-xs px-2 py-0.5 rounded-full bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-400 capitalize">
{CLIENT_PROTOCOL_MAP[config.type]}
</span>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Input
label="Host path (left side of :)"
placeholder="/mnt/data/downloads"
value={config.hostPath}
onChange={(e) => onUpdateConfig(config.type, 'hostPath', e.target.value)}
className="font-mono"
helperText="The real path on your server"
/>
<Input
label="Container path (right side of :)"
placeholder="/downloads"
value={config.containerMountPath}
onChange={(e) => onUpdateConfig(config.type, 'containerMountPath', e.target.value)}
className="font-mono"
helperText="The path inside the container"
/>
</div>
{config.containerMountPath && config.hostPath && (
<div className="text-sm text-gray-600 dark:text-gray-400 font-mono bg-gray-100 dark:bg-gray-900 rounded px-3 py-2">
{config.hostPath}:{config.containerMountPath}
</div>
)}
{/* Remote path mapping toggle */}
<div className="border-t border-gray-200 dark:border-gray-700 pt-4">
<div className="flex items-start gap-3">
<input
type="checkbox"
id={`remote-${config.type}`}
checked={config.remotePathMapping}
onChange={(e) => onUpdateConfig(config.type, 'remotePathMapping', e.target.checked)}
className="mt-1 h-5 w-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<div className="flex-1">
<label
htmlFor={`remote-${config.type}`}
className="block text-sm font-medium text-gray-900 dark:text-gray-100 cursor-pointer"
>
This client runs on a different machine than RMAB
</label>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Enable this if the download client is on a seedbox, separate server, or otherwise has a
different filesystem than where RMAB runs. Also enable this if the client runs on the
host (not in Docker) while RMAB runs in Docker.
</p>
</div>
</div>
{config.remotePathMapping && (
<div className="mt-3 ml-8">
<Input
label="Remote path (as seen by the download client)"
placeholder="/remote/mnt/downloads/complete"
value={config.remotePath}
onChange={(e) => onUpdateConfig(config.type, 'remotePath', e.target.value)}
className="font-mono"
helperText="The path the download client reports when a download completes. This is often the same as the client's save path."
/>
</div>
)}
</div>
</div>
))}
</div>
<div className="flex justify-between pt-4">
<Button onClick={onBack} variant="outline">
Back
</Button>
<Button onClick={onNext} disabled={!allFilled}>
Generate Configuration
</Button>
</div>
</div>
);
}
function ResultsStep({
configs,
onBack,
onRestart,
}: {
configs: ClientConfig[];
onBack: () => void;
onRestart: () => void;
}) {
// Determine if we need custom paths (multiple clients with different save paths)
const savePaths = configs.map((c) => c.savePath.replace(/\/+$/, ''));
const uniqueSavePaths = [...new Set(savePaths)];
const needsCustomPaths = configs.length > 1 && uniqueSavePaths.length > 1;
// Calculate RMAB download directory
const rmabDownloadDir = needsCustomPaths ? findCommonRoot(savePaths) : savePaths[0];
// Calculate custom paths per client (only if needed)
const clientCustomPaths = needsCustomPaths
? configs.map((c) => ({
type: c.type,
customPath: getRelativePath(rmabDownloadDir, c.savePath.replace(/\/+$/, '')),
}))
: [];
// Calculate RMAB volume mapping
// We need the host path that corresponds to the rmabDownloadDir
// If all clients share the same save path, we use that client's host path directly.
// If multiple different paths, we find the common host root.
let rmabHostPath: string;
let rmabContainerPath: string;
if (!needsCustomPaths) {
// Single path scenario — use the first client's host path
// But we need to consider if the container mount path differs from the save path
const config = configs[0];
const saveRelativeToMount = getRelativePath(
config.containerMountPath.replace(/\/+$/, ''),
config.savePath.replace(/\/+$/, '')
);
if (saveRelativeToMount) {
// Save path is deeper than the mount: host must include that extra depth
rmabHostPath = config.hostPath.replace(/\/+$/, '') + '/' + saveRelativeToMount;
} else {
rmabHostPath = config.hostPath;
}
rmabContainerPath = rmabDownloadDir;
} else {
// Multiple different paths — we need to find the host root that covers all
// For each client, compute the host path that corresponds to the common container root
const hostRoots = configs.map((c) => {
const mountRelativeToCommon = getRelativePath(
rmabDownloadDir,
c.containerMountPath.replace(/\/+$/, '')
);
const saveRelativeToMount = getRelativePath(
c.containerMountPath.replace(/\/+$/, ''),
c.savePath.replace(/\/+$/, '')
);
// The host path maps to containerMountPath. We need to go up if rmabDownloadDir
// is a parent of the container mount path.
const containerMountNorm = c.containerMountPath.replace(/\/+$/, '');
const rmabDirNorm = rmabDownloadDir.replace(/\/+$/, '');
if (containerMountNorm === rmabDirNorm) {
return c.hostPath.replace(/\/+$/, '');
} else if (containerMountNorm.startsWith(rmabDirNorm + '/')) {
// Container mount is deeper than RMAB dir — we need to go up on the host side
const depth = containerMountNorm.slice(rmabDirNorm.length + 1).split('/').length;
const hostSegments = c.hostPath.replace(/\/+$/, '').split('/');
return hostSegments.slice(0, -depth).join('/') || '/';
} else if (rmabDirNorm.startsWith(containerMountNorm + '/')) {
// RMAB dir is deeper than container mount — append the extra to host
const extra = rmabDirNorm.slice(containerMountNorm.length + 1);
return c.hostPath.replace(/\/+$/, '') + '/' + extra;
}
return c.hostPath.replace(/\/+$/, '');
});
rmabHostPath = findHostCommonRoot(
configs.map((c, i) => ({ ...c, hostPath: hostRoots[i] }))
);
rmabContainerPath = rmabDownloadDir;
}
// Build the RMAB compose snippet
const composeSnippet = `services:
readmeabook:
volumes:
- ${rmabHostPath}:${rmabContainerPath}
# ... your other RMAB volumes (config, media, etc.)`;
// Build remote path mapping info
const remoteClients = configs.filter((c) => c.remotePathMapping);
return (
<div className="space-y-6">
<div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
Your recommended configuration
</h2>
<p className="text-gray-600 dark:text-gray-400 mt-2">
Based on your inputs, here&apos;s how to configure ReadMeABook and your download clients.
</p>
</div>
{/* RMAB Download Directory */}
<div className="space-y-2">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
1. RMAB Download Directory Setting
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">
Set this in RMAB&apos;s settings under <strong>Admin &rarr; Settings &rarr; Paths &rarr; Download Directory</strong>.
</p>
<CodeBlock label="Download Directory">{rmabDownloadDir}</CodeBlock>
</div>
{/* Custom paths per client */}
{needsCustomPaths && clientCustomPaths.some((c) => c.customPath) && (
<div className="space-y-2">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
2. Client Custom Paths
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">
Since your clients save to different locations, set these custom paths on each download client
in RMAB (<strong>Admin &rarr; Settings &rarr; Download Clients &rarr; Edit &rarr; Custom Path</strong>).
</p>
<div className="space-y-2">
{clientCustomPaths.map((c) => (
<div key={c.type} className="flex items-center gap-3 bg-gray-50 dark:bg-gray-800/50 rounded-lg p-3 border border-gray-200 dark:border-gray-700">
<span className="font-medium text-gray-900 dark:text-gray-100 min-w-[120px]">
{CLIENT_DISPLAY_NAMES[c.type as DownloadClientType]}:
</span>
<code className="font-mono text-sm bg-gray-200 dark:bg-gray-700 px-2 py-1 rounded text-gray-800 dark:text-gray-200">
{c.customPath || '(none — same as download directory)'}
</code>
</div>
))}
</div>
</div>
)}
{/* RMAB Docker Compose Volume */}
<div className="space-y-2">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
{needsCustomPaths ? '3' : '2'}. RMAB Docker Compose Volume Mapping
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">
Add this volume mapping to your RMAB docker-compose.yml. This ensures RMAB can see the
same files your download clients produce.
</p>
<CodeBlock label="docker-compose.yml">{composeSnippet}</CodeBlock>
</div>
{/* Golden Rule explanation */}
<WarningBox>
<p className="font-semibold mb-1">The Golden Rule</p>
<p>
Both your download client and RMAB must see files at the <strong>same container path</strong>.
The volume mapping above ensures that when your download client saves a file
to <code className="bg-amber-100 dark:bg-amber-800 px-1 rounded">{configs[0]?.savePath}</code>,
RMAB can also find it at that same path.
</p>
</WarningBox>
{/* Verification */}
<div className="space-y-2">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
{needsCustomPaths ? '4' : '3'}. Verify your setup
</h3>
<div className="bg-gray-50 dark:bg-gray-800/50 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
<ul className="space-y-2 text-sm text-gray-700 dark:text-gray-300">
{configs.map((c) => (
<li key={c.type} className="flex items-start gap-2">
<span className="text-gray-400 mt-0.5">&#8226;</span>
<span>
<strong>{CLIENT_DISPLAY_NAMES[c.type]}</strong> saves
to <code className="bg-gray-200 dark:bg-gray-700 px-1 rounded font-mono text-xs">{c.savePath}</code>
{' '}&rarr; host path <code className="bg-gray-200 dark:bg-gray-700 px-1 rounded font-mono text-xs">{c.hostPath}</code>
{needsCustomPaths && (
<>
{' '}&rarr; RMAB custom
path: <code className="bg-gray-200 dark:bg-gray-700 px-1 rounded font-mono text-xs">
{getRelativePath(rmabDownloadDir, c.savePath.replace(/\/+$/, '')) || '(none)'}
</code>
</>
)}
</span>
</li>
))}
<li className="flex items-start gap-2">
<span className="text-gray-400 mt-0.5">&#8226;</span>
<span>
<strong>RMAB</strong> mounts <code className="bg-gray-200 dark:bg-gray-700 px-1 rounded font-mono text-xs">{rmabHostPath}:{rmabContainerPath}</code>
{' '}&rarr; download directory set
to <code className="bg-gray-200 dark:bg-gray-700 px-1 rounded font-mono text-xs">{rmabDownloadDir}</code>
</span>
</li>
</ul>
</div>
</div>
{/* Remote Path Mapping */}
{remoteClients.length > 0 && (
<div className="space-y-2">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
Remote Path Mapping
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">
These clients run on a different machine. Configure remote path mapping for each in
RMAB (<strong>Admin &rarr; Settings &rarr; Download Clients &rarr; Edit</strong>).
</p>
<div className="space-y-3">
{remoteClients.map((c) => {
const localPath = needsCustomPaths
? rmabDownloadDir + '/' + getRelativePath(rmabDownloadDir, c.savePath.replace(/\/+$/, ''))
: rmabDownloadDir;
return (
<div key={c.type} className="bg-gray-50 dark:bg-gray-800/50 rounded-lg p-4 border border-gray-200 dark:border-gray-700 space-y-2">
<div className="font-medium text-gray-900 dark:text-gray-100">
{CLIENT_DISPLAY_NAMES[c.type]}
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 text-sm">
<div>
<span className="text-gray-500 dark:text-gray-400 block mb-1">Enable Remote Path Mapping:</span>
<code className="bg-gray-200 dark:bg-gray-700 px-2 py-1 rounded font-mono text-gray-800 dark:text-gray-200">Yes</code>
</div>
<div>
<span className="text-gray-500 dark:text-gray-400 block mb-1">Remote Path:</span>
<code className="bg-gray-200 dark:bg-gray-700 px-2 py-1 rounded font-mono text-gray-800 dark:text-gray-200">{c.remotePath}</code>
</div>
<div className="sm:col-span-2">
<span className="text-gray-500 dark:text-gray-400 block mb-1">Local Path:</span>
<code className="bg-gray-200 dark:bg-gray-700 px-2 py-1 rounded font-mono text-gray-800 dark:text-gray-200">{localPath}</code>
</div>
</div>
<InfoBox>
<p>
When this client reports a file at <code className="bg-blue-100 dark:bg-blue-800 px-1 rounded">{c.remotePath}/audiobook.m4b</code>,
RMAB will translate it to <code className="bg-blue-100 dark:bg-blue-800 px-1 rounded">{localPath}/audiobook.m4b</code>.
</p>
</InfoBox>
</div>
);
})}
</div>
</div>
)}
<div className="flex justify-between pt-4">
<Button onClick={onBack} variant="outline">
Back
</Button>
<Button onClick={onRestart} variant="secondary">
Start Over
</Button>
</div>
</div>
);
}
// =========================================================================
// MAIN PAGE
// =========================================================================
export default function PathHelperPage() {
const [step, setStep] = useState<Step>('clients');
const [selectedClients, setSelectedClients] = useState<Set<DownloadClientType>>(new Set());
const [clientConfigs, setClientConfigs] = useState<Map<DownloadClientType, ClientConfig>>(new Map());
// Build ordered configs array from selected clients
const configs = useMemo(() => {
return ALL_CLIENTS
.filter((c) => selectedClients.has(c))
.map((type) => {
const existing = clientConfigs.get(type);
return (
existing || {
type,
savePath: DEFAULT_SAVE_PATHS[type],
hostPath: '',
containerMountPath: '',
remotePathMapping: false,
remotePath: '',
}
);
});
}, [selectedClients, clientConfigs]);
const toggleClient = (client: DownloadClientType) => {
setSelectedClients((prev) => {
const next = new Set(prev);
if (next.has(client)) {
next.delete(client);
} else {
next.add(client);
// Initialize config if not exists
if (!clientConfigs.has(client)) {
setClientConfigs((prev) => {
const next = new Map(prev);
next.set(client, {
type: client,
savePath: DEFAULT_SAVE_PATHS[client],
hostPath: '',
containerMountPath: '',
remotePathMapping: false,
remotePath: '',
});
return next;
});
}
}
return next;
});
};
const updateConfig = (type: DownloadClientType, field: keyof ClientConfig, value: string | boolean) => {
setClientConfigs((prev) => {
const next = new Map(prev);
const existing = next.get(type);
if (existing) {
next.set(type, { ...existing, [field]: value });
}
return next;
});
};
const goToStep = (target: Step) => setStep(target);
const restart = () => {
setStep('clients');
setSelectedClients(new Set());
setClientConfigs(new Map());
};
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
{/* Header */}
<div className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 shadow-sm">
<div className="container mx-auto px-4 py-6 max-w-4xl">
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
Path Mapping Helper
</h1>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
Get your download client volume mappings configured correctly for ReadMeABook
</p>
</div>
</div>
{/* Step Indicator */}
<div className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
<div className="container mx-auto px-2 sm:px-4 max-w-4xl">
<StepIndicator currentStep={step} />
</div>
</div>
{/* Main Content */}
<div className="container mx-auto px-4 py-8 max-w-4xl">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-8">
{step === 'clients' && (
<ClientSelectionStep
selectedClients={selectedClients}
onToggle={toggleClient}
onNext={() => goToStep('save-paths')}
/>
)}
{step === 'save-paths' && (
<SavePathsStep
configs={configs}
onUpdateConfig={updateConfig}
onNext={() => goToStep('host-paths')}
onBack={() => goToStep('clients')}
/>
)}
{step === 'host-paths' && (
<HostPathsStep
configs={configs}
onUpdateConfig={updateConfig}
onNext={() => goToStep('results')}
onBack={() => goToStep('save-paths')}
/>
)}
{step === 'results' && (
<ResultsStep
configs={configs}
onBack={() => goToStep('host-paths')}
onRestart={restart}
/>
)}
</div>
</div>
</div>
);
}
@@ -38,6 +38,8 @@ interface AudiobookDetailsModalProps {
hideRequestActions?: boolean;
hasReportedIssue?: boolean;
aiReason?: string | null;
/** Optional admin action buttons (Approve / Search / Deny) rendered as a second row in the action bar */
adminActions?: React.ReactNode;
}
// Status helper
@@ -80,6 +82,7 @@ export function AudiobookDetailsModal({
hideRequestActions = false,
hasReportedIssue = false,
aiReason = null,
adminActions,
}: AudiobookDetailsModalProps) {
const { user } = useAuth();
const { squareCovers } = usePreferences();
@@ -548,6 +551,30 @@ export function AudiobookDetailsModal({
</a>
</div>
{/* Language */}
{audiobook.language && (
<div>
<p className="text-gray-500 dark:text-gray-400">Language</p>
<p className="text-gray-900 dark:text-gray-100 capitalize">{audiobook.language}</p>
</div>
)}
{/* Format */}
{audiobook.formatType && (
<div>
<p className="text-gray-500 dark:text-gray-400">Format</p>
<p className="text-gray-900 dark:text-gray-100 capitalize">{audiobook.formatType}</p>
</div>
)}
{/* Publisher */}
{audiobook.publisherName && (
<div>
<p className="text-gray-500 dark:text-gray-400">Publisher</p>
<p className="text-gray-900 dark:text-gray-100">{audiobook.publisherName}</p>
</div>
)}
{/* Download Link - subtle utility, visible from any context */}
{isAvailable && downloadAvailable && requestId && user?.permissions?.download !== false && (
<div>
@@ -739,6 +766,13 @@ export function AudiobookDetailsModal({
)}
</div>
{/* Admin Actions Row (Approve / Search / Deny) — injected by admin pages */}
{adminActions && (
<div className="flex items-center gap-2 mt-3 pt-3 border-t border-amber-200 dark:border-amber-700/50">
{adminActions}
</div>
)}
</div>
)}
+36 -11
View File
@@ -1,4 +1,4 @@
/**
/**
* Component: Notification Event Constants
* Documentation: documentation/backend/services/notifications.md
*
@@ -10,16 +10,28 @@ export type NotificationSeverity = 'info' | 'success' | 'error' | 'warning';
export type NotificationPriority = 'normal' | 'high';
/**
* Central registry of notification events.
* Normalized interface for event metadata.
* Each entry in NOTIFICATION_EVENTS is structurally validated against this via `satisfies`.
*
* Each entry defines:
* - `label`: Human-readable name shown in the UI
* - `title`: Default title used in notification messages
* - `titleByRequestType`: Optional map of request-type-specific titles (e.g. audiobook → "Audiobook Available")
* - `emoji`: Emoji prefix for notification titles
* - `severity`: Drives provider formatting (colors, Apprise types, ntfy tags)
* - `priority`: Drives notification urgency (Pushover/ntfy priority levels)
* - `messageLabel`: Optional label for the `message` payload field (defaults to "Error" if omitted)
*/
export interface NotificationEventConfig {
label: string;
title: string;
titleByRequestType?: Record<string, string>;
emoji: string;
severity: NotificationSeverity;
priority: NotificationPriority;
messageLabel?: string;
}
/** Central registry of notification events. */
export const NOTIFICATION_EVENTS = {
request_pending_approval: {
label: 'Request Pending Approval',
@@ -31,17 +43,29 @@ export const NOTIFICATION_EVENTS = {
request_approved: {
label: 'Request Approved',
title: 'Request Approved',
emoji: '\u2705',
emoji: '',
severity: 'success' as const,
priority: 'normal' as const,
},
request_grabbed: {
label: 'Request Grabbed',
title: 'Download Grabbed',
titleByRequestType: {
audiobook: 'Audiobook Grabbed',
ebook: 'Ebook Grabbed',
},
emoji: '\u{1F4E5}',
severity: 'info' as const,
priority: 'normal' as const,
messageLabel: 'Details',
},
request_available: {
label: 'Request Available',
title: 'Request Available',
titleByRequestType: {
audiobook: 'Audiobook Available',
ebook: 'Ebook Available',
} as Record<string, string>,
},
emoji: '\u{1F389}',
severity: 'success' as const,
priority: 'high' as const,
@@ -49,7 +73,7 @@ export const NOTIFICATION_EVENTS = {
request_error: {
label: 'Request Error',
title: 'Request Error',
emoji: '\u274C',
emoji: '',
severity: 'error' as const,
priority: 'high' as const,
},
@@ -59,8 +83,9 @@ export const NOTIFICATION_EVENTS = {
emoji: '\u{1F6A9}',
severity: 'warning' as const,
priority: 'high' as const,
messageLabel: 'Reason',
},
} as const;
} satisfies Record<string, NotificationEventConfig>;
/** Union type of all valid notification event keys */
export type NotificationEvent = keyof typeof NOTIFICATION_EVENTS;
@@ -72,7 +97,7 @@ export const NOTIFICATION_EVENT_KEYS = Object.keys(NOTIFICATION_EVENTS) as [Noti
export type NotificationEventMeta = (typeof NOTIFICATION_EVENTS)[NotificationEvent];
/** Helper: get event metadata by key */
export function getEventMeta(event: NotificationEvent) {
export function getEventMeta(event: NotificationEvent): NotificationEventConfig {
return NOTIFICATION_EVENTS[event];
}
@@ -82,9 +107,9 @@ export function getEventMeta(event: NotificationEvent) {
* returns the type-specific title. Otherwise falls back to the default `title`.
*/
export function getEventTitle(event: NotificationEvent, requestType?: string): string {
const meta = NOTIFICATION_EVENTS[event];
if (requestType && 'titleByRequestType' in meta) {
const typeTitle = (meta as typeof meta & { titleByRequestType: Record<string, string> }).titleByRequestType[requestType];
const meta = getEventMeta(event);
if (requestType && meta.titleByRequestType) {
const typeTitle = meta.titleByRequestType[requestType];
if (typeTitle) return typeTitle;
}
return meta.title;
+3
View File
@@ -34,6 +34,9 @@ export interface Audiobook {
requestedByUsername?: string | null; // Username who requested (only if not current user)
hasReportedIssue?: boolean; // True if an open issue exists for this audiobook
isIgnored?: boolean; // True if this user has ignored this audiobook from auto-requests
language?: string;
formatType?: string;
publisherName?: string;
}
export function useAudiobooks(type: 'popular' | 'new-releases', limit: number = 20, page: number = 1, hideAvailable: boolean = false) {
+3 -4
View File
@@ -19,6 +19,7 @@ import {
import { RMABLogger } from '../utils/logger';
import { parseRuntime } from '../utils/parse-runtime';
import { randomDelay } from '../utils/scrape-resilience';
import { extractAllNarrators } from '../utils/extract-narrator';
const logger = RMABLogger.create('Audible.Series');
@@ -442,10 +443,8 @@ function parseSeriesBooks(
const authorHref = authorLink.attr('href') || '';
const authorAsinMatch = authorHref.match(/\/author\/[^/]+\/([A-Z0-9]{10})/);
// Narrator
const narratorText = $el.find('a[href*="searchNarrator="]').first().text().trim() ||
$el.find('.narratorLabel').text().trim() ||
'';
// Narrator — capture all narrator links (multi-narrator productions are common)
const narratorText = extractAllNarrators($, $el);
// Cover art
const coverArtUrl = $el.find('img').first().attr('src')?.replace(/\._.*_\./, '._SL500_.') || '';
File diff suppressed because it is too large Load Diff
@@ -138,16 +138,37 @@ async function persistSectionBooks(
logger: ReturnType<typeof RMABLogger.forJob>,
labelForErrors: string,
): Promise<number> {
// Defensive dedup: the (asin, categoryId) unique constraint means a duplicate ASIN
// in `books` crashes the second .create() with P2002. The HTML parser already dedupes
// per page and across pages against the cumulative accumulator, but a warn-on-fire
// signal here lets us detect upstream surprises (e.g. Audible serving the same item
// in both a carousel and the main grid) without the noisy duplicate-key Postgres
// errors. Keep the first occurrence so Audible's editorial ordering is preserved.
const seenAsins = new Set<string>();
const dedupedBooks = books.filter((b) => {
if (!b?.asin || seenAsins.has(b.asin)) return false;
seenAsins.add(b.asin);
return true;
});
const droppedCount = books.length - dedupedBooks.length;
if (droppedCount > 0) {
logger.warn(
`Dropped ${droppedCount} duplicate ASIN(s) from ${categoryId} input list before persist`,
);
}
// 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...`);
logger.info(
`Cleared previous entries for ${categoryId}, saving ${dedupedBooks.length} books...`,
);
let saved = 0;
for (let i = 0; i < books.length; i++) {
const book = books[i];
for (let i = 0; i < dedupedBooks.length; i++) {
const book = dedupedBooks[i];
try {
// Cache thumbnail if coverArtUrl exists
let cachedCoverPath: string | null = null;
@@ -31,13 +31,16 @@ export async function processDownloadTorrent(payload: DownloadTorrentPayload): P
try {
// Update request status to downloading
await prisma.request.update({
const request = await prisma.request.update({
where: { id: requestId },
data: {
status: 'downloading',
progress: 0,
updatedAt: new Date(),
},
include: {
user: { select: { plexUsername: true } },
},
});
// Detect protocol from result and get appropriate client
@@ -103,8 +106,22 @@ export async function processDownloadTorrent(payload: DownloadTorrentPayload): P
logger.info(`Created download history record: ${downloadHistory.id}`);
// Trigger monitor download job with initial delay
// Send grab notification (non-blocking — failures here don't fail the download)
const jobQueue = getJobQueueService();
const grabMessage = `${torrent.title} via ${torrent.indexer} (${client.clientType})`;
await jobQueue.addNotificationJob(
'request_grabbed',
requestId,
audiobook.title,
audiobook.author,
request.user.plexUsername || 'Unknown User',
grabMessage,
request.type
).catch((error) => {
logger.error('Failed to queue grab notification', { error: error instanceof Error ? error.message : String(error) });
});
// Trigger monitor download job with initial delay
await jobQueue.addMonitorJob(
requestId,
downloadHistory.id,
@@ -127,6 +127,7 @@ export class AppriseProvider implements INotificationProvider {
private formatMessage(payload: NotificationPayload): { title: string; body: string } {
const { event, title, author, userName, message, requestType } = payload;
const meta = getEventMeta(event);
const isIssue = event === 'issue_reported';
const messageLines = [
@@ -136,7 +137,9 @@ export class AppriseProvider implements INotificationProvider {
];
if (message) {
messageLines.push(isIssue ? `\u{1F4DD} Reason: ${message}` : `\u26A0\uFE0F Error: ${message}`);
const messageLabel = meta.messageLabel ?? 'Error';
const msgEmoji = meta.severity === 'error' ? '\u26A0\uFE0F' : '\u{1F4DD}';
messageLines.push(`${msgEmoji} ${messageLabel}: ${message}`);
}
return {
@@ -71,7 +71,7 @@ export class DiscordProvider implements INotificationProvider {
];
if (message) {
fields.push({ name: isIssue ? 'Reason' : 'Error', value: message, inline: false });
fields.push({ name: meta.messageLabel ?? 'Error', value: message, inline: false });
}
return {
@@ -84,6 +84,7 @@ export class NtfyProvider implements INotificationProvider {
private formatMessage(payload: NotificationPayload): { title: string; message: string } {
const { event, title, author, userName, message, requestType } = payload;
const meta = getEventMeta(event);
const isIssue = event === 'issue_reported';
const messageLines = [
@@ -93,7 +94,9 @@ export class NtfyProvider implements INotificationProvider {
];
if (message) {
messageLines.push(isIssue ? `\u{1F4DD} Reason: ${message}` : `\u26A0\uFE0F Error: ${message}`);
const messageLabel = meta.messageLabel ?? 'Error';
const msgEmoji = meta.severity === 'error' ? '\u26A0\uFE0F' : '\u{1F4DD}';
messageLines.push(`${msgEmoji} ${messageLabel}: ${message}`);
}
return {
@@ -91,7 +91,9 @@ export class PushoverProvider implements INotificationProvider {
];
if (message) {
messageLines.push('', isIssue ? `\u{1F4DD} Reason: ${message}` : `\u26A0\uFE0F Error: ${message}`);
const messageLabel = meta.messageLabel ?? 'Error';
const msgEmoji = meta.severity === 'error' ? '\u26A0\uFE0F' : '\u{1F4DD}';
messageLines.push('', `${msgEmoji} ${messageLabel}: ${message}`);
}
return {
+92 -1
View File
@@ -9,7 +9,8 @@
import { prisma } from '@/lib/db';
import { RMABLogger } from '@/lib/utils/logger';
import type { DedupGroup } from '@/lib/utils/deduplicate-audiobooks';
import { metadataScore, type DedupGroup } from '@/lib/utils/deduplicate-audiobooks';
import type { AudibleAudiobook } from '@/lib/integrations/audible.service';
const logger = RMABLogger.create('WorksService');
@@ -182,6 +183,96 @@ export async function seedAsin(
}
}
// ---------------------------------------------------------------------------
// View-level collapse (consult the works table after local dedup)
// ---------------------------------------------------------------------------
/**
* Collapse books that already share a Work record according to the works table.
*
* The local `deduplicateAndCollectGroups()` pass is title/narrator/duration-based
* and stateless it can fail to merge ASINs whose source metadata diverges (e.g.
* a series-page scrape captures different "first narrators" for two ASINs of the
* same recording, or two paginated pages each contain one ASIN and never compare
* them). The works table is the durable source of truth for "same book" identity,
* populated by every prior dedup pass and by request-time seeding. This pass
* applies that knowledge to the current view.
*
* Behavior:
* - Books whose ASINs map to a shared workId collapse to a single representative
* chosen by `metadataScore()` (same ranking as local dedup).
* - Books not present in any work, or in single-ASIN works, pass through untouched.
* - Original ordering is preserved (the kept representative sits at the position
* of the first occurrence of its work in the input list).
* - DB failure is non-fatal: the input list is returned unchanged so the view
* still renders (degrades to local-dedup-only behavior).
*/
export async function collapseByExistingWorks(
books: AudibleAudiobook[],
): Promise<AudibleAudiobook[]> {
if (books.length <= 1) return books;
try {
const asins = books.map(b => b.asin);
const entries = await prisma.workAsin.findMany({
where: { asin: { in: asins } },
select: { asin: true, workId: true },
});
if (entries.length === 0) return books;
// Map ASIN → workId for fast lookup in the loop below
const asinToWorkId = new Map<string, string>();
for (const entry of entries) {
asinToWorkId.set(entry.asin, entry.workId);
}
// Walk the input once, preserving position. For each work seen, keep a
// running "best" book; for books not in any work, emit immediately.
const result: AudibleAudiobook[] = [];
const workIdToResultIndex = new Map<string, number>();
for (const book of books) {
const workId = asinToWorkId.get(book.asin);
if (!workId) {
result.push(book);
continue;
}
const existingIndex = workIdToResultIndex.get(workId);
if (existingIndex === undefined) {
workIdToResultIndex.set(workId, result.length);
result.push(book);
continue;
}
// A sibling from this work is already in the result. Keep whichever
// has the richer metadata; on tie, keep the earlier entry (already there).
const existing = result[existingIndex];
if (metadataScore(book) > metadataScore(existing)) {
result[existingIndex] = book;
}
}
const collapsed = books.length - result.length;
if (collapsed > 0) {
logger.debug('Collapsed books via works table', {
inputCount: books.length,
outputCount: result.length,
collapsed,
});
}
return result;
} catch (error) {
logger.error('collapseByExistingWorks failed; returning input unchanged', {
error: error instanceof Error ? error.message : String(error),
bookCount: books.length,
});
return books;
}
}
// ---------------------------------------------------------------------------
// Sibling ASIN lookup (for library matching expansion)
// ---------------------------------------------------------------------------
+10 -1
View File
@@ -11,6 +11,7 @@ export interface AudibleRegionConfig {
code: AudibleRegion;
name: string;
baseUrl: string;
apiBaseUrl: string;
audnexusParam: string;
language: SupportedLanguage;
}
@@ -20,6 +21,7 @@ export const AUDIBLE_REGIONS: Record<AudibleRegion, AudibleRegionConfig> = {
code: 'us',
name: 'United States',
baseUrl: 'https://www.audible.com',
apiBaseUrl: 'https://api.audible.com',
audnexusParam: 'us',
language: 'en',
},
@@ -27,6 +29,7 @@ export const AUDIBLE_REGIONS: Record<AudibleRegion, AudibleRegionConfig> = {
code: 'ca',
name: 'Canada',
baseUrl: 'https://www.audible.ca',
apiBaseUrl: 'https://api.audible.ca',
audnexusParam: 'ca',
language: 'en',
},
@@ -34,6 +37,7 @@ export const AUDIBLE_REGIONS: Record<AudibleRegion, AudibleRegionConfig> = {
code: 'uk',
name: 'United Kingdom',
baseUrl: 'https://www.audible.co.uk',
apiBaseUrl: 'https://api.audible.co.uk',
audnexusParam: 'uk',
language: 'en',
},
@@ -41,6 +45,7 @@ export const AUDIBLE_REGIONS: Record<AudibleRegion, AudibleRegionConfig> = {
code: 'au',
name: 'Australia',
baseUrl: 'https://www.audible.com.au',
apiBaseUrl: 'https://api.audible.com.au',
audnexusParam: 'au',
language: 'en',
},
@@ -48,6 +53,7 @@ export const AUDIBLE_REGIONS: Record<AudibleRegion, AudibleRegionConfig> = {
code: 'in',
name: 'India',
baseUrl: 'https://www.audible.in',
apiBaseUrl: 'https://api.audible.in',
audnexusParam: 'in',
language: 'en',
},
@@ -55,6 +61,7 @@ export const AUDIBLE_REGIONS: Record<AudibleRegion, AudibleRegionConfig> = {
code: 'de',
name: 'Germany',
baseUrl: 'https://www.audible.de',
apiBaseUrl: 'https://api.audible.de',
audnexusParam: 'de',
language: 'de',
},
@@ -62,6 +69,7 @@ export const AUDIBLE_REGIONS: Record<AudibleRegion, AudibleRegionConfig> = {
code: 'es',
name: 'Spain',
baseUrl: 'https://www.audible.es',
apiBaseUrl: 'https://api.audible.es',
audnexusParam: 'es',
language: 'es',
},
@@ -69,9 +77,10 @@ export const AUDIBLE_REGIONS: Record<AudibleRegion, AudibleRegionConfig> = {
code: 'fr',
name: 'France',
baseUrl: 'https://www.audible.fr',
apiBaseUrl: 'https://api.audible.fr',
audnexusParam: 'fr',
language: 'fr',
}
},
};
export const DEFAULT_AUDIBLE_REGION: AudibleRegion = 'us';
+6 -1
View File
@@ -109,7 +109,12 @@ export function areDurationsCompatible(a?: number, b?: number): boolean {
// Metadata scoring (for picking best representative)
// ---------------------------------------------------------------------------
function metadataScore(book: AudibleAudiobook): number {
/**
* Score a book by how much metadata it carries. Used as the tie-breaker when
* collapsing duplicates the entry with the richest metadata wins. Exported
* so the works-table collapse pass can apply the same ranking.
*/
export function metadataScore(book: AudibleAudiobook): number {
let score = 0;
if (book.coverArtUrl) score++;
if (book.rating != null) score++;
+37
View File
@@ -0,0 +1,37 @@
/**
* Component: Narrator Extraction Utility
* Documentation: documentation/integrations/audible.md
*
* Shared helper for Audible HTML scrapers. Audible product listings render
* each narrator as a separate `<a href="?searchNarrator=...">` link; using
* `.first()` on that selector silently drops co-narrators and breaks dedup
* for multi-narrator productions (e.g. full-cast audiobooks). This helper
* captures every narrator link and joins them, falling back to the
* `.narratorLabel` span when no anchor links are present.
*/
import type * as cheerio from 'cheerio';
import type { AnyNode } from 'domhandler';
/**
* Extract a comma-joined narrator string from an Audible product list item.
*
* Order is not semantically significant downstream `normalizeNarrator()`
* sorts before comparison but document-order preserves a stable, legible
* value for caching and logging.
*/
export function extractAllNarrators(
$: cheerio.CheerioAPI,
$el: cheerio.Cheerio<AnyNode>,
): string {
const links = $el.find('a[href*="searchNarrator="]');
if (links.length > 0) {
const names: string[] = [];
links.each((_, link) => {
const name = $(link).text().trim();
if (name) names.push(name);
});
if (names.length > 0) return names.join(', ');
}
return $el.find('.narratorLabel').text().trim();
}
+9 -3
View File
@@ -38,12 +38,18 @@ export function getBrowserHeaders(userAgent: string): Record<string, string> {
}
/**
* Jittered exponential backoff: 2^attempt * baseMs * random(0.5, 1.5)
* Jittered exponential backoff: 2^attempt * baseMs * random(0.5, 1.5),
* optionally capped so high attempt counts don't produce absurd waits.
* Avoids predictable retry timing that is trivially fingerprinted.
*/
export function jitteredBackoff(attempt: number, baseMs: number = 1000): number {
export function jitteredBackoff(
attempt: number,
baseMs: number = 1000,
maxBackoffMs: number = Number.POSITIVE_INFINITY,
): number {
const jitter = 0.5 + Math.random(); // 0.5 1.5
return Math.round(Math.pow(2, attempt) * baseMs * jitter);
const raw = Math.pow(2, attempt) * baseMs * jitter;
return Math.round(Math.min(raw, maxBackoffMs));
}
/** Random integer in [minMs, maxMs] */
File diff suppressed because it is too large Load Diff
@@ -198,4 +198,69 @@ describe('processAudibleRefresh', () => {
const { processAudibleRefresh } = await import('@/lib/processors/audible-refresh.processor');
await expect(processAudibleRefresh({ jobId: 'job-2' })).rejects.toThrow('DB down');
});
it('deduplicates ASINs in the input list before persisting, preserving order', async () => {
// Two `A` entries should collapse to one. Final ranks must be contiguous
// (1, 2, 3) and follow Audible's editorial ordering (A, B, C).
const popular = [
{ asin: 'A', title: 'Book A', author: 'X', coverArtUrl: null },
{ asin: 'B', title: 'Book B', author: 'X', coverArtUrl: null },
{ asin: 'A', title: 'Book A (duplicate)', author: 'X', coverArtUrl: null },
{ asin: 'C', title: 'Book C', author: 'X', coverArtUrl: null },
];
audibleServiceMock.getPopularAudiobooks.mockResolvedValue(popular);
audibleServiceMock.getNewReleases.mockResolvedValue([]);
thumbnailCacheMock.cleanupUnusedThumbnails.mockResolvedValue(0);
prismaMock.audibleCache.upsert.mockResolvedValue({});
prismaMock.audibleCacheCategory.deleteMany.mockResolvedValue({ count: 0 });
prismaMock.audibleCacheCategory.create.mockResolvedValue({});
prismaMock.userHomeSection.findMany.mockResolvedValue([]);
prismaMock.audibleCache.findMany.mockResolvedValue([]);
const { processAudibleRefresh } = await import('@/lib/processors/audible-refresh.processor');
const result = await processAudibleRefresh({ jobId: 'job-dedup' });
expect(result.popularSaved).toBe(3);
// Only 3 category entries created — the duplicate `A` was dropped.
const popularCreates = (prismaMock.audibleCacheCategory.create.mock.calls as Array<[{ data: { asin: string; categoryId: string; rank: number } }]>)
.map((c) => c[0].data)
.filter((d) => d.categoryId === '__popular__');
expect(popularCreates).toHaveLength(3);
expect(popularCreates.map((d) => d.asin)).toEqual(['A', 'B', 'C']);
expect(popularCreates.map((d) => d.rank)).toEqual([1, 2, 3]);
// upsert called once per unique ASIN, not per input row.
expect(prismaMock.audibleCache.upsert).toHaveBeenCalledTimes(3);
});
it('drops entries with missing ASINs as part of dedup', async () => {
const popular = [
{ asin: 'A', title: 'Book A', author: 'X', coverArtUrl: null },
{ asin: '', title: 'Book with empty asin', author: 'X', coverArtUrl: null },
{ asin: null, title: 'Book with null asin', author: 'X', coverArtUrl: null },
{ asin: 'B', title: 'Book B', author: 'X', coverArtUrl: null },
];
audibleServiceMock.getPopularAudiobooks.mockResolvedValue(popular as any);
audibleServiceMock.getNewReleases.mockResolvedValue([]);
thumbnailCacheMock.cleanupUnusedThumbnails.mockResolvedValue(0);
prismaMock.audibleCache.upsert.mockResolvedValue({});
prismaMock.audibleCacheCategory.deleteMany.mockResolvedValue({ count: 0 });
prismaMock.audibleCacheCategory.create.mockResolvedValue({});
prismaMock.userHomeSection.findMany.mockResolvedValue([]);
prismaMock.audibleCache.findMany.mockResolvedValue([]);
const { processAudibleRefresh } = await import('@/lib/processors/audible-refresh.processor');
const result = await processAudibleRefresh({ jobId: 'job-empty-asin' });
expect(result.popularSaved).toBe(2);
const popularCreates = (prismaMock.audibleCacheCategory.create.mock.calls as Array<[{ data: { asin: string; categoryId: string; rank: number } }]>)
.map((c) => c[0].data)
.filter((d) => d.categoryId === '__popular__');
expect(popularCreates.map((d) => d.asin)).toEqual(['A', 'B']);
expect(popularCreates.map((d) => d.rank)).toEqual([1, 2]);
});
});
@@ -59,6 +59,7 @@ describe('processDownloadTorrent', () => {
vi.clearAllMocks();
// Restore default implementations cleared by clearAllMocks
configMock.getMany.mockResolvedValue({ prowlarr_api_key: null });
jobQueueMock.addNotificationJob.mockResolvedValue(undefined);
});
const torrentPayload = {
@@ -110,7 +111,7 @@ describe('processDownloadTorrent', () => {
enabled: true,
category: 'readmeabook',
});
prismaMock.request.update.mockResolvedValue({});
prismaMock.request.update.mockResolvedValue({ type: 'audiobook', user: { plexUsername: 'testuser' } });
prismaMock.downloadHistory.create.mockResolvedValue({ id: 'dh-1' });
const { processDownloadTorrent } = await import('@/lib/processors/download-torrent.processor');
@@ -141,7 +142,7 @@ describe('processDownloadTorrent', () => {
enabled: true,
category: 'readmeabook',
});
prismaMock.request.update.mockResolvedValue({});
prismaMock.request.update.mockResolvedValue({ type: 'audiobook', user: { plexUsername: 'testuser' } });
prismaMock.downloadHistory.create.mockResolvedValue({ id: 'dh-2' });
const { processDownloadTorrent } = await import('@/lib/processors/download-torrent.processor');
@@ -186,7 +187,7 @@ describe('processDownloadTorrent', () => {
enabled: true,
category: 'readmeabook',
});
prismaMock.request.update.mockResolvedValue({});
prismaMock.request.update.mockResolvedValue({ type: 'audiobook', user: { plexUsername: 'testuser' } });
prismaMock.downloadHistory.create.mockResolvedValue({ id: 'dh-1' });
const { processDownloadTorrent } = await import('@/lib/processors/download-torrent.processor');
+58
View File
@@ -458,6 +458,64 @@ describe('AppriseProvider', () => {
});
});
describe('messageLabel rendering by event', () => {
const basePayload = {
requestId: 'req-1',
title: 'Test Book',
author: 'Test Author',
userName: 'Test User',
timestamp: new Date('2024-01-01T00:00:00Z'),
};
it('renders "⚠️ Error:" with error emoji for request_error', async () => {
fetchMock.mockResolvedValue({ ok: true, text: async () => 'ok' });
const { AppriseProvider } = await import('@/lib/services/notification');
const provider = new AppriseProvider();
await provider.send(
{ serverUrl: 'http://apprise:8000', urls: 'slack://token' },
{ ...basePayload, event: 'request_error', message: 'Boom' }
);
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
expect(body.body).toContain('⚠️ Error: Boom');
expect(body.body).not.toContain('📝');
});
it('renders "📝 Reason:" with note emoji for issue_reported', async () => {
fetchMock.mockResolvedValue({ ok: true, text: async () => 'ok' });
const { AppriseProvider } = await import('@/lib/services/notification');
const provider = new AppriseProvider();
await provider.send(
{ serverUrl: 'http://apprise:8000', urls: 'slack://token' },
{ ...basePayload, event: 'issue_reported', issueId: 'iss-1', message: 'Chapter 3 cuts off' }
);
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
expect(body.body).toContain('📝 Reason: Chapter 3 cuts off');
expect(body.body).not.toContain('⚠️');
expect(body.body).not.toContain('Error:');
});
it('renders "📝 Details:" with note emoji for request_grabbed', async () => {
fetchMock.mockResolvedValue({ ok: true, text: async () => 'ok' });
const { AppriseProvider } = await import('@/lib/services/notification');
const provider = new AppriseProvider();
await provider.send(
{ serverUrl: 'http://apprise:8000', urls: 'slack://token' },
{ ...basePayload, event: 'request_grabbed', message: 'Test Book [M4B] via NZBGeek (SABnzbd)', requestType: 'audiobook' }
);
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
expect(body.body).toContain('📝 Details: Test Book [M4B] via NZBGeek (SABnzbd)');
expect(body.body).not.toContain('⚠️');
expect(body.body).not.toContain('Error:');
expect(body.title).toBe('Audiobook Grabbed');
});
});
describe('integration with NotificationService.sendToBackend', () => {
it('decrypts sensitive fields and sends to Apprise', async () => {
fetchMock.mockResolvedValue({
+58
View File
@@ -267,6 +267,64 @@ describe('NtfyProvider', () => {
});
});
describe('messageLabel rendering by event', () => {
const basePayload = {
requestId: 'req-1',
title: 'Test Book',
author: 'Test Author',
userName: 'Test User',
timestamp: new Date('2024-01-01T00:00:00Z'),
};
it('renders "⚠️ Error:" with error emoji for request_error', async () => {
fetchMock.mockResolvedValue({ ok: true, json: async () => ({ id: 'msg' }) });
const { NtfyProvider } = await import('@/lib/services/notification');
const provider = new NtfyProvider();
await provider.send(
{ topic: 'audiobooks' },
{ ...basePayload, event: 'request_error', message: 'Boom' }
);
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
expect(body.message).toContain('⚠️ Error: Boom');
expect(body.message).not.toContain('📝');
});
it('renders "📝 Reason:" with note emoji for issue_reported', async () => {
fetchMock.mockResolvedValue({ ok: true, json: async () => ({ id: 'msg' }) });
const { NtfyProvider } = await import('@/lib/services/notification');
const provider = new NtfyProvider();
await provider.send(
{ topic: 'audiobooks' },
{ ...basePayload, event: 'issue_reported', issueId: 'iss-1', message: 'Chapter 3 cuts off' }
);
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
expect(body.message).toContain('📝 Reason: Chapter 3 cuts off');
expect(body.message).not.toContain('⚠️');
expect(body.message).not.toContain('Error:');
});
it('renders "📝 Details:" with note emoji for request_grabbed', async () => {
fetchMock.mockResolvedValue({ ok: true, json: async () => ({ id: 'msg' }) });
const { NtfyProvider } = await import('@/lib/services/notification');
const provider = new NtfyProvider();
await provider.send(
{ topic: 'audiobooks' },
{ ...basePayload, event: 'request_grabbed', message: 'Test Book [M4B] via NZBGeek (SABnzbd)', requestType: 'audiobook' }
);
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
expect(body.message).toContain('📝 Details: Test Book [M4B] via NZBGeek (SABnzbd)');
expect(body.message).not.toContain('⚠️');
expect(body.message).not.toContain('Error:');
expect(body.title).toBe('Audiobook Grabbed');
});
});
describe('integration with NotificationService.sendToBackend', () => {
it('decrypts accessToken and sends to ntfy', async () => {
fetchMock.mockResolvedValue({
+189
View File
@@ -6,6 +6,15 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { createPrismaMock } from '../helpers/prisma';
import type { DedupGroup } from '@/lib/utils/deduplicate-audiobooks';
import type { AudibleAudiobook } from '@/lib/integrations/audible.service';
function makeBook(overrides: Partial<AudibleAudiobook> & { asin: string }): AudibleAudiobook {
return {
title: 'Test Book',
author: 'Test Author',
...overrides,
};
}
const prismaMock = createPrismaMock();
@@ -304,3 +313,183 @@ describe('getSiblingAsins', () => {
expect(result.has('ASIN_LONELY')).toBe(false);
});
});
describe('collapseByExistingWorks', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.resetModules();
});
it('returns input unchanged when the list is empty or has one entry', async () => {
const { collapseByExistingWorks } = await import('@/lib/services/works.service');
expect(await collapseByExistingWorks([])).toEqual([]);
expect(prismaMock.workAsin.findMany).not.toHaveBeenCalled();
const single = [makeBook({ asin: 'A1' })];
expect(await collapseByExistingWorks(single)).toEqual(single);
expect(prismaMock.workAsin.findMany).not.toHaveBeenCalled();
});
it('returns input unchanged when none of the ASINs are in any work', async () => {
prismaMock.workAsin.findMany.mockResolvedValue([]);
const { collapseByExistingWorks } = await import('@/lib/services/works.service');
const books = [
makeBook({ asin: 'A1', title: 'Alpha' }),
makeBook({ asin: 'A2', title: 'Beta' }),
];
const result = await collapseByExistingWorks(books);
expect(result).toEqual(books);
});
it('collapses two ASINs that share a work to a single representative', async () => {
prismaMock.workAsin.findMany.mockResolvedValue([
{ asin: 'A1', workId: 'work-1' },
{ asin: 'A2', workId: 'work-1' },
]);
const { collapseByExistingWorks } = await import('@/lib/services/works.service');
const books = [
makeBook({ asin: 'A1', title: 'The Passengers', coverArtUrl: 'cover.jpg' }),
makeBook({ asin: 'A2', title: 'The Passengers' }),
];
const result = await collapseByExistingWorks(books);
expect(result).toHaveLength(1);
// A1 wins — it has the cover URL (higher metadata score)
expect(result[0].asin).toBe('A1');
});
it('keeps the richest-metadata entry when collapsing, regardless of input order', async () => {
prismaMock.workAsin.findMany.mockResolvedValue([
{ asin: 'A1', workId: 'work-1' },
{ asin: 'A2', workId: 'work-1' },
]);
const { collapseByExistingWorks } = await import('@/lib/services/works.service');
// A1 first (sparse), A2 second (rich) — A2 should win on score
const books = [
makeBook({ asin: 'A1', title: 'Book' }),
makeBook({
asin: 'A2',
title: 'Book',
coverArtUrl: 'cover.jpg',
rating: 4.5,
durationMinutes: 600,
narrator: 'Full Cast',
description: 'Rich book',
releaseDate: '2024-01-01',
genres: ['Fiction'],
}),
];
const result = await collapseByExistingWorks(books);
expect(result).toHaveLength(1);
expect(result[0].asin).toBe('A2');
});
it('preserves position of the work in the input order', async () => {
prismaMock.workAsin.findMany.mockResolvedValue([
{ asin: 'A2', workId: 'work-1' },
{ asin: 'A4', workId: 'work-1' },
]);
const { collapseByExistingWorks } = await import('@/lib/services/works.service');
const books = [
makeBook({ asin: 'A1', title: 'Alpha' }),
makeBook({ asin: 'A2', title: 'Beta' }),
makeBook({ asin: 'A3', title: 'Gamma' }),
makeBook({ asin: 'A4', title: 'Beta' }),
makeBook({ asin: 'A5', title: 'Delta' }),
];
const result = await collapseByExistingWorks(books);
// A2 and A4 collapse to one entry at position 1 (the first occurrence)
expect(result.map(b => b.asin)).toEqual(['A1', 'A2', 'A3', 'A5']);
});
it('handles multiple independent works in the same batch', async () => {
prismaMock.workAsin.findMany.mockResolvedValue([
{ asin: 'A1', workId: 'work-1' },
{ asin: 'A2', workId: 'work-1' },
{ asin: 'B1', workId: 'work-2' },
{ asin: 'B2', workId: 'work-2' },
{ asin: 'B3', workId: 'work-2' },
]);
const { collapseByExistingWorks } = await import('@/lib/services/works.service');
const books = [
makeBook({ asin: 'A1' }),
makeBook({ asin: 'B1' }),
makeBook({ asin: 'A2' }),
makeBook({ asin: 'B2' }),
makeBook({ asin: 'B3' }),
makeBook({ asin: 'C1' }),
];
const result = await collapseByExistingWorks(books);
expect(result.map(b => b.asin)).toEqual(['A1', 'B1', 'C1']);
});
it('passes through books that are not in any work alongside collapsed ones', async () => {
prismaMock.workAsin.findMany.mockResolvedValue([
{ asin: 'A1', workId: 'work-1' },
{ asin: 'A2', workId: 'work-1' },
]);
const { collapseByExistingWorks } = await import('@/lib/services/works.service');
const books = [
makeBook({ asin: 'STANDALONE_1', title: 'Standalone 1' }),
makeBook({ asin: 'A1', title: 'Same Book' }),
makeBook({ asin: 'STANDALONE_2', title: 'Standalone 2' }),
makeBook({ asin: 'A2', title: 'Same Book' }),
];
const result = await collapseByExistingWorks(books);
expect(result).toHaveLength(3);
expect(result.map(b => b.asin)).toEqual(['STANDALONE_1', 'A1', 'STANDALONE_2']);
});
it('returns input unchanged on DB failure (does not throw)', async () => {
prismaMock.workAsin.findMany.mockRejectedValue(new Error('DB exploded'));
const { collapseByExistingWorks } = await import('@/lib/services/works.service');
const books = [
makeBook({ asin: 'A1' }),
makeBook({ asin: 'A2' }),
];
const result = await collapseByExistingWorks(books);
expect(result).toEqual(books);
});
it('only queries the workAsin table once per call', async () => {
prismaMock.workAsin.findMany.mockResolvedValue([
{ asin: 'A1', workId: 'work-1' },
{ asin: 'A2', workId: 'work-1' },
]);
const { collapseByExistingWorks } = await import('@/lib/services/works.service');
await collapseByExistingWorks([
makeBook({ asin: 'A1' }),
makeBook({ asin: 'A2' }),
makeBook({ asin: 'A3' }),
]);
expect(prismaMock.workAsin.findMany).toHaveBeenCalledTimes(1);
expect(prismaMock.workAsin.findMany).toHaveBeenCalledWith({
where: { asin: { in: ['A1', 'A2', 'A3'] } },
select: { asin: true, workId: true },
});
});
});
+95
View File
@@ -0,0 +1,95 @@
/**
* Component: Narrator Extraction Utility Tests
* Documentation: documentation/integrations/audible.md
*/
import { describe, expect, it } from 'vitest';
import * as cheerio from 'cheerio';
import { extractAllNarrators } from '@/lib/utils/extract-narrator';
function load(html: string) {
const $ = cheerio.load(`<div id="item">${html}</div>`);
return { $, $el: $('#item') };
}
describe('extractAllNarrators', () => {
it('returns the single narrator name when only one searchNarrator link is present', () => {
const { $, $el } = load(
`<a href="/search?searchNarrator=Andy%20Serkis">Andy Serkis</a>`,
);
expect(extractAllNarrators($, $el)).toBe('Andy Serkis');
});
it('joins multiple narrator names from separate searchNarrator links', () => {
const { $, $el } = load(`
<a href="/search?searchNarrator=Kristin%20Atherton">Kristin Atherton</a>,
<a href="/search?searchNarrator=Roy%20McMillan">Roy McMillan</a>,
<a href="/search?searchNarrator=Clare%20Corbett">Clare Corbett</a>,
<a href="/search?searchNarrator=Tom%20Bateman">Tom Bateman</a>,
<a href="/search?searchNarrator=Patience%20Tomlinson">Patience Tomlinson</a>,
<a href="/search?searchNarrator=Shaheen%20Khan">Shaheen Khan</a>
`);
expect(extractAllNarrators($, $el)).toBe(
'Kristin Atherton, Roy McMillan, Clare Corbett, Tom Bateman, Patience Tomlinson, Shaheen Khan',
);
});
it('preserves document order (downstream sorts before comparing, but order should be stable)', () => {
const { $, $el } = load(`
<a href="/search?searchNarrator=Z">Zelda</a>
<a href="/search?searchNarrator=A">Alice</a>
<a href="/search?searchNarrator=M">Mallory</a>
`);
expect(extractAllNarrators($, $el)).toBe('Zelda, Alice, Mallory');
});
it('falls back to .narratorLabel text when no searchNarrator links exist', () => {
const { $, $el } = load(
`<span class="narratorLabel">Narrated by: Single Narrator</span>`,
);
expect(extractAllNarrators($, $el)).toBe('Narrated by: Single Narrator');
});
it('prefers searchNarrator links over .narratorLabel when both are present', () => {
const { $, $el } = load(`
<span class="narratorLabel">Narrated by: ONLY ONE</span>
<a href="/search?searchNarrator=First">First</a>
<a href="/search?searchNarrator=Second">Second</a>
`);
expect(extractAllNarrators($, $el)).toBe('First, Second');
});
it('returns empty string when neither links nor .narratorLabel exist', () => {
const { $, $el } = load(`<span>some other content</span>`);
expect(extractAllNarrators($, $el)).toBe('');
});
it('skips empty link text and joins only non-empty names', () => {
const { $, $el } = load(`
<a href="/search?searchNarrator=A"></a>
<a href="/search?searchNarrator=B">Bob</a>
<a href="/search?searchNarrator=C"> </a>
<a href="/search?searchNarrator=D">Diana</a>
`);
expect(extractAllNarrators($, $el)).toBe('Bob, Diana');
});
it('trims whitespace from each captured name', () => {
const { $, $el } = load(`
<a href="/search?searchNarrator=A"> Alice </a>
<a href="/search?searchNarrator=B">
Bob
</a>
`);
expect(extractAllNarrators($, $el)).toBe('Alice, Bob');
});
it('falls back to .narratorLabel when all searchNarrator links are empty', () => {
const { $, $el } = load(`
<a href="/search?searchNarrator=A"></a>
<a href="/search?searchNarrator=B"> </a>
<span class="narratorLabel">Fallback Narrator</span>
`);
expect(extractAllNarrators($, $el)).toBe('Fallback Narrator');
});
});
+18
View File
@@ -67,6 +67,24 @@ describe('jitteredBackoff', () => {
expect(value).toBeGreaterThanOrEqual(250);
expect(value).toBeLessThanOrEqual(750);
});
it('caps the result at maxBackoffMs when the raw backoff would exceed it', () => {
// attempt=10 with base=1000 produces 2^10 * 1000 * [0.5..1.5] = 512_000..1_536_000,
// all of which exceed a 60_000ms cap.
for (let i = 0; i < 50; i++) {
const value = jitteredBackoff(10, 1000, 60_000);
expect(value).toBeLessThanOrEqual(60_000);
}
});
it('returns the un-capped jittered value when below the cap', () => {
// attempt=0 with base=1000 produces 500..1500, all below a 60_000ms cap.
for (let i = 0; i < 50; i++) {
const value = jitteredBackoff(0, 1000, 60_000);
expect(value).toBeGreaterThanOrEqual(500);
expect(value).toBeLessThanOrEqual(1500);
}
});
});
describe('randomDelay', () => {