diff --git a/documentation/integrations/audible.md b/documentation/integrations/audible.md index 9711695..3ac4740 100644 --- a/documentation/integrations/audible.md +++ b/documentation/integrations/audible.md @@ -1,104 +1,120 @@ # Audible Integration -**Status:** ✅ Implemented (Audnexus API + Web Scraping) +**Status:** Implemented | Unauthenticated Audible JSON catalog API (primary) + Audnexus API (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. All catalog operations (search, popular, new releases, categories, category books, author books, single-product details) now call Audible's unauthenticated public JSON catalog API (`api.audible./1.0/catalog/*`). Per-ASIN detail lookups prefer Audnexus; the catalog API is used as fallback. -**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 +## Architecture -**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 +- **Primary data source:** Audible JSON catalog API, same endpoint used by the official Audible mobile apps. No authentication, no API key, no user credentials, no special headers. +- **Per-ASIN details:** Audnexus (`api.audnex.us/books/{asin}`) remains primary; catalog API (`/1.0/catalog/products/{asin}`) is the fallback when Audnexus returns 404. +- **HTML scraping:** Removed from `audible.service.ts`. The only remaining HTML path is `audible-series.ts` (series-page scraping, out of scope). +- **`www.audible.`:** Still used by `audible-series.ts` and by `getBaseUrl()` for "View on Audible" link generation. Not used for any catalog operation. + +## Data Sources + +All catalog operations are HTTP GET against `{apiBaseUrl}` (region-dependent, e.g. `https://api.audible.com`): + +| Operation | Endpoint | Key params | +|---|---|---| +| Search | `/1.0/catalog/products` | `keywords=` | +| Author books | `/1.0/catalog/products` | `author=` (name, NOT ASIN) | +| Popular | `/1.0/catalog/products` | `products_sort_by=BestSellers` | +| New releases | `/1.0/catalog/products` | `products_sort_by=-ReleaseDate` | +| Category books | `/1.0/catalog/products` | `category_id=&products_sort_by=BestSellers` | +| 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=` + +## `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 + +- **`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.** 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 51–100, not 1–50. All 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). + +## Rate Limiting & Resilience + +- 503s still possible but dramatically less frequent than the HTML surface. +- `fetchWithRetry()` — jittered exponential backoff, 5 retries, retries on 503/429/5xx. +- `AdaptivePacer` circuit-breaker preserved. +- Inter-page base delay on API paths: **500–1500ms** (down from 2000–4000ms for HTML). +- API responses include `Cache-Control: private, max-age=1800`. ## 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. +- `htmlClient` — `baseURL=baseUrl`, browser headers, default params `ipRedirectOverride=true` + `language=`. Used only by `audible-series.ts` and `getBaseUrl()`-based link generation. +- Audnexus calls include `region=`. **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 +128,42 @@ 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, where it's needed to score multiple release candidates. Library availability checks require exact ASIN matches only. +**Note:** Fuzzy matching (70% threshold) is preserved in `ranking-algorithm.ts` for Prowlarr torrent ranking. Library availability checks require exact ASIN matches only. ## 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 via catalog API. +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 API hits). ## 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 +190,7 @@ interface AudibleAudiobook { asin: string; title: string; author: string; + authorAsin?: string; narrator?: string; description?: string; coverArtUrl?: string; @@ -189,6 +198,9 @@ interface AudibleAudiobook { releaseDate?: string; rating?: number; genres?: string[]; + series?: string; + seriesPart?: string; + seriesAsin?: string; } interface EnrichedAudibleAudiobook extends AudibleAudiobook { @@ -197,48 +209,45 @@ 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, `htmlClient` for series-page scraping only) +- 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` - -**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 - **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=` 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) diff --git a/src/lib/integrations/audible.service.ts b/src/lib/integrations/audible.service.ts index bc1bd90..cd30487 100644 --- a/src/lib/integrations/audible.service.ts +++ b/src/lib/integrations/audible.service.ts @@ -1,40 +1,32 @@ /** - * Component: Audible Integration Service (Web Scraping) + * Component: Audible Integration Service * Documentation: documentation/integrations/audible.md */ import axios, { AxiosInstance } from 'axios'; -import * as cheerio from 'cheerio'; import { RMABLogger } from '../utils/logger'; import { getConfigService } from '../services/config.service'; import { AudibleRegion, AUDIBLE_REGIONS, DEFAULT_AUDIBLE_REGION } from '../types/audible'; import { getLanguageForRegion, - stripPrefixes, - buildContainsSelector, - extractByPatterns, isAcceptedLanguage, - type LanguageConfig, } from '../constants/language-config'; import { pickUserAgent, getBrowserHeaders, jitteredBackoff, + randomDelay, AdaptivePacer, FetchResultMeta, } from '../utils/scrape-resilience'; -import { parseRuntime as parseRuntimeUtil } from '../utils/parse-runtime'; -// Module-level logger const logger = RMABLogger.create('Audible'); -/** - * Audible supports a pageSize query parameter (default ~20). - * Using 50 significantly reduces the number of HTTP requests needed - * for bulk operations like popular/new-release refreshes and search. - */ const AUDIBLE_PAGE_SIZE = 50; +const CATALOG_RESPONSE_GROUPS = + 'contributors,product_desc,product_attrs,product_extended_attrs,media,rating,series,category_ladders,product_details'; + export interface AudibleAudiobook { asin: string; title: string; @@ -67,112 +59,219 @@ export interface AuthorBooksResult { totalResults: number; } +interface CatalogProductAuthor { + asin?: string; + name: string; +} + +interface CatalogProductNarrator { + name: string; +} + +interface CatalogProductSeries { + asin?: string; + title?: string; + sequence?: string; +} + +interface CatalogProductLadderNode { + name: string; +} + +interface CatalogProductLadder { + ladder: CatalogProductLadderNode[]; +} + +interface CatalogProduct { + asin: string; + title?: string; + authors?: CatalogProductAuthor[]; + narrators?: CatalogProductNarrator[]; + publisher_summary?: string; + merchandising_summary?: string; + product_images?: Record; + runtime_length_min?: number; + release_date?: string; + language?: string; + rating?: { + overall_distribution?: { + display_stars?: number; + }; + }; + category_ladders?: CatalogProductLadder[]; + series?: CatalogProductSeries[]; +} + +interface CatalogProductsResponse { + products: CatalogProduct[]; + total_results?: number; +} + +interface CatalogProductResponse { + product: CatalogProduct; +} + +interface CatalogCategoriesResponse { + categories?: Array<{ id: string; name: string }>; +} + +function stripHtml(html: string): string { + return html + .replace(/<[^>]+>/g, '') + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/ /g, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +function mapCatalogProduct(product: CatalogProduct): AudibleAudiobook { + const author = product.authors?.map((a) => a.name).join(', ') ?? ''; + const authorAsin = product.authors?.[0]?.asin ?? undefined; + const narrator = + product.narrators && product.narrators.length > 0 + ? product.narrators.map((n) => n.name).join(', ') + : undefined; + + const rawDescription = product.publisher_summary ?? product.merchandising_summary; + const description = rawDescription ? stripHtml(rawDescription) : undefined; + + const coverArtUrl = product.product_images?.['500'] ?? undefined; + + const genreNames = + product.category_ladders?.flatMap((ladder) => + ladder.ladder.map((node) => node.name), + ) ?? []; + const genres = + genreNames.length > 0 + ? [...new Set(genreNames)].slice(0, 5) + : undefined; + + let series: string | undefined; + let seriesPart: string | undefined; + let seriesAsin: string | undefined; + + if (product.series && product.series.length > 0) { + const preferred = + product.series.find((s) => s.sequence && s.sequence.trim() !== '') ?? + product.series[0]; + + series = preferred.title ?? undefined; + seriesAsin = preferred.asin ?? undefined; + + if (preferred.sequence && preferred.sequence.trim() !== '') { + const digitMatch = preferred.sequence.match(/\d+(?:\.\d+)?/); + seriesPart = digitMatch ? digitMatch[0] : preferred.sequence; + } + } + + return { + asin: product.asin, + title: product.title ?? '', + author, + authorAsin, + narrator, + description, + coverArtUrl, + durationMinutes: product.runtime_length_min ?? undefined, + releaseDate: product.release_date ?? undefined, + rating: product.rating?.overall_distribution?.display_stars ?? undefined, + genres, + series, + seriesPart, + seriesAsin, + }; +} + export class AudibleService { - private client!: AxiosInstance; + private htmlClient!: AxiosInstance; + private apiClient!: AxiosInstance; private baseUrl: string = 'https://www.audible.com'; private region: AudibleRegion = 'us'; private initialized: boolean = false; private sessionUserAgent: string = ''; private pacer: AdaptivePacer = new AdaptivePacer(); - constructor() { - // Client will be created lazily on first use - } - - /** - * Get the current Audible base URL for the configured region - */ public getBaseUrl(): string { return this.baseUrl; } - /** - * Get the current Audible region code - */ public getRegion(): AudibleRegion { return this.region; } - /** - * Public fetch wrapper for external scraping modules (e.g. audible-series.ts). - * Ensures the service is initialized and delegates to fetchWithRetry. - */ public async fetch(url: string, config: any = {}): Promise<{ data: any; meta: FetchResultMeta }> { await this.initialize(); return this.fetchWithRetry(url, config); } - /** - * Get the language config for the current region - */ - private getLangConfig(): LanguageConfig { - return getLanguageForRegion(this.region); - } - - /** - * Force re-initialization (used when region config changes) - */ public forceReinitialize(): void { logger.info('Force re-initializing AudibleService'); this.initialized = false; } - /** - * Initialize service with configured region - * Lazy initialization allows async config loading - * Automatically re-initializes if region has changed - */ private async initialize(): Promise { - // If already initialized, check if region has changed if (this.initialized) { const configService = getConfigService(); const currentRegion = await configService.getAudibleRegion(); - // If region changed, force re-initialization if (currentRegion !== this.region) { logger.info(`Region changed from ${this.region} to ${currentRegion}, re-initializing`); this.initialized = false; } else { - return; // Region unchanged, use existing initialization + return; } } try { const configService = getConfigService(); this.region = await configService.getAudibleRegion(); - this.baseUrl = AUDIBLE_REGIONS[this.region].baseUrl; + const regionConfig = AUDIBLE_REGIONS[this.region]; + this.baseUrl = regionConfig.baseUrl; this.sessionUserAgent = pickUserAgent(); this.pacer.reset(); logger.info(`Initializing Audible service with region: ${this.region} (${this.baseUrl})`); - // Get language config for the region const langConfig = getLanguageForRegion(this.region); - // Create axios client with region-specific base URL and realistic browser headers - this.client = axios.create({ - baseURL: this.baseUrl, + this.htmlClient = axios.create({ + baseURL: regionConfig.baseUrl, timeout: 15000, headers: getBrowserHeaders(this.sessionUserAgent), params: { - ipRedirectOverride: 'true', // Prevent IP-based region redirects - language: langConfig.scraping.audibleLocaleParam, // Force locale (prevents IP-based language serving) + ipRedirectOverride: 'true', + language: langConfig.scraping.audibleLocaleParam, + }, + }); + + this.apiClient = axios.create({ + baseURL: regionConfig.apiBaseUrl, + timeout: 10000, + headers: { + Accept: 'application/json', + 'User-Agent': 'ReadMeABook/1.0', }, }); this.initialized = true; } catch (error) { - logger.error('Failed to initialize AudibleService', { error: error instanceof Error ? error.message : String(error) }); - // Fallback to default region + logger.error('Failed to initialize AudibleService', { + error: error instanceof Error ? error.message : String(error), + }); this.region = DEFAULT_AUDIBLE_REGION; - this.baseUrl = AUDIBLE_REGIONS[this.region].baseUrl; + const fallbackConfig = AUDIBLE_REGIONS[this.region]; + this.baseUrl = fallbackConfig.baseUrl; this.sessionUserAgent = pickUserAgent(); this.pacer.reset(); const fallbackLangConfig = getLanguageForRegion(this.region); - this.client = axios.create({ - baseURL: this.baseUrl, + this.htmlClient = axios.create({ + baseURL: fallbackConfig.baseUrl, timeout: 15000, headers: getBrowserHeaders(this.sessionUserAgent), params: { @@ -180,18 +279,25 @@ export class AudibleService { language: fallbackLangConfig.scraping.audibleLocaleParam, }, }); + + this.apiClient = axios.create({ + baseURL: fallbackConfig.apiBaseUrl, + timeout: 10000, + headers: { + Accept: 'application/json', + 'User-Agent': 'ReadMeABook/1.0', + }, + }); + this.initialized = true; } } - /** - * Fetch with retry logic and jittered exponential backoff. - * Returns the axios response plus metadata about retries encountered. - */ private async fetchWithRetry( url: string, config: any = {}, - maxRetries: number = 5 + maxRetries: number = 5, + client: AxiosInstance = this.htmlClient, ): Promise<{ data: any; meta: FetchResultMeta }> { let lastError: Error | null = null; let retriesUsed = 0; @@ -199,7 +305,7 @@ export class AudibleService { for (let attempt = 0; attempt <= maxRetries; attempt++) { try { - const response = await this.client.get(url, config); + const response = await client.get(url, config); return { data: response, meta: { retriesUsed, encountered503 } }; } catch (error: any) { lastError = error; @@ -208,38 +314,32 @@ export class AudibleService { if (status === 503) encountered503 = true; - // Don't retry on 404, 403, etc. if (!isRetryable) { throw error; } - // Don't retry on last attempt if (attempt === maxRetries) { break; } retriesUsed++; - // Jittered exponential backoff instead of predictable doubling const backoffMs = jitteredBackoff(attempt); - logger.info(` Request failed (${status || 'network error'}), retrying in ${backoffMs}ms (attempt ${attempt + 1}/${maxRetries})...`); + logger.info( + ` Request failed (${status || 'network error'}), retrying in ${backoffMs}ms (attempt ${attempt + 1}/${maxRetries})...`, + ); await this.delay(backoffMs); } } - // All retries exhausted throw lastError || new Error('Request failed after retries'); } - /** - * External API fetch with retry logic and exponential backoff - * Used for Audnexus and other external APIs - */ private async externalFetchWithRetry( url: string, config: any = {}, - maxRetries: number = 3 + maxRetries: number = 3, ): Promise { let lastError: Error | null = null; @@ -251,12 +351,10 @@ export class AudibleService { const status = error.response?.status; const isRetryable = !status || status === 503 || status === 429 || status >= 500; - // Don't retry on 404, 403, etc. if (!isRetryable) { throw error; } - // Don't retry on deterministic 500 errors (e.g. "Release date is in the future") if (status === 500) { const message = error.response?.data?.message || ''; if (message.includes('Release date is in the future')) { @@ -265,26 +363,22 @@ export class AudibleService { } } - // Don't retry on last attempt if (attempt === maxRetries) { break; } - // Exponential backoff: 2^attempt * 1000ms (1s, 2s, 4s...) const backoffMs = Math.pow(2, attempt) * 1000; - logger.info(` External API request failed (${status || 'network error'}), retrying in ${backoffMs}ms (attempt ${attempt + 1}/${maxRetries})...`); + logger.info( + ` External API request failed (${status || 'network error'}), retrying in ${backoffMs}ms (attempt ${attempt + 1}/${maxRetries})...`, + ); await this.delay(backoffMs); } } - // All retries exhausted throw lastError || new Error('External API request failed after retries'); } - /** - * Get popular audiobooks from best sellers (with pagination support) - */ async getPopularAudiobooks(limit: number = 20): Promise { await this.initialize(); @@ -300,85 +394,49 @@ export class AudibleService { try { logger.info(` Fetching page ${page}/${maxPages}...`); - const { data: response, meta } = await this.fetchWithRetry('/adblbestsellers', { - params: { - ipRedirectOverride: 'true', // Explicitly include to prevent IP-based region redirects - pageSize: AUDIBLE_PAGE_SIZE, - ...(page > 1 ? { page } : {}), + const { data: response, meta } = await this.fetchWithRetry( + '/1.0/catalog/products', + { + params: { + products_sort_by: 'BestSellers', + num_results: AUDIBLE_PAGE_SIZE, + page: page - 1, + response_groups: CATALOG_RESPONSE_GROUPS, + }, }, - }); - const $ = cheerio.load(response.data); + 5, + this.apiClient, + ); - let foundOnPage = 0; + const envelope: CatalogProductsResponse = response.data; + const products = envelope.products ?? []; + const totalResults = envelope.total_results ?? 0; - // Parse audiobook items from best sellers page - $('.productListItem').each((index, element) => { - if (audiobooks.length >= limit) return false; - - const $el = $(element); - - // Extract ASIN from data attribute or link - handle both /pd/ and /ac/ URLs - const asin = $el.find('li').attr('data-asin') || - $el.find('a').attr('href')?.match(/\/(?:pd|ac)\/[^\/]+\/([A-Z0-9]{10})/)?.[1] || ''; - - if (!asin) return; - - // Skip duplicates - if (audiobooks.some(book => book.asin === asin)) return; - - const title = $el.find('h3 a').text().trim() || - $el.find('.bc-heading a').text().trim(); - - const authorText = $el.find('.authorLabel').text().trim() || - $el.find('.bc-size-small .bc-text-bold').first().text().trim(); - - // Extract author ASIN from author link if available - const authorHref = $el.find('a[href*="/author/"]').first().attr('href') || ''; - const authorAsinMatch = authorHref.match(/\/author\/[^\/]+\/([A-Z0-9]{10})/); - - const narratorText = $el.find('.narratorLabel').text().trim() || - $el.find('.bc-size-small .bc-text-bold').eq(1).text().trim(); - - const coverArtUrl = $el.find('img').attr('src') || ''; - - const ratingText = $el.find('.ratingsLabel').text().trim(); - const rating = ratingText ? parseFloat(ratingText.split(' ')[0]) : undefined; - - const langConfig = this.getLangConfig(); - - audiobooks.push({ - asin, - title, - author: stripPrefixes(authorText, langConfig.scraping.authorPrefixes), - authorAsin: authorAsinMatch?.[1] || undefined, - narrator: stripPrefixes(narratorText, langConfig.scraping.narratorPrefixes), - coverArtUrl: coverArtUrl.replace(/\._.*_\./, '._SL500_.'), - rating, - }); - - foundOnPage++; - }); - - logger.info(` Found ${foundOnPage} audiobooks on page ${page}`); - - // If we got significantly fewer than requested, probably no more pages - if (foundOnPage < AUDIBLE_PAGE_SIZE / 2) { - logger.info(` Reached end of available pages`); - break; + for (const product of products) { + if (audiobooks.length >= limit) break; + if (audiobooks.some((b) => b.asin === product.asin)) continue; + audiobooks.push(mapCatalogProduct(product)); } + logger.info(` Found ${products.length} audiobooks on page ${page}`); + + const hasMore = + totalResults > 0 + ? totalResults > page * AUDIBLE_PAGE_SIZE + : products.length >= AUDIBLE_PAGE_SIZE; + + if (!hasMore) break; + page++; - // Adaptive delay between pages based on retry pressure if (page <= maxPages && audiobooks.length < limit) { - await this.delay(this.pacer.reportPageResult(meta)); + await this.delay(this.apiPageDelay(meta)); } } catch (error) { logger.error(`Failed to fetch page ${page} of popular audiobooks`, { error: error instanceof Error ? error.message : String(error), - collectedSoFar: audiobooks.length + collectedSoFar: audiobooks.length, }); - // Stop pagination on error, but return what we collected break; } } @@ -387,9 +445,6 @@ export class AudibleService { return audiobooks; } - /** - * Get new release audiobooks (with pagination support) - */ async getNewReleases(limit: number = 20): Promise { await this.initialize(); @@ -405,84 +460,49 @@ export class AudibleService { try { logger.info(` Fetching page ${page}/${maxPages}...`); - const { data: response, meta } = await this.fetchWithRetry('/newreleases', { - params: { - ipRedirectOverride: 'true', // Explicitly include to prevent IP-based region redirects - pageSize: AUDIBLE_PAGE_SIZE, - ...(page > 1 ? { page } : {}), + const { data: response, meta } = await this.fetchWithRetry( + '/1.0/catalog/products', + { + params: { + products_sort_by: '-ReleaseDate', + num_results: AUDIBLE_PAGE_SIZE, + page: page - 1, + response_groups: CATALOG_RESPONSE_GROUPS, + }, }, - }); - const $ = cheerio.load(response.data); + 5, + this.apiClient, + ); - let foundOnPage = 0; + const envelope: CatalogProductsResponse = response.data; + const products = envelope.products ?? []; + const totalResults = envelope.total_results ?? 0; - // Parse audiobook items from new releases page - $('.productListItem').each((index, element) => { - if (audiobooks.length >= limit) return false; - - const $el = $(element); - - // Extract ASIN from data attribute or link - handle both /pd/ and /ac/ URLs - const asin = $el.find('li').attr('data-asin') || - $el.find('a').attr('href')?.match(/\/(?:pd|ac)\/[^\/]+\/([A-Z0-9]{10})/)?.[1] || ''; - - if (!asin) return; - - // Skip duplicates - if (audiobooks.some(book => book.asin === asin)) return; - - const title = $el.find('h3 a').text().trim() || - $el.find('.bc-heading a').text().trim(); - - const authorText = $el.find('.authorLabel').text().trim() || - $el.find('.bc-size-small .bc-text-bold').first().text().trim(); - - // Extract author ASIN from author link if available - const authorHref = $el.find('a[href*="/author/"]').first().attr('href') || ''; - const authorAsinMatch = authorHref.match(/\/author\/[^\/]+\/([A-Z0-9]{10})/); - - const narratorText = $el.find('.narratorLabel').text().trim(); - - const coverArtUrl = $el.find('img').attr('src') || ''; - - const ratingText = $el.find('.ratingsLabel').text().trim(); - const rating = ratingText ? parseFloat(ratingText.split(' ')[0]) : undefined; - - const langConfig = this.getLangConfig(); - - audiobooks.push({ - asin, - title, - author: stripPrefixes(authorText, langConfig.scraping.authorPrefixes), - authorAsin: authorAsinMatch?.[1] || undefined, - narrator: stripPrefixes(narratorText, langConfig.scraping.narratorPrefixes), - coverArtUrl: coverArtUrl.replace(/\._.*_\./, '._SL500_.'), - rating, - }); - - foundOnPage++; - }); - - logger.info(` Found ${foundOnPage} audiobooks on page ${page}`); - - // If we got significantly fewer than requested, probably no more pages - if (foundOnPage < AUDIBLE_PAGE_SIZE / 2) { - logger.info(` Reached end of available pages`); - break; + for (const product of products) { + if (audiobooks.length >= limit) break; + if (audiobooks.some((b) => b.asin === product.asin)) continue; + audiobooks.push(mapCatalogProduct(product)); } + logger.info(` Found ${products.length} audiobooks on page ${page}`); + + const hasMore = + totalResults > 0 + ? totalResults > page * AUDIBLE_PAGE_SIZE + : products.length >= AUDIBLE_PAGE_SIZE; + + if (!hasMore) break; + page++; - // Adaptive delay between pages based on retry pressure if (page <= maxPages && audiobooks.length < limit) { - await this.delay(this.pacer.reportPageResult(meta)); + await this.delay(this.apiPageDelay(meta)); } } catch (error) { logger.error(`Failed to fetch page ${page} of new releases`, { error: error instanceof Error ? error.message : String(error), - collectedSoFar: audiobooks.length + collectedSoFar: audiobooks.length, }); - // Stop pagination on error, but return what we collected break; } } @@ -491,216 +511,109 @@ export class AudibleService { return audiobooks; } - /** - * Search for audiobooks - */ async search(query: string, page: number = 1): Promise { await this.initialize(); try { logger.info(` Searching for "${query}"...`); - const { data: response } = await this.fetchWithRetry('/search', { - params: { - ipRedirectOverride: 'true', // Explicitly include to prevent IP-based region redirects - keywords: query, - pageSize: AUDIBLE_PAGE_SIZE, - page, + const { data: response } = await this.fetchWithRetry( + '/1.0/catalog/products', + { + params: { + keywords: query, + num_results: AUDIBLE_PAGE_SIZE, + page: page - 1, + response_groups: CATALOG_RESPONSE_GROUPS, + }, }, - }); + 5, + this.apiClient, + ); - const $ = cheerio.load(response.data); + const envelope: CatalogProductsResponse = response.data; + const products = envelope.products ?? []; + const totalResults = envelope.total_results ?? 0; - const audiobooks: AudibleAudiobook[] = []; + const results = products.map(mapCatalogProduct); - // Parse search results - Audible uses s-result-item for search pages - $('.s-result-item, .productListItem').each((index, element) => { - const $el = $(element); - - // Extract ASIN from product detail link - handle both /pd/ and /ac/ URLs - const asin = $el.find('li').attr('data-asin') || - $el.find('a[href*="/pd/"]').attr('href')?.match(/\/pd\/[^\/]+\/([A-Z0-9]{10})/)?.[1] || - $el.find('a[href*="/ac/"]').attr('href')?.match(/\/ac\/[^\/]+\/([A-Z0-9]{10})/)?.[1] || - $el.find('a').attr('href')?.match(/\/(?:pd|ac)\/[^\/]+\/([A-Z0-9]{10})/)?.[1] || ''; - - if (!asin) return; - - // Extract title from h2 tag (search results) or h3 (legacy) - const title = $el.find('h2').first().text().trim() || - $el.find('h3 a').text().trim() || - $el.find('.bc-heading a').text().trim(); - - // Extract author from author link - const authorLink = $el.find('a[href*="/author/"]').first(); - const authorText = authorLink.text().trim() || - $el.find('.authorLabel').text().trim() || - $el.find('.bc-size-small .bc-text-bold').first().text().trim(); - - // Extract author ASIN from author link href - const authorHref = authorLink.attr('href') || ''; - const authorAsinMatch = authorHref.match(/\/author\/[^\/]+\/([A-Z0-9]{10})/); - - // Extract narrator from narrator search link - const narratorText = $el.find('a[href*="searchNarrator="]').first().text().trim() || - $el.find('.narratorLabel').text().trim(); - - const coverArtUrl = $el.find('img').attr('src') || ''; - - const langConfig = this.getLangConfig(); - - // Extract runtime/duration - const runtimeText = $el.find('.runtimeLabel').text().trim() || - $el.find(buildContainsSelector('span', langConfig.scraping.lengthLabels)).text().trim(); - const durationMinutes = this.parseRuntime(runtimeText); - - // Extract rating - const ratingText = $el.find('.ratingsLabel').text().trim() || - $el.find('.a-icon-star span').first().text().trim(); - const rating = ratingText ? parseFloat(ratingText.split(' ')[0]) : undefined; - - audiobooks.push({ - asin, - title, - author: stripPrefixes(authorText, langConfig.scraping.authorPrefixes), - authorAsin: authorAsinMatch?.[1] || undefined, - narrator: stripPrefixes(narratorText, langConfig.scraping.narratorPrefixes), - coverArtUrl: coverArtUrl.replace(/\._.*_\./, '._SL500_.'), - durationMinutes, - rating, - }); - }); - - // Try to extract total results count - const resultsText = $('.resultsInfo').text().trim(); - const totalResults = parseInt(resultsText.match(/of ([\d,]+)/)?.[1]?.replace(/,/g, '') || '0'); - - logger.info(` Found ${audiobooks.length} results for "${query}"`); + logger.info(` Found ${results.length} results for "${query}"`); return { query, - results: audiobooks, + results, totalResults, page, - hasMore: audiobooks.length > 0 && (totalResults > 0 - ? totalResults > page * AUDIBLE_PAGE_SIZE - : audiobooks.length >= AUDIBLE_PAGE_SIZE), + hasMore: + results.length > 0 && + (totalResults > 0 + ? totalResults > page * AUDIBLE_PAGE_SIZE + : results.length >= AUDIBLE_PAGE_SIZE), }; } catch (error) { - logger.error('Search failed', { error: error instanceof Error ? error.message : String(error) }); - return { - query, - results: [], - totalResults: 0, - page, - hasMore: false, - }; + logger.error('Search failed', { + error: error instanceof Error ? error.message : String(error), + }); + return { query, results: [], totalResults: 0, page, hasMore: false }; } } /** - * Search for all books by a specific author, validated by ASIN. - * Uses Audible's searchAuthor parameter and paginates through all results. - * Filters: (1) author link must contain the target ASIN, (2) language must be English. + * The catalog API `author=` param takes an author name (not ASIN), so we filter + * client-side by checking that at least one author entry matches the target ASIN. */ - async searchByAuthorAsin(authorName: string, authorAsin: string, page: number = 1): Promise { + async searchByAuthorAsin( + authorName: string, + authorAsin: string, + page: number = 1, + ): Promise { await this.initialize(); + const langConfig = getLanguageForRegion(this.region); const books: AudibleAudiobook[] = []; - const seenAsins = new Set(); try { logger.info(`Searching books by author "${authorName}" (ASIN: ${authorAsin}), page ${page}...`); - const { data: response } = await this.fetchWithRetry('/search', { - params: { - ipRedirectOverride: 'true', - searchAuthor: authorName, - pageSize: AUDIBLE_PAGE_SIZE, - page, + const { data: response } = await this.fetchWithRetry( + '/1.0/catalog/products', + { + params: { + author: authorName, + num_results: AUDIBLE_PAGE_SIZE, + page: page - 1, + response_groups: CATALOG_RESPONSE_GROUPS, + }, }, - }); + 5, + this.apiClient, + ); - const $ = cheerio.load(response.data); + const envelope: CatalogProductsResponse = response.data; + const products = envelope.products ?? []; + const totalResults = envelope.total_results ?? 0; - // Count raw items on page before filtering (for hasMore fallback) - const pageItemCount = $('.s-result-item, .productListItem').length; + for (const product of products) { + const authorMatch = product.authors?.some((a) => a.asin === authorAsin) ?? false; + if (!authorMatch) continue; - $('.s-result-item, .productListItem').each((_index, element) => { - const $el = $(element); + const langMatch = product.language + ? isAcceptedLanguage(product.language, langConfig) + : false; + if (!langMatch) continue; - // --- Language filter: require matching language for region --- - const langConfig = this.getLangConfig(); - const langText = $el.find(buildContainsSelector('span', langConfig.scraping.languageLabels)).text().trim() || - $el.find('.languageLabel').text().trim(); - const langLabelPattern = new RegExp(`(?:${langConfig.scraping.languageLabels.map(l => l.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')})\\s*(.+)`, 'i'); - const langMatch = langText.match(langLabelPattern); - const language = langMatch?.[1]?.trim(); - if (!language || !isAcceptedLanguage(language, langConfig)) return; + books.push(mapCatalogProduct(product)); + } - // --- Author ASIN filter: verify target ASIN in author links --- - const authorLinks = $el.find('a[href*="/author/"]'); - let hasMatchingAuthor = false; - authorLinks.each((_i, link) => { - const href = $(link).attr('href') || ''; - const asinMatch = href.match(/\/author\/[^\/]+\/([A-Z0-9]{10})/); - if (asinMatch && asinMatch[1] === authorAsin) { - hasMatchingAuthor = true; - return false; // break .each() - } - }); - if (!hasMatchingAuthor) return; + const hasMore = + books.length > 0 && + (totalResults > 0 + ? totalResults > page * AUDIBLE_PAGE_SIZE + : products.length >= AUDIBLE_PAGE_SIZE); - // --- Extract book ASIN --- - const bookAsin = $el.find('li').attr('data-asin') || - $el.find('a[href*="/pd/"]').attr('href')?.match(/\/pd\/[^\/]+\/([A-Z0-9]{10})/)?.[1] || - $el.find('a[href*="/ac/"]').attr('href')?.match(/\/ac\/[^\/]+\/([A-Z0-9]{10})/)?.[1] || - $el.find('a').attr('href')?.match(/\/(?:pd|ac)\/[^\/]+\/([A-Z0-9]{10})/)?.[1] || ''; - if (!bookAsin || seenAsins.has(bookAsin)) return; - seenAsins.add(bookAsin); - - // --- Parse book details --- - const title = $el.find('h2').first().text().trim() || - $el.find('h3 a').text().trim() || - $el.find('.bc-heading a').text().trim(); - - const authorText = $el.find('a[href*="/author/"]').first().text().trim() || - $el.find('.authorLabel').text().trim() || - $el.find('.bc-size-small .bc-text-bold').first().text().trim(); - - const narratorText = $el.find('a[href*="searchNarrator="]').first().text().trim() || - $el.find('.narratorLabel').text().trim(); - - const coverArtUrl = $el.find('img').attr('src') || ''; - - const runtimeText = $el.find('.runtimeLabel').text().trim() || - $el.find(buildContainsSelector('span', langConfig.scraping.lengthLabels)).text().trim(); - const durationMinutes = this.parseRuntime(runtimeText); - - const ratingText = $el.find('.ratingsLabel').text().trim() || - $el.find('.a-icon-star span').first().text().trim(); - const rating = ratingText ? parseFloat(ratingText.split(' ')[0]) : undefined; - - books.push({ - asin: bookAsin, - title, - author: stripPrefixes(authorText, langConfig.scraping.authorPrefixes), - authorAsin, - narrator: stripPrefixes(narratorText, langConfig.scraping.narratorPrefixes), - coverArtUrl: coverArtUrl.replace(/\._.*_\./, '._SL500_.'), - durationMinutes, - rating, - }); - }); - - // Check total results for pagination - const resultsText = $('.resultsInfo').text().trim(); - const totalResults = parseInt(resultsText.match(/of ([\d,]+)/)?.[1]?.replace(/,/g, '') || '0'); - // Use totalResults if available; otherwise fall back to whether Audible returned a full page - const hasMore = books.length > 0 && (totalResults > 0 - ? totalResults > page * AUDIBLE_PAGE_SIZE - : pageItemCount >= AUDIBLE_PAGE_SIZE); - - logger.info(`Author books page ${page}: ${books.length} valid results (${totalResults} Audible total)`); + logger.info( + `Author books page ${page}: ${books.length} valid results (${totalResults} Audible total)`, + ); return { books, hasMore, page, totalResults }; } catch (error) { logger.error(`Author books search failed for "${authorName}"`, { @@ -710,55 +623,45 @@ export class AudibleService { } } - /** - * Get detailed audiobook information - * Primary: Audnexus API (reliable, structured data) - * Fallback: Audible scraping - */ async getAudiobookDetails(asin: string): Promise { await this.initialize(); try { logger.info(` Fetching details for ASIN ${asin}...`); - // Try Audnexus first (more reliable) const audnexusData = await this.fetchFromAudnexus(asin); if (audnexusData) { logger.info(` Successfully fetched from Audnexus for "${audnexusData.title}"`); return audnexusData; } - logger.info(` Audnexus failed, falling back to Audible scraping...`); + logger.info(` Audnexus failed, falling back to Audible catalog API...`); - // Fallback to Audible scraping - return await this.scrapeAudibleDetails(asin); + return await this.fetchAudibleDetailsFromApi(asin); } catch (error) { - logger.error(`Failed to fetch details for ${asin}`, { error: error instanceof Error ? error.message : String(error) }); + logger.error(`Failed to fetch details for ${asin}`, { + error: error instanceof Error ? error.message : String(error), + }); return null; } } - /** - * Fetch audiobook details from Audnexus API - */ private async fetchFromAudnexus(asin: string): Promise { try { const audnexusRegion = AUDIBLE_REGIONS[this.region].audnexusParam; logger.debug(`Fetching ASIN from Audnexus: ${asin} (region: ${audnexusRegion})`); - const response = await this.externalFetchWithRetry(`https://api.audnex.us/books/${asin}`, { - params: { - region: audnexusRegion, // Pass region parameter to Audnexus + const response = await this.externalFetchWithRetry( + `https://api.audnex.us/books/${asin}`, + { + params: { region: audnexusRegion }, + timeout: 10000, + headers: { 'User-Agent': 'ReadMeABook/1.0' }, }, - timeout: 10000, - headers: { - 'User-Agent': 'ReadMeABook/1.0', - }, - }); + ); const data = response.data; - // Build result from Audnexus data const result: AudibleAudiobook = { asin, title: data.title || '', @@ -770,13 +673,12 @@ export class AudibleService { durationMinutes: data.runtimeLengthMin ? parseInt(data.runtimeLengthMin) : undefined, releaseDate: data.releaseDate || undefined, rating: data.rating ? parseFloat(data.rating) : undefined, - genres: data.genres?.map((g: any) => typeof g === 'string' ? g : g.name).slice(0, 5) || undefined, + genres: data.genres?.map((g: any) => (typeof g === 'string' ? g : g.name)).slice(0, 5) || undefined, series: data.seriesPrimary?.name || undefined, seriesPart: data.seriesPrimary?.position || undefined, seriesAsin: data.seriesPrimary?.asin || undefined, }; - // Ensure cover art URL is high quality if (result.coverArtUrl && !result.coverArtUrl.includes('_SL500_')) { result.coverArtUrl = result.coverArtUrl.replace(/\._.*_\./, '._SL500_.'); } @@ -791,7 +693,7 @@ export class AudibleService { genreCount: result.genres?.length || 0, series: result.series, seriesPart: result.seriesPart, - seriesAsin: result.seriesAsin + seriesAsin: result.seriesAsin, }); return result; @@ -805,367 +707,46 @@ export class AudibleService { } } - /** - * Scrape audiobook details from Audible (fallback method) - */ - private async scrapeAudibleDetails(asin: string): Promise { + private async fetchAudibleDetailsFromApi(asin: string): Promise { try { - const { data: response } = await this.fetchWithRetry(`/pd/${asin}`, { - params: { - ipRedirectOverride: 'true', // Explicitly include to prevent IP-based region redirects - }, - }); - const $ = cheerio.load(response.data); + const { data: response } = await this.fetchWithRetry( + `/1.0/catalog/products/${asin}`, + { params: { response_groups: CATALOG_RESPONSE_GROUPS } }, + 5, + this.apiClient, + ); - // Initialize result object - let result: AudibleAudiobook = { - asin, - title: '', - author: '', - narrator: '', - description: '', - coverArtUrl: '', - }; + const envelope: CatalogProductResponse = response.data; + const product = envelope.product; - // Debug: Save HTML in development - const isDev = process.env.NODE_ENV === 'development'; - if (isDev) { - const fs = require('fs'); - const path = require('path'); - const debugPath = path.join('/tmp', `audible-${asin}.html`); - fs.writeFileSync(debugPath, response.data); - logger.info(` Saved HTML to ${debugPath} for debugging`); + // The API returns HTTP 200 with a stub object for invalid ASINs; + // a missing title is the reliable signal that the ASIN is unrecognised. + if (!product?.title) { + logger.debug(`Catalog API returned stub for ASIN ${asin} (no title)`); + return null; } - // Try to extract JSON-LD structured data first - const jsonLdScripts = $('script[type="application/ld+json"]'); - logger.info(` Found ${jsonLdScripts.length} JSON-LD script tags`); - - jsonLdScripts.each((i, elem) => { - try { - const jsonData = JSON.parse($(elem).html() || '{}'); - logger.info(` JSON-LD ${i} type:`, jsonData['@type']); - - if (jsonData['@type'] === 'Book' || jsonData['@type'] === 'Audiobook' || jsonData['@type'] === 'Product') { - logger.debug('Found valid JSON-LD structured data'); - - if (jsonData.name) result.title = jsonData.name; - - if (jsonData.author) { - result.author = Array.isArray(jsonData.author) - ? jsonData.author.map((a: any) => a.name || a).join(', ') - : jsonData.author?.name || jsonData.author || ''; - } - - if (jsonData.readBy) { - result.narrator = Array.isArray(jsonData.readBy) - ? jsonData.readBy.map((n: any) => n.name || n).join(', ') - : jsonData.readBy?.name || jsonData.readBy || ''; - } - - if (jsonData.description) result.description = jsonData.description; - if (jsonData.image) result.coverArtUrl = jsonData.image; - if (jsonData.aggregateRating?.ratingValue) result.rating = jsonData.aggregateRating.ratingValue; - if (jsonData.datePublished) result.releaseDate = jsonData.datePublished; - - if (jsonData.duration) { - const durationMatch = jsonData.duration.match(/PT(\d+)H(\d+)M/); - if (durationMatch) { - result.durationMinutes = parseInt(durationMatch[1]) * 60 + parseInt(durationMatch[2]); - } - } - } - } catch (e) { - logger.debug(`JSON-LD ${i} parsing failed`, { error: e instanceof Error ? e.message : String(e) }); - } - }); - - // Fallback to HTML parsing for any missing fields - // Title - try multiple selectors - if (!result.title) { - result.title = $('h1.bc-heading').first().text().trim() || - $('h1[class*="heading"]').first().text().trim() || - $('.bc-container h1').first().text().trim() || - $('h1').first().text().trim(); - logger.info(` Title from HTML: "${result.title}"`); - } - - // Author - try multiple approaches (only in product details area) - if (!result.author) { - // Look specifically in the product details section, not the whole page - const productSection = $('.bc-section, .product-top-section, [class*="product"]').first(); - const authors: string[] = []; - - // First try labeled author sections - productSection.find('li.authorLabel a, span.authorLabel a, .authorLabel a').each((_, elem) => { - const text = $(elem).text().trim(); - if (text && text.length > 0 && text.length < 80) { - authors.push(text); - } - }); - - // If no labeled authors, look for author links near the title (first 3 only to avoid recommendations) - if (authors.length === 0) { - $('a[href*="/author/"]').slice(0, 3).each((_, elem) => { - const text = $(elem).text().trim(); - // Filter out navigation breadcrumbs and promotional text - if (text && text.length > 1 && text.length < 80 && - !text.includes('›') && !text.includes('...') && - !text.toLowerCase().includes('more') && !text.toLowerCase().includes('see all')) { - authors.push(text); - } - }); - } - - if (authors.length > 0) { - // Deduplicate and limit to max 3 authors - result.author = [...new Set(authors)].slice(0, 3).join(', '); - } - - const authorLangConfig = this.getLangConfig(); - result.author = stripPrefixes(result.author, authorLangConfig.scraping.authorPrefixes); - logger.info(` Author from HTML: "${result.author}"`); - } - - // Author ASIN - extract from the first author link - if (!result.authorAsin) { - const firstAuthorHref = $('a[href*="/author/"]').first().attr('href') || ''; - const authorAsinMatch = firstAuthorHref.match(/\/author\/[^\/]+\/([A-Z0-9]{10})/); - if (authorAsinMatch) { - result.authorAsin = authorAsinMatch[1]; - } - } - - // Narrator - try multiple approaches (only in product details area) - if (!result.narrator) { - // Look specifically in the product details section - const productSection = $('.bc-section, .product-top-section, [class*="product"]').first(); - const narrators: string[] = []; - - // First try labeled narrator sections - productSection.find('li.narratorLabel a, span.narratorLabel a, .narratorLabel a').each((_, elem) => { - const text = $(elem).text().trim(); - if (text && text.length > 0 && text.length < 80) { - narrators.push(text); - } - }); - - // If no labeled narrators, look for narrator links (first 5 only) - if (narrators.length === 0) { - $('a[href*="/narrator/"]').slice(0, 5).each((_, elem) => { - const text = $(elem).text().trim(); - if (text && text.length > 1 && text.length < 80 && - !text.includes('›') && !text.includes('...')) { - narrators.push(text); - } - }); - } - - if (narrators.length > 0) { - // Deduplicate and limit to reasonable count - result.narrator = [...new Set(narrators)].slice(0, 5).join(', '); - } - - if (result.narrator) { - const detailLangConfig = this.getLangConfig(); - result.narrator = stripPrefixes(result.narrator, detailLangConfig.scraping.narratorPrefixes); - } - logger.info(` Narrator from HTML: "${result.narrator || ''}"`); - } - - // Description - try multiple approaches with strict filtering - if (!result.description) { - const descLangConfig = this.getLangConfig(); - const excludePatterns = descLangConfig.scraping.descriptionExcludePatterns; - - const isValidDescription = (text: string): boolean => { - if (!text || text.length < 50 || text.length > 5000) return false; - // Reject if it contains promotional patterns - for (const pattern of excludePatterns) { - if (pattern.test(text)) return false; - } - return true; - }; - - // Try specific description selectors first - const candidates = [ - $('.bc-expander-content').first().text().trim(), - $('[class*="productPublisherSummary"]').first().text().trim(), - $('[data-widget="publisherSummary"]').first().text().trim(), - $('.bc-section p').first().text().trim(), - ]; - - // Find first valid candidate - for (const candidate of candidates) { - if (isValidDescription(candidate)) { - result.description = candidate; - break; - } - } - - // If still no description, search for valid paragraphs - if (!result.description) { - $('p, div[class*="description"]').each((_, elem) => { - const text = $(elem).text().trim(); - if (isValidDescription(text) && text.length > (result.description?.length || 0)) { - result.description = text; - } - }); - } - - logger.info(` Description length: ${result.description?.length || 0} chars`); - } - - // Cover art - try multiple selectors - if (!result.coverArtUrl) { - result.coverArtUrl = $('img.bc-image-inset-border').attr('src') || - $('img[class*="product-image"]').first().attr('src') || - $('img[class*="cover"]').first().attr('src') || - $('.bc-pub-detail-image img').attr('src') || - $('img[src*="images-na.ssl-images-amazon.com"]').first().attr('src') || - $('img[src*="m.media-amazon.com"]').first().attr('src') || - ''; - if (result.coverArtUrl) { - result.coverArtUrl = result.coverArtUrl.replace(/\._.*_\./, '._SL500_.'); - } - } - - // Runtime/Duration - try multiple approaches - if (!result.durationMinutes) { - const rtLangConfig = this.getLangConfig(); - - // Look for runtime text in various places - const runtimeText = - $('li.runtimeLabel span').text().trim() || - $('.runtimeLabel').text().trim() || - $(buildContainsSelector('span', rtLangConfig.scraping.lengthLabels)).parent().text().trim() || - $(buildContainsSelector('li', rtLangConfig.scraping.lengthLabels)).text().trim() || - (() => { - // Look for any text matching duration pattern - let found = ''; - $('li, span, div').each((_, elem) => { - const text = $(elem).text().trim(); - if (text.match(rtLangConfig.scraping.durationDetectionPattern) && text.length < 100) { - found = text; - return false; // break - } - }); - return found; - })(); - - result.durationMinutes = this.parseRuntime(runtimeText); - logger.info(` Duration from "${runtimeText}": ${result.durationMinutes} minutes`); - } - - // Rating - try multiple approaches - if (!result.rating) { - const ratingLangConfig = this.getLangConfig(); - const ratingText = - $('.ratingsLabel').text().trim() || - $('[class*="rating"]').first().text().trim() || - $(`span:contains("${ratingLangConfig.scraping.ratingTextSelector}")`).parent().text().trim() || - (() => { - // Look for rating pattern using language-specific patterns - let found = ''; - $('span, div').each((_, elem) => { - const text = $(elem).text().trim(); - if (text.length < 50) { - for (const pattern of ratingLangConfig.scraping.ratingPatterns) { - if (pattern.test(text)) { - found = text; - return false; - } - } - } - }); - return found; - })(); - - if (ratingText) { - let ratingValue: number | undefined; - for (const pattern of ratingLangConfig.scraping.ratingPatterns) { - const ratingMatch = ratingText.match(pattern); - if (ratingMatch) { - // Handle comma as decimal separator (e.g. "4,5" in German/Spanish) - ratingValue = parseFloat(ratingMatch[1].replace(',', '.')); - break; - } - } - result.rating = ratingValue; - } - logger.info(` Rating from "${ratingText}": ${result.rating}`); - } - - // Release date - try multiple selectors - if (!result.releaseDate) { - const rdLangConfig = this.getLangConfig(); - const releaseDateText = - $(buildContainsSelector('li', rdLangConfig.scraping.releaseDateLabels)).text().trim() || - $(buildContainsSelector('span', rdLangConfig.scraping.releaseDateLabels)).parent().text().trim() || - $('[class*="release"]').text().trim(); - - const dateMatch = extractByPatterns(releaseDateText, rdLangConfig.scraping.releaseDatePatterns) || - releaseDateText.match(/(\w+ \d{1,2},? \d{4})/)?.[1]; - if (dateMatch) { - result.releaseDate = dateMatch.trim(); - } - logger.info(` Release date from "${releaseDateText}": ${result.releaseDate}`); - } - - // Genres - try to extract categories - const genres: string[] = []; - $('a[href*="/cat/"]').each((_, el) => { - const genre = $(el).text().trim(); - if (genre && !genres.includes(genre) && genre.length < 50 && genre.length > 2) { - genres.push(genre); - } - }); - if (genres.length > 0) { - result.genres = genres.slice(0, 5); // Limit to 5 genres - logger.info(` Genres: ${result.genres.join(', ')}`); - } - - logger.info(`Successfully fetched details for "${result.title}"`); - logger.debug('Final result', { - title: result.title, - author: result.author, - narrator: result.narrator, - descLength: result.description?.length || 0, - duration: result.durationMinutes, - rating: result.rating, - genreCount: result.genres?.length || 0 - }); - - return result; + return mapCatalogProduct(product); } catch (error) { - logger.error(`Failed to fetch details for ${asin}`, { error: error instanceof Error ? error.message : String(error) }); + logger.error(`Catalog API details fetch failed for ${asin}`, { + error: error instanceof Error ? error.message : String(error), + }); return null; } } - /** - * Parse runtime text to minutes using language-specific patterns. - * Delegates to shared utility in src/lib/utils/parse-runtime.ts. - */ - private parseRuntime(runtimeText: string): number | undefined { - return parseRuntimeUtil(runtimeText, this.getLangConfig()); - } - - /** - * Get runtime (in minutes) for an audiobook by ASIN - * Lightweight method for size validation during search - * Returns null if not found or error - */ async getRuntime(asin: string): Promise { try { - // Use Audnexus API for fast, reliable runtime data const audnexusRegion = AUDIBLE_REGIONS[this.region].audnexusParam; - const response = await this.externalFetchWithRetry(`https://api.audnex.us/books/${asin}`, { - params: { region: audnexusRegion }, - timeout: 5000, // Quick timeout for search performance - headers: { 'User-Agent': 'ReadMeABook/1.0' }, - }); + const response = await this.externalFetchWithRetry( + `https://api.audnex.us/books/${asin}`, + { + params: { region: audnexusRegion }, + timeout: 5000, + headers: { 'User-Agent': 'ReadMeABook/1.0' }, + }, + ); const runtimeMin = response.data?.runtimeLengthMin; if (runtimeMin) { @@ -1181,38 +762,24 @@ export class AudibleService { } } - /** - * Get top-level categories from Audible's categories page. - * Scrapes {baseUrl}/categories and returns {id, name}[] for top-level nodes. - */ async getCategories(): Promise<{ id: string; name: string }[]> { await this.initialize(); logger.info('Fetching Audible categories...'); try { - const { data: response } = await this.fetchWithRetry('/categories', { - params: { ipRedirectOverride: 'true' }, - }); + const { data: response } = await this.fetchWithRetry( + '/1.0/catalog/categories', + {}, + 5, + this.apiClient, + ); - const $ = cheerio.load(response.data); - const categories: { id: string; name: string }[] = []; - - // Top-level category links are in the main categories grid - // They follow the pattern /cat/{name}/{nodeId} - $('a[href*="/cat/"]').each((_index, element) => { - const $el = $(element); - const href = $el.attr('href') || ''; - const match = href.match(/\/cat\/[^\/]+\/(\d+)/); - if (!match) return; - - const id = match[1]; - const name = $el.text().trim(); - - if (name && !categories.some((c) => c.id === id)) { - categories.push({ id, name }); - } - }); + const envelope: CatalogCategoriesResponse = response.data; + const categories = (envelope.categories ?? []).map((c) => ({ + id: c.id, + name: c.name, + })); logger.info(`Found ${categories.length} top-level categories`); return categories; @@ -1224,10 +791,6 @@ export class AudibleService { } } - /** - * Get audiobooks for a specific category using Audible search with node parameter. - * Scrapes {baseUrl}/search?node={categoryId}&pageSize=50, up to `limit` results. - */ async getCategoryBooks(categoryId: string, limit: number = 200): Promise { await this.initialize(); @@ -1241,81 +804,44 @@ export class AudibleService { while (audiobooks.length < limit && page <= maxPages) { try { - const { data: response, meta } = await this.fetchWithRetry('/search', { - params: { - ipRedirectOverride: 'true', - node: categoryId, - pageSize: AUDIBLE_PAGE_SIZE, - sort: 'popularity-rank', - ...(page > 1 ? { page } : {}), + const { data: response, meta } = await this.fetchWithRetry( + '/1.0/catalog/products', + { + params: { + category_id: categoryId, + products_sort_by: 'BestSellers', + num_results: AUDIBLE_PAGE_SIZE, + page: page - 1, + response_groups: CATALOG_RESPONSE_GROUPS, + }, }, - }); + 5, + this.apiClient, + ); - const $ = cheerio.load(response.data); - let foundOnPage = 0; + const envelope: CatalogProductsResponse = response.data; + const products = envelope.products ?? []; + const totalResults = envelope.total_results ?? 0; - // Parse search results — same selectors as search() - $('.s-result-item, .productListItem').each((_index, element) => { - if (audiobooks.length >= limit) return false; - const $el = $(element); + for (const product of products) { + if (audiobooks.length >= limit) break; + if (audiobooks.some((b) => b.asin === product.asin)) continue; + audiobooks.push(mapCatalogProduct(product)); + } - const asin = - $el.find('li').attr('data-asin') || - $el.find('a').attr('href')?.match(/\/(?:pd|ac)\/[^\/]+\/([A-Z0-9]{10})/)?.[1] || - ''; - if (!asin || audiobooks.some((b) => b.asin === asin)) return; + logger.info(`Category ${categoryId}: found ${products.length} books on page ${page}`); - const title = - $el.find('h2').first().text().trim() || - $el.find('h3 a').text().trim() || - $el.find('.bc-heading a').text().trim(); + const hasMore = + totalResults > 0 + ? totalResults > page * AUDIBLE_PAGE_SIZE + : products.length >= AUDIBLE_PAGE_SIZE; - const authorLink = $el.find('a[href*="/author/"]').first(); - const authorText = - authorLink.text().trim() || - $el.find('.authorLabel').text().trim(); - const authorHref = authorLink.attr('href') || ''; - const authorAsinMatch = authorHref.match(/\/author\/[^\/]+\/([A-Z0-9]{10})/); - - const narratorText = - $el.find('a[href*="searchNarrator="]').first().text().trim() || - $el.find('.narratorLabel').text().trim(); - - const coverArtUrl = $el.find('img').attr('src') || ''; - - const langConfig = this.getLangConfig(); - const runtimeText = - $el.find('.runtimeLabel').text().trim() || - $el.find(buildContainsSelector('span', langConfig.scraping.lengthLabels)).text().trim(); - const durationMinutes = this.parseRuntime(runtimeText); - - const ratingText = - $el.find('.ratingsLabel').text().trim() || - $el.find('.a-icon-star span').first().text().trim(); - const rating = ratingText ? parseFloat(ratingText.split(' ')[0]) : undefined; - - audiobooks.push({ - asin, - title, - author: stripPrefixes(authorText, langConfig.scraping.authorPrefixes), - authorAsin: authorAsinMatch?.[1] || undefined, - narrator: stripPrefixes(narratorText, langConfig.scraping.narratorPrefixes), - coverArtUrl: coverArtUrl.replace(/\._.*_\./, '._SL500_.'), - durationMinutes, - rating, - }); - - foundOnPage++; - }); - - logger.info(`Category ${categoryId}: found ${foundOnPage} books on page ${page}`); - - if (foundOnPage < AUDIBLE_PAGE_SIZE / 2) break; + if (!hasMore) break; page++; if (page <= maxPages && audiobooks.length < limit) { - await this.delay(this.pacer.reportPageResult(meta)); + await this.delay(this.apiPageDelay(meta)); } } catch (error) { logger.error(`Failed to fetch category ${categoryId} page ${page}`, { @@ -1326,19 +852,25 @@ export class AudibleService { } } - logger.info(`Category ${categoryId}: collected ${audiobooks.length} books across ${page - 1} pages`); + logger.info( + `Category ${categoryId}: collected ${audiobooks.length} books across ${page - 1} pages`, + ); return audiobooks; } - /** - * Add delay between requests to respect rate limits - */ + private apiPageDelay(meta: FetchResultMeta): number { + if (meta.retriesUsed > 0) { + return this.pacer.reportPageResult(meta); + } + this.pacer.reportPageResult(meta); + return randomDelay(500, 1500); + } + private async delay(ms: number): Promise { - return new Promise(resolve => setTimeout(resolve, ms)); + return new Promise((resolve) => setTimeout(resolve, ms)); } } -// Singleton instance let audibleService: AudibleService | null = null; export function getAudibleService(): AudibleService { diff --git a/src/lib/types/audible.ts b/src/lib/types/audible.ts index 2160d92..fa594f0 100644 --- a/src/lib/types/audible.ts +++ b/src/lib/types/audible.ts @@ -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 = { 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 = { 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 = { 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 = { 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 = { 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 = { 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 = { 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 = { 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';