diff --git a/documentation/integrations/audible.md b/documentation/integrations/audible.md index fbab910..e0cd736 100644 --- a/documentation/integrations/audible.md +++ b/documentation/integrations/audible.md @@ -26,11 +26,18 @@ Audiobook metadata from Audnexus API (primary) and Audible.com scraping (fallbac Configurable Audible region for accurate metadata matching across different international Audible stores. **Supported Regions:** -- United States (`us`) - `audible.com` (default) -- Canada (`ca`) - `audible.ca` -- United Kingdom (`uk`) - `audible.co.uk` -- Australia (`au`) - `audible.com.au` -- India (`in`) - `audible.in` +- 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) + +**`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 *") **Why Regions Matter:** - Each Audible region uses different ASINs for the same audiobook @@ -48,7 +55,7 @@ Configurable Audible region for accurate metadata matching across different inte - 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:** Cookie `lc-acbus=en_US` + `handleLocaleRedirect()` detects non-English culture codes in response URLs and re-requests using the English URL from Audible's locale picker +- **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 @@ -228,12 +235,8 @@ interface EnrichedAudibleAudiobook extends AudibleAudiobook { - **Affects:** All Audiobookshelf metadata matching operations **Non-English locale pages served to users outside US (2026-02-05)** -- **Problem:** Audible uses IP geolocation to add culture codes (e.g., `es_US`, `fr_CA`) to URLs, serving locale-specific pages. `ipRedirectOverride=true` only prevents region redirects (audible.com → audible.co.uk), NOT language/locale redirects within the same region. -- **Impact:** Users self-hosting from non-English-speaking countries (e.g., Dominican Republic) got Spanish bestsellers/new releases on their homepage because the `audible_refresh` job scraped locale-redirected pages. -- **Fix:** Three-layer defense in `AudibleService`: - 1. **Cookie:** `lc-acbus=en_US` header hints English locale preference - 2. **Locale picker detection (primary):** After every request, checks response URL for non-`en_*` culture codes (`xx_YY` pattern). If found, parses page HTML for Audible's `` locale picker, extracts the English option's `data-value` URL, and re-requests. Data-driven — uses Audible's own English URL rather than guessing. - 3. **Fallback URL rewrite:** If no locale picker found, strips the culture code from the path and adds `language=en_US` query param (mirrors picker pattern). -- **Verification:** After correction, validates the response URL no longer contains a non-English culture code and logs success/failure. -- **Location:** `src/lib/integrations/audible.service.ts` — `handleLocaleRedirect()`, `initialize()` -- **Affects:** All Audible scraping: popular, new releases, search, detail pages (via `fetchWithRetry`) +- **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 diff --git a/package.json b/package.json index 23857e6..c2ed5a2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "readmeabook", - "version": "1.0.1", + "version": "1.0.2", "private": true, "scripts": { "dev": "next dev", diff --git a/src/app/admin/settings/tabs/LibraryTab/AudiobookshelfSection.tsx b/src/app/admin/settings/tabs/LibraryTab/AudiobookshelfSection.tsx index c3b0807..03bc2d4 100644 --- a/src/app/admin/settings/tabs/LibraryTab/AudiobookshelfSection.tsx +++ b/src/app/admin/settings/tabs/LibraryTab/AudiobookshelfSection.tsx @@ -6,6 +6,7 @@ import { Input } from '@/components/ui/Input'; import { Button } from '@/components/ui/Button'; import { Settings, ABSLibrary } from '../../lib/types'; +import { AUDIBLE_REGIONS } from '@/lib/types/audible'; interface AudiobookshelfSectionProps { settings: Settings; @@ -161,13 +162,39 @@ export function AudiobookshelfSection({ onChange={(e) => handleAudibleRegionChange(e.target.value)} className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500" > - - - - - - + {Object.values(AUDIBLE_REGIONS).map((region) => ( + + ))} + {AUDIBLE_REGIONS[settings.audibleRegion as keyof typeof AUDIBLE_REGIONS]?.isEnglish === false && ( +
+
+ + + +
+

+ Non-English Region +

+

+ Many features such as search, discovery, and metadata matching are not yet fully + supported for non-English regions. You may still proceed, but expect limited + functionality. +

+
+
+
+ )}

Select the Audible region that matches your metadata engine (Audnexus/Audible Agent) configuration in Audiobookshelf. This ensures accurate book matching and metadata. diff --git a/src/app/admin/settings/tabs/LibraryTab/PlexSection.tsx b/src/app/admin/settings/tabs/LibraryTab/PlexSection.tsx index b0bc8a8..19c3293 100644 --- a/src/app/admin/settings/tabs/LibraryTab/PlexSection.tsx +++ b/src/app/admin/settings/tabs/LibraryTab/PlexSection.tsx @@ -6,6 +6,7 @@ import { Input } from '@/components/ui/Input'; import { Button } from '@/components/ui/Button'; import { Settings, PlexLibrary } from '../../lib/types'; +import { AUDIBLE_REGIONS } from '@/lib/types/audible'; interface PlexSectionProps { settings: Settings; @@ -161,13 +162,39 @@ export function PlexSection({ onChange={(e) => handleAudibleRegionChange(e.target.value)} className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500" > - - - - - - + {Object.values(AUDIBLE_REGIONS).map((region) => ( + + ))} + {AUDIBLE_REGIONS[settings.audibleRegion as keyof typeof AUDIBLE_REGIONS]?.isEnglish === false && ( +

+
+ + + +
+

+ Non-English Region +

+

+ Many features such as search, discovery, and metadata matching are not yet fully + supported for non-English regions. You may still proceed, but expect limited + functionality. +

+
+
+
+ )}

Select the Audible region that matches your metadata engine (Audnexus/Audible Agent) configuration in Plex. This ensures accurate book matching and metadata. diff --git a/src/app/api/admin/settings/audible/route.ts b/src/app/api/admin/settings/audible/route.ts index 0ecebf0..ce964b9 100644 --- a/src/app/api/admin/settings/audible/route.ts +++ b/src/app/api/admin/settings/audible/route.ts @@ -8,11 +8,12 @@ import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middlewar import { getConfigService } from '@/lib/services/config.service'; import { getAudibleService } from '@/lib/integrations/audible.service'; import { getJobQueueService } from '@/lib/services/job-queue.service'; +import { AUDIBLE_REGIONS } from '@/lib/types/audible'; import { RMABLogger } from '@/lib/utils/logger'; const logger = RMABLogger.create('API.Admin.Settings.Audible'); -const VALID_REGIONS = ['us', 'ca', 'uk', 'au', 'in', 'de']; +const VALID_REGIONS = Object.keys(AUDIBLE_REGIONS); export async function PUT(request: NextRequest) { return requireAuth(request, async (req: AuthenticatedRequest) => { @@ -24,7 +25,7 @@ export async function PUT(request: NextRequest) { if (!region || !VALID_REGIONS.includes(region)) { logger.warn('Invalid region provided', { region }); return NextResponse.json( - { success: false, error: 'Invalid Audible region. Must be one of: us, ca, uk, au, in, de' }, + { success: false, error: `Invalid Audible region. Must be one of: ${VALID_REGIONS.join(', ')}` }, { status: 400 } ); } diff --git a/src/app/setup/steps/BackendSelectionStep.tsx b/src/app/setup/steps/BackendSelectionStep.tsx index 114c783..f8f44bb 100644 --- a/src/app/setup/steps/BackendSelectionStep.tsx +++ b/src/app/setup/steps/BackendSelectionStep.tsx @@ -6,7 +6,7 @@ 'use client'; import { Button } from '@/components/ui/Button'; -import { AudibleRegion } from '@/lib/types/audible'; +import { AudibleRegion, AUDIBLE_REGIONS } from '@/lib/types/audible'; interface BackendSelectionStepProps { value: 'plex' | 'audiobookshelf'; @@ -113,13 +113,39 @@ export function BackendSelectionStep({ onChange={(e) => onAudibleRegionChange(e.target.value as AudibleRegion)} className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500" > - - - - - - + {Object.values(AUDIBLE_REGIONS).map((region) => ( + + ))} + {AUDIBLE_REGIONS[audibleRegion]?.isEnglish === false && ( +

+
+ + + +
+

+ Non-English Region +

+

+ Many features such as search, discovery, and metadata matching are not yet fully + supported for non-English regions. You may still proceed, but expect limited + functionality. +

+
+
+
+ )}

Select the Audible region that matches your metadata engine (Audnexus/Audible Agent) configuration in {value === 'plex' ? 'Plex' : 'Audiobookshelf'}. This ensures accurate book matching and metadata. diff --git a/src/lib/integrations/audible.service.ts b/src/lib/integrations/audible.service.ts index 6474122..7915fe7 100644 --- a/src/lib/integrations/audible.service.ts +++ b/src/lib/integrations/audible.service.ts @@ -88,10 +88,10 @@ export class AudibleService { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', 'Accept-Language': 'en-US,en;q=0.9', - 'Cookie': 'lc-acbus=en_US', // Force English locale (prevents IP-based language redirect for non-US IPs) }, params: { ipRedirectOverride: 'true', // Prevent IP-based region redirects + language: 'english', // Force English locale (prevents IP-based language serving for non-English IPs) }, }); @@ -108,118 +108,16 @@ export class AudibleService { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', 'Accept-Language': 'en-US,en;q=0.9', - 'Cookie': 'lc-acbus=en_US', // Force English locale }, params: { ipRedirectOverride: 'true', + language: 'english', }, }); this.initialized = true; } } - /** - * Detect and correct non-English locale pages from Audible. - * - * Audible uses IP geolocation to serve locale-specific pages by adding culture - * codes to URLs (e.g., /adblbestsellers → /es_US/charts/best for Spanish-speaking IPs). - * ipRedirectOverride only prevents region redirects (audible.com → audible.co.uk), - * NOT language/locale redirects within the same region. - * - * Strategy (data-driven): - * 1. Check response URL for any non-English culture code (xx_YY where xx != 'en') - * 2. Parse the page's locale picker (adbl-toggle-chip elements) to find the English URL - * 3. Re-request using Audible's own English URL (from the picker's data-value attribute) - * 4. Fallback: strip culture code from URL + add language=en_US param if no picker found - * - * Returns corrected response, or null if no correction needed. - */ - private async handleLocaleRedirect(response: any): Promise { - try { - // Extract final URL after all redirects (Node.js http internals) - const finalUrl: string = response.request?.res?.responseUrl || - response.request?._redirectable?._currentUrl || ''; - - if (!finalUrl) return null; - - // Check for non-English culture code in URL path - // Culture codes: xx_YY (e.g., es_US, fr_CA, pt_BR, de_DE, ja_JP) - // Match in path segment: must follow a / and be followed by / or end-of-path or query string - const localeMatch = finalUrl.match(/\/([a-z]{2}_[A-Z]{2})(\/|$|\?)/); - if (!localeMatch || localeMatch[1].startsWith('en')) { - return null; // No culture code found, or already English - } - - const detectedLocale = localeMatch[1]; - logger.warn(`Detected non-English locale (${detectedLocale}) in Audible response URL: ${finalUrl}`); - - // --- Primary strategy: parse the locale picker from the page HTML --- - // Audible pages include a locale picker with web components: - // English - // Français - // The English option's data-value gives us the exact correct English URL for this page. - const $ = cheerio.load(response.data); - const englishChip = $('adbl-toggle-chip[data-locale^="en"]').first(); - - if (englishChip.length > 0) { - const englishPath = englishChip.attr('data-value'); - const englishLocale = englishChip.attr('data-locale'); - - if (englishPath) { - logger.info(`Found English option (${englishLocale}) in locale picker: ${englishPath}`); - - // Re-request using the English URL from the picker - // data-value is a relative path (e.g., "/charts/best?language=en_CA") - // Client defaults add ipRedirectOverride=true automatically - const correctedResponse = await this.client.get(englishPath); - - // Verify the correction actually resolved to English - const correctedUrl: string = correctedResponse.request?.res?.responseUrl || - correctedResponse.request?._redirectable?._currentUrl || ''; - if (correctedUrl) { - const verifyMatch = correctedUrl.match(/\/([a-z]{2}_[A-Z]{2})(\/|$|\?)/); - if (verifyMatch && !verifyMatch[1].startsWith('en')) { - logger.warn(`Locale correction incomplete — corrected URL still contains non-English locale (${verifyMatch[1]}): ${correctedUrl}`); - } else { - logger.info(`Locale correction successful (${detectedLocale} → ${englishLocale})`); - } - } - - return correctedResponse; - } - - logger.warn('English locale chip found but missing data-value attribute'); - } else { - logger.warn('No locale picker found on page, attempting fallback URL rewrite'); - } - - // --- Fallback strategy: URL rewrite --- - // Strip the non-English culture code from the path and add language=en_US param. - // This mirrors the locale picker pattern: English URLs have no prefix + language param. - try { - const urlObj = new URL(finalUrl); - urlObj.pathname = urlObj.pathname.replace(`/${detectedLocale}`, ''); - urlObj.searchParams.set('language', 'en_US'); - - // Build relative path (client will prepend baseURL) - const fallbackPath = urlObj.pathname + urlObj.search; - logger.info(`Fallback: re-requesting with URL rewrite: ${fallbackPath}`); - - return await this.client.get(fallbackPath); - } catch (urlError) { - logger.warn('Fallback URL rewrite failed', { - error: urlError instanceof Error ? urlError.message : String(urlError), - }); - } - } catch (error) { - logger.debug('Locale correction failed entirely, using original response', { - error: error instanceof Error ? error.message : String(error), - }); - } - - return null; - } - /** * Fetch with retry logic and exponential backoff * Retries on network errors and rate limiting (503, 429) @@ -233,10 +131,7 @@ export class AudibleService { for (let attempt = 0; attempt <= maxRetries; attempt++) { try { - const response = await this.client.get(url, config); - - // Check if redirected to non-English locale (e.g., /es_US/) and correct it - return await this.handleLocaleRedirect(response) || response; + return await this.client.get(url, config); } catch (error: any) { lastError = error; const status = error.response?.status; diff --git a/src/lib/types/audible.ts b/src/lib/types/audible.ts index 87ed7f9..e416fd5 100644 --- a/src/lib/types/audible.ts +++ b/src/lib/types/audible.ts @@ -10,6 +10,7 @@ export interface AudibleRegionConfig { name: string; baseUrl: string; audnexusParam: string; + isEnglish: boolean; } export const AUDIBLE_REGIONS: Record = { @@ -18,36 +19,42 @@ export const AUDIBLE_REGIONS: Record = { name: 'United States', baseUrl: 'https://www.audible.com', audnexusParam: 'us', + isEnglish: true, }, ca: { code: 'ca', name: 'Canada', baseUrl: 'https://www.audible.ca', audnexusParam: 'ca', + isEnglish: true, }, uk: { code: 'uk', name: 'United Kingdom', baseUrl: 'https://www.audible.co.uk', audnexusParam: 'uk', + isEnglish: true, }, au: { code: 'au', name: 'Australia', baseUrl: 'https://www.audible.com.au', audnexusParam: 'au', + isEnglish: true, }, in: { code: 'in', name: 'India', baseUrl: 'https://www.audible.in', audnexusParam: 'in', + isEnglish: true, }, de: { code: 'de', name: 'Germany', baseUrl: 'https://www.audible.de', audnexusParam: 'de', + isEnglish: false, }, };