Add language config and locale-aware parsing

Introduce centralized language configuration and wire locale-aware behavior across scraping and ranking. Adds src/lib/constants/language-config.ts with per-language scraping rules, stop words, and character replacements; replaces AudibleRegion.isEnglish with a language field in types and AUDIBLE_REGIONS. Update AudibleService, ebook scraper, processors, and API routes to use getLanguageForRegion so Anna's Archive searches, scraping selectors, runtime/rating parsing, and ranking use language-specific params and filters. Extend ranking algorithm to accept stopWords and characterReplacements and apply them during normalization and matching. Update UI selects to mark non-English regions and adjust tests accordingly.
This commit is contained in:
kikootwo
2026-02-20 06:32:44 -05:00
parent c146383735
commit 5d8ac2f73d
18 changed files with 525 additions and 112 deletions
@@ -164,11 +164,11 @@ export function AudiobookshelfSection({
> >
{Object.values(AUDIBLE_REGIONS).map((region) => ( {Object.values(AUDIBLE_REGIONS).map((region) => (
<option key={region.code} value={region.code}> <option key={region.code} value={region.code}>
{region.name}{!region.isEnglish ? ' *' : ''} {region.name}{region.language !== 'en' ? ' *' : ''}
</option> </option>
))} ))}
</select> </select>
{AUDIBLE_REGIONS[settings.audibleRegion as keyof typeof AUDIBLE_REGIONS]?.isEnglish === false && ( {AUDIBLE_REGIONS[settings.audibleRegion as keyof typeof AUDIBLE_REGIONS]?.language !== 'en' && (
<div className="bg-amber-50 dark:bg-amber-900/20 rounded-lg p-4 border border-amber-200 dark:border-amber-800 mt-2"> <div className="bg-amber-50 dark:bg-amber-900/20 rounded-lg p-4 border border-amber-200 dark:border-amber-800 mt-2">
<div className="flex gap-3"> <div className="flex gap-3">
<svg <svg
@@ -164,11 +164,11 @@ export function PlexSection({
> >
{Object.values(AUDIBLE_REGIONS).map((region) => ( {Object.values(AUDIBLE_REGIONS).map((region) => (
<option key={region.code} value={region.code}> <option key={region.code} value={region.code}>
{region.name}{!region.isEnglish ? ' *' : ''} {region.name}{region.language !== 'en' ? ' *' : ''}
</option> </option>
))} ))}
</select> </select>
{AUDIBLE_REGIONS[settings.audibleRegion as keyof typeof AUDIBLE_REGIONS]?.isEnglish === false && ( {AUDIBLE_REGIONS[settings.audibleRegion as keyof typeof AUDIBLE_REGIONS]?.language !== 'en' && (
<div className="bg-amber-50 dark:bg-amber-900/20 rounded-lg p-4 border border-amber-200 dark:border-amber-800 mt-2"> <div className="bg-amber-50 dark:bg-amber-900/20 rounded-lg p-4 border border-amber-200 dark:border-amber-800 mt-2">
<div className="flex gap-3"> <div className="flex gap-3">
<svg <svg
@@ -18,6 +18,8 @@ import { findPlexMatch } from '@/lib/utils/audiobook-matcher';
import { getAudibleService } from '@/lib/integrations/audible.service'; import { getAudibleService } from '@/lib/integrations/audible.service';
import { RMABLogger } from '@/lib/utils/logger'; import { RMABLogger } from '@/lib/utils/logger';
import { resolveInteractiveSearchAccess } from '@/lib/utils/permissions'; import { resolveInteractiveSearchAccess } from '@/lib/utils/permissions';
import { getLanguageForRegion } from '@/lib/constants/language-config';
import type { AudibleRegion } from '@/lib/types/audible';
import { import {
searchByAsin, searchByAsin,
searchByTitle, searchByTitle,
@@ -227,6 +229,11 @@ export async function POST(
const format = preferredFormat || 'epub'; const format = preferredFormat || 'epub';
const annasBaseUrl = baseUrl || 'https://annas-archive.li'; const annasBaseUrl = baseUrl || 'https://annas-archive.li';
// Get language code from Audible region config
const region = await configService.getAudibleRegion() as AudibleRegion;
const langConfig = getLanguageForRegion(region);
const languageCode = langConfig.annasArchiveLang;
if (!isAnnasArchiveEnabled && !isIndexerSearchEnabled) { if (!isAnnasArchiveEnabled && !isIndexerSearchEnabled) {
return NextResponse.json( return NextResponse.json(
{ error: 'No ebook sources enabled. Enable Anna\'s Archive or Indexer Search in settings.' }, { error: 'No ebook sources enabled. Enable Anna\'s Archive or Indexer Search in settings.' },
@@ -250,7 +257,8 @@ export async function POST(
audiobook.author, audiobook.author,
format, format,
annasBaseUrl, annasBaseUrl,
flaresolverrUrl || undefined flaresolverrUrl || undefined,
languageCode
).catch((err) => { ).catch((err) => {
logger.error(`Anna's Archive search failed: ${err.message}`); logger.error(`Anna's Archive search failed: ${err.message}`);
return null; return null;
@@ -322,7 +330,8 @@ async function searchAnnasArchiveForInteractive(
author: string, author: string,
preferredFormat: string, preferredFormat: string,
baseUrl: string, baseUrl: string,
flaresolverrUrl?: string flaresolverrUrl?: string,
languageCode: string = 'en'
): Promise<EbookSearchResult[]> { ): Promise<EbookSearchResult[]> {
let md5: string | null = null; let md5: string | null = null;
let searchMethod: 'asin' | 'title' = 'title'; let searchMethod: 'asin' | 'title' = 'title';
@@ -330,7 +339,7 @@ async function searchAnnasArchiveForInteractive(
// Try ASIN search first // Try ASIN search first
if (asin) { if (asin) {
logger.info(`Searching Anna's Archive by ASIN: ${asin}`); logger.info(`Searching Anna's Archive by ASIN: ${asin}`);
md5 = await searchByAsin(asin, preferredFormat, baseUrl, undefined, flaresolverrUrl); md5 = await searchByAsin(asin, preferredFormat, baseUrl, undefined, flaresolverrUrl, languageCode);
if (md5) { if (md5) {
searchMethod = 'asin'; searchMethod = 'asin';
logger.info(`Found via ASIN: ${md5}`); logger.info(`Found via ASIN: ${md5}`);
@@ -340,7 +349,7 @@ async function searchAnnasArchiveForInteractive(
// Fallback to title search // Fallback to title search
if (!md5) { if (!md5) {
logger.info(`Searching Anna's Archive by title: "${title}"`); logger.info(`Searching Anna's Archive by title: "${title}"`);
md5 = await searchByTitle(title, author, preferredFormat, baseUrl, undefined, flaresolverrUrl); md5 = await searchByTitle(title, author, preferredFormat, baseUrl, undefined, flaresolverrUrl, languageCode);
if (md5) { if (md5) {
logger.info(`Found via title: ${md5}`); logger.info(`Found via title: ${md5}`);
} }
@@ -461,6 +470,10 @@ async function searchIndexersForInteractive(
return []; return [];
} }
// Get language-specific stop words for ranking
const rankRegion = await configService.getAudibleRegion() as AudibleRegion;
const rankLangConfig = getLanguageForRegion(rankRegion);
// Rank results with ebook scoring // Rank results with ebook scoring
const rankedResults = rankEbookTorrents(allResults, { const rankedResults = rankEbookTorrents(allResults, {
title, title,
@@ -470,6 +483,8 @@ async function searchIndexersForInteractive(
indexerPriorities, indexerPriorities,
flagConfigs, flagConfigs,
requireAuthor: false, requireAuthor: false,
stopWords: rankLangConfig.stopWords,
characterReplacements: rankLangConfig.characterReplacements,
}); });
// Convert to unified result type // Convert to unified result type
@@ -10,6 +10,8 @@ import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
import { getProwlarrService } from '@/lib/integrations/prowlarr.service'; import { getProwlarrService } from '@/lib/integrations/prowlarr.service';
import { rankTorrents } from '@/lib/utils/ranking-algorithm'; import { rankTorrents } from '@/lib/utils/ranking-algorithm';
import { groupIndexersByCategories, getGroupDescription } from '@/lib/utils/indexer-grouping'; import { groupIndexersByCategories, getGroupDescription } from '@/lib/utils/indexer-grouping';
import { getLanguageForRegion } from '@/lib/constants/language-config';
import type { AudibleRegion } from '@/lib/types/audible';
import { z } from 'zod'; import { z } from 'zod';
import { RMABLogger } from '@/lib/utils/logger'; import { RMABLogger } from '@/lib/utils/logger';
@@ -140,13 +142,19 @@ export async function POST(request: NextRequest) {
logger.info(`Will filter ${belowThreshold.length} results < ${sizeMBThreshold} MB (likely ebooks)`); logger.info(`Will filter ${belowThreshold.length} results < ${sizeMBThreshold} MB (likely ebooks)`);
} }
// Get language-specific stop words for ranking
const region = await configService.getAudibleRegion() as AudibleRegion;
const langConfig = getLanguageForRegion(region);
// Rank torrents using the ranking algorithm with indexer priorities and flag configs // Rank torrents using the ranking algorithm with indexer priorities and flag configs
// Note: rankTorrents now filters out results < 20 MB internally // Note: rankTorrents now filters out results < 20 MB internally
// requireAuthor: false - interactive search, show all results for user decision // requireAuthor: false - interactive search, show all results for user decision
const rankedResults = rankTorrents(results, { title, author, durationMinutes }, { const rankedResults = rankTorrents(results, { title, author, durationMinutes }, {
indexerPriorities, indexerPriorities,
flagConfigs, flagConfigs,
requireAuthor: false // Interactive mode - let user decide requireAuthor: false, // Interactive mode - let user decide
stopWords: langConfig.stopWords,
characterReplacements: langConfig.characterReplacements,
}); });
// Log filter results // Log filter results
@@ -14,6 +14,8 @@ import { getProwlarrService } from '@/lib/integrations/prowlarr.service';
import { rankEbookTorrents, RankedEbookTorrent } from '@/lib/utils/ranking-algorithm'; import { rankEbookTorrents, RankedEbookTorrent } from '@/lib/utils/ranking-algorithm';
import { groupIndexersByCategories, getGroupDescription } from '@/lib/utils/indexer-grouping'; import { groupIndexersByCategories, getGroupDescription } from '@/lib/utils/indexer-grouping';
import { RMABLogger } from '@/lib/utils/logger'; import { RMABLogger } from '@/lib/utils/logger';
import { getLanguageForRegion } from '@/lib/constants/language-config';
import type { AudibleRegion } from '@/lib/types/audible';
import { import {
searchByAsin, searchByAsin,
searchByTitle, searchByTitle,
@@ -121,6 +123,11 @@ export async function POST(
const format = preferredFormat || 'epub'; const format = preferredFormat || 'epub';
const annasBaseUrl = baseUrl || 'https://annas-archive.li'; const annasBaseUrl = baseUrl || 'https://annas-archive.li';
// Get language code from Audible region config
const region = await configService.getAudibleRegion() as AudibleRegion;
const langConfig = getLanguageForRegion(region);
const languageCode = langConfig.annasArchiveLang;
if (!isAnnasArchiveEnabled && !isIndexerSearchEnabled) { if (!isAnnasArchiveEnabled && !isIndexerSearchEnabled) {
return NextResponse.json( return NextResponse.json(
{ error: 'No ebook sources enabled. Enable Anna\'s Archive or Indexer Search in settings.' }, { error: 'No ebook sources enabled. Enable Anna\'s Archive or Indexer Search in settings.' },
@@ -145,7 +152,8 @@ export async function POST(
audiobook.author, audiobook.author,
format, format,
annasBaseUrl, annasBaseUrl,
flaresolverrUrl || undefined flaresolverrUrl || undefined,
languageCode
).catch((err) => { ).catch((err) => {
logger.error(`Anna's Archive search failed: ${err.message}`); logger.error(`Anna's Archive search failed: ${err.message}`);
return null; return null;
@@ -217,7 +225,8 @@ async function searchAnnasArchiveForInteractive(
author: string, author: string,
preferredFormat: string, preferredFormat: string,
baseUrl: string, baseUrl: string,
flaresolverrUrl?: string flaresolverrUrl?: string,
languageCode: string = 'en'
): Promise<EbookSearchResult[]> { ): Promise<EbookSearchResult[]> {
let md5: string | null = null; let md5: string | null = null;
let searchMethod: 'asin' | 'title' = 'title'; let searchMethod: 'asin' | 'title' = 'title';
@@ -225,7 +234,7 @@ async function searchAnnasArchiveForInteractive(
// Try ASIN search first // Try ASIN search first
if (asin) { if (asin) {
logger.info(`Searching Anna's Archive by ASIN: ${asin}`); logger.info(`Searching Anna's Archive by ASIN: ${asin}`);
md5 = await searchByAsin(asin, preferredFormat, baseUrl, undefined, flaresolverrUrl); md5 = await searchByAsin(asin, preferredFormat, baseUrl, undefined, flaresolverrUrl, languageCode);
if (md5) { if (md5) {
searchMethod = 'asin'; searchMethod = 'asin';
logger.info(`Found via ASIN: ${md5}`); logger.info(`Found via ASIN: ${md5}`);
@@ -235,7 +244,7 @@ async function searchAnnasArchiveForInteractive(
// Fallback to title search // Fallback to title search
if (!md5) { if (!md5) {
logger.info(`Searching Anna's Archive by title: "${title}"`); logger.info(`Searching Anna's Archive by title: "${title}"`);
md5 = await searchByTitle(title, author, preferredFormat, baseUrl, undefined, flaresolverrUrl); md5 = await searchByTitle(title, author, preferredFormat, baseUrl, undefined, flaresolverrUrl, languageCode);
if (md5) { if (md5) {
logger.info(`Found via title: ${md5}`); logger.info(`Found via title: ${md5}`);
} }
@@ -356,6 +365,10 @@ async function searchIndexersForInteractive(
return []; return [];
} }
// Get language-specific stop words for ranking
const rankRegion = await configService.getAudibleRegion() as AudibleRegion;
const rankLangConfig = getLanguageForRegion(rankRegion);
// Rank results with ebook scoring // Rank results with ebook scoring
// Use requireAuthor=false for interactive mode (let user decide) // Use requireAuthor=false for interactive mode (let user decide)
const rankedResults = rankEbookTorrents(allResults, { const rankedResults = rankEbookTorrents(allResults, {
@@ -366,6 +379,8 @@ async function searchIndexersForInteractive(
indexerPriorities, indexerPriorities,
flagConfigs, flagConfigs,
requireAuthor: false, requireAuthor: false,
stopWords: rankLangConfig.stopWords,
characterReplacements: rankLangConfig.characterReplacements,
}); });
// Log ranking debug info (same format as search-ebook.processor.ts) // Log ranking debug info (same format as search-ebook.processor.ts)
@@ -9,6 +9,8 @@ import { prisma } from '@/lib/db';
import { getProwlarrService } from '@/lib/integrations/prowlarr.service'; import { getProwlarrService } from '@/lib/integrations/prowlarr.service';
import { rankTorrents } from '@/lib/utils/ranking-algorithm'; import { rankTorrents } from '@/lib/utils/ranking-algorithm';
import { groupIndexersByCategories, getGroupDescription } from '@/lib/utils/indexer-grouping'; import { groupIndexersByCategories, getGroupDescription } from '@/lib/utils/indexer-grouping';
import { getLanguageForRegion } from '@/lib/constants/language-config';
import type { AudibleRegion } from '@/lib/types/audible';
import { RMABLogger } from '@/lib/utils/logger'; import { RMABLogger } from '@/lib/utils/logger';
import { resolveInteractiveSearchAccess } from '@/lib/utils/permissions'; import { resolveInteractiveSearchAccess } from '@/lib/utils/permissions';
@@ -189,6 +191,10 @@ export async function POST(
} }
} }
// Get language-specific stop words for ranking
const region = await configService.getAudibleRegion() as AudibleRegion;
const langConfig = getLanguageForRegion(region);
// Rank torrents using the ranking algorithm with indexer priorities and flag configs // Rank torrents using the ranking algorithm with indexer priorities and flag configs
// Always use the audiobook's title/author for ranking (not custom search query) // Always use the audiobook's title/author for ranking (not custom search query)
// requireAuthor: false - interactive mode, show all results for user decision // requireAuthor: false - interactive mode, show all results for user decision
@@ -199,7 +205,9 @@ export async function POST(
}, { }, {
indexerPriorities, indexerPriorities,
flagConfigs, flagConfigs,
requireAuthor: false // Interactive mode - let user decide requireAuthor: false, // Interactive mode - let user decide
stopWords: langConfig.stopWords,
characterReplacements: langConfig.characterReplacements,
}); });
// No threshold filtering for interactive search - show all results // No threshold filtering for interactive search - show all results
+2 -2
View File
@@ -115,11 +115,11 @@ export function BackendSelectionStep({
> >
{Object.values(AUDIBLE_REGIONS).map((region) => ( {Object.values(AUDIBLE_REGIONS).map((region) => (
<option key={region.code} value={region.code}> <option key={region.code} value={region.code}>
{region.name}{!region.isEnglish ? ' *' : ''} {region.name}{region.language !== 'en' ? ' *' : ''}
</option> </option>
))} ))}
</select> </select>
{AUDIBLE_REGIONS[audibleRegion]?.isEnglish === false && ( {AUDIBLE_REGIONS[audibleRegion]?.language !== 'en' && (
<div className="bg-amber-50 dark:bg-amber-900/20 rounded-lg p-4 border border-amber-200 dark:border-amber-800 mt-2"> <div className="bg-amber-50 dark:bg-amber-900/20 rounded-lg p-4 border border-amber-200 dark:border-amber-800 mt-2">
<div className="flex gap-3"> <div className="flex gap-3">
<svg <svg
+252
View File
@@ -0,0 +1,252 @@
/**
* Component: Centralized Language Configuration
* Documentation: documentation/integrations/audible.md
*
* Single source of truth for all language-specific configuration.
* To add a new language:
* 1. Add code to SupportedLanguage union
* 2. Add full LanguageConfig entry in LANGUAGE_CONFIGS
* 3. Map regions in REGION_LANGUAGE_MAP
* 4. Add region to AUDIBLE_REGIONS in audible.ts with language: 'xx'
*/
import type { AudibleRegion } from '../types/audible';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type SupportedLanguage = 'en' | 'de' | 'es';
export interface ScrapingConfig {
/** Audible locale query-param value (e.g. 'english', 'deutsch') */
audibleLocaleParam: string;
/** Author label prefixes to strip (e.g. ['By:', 'Written by:']) */
authorPrefixes: string[];
/** Narrator label prefixes to strip */
narratorPrefixes: string[];
/** Length / duration labels used in Cheerio :contains() selectors */
lengthLabels: string[];
/** Language field labels */
languageLabels: string[];
/** Release date field labels */
releaseDateLabels: string[];
/** Accepted language values for filtering (lowercase) */
acceptedLanguageValues: string[];
/** Regex patterns that match hour portions in runtime strings */
runtimeHourPatterns: RegExp[];
/** Regex patterns that match minute portions in runtime strings */
runtimeMinutePatterns: RegExp[];
/** Regex patterns for extracting numeric rating */
ratingPatterns: RegExp[];
/** Regex patterns for extracting release date text */
releaseDatePatterns: RegExp[];
/** Promotional / non-description text patterns to exclude */
descriptionExcludePatterns: RegExp[];
/** Duration detection pattern for generic element scanning */
durationDetectionPattern: RegExp;
/** Rating text selector pattern (e.g. 'out of 5 stars') */
ratingTextSelector: string;
}
export interface LanguageConfig {
code: SupportedLanguage;
/** Anna's Archive language filter code */
annasArchiveLang: string;
/** EPUB language code */
epubCode: string;
/** Stop words for ranking algorithm (filtered from match scoring) */
stopWords: string[];
/** Character replacements applied before NFD normalization in ranking (e.g. ß→ss) */
characterReplacements: Record<string, string>;
/** All scraping-related config */
scraping: ScrapingConfig;
}
// ---------------------------------------------------------------------------
// Language Configurations
// ---------------------------------------------------------------------------
const ENGLISH_CONFIG: LanguageConfig = {
code: 'en',
annasArchiveLang: 'en',
epubCode: 'en',
stopWords: ['the', 'a', 'an', 'of', 'on', 'in', 'at', 'by', 'for'],
characterReplacements: {},
scraping: {
audibleLocaleParam: 'english',
authorPrefixes: ['By:', 'Written by:'],
narratorPrefixes: ['Narrated by:'],
lengthLabels: ['Length:'],
languageLabels: ['Language:'],
releaseDateLabels: ['Release date:'],
acceptedLanguageValues: ['english'],
runtimeHourPatterns: [/(\d+)\s*hrs?/i, /(\d+)\s*hours?/i],
runtimeMinutePatterns: [/(\d+)\s*mins?/i, /(\d+)\s*minutes?/i],
ratingPatterns: [/(\d+\.?\d*)\s*out of/i],
releaseDatePatterns: [/Release date:\s*(.+)/i],
descriptionExcludePatterns: [
/\$\d+\.\d+/,
/cancel anytime/i,
/free trial/i,
/membership/i,
/subscribe/i,
/offer.*ends/i,
/^\s*by\s+[\w\s,]+$/i,
],
durationDetectionPattern: /\d+\s*(hr|hour|h)\s*\d*\s*(min|minute|m)?/i,
ratingTextSelector: 'out of 5 stars',
},
};
const GERMAN_CONFIG: LanguageConfig = {
code: 'de',
annasArchiveLang: 'de',
epubCode: 'de',
stopWords: ['der', 'die', 'das', 'ein', 'eine', 'und', 'von', 'zu', 'den', 'dem', 'des'],
characterReplacements: { '\u00df': 'ss' },
scraping: {
audibleLocaleParam: 'deutsch',
authorPrefixes: ['Von:', 'Geschrieben von:', 'Autor:'],
narratorPrefixes: ['Gesprochen von:', 'Sprecher:'],
lengthLabels: ['Spieldauer:', 'Dauer:', 'L\u00e4nge:'],
languageLabels: ['Sprache:'],
releaseDateLabels: ['Erscheinungsdatum:'],
acceptedLanguageValues: ['deutsch', 'german'],
runtimeHourPatterns: [/(\d+)\s*Std\.?/i, /(\d+)\s*Stunden?/i],
runtimeMinutePatterns: [/(\d+)\s*Min\.?/i, /(\d+)\s*Minuten?/i],
ratingPatterns: [/(\d+[.,]?\d*)\s*von\s*5/i],
releaseDatePatterns: [/Erscheinungsdatum:\s*(.+)/i],
descriptionExcludePatterns: [
/\$\d+\.\d+/,
/\d+,\d+\s*\u20ac/,
/jederzeit k\u00fcndbar/i,
/kostenlos testen/i,
/Mitgliedschaft/i,
/abonnieren/i,
/Angebot.*endet/i,
/^\s*von\s+[\w\s,]+$/i,
],
durationDetectionPattern: /\d+\s*(Std|Stunden?|h)\s*\.?\s*\d*\s*(Min|Minuten?|m)?/i,
ratingTextSelector: 'von 5 Sternen',
},
};
const SPANISH_CONFIG: LanguageConfig = {
code: 'es',
annasArchiveLang: 'es',
epubCode: 'es',
stopWords: ['el', 'la', 'los', 'las', 'un', 'una', 'de', 'del', 'en', 'y', 'por'],
characterReplacements: {},
scraping: {
audibleLocaleParam: 'espa\u00f1ol',
authorPrefixes: ['De:', 'Escrito por:', 'Autor:'],
narratorPrefixes: ['Narrado por:'],
lengthLabels: ['Duraci\u00f3n:'],
languageLabels: ['Idioma:'],
releaseDateLabels: ['Fecha de lanzamiento:'],
acceptedLanguageValues: ['espa\u00f1ol', 'spanish'],
runtimeHourPatterns: [/(\d+)\s*h\b/i, /(\d+)\s*horas?/i],
runtimeMinutePatterns: [/(\d+)\s*min/i, /(\d+)\s*minutos?/i],
ratingPatterns: [/(\d+[.,]?\d*)\s*de\s*5/i],
releaseDatePatterns: [/Fecha de lanzamiento:\s*(.+)/i],
descriptionExcludePatterns: [
/\$\d+\.\d+/,
/\d+,\d+\s*\u20ac/,
/cancela cuando quieras/i,
/prueba gratis/i,
/suscripci\u00f3n/i,
/suscr\u00edbete/i,
/oferta.*termina/i,
/^\s*de\s+[\w\s,]+$/i,
],
durationDetectionPattern: /\d+\s*(h|horas?)\s*\d*\s*(min|minutos?)?/i,
ratingTextSelector: 'de 5 estrellas',
},
};
// ---------------------------------------------------------------------------
// Lookup Maps
// ---------------------------------------------------------------------------
export const LANGUAGE_CONFIGS: Record<SupportedLanguage, LanguageConfig> = {
en: ENGLISH_CONFIG,
de: GERMAN_CONFIG,
es: SPANISH_CONFIG,
};
/**
* Maps Audible region codes to language codes.
* All English-speaking regions map to 'en'.
*/
export const REGION_LANGUAGE_MAP: Record<AudibleRegion, SupportedLanguage> = {
us: 'en',
ca: 'en',
uk: 'en',
au: 'en',
in: 'en',
de: 'de',
es: 'es',
};
// ---------------------------------------------------------------------------
// Helper Functions
// ---------------------------------------------------------------------------
/**
* Get the full language configuration for an Audible region.
*/
export function getLanguageForRegion(region: AudibleRegion): LanguageConfig {
const langCode = REGION_LANGUAGE_MAP[region];
return LANGUAGE_CONFIGS[langCode];
}
/**
* Strip any matching prefixes from text (case-insensitive).
* Returns the text with the first matching prefix removed, trimmed.
*
* Example: stripPrefixes('By: Author Name', ['By:', 'Written by:']) => 'Author Name'
*/
export function stripPrefixes(text: string, prefixes: string[]): string {
const trimmed = text.trim();
for (const prefix of prefixes) {
if (trimmed.toLowerCase().startsWith(prefix.toLowerCase())) {
return trimmed.slice(prefix.length).trim();
}
}
return trimmed;
}
/**
* Build a Cheerio selector that matches any of the given labels using :contains().
* Returns a comma-separated selector string.
*
* Example: buildContainsSelector('span', ['Length:', 'Dauer:'])
* => 'span:contains("Length:"), span:contains("Dauer:")'
*/
export function buildContainsSelector(element: string, labels: string[]): string {
return labels.map(label => `${element}:contains("${label}")`).join(', ');
}
/**
* Extract a value from text by trying multiple label patterns.
* Returns the captured group from the first matching pattern, or null.
*/
export function extractByPatterns(text: string, patterns: RegExp[]): string | null {
for (const pattern of patterns) {
const match = text.match(pattern);
if (match?.[1]) {
return match[1].trim();
}
}
return null;
}
/**
* Check if a language value matches the accepted values for a language config.
* Comparison is case-insensitive.
*/
export function isAcceptedLanguage(languageValue: string, config: LanguageConfig): boolean {
const normalized = languageValue.toLowerCase().trim();
return config.scraping.acceptedLanguageValues.includes(normalized);
}
+101 -51
View File
@@ -8,6 +8,14 @@ import * as cheerio from 'cheerio';
import { RMABLogger } from '../utils/logger'; import { RMABLogger } from '../utils/logger';
import { getConfigService } from '../services/config.service'; import { getConfigService } from '../services/config.service';
import { AudibleRegion, AUDIBLE_REGIONS, DEFAULT_AUDIBLE_REGION } from '../types/audible'; import { AudibleRegion, AUDIBLE_REGIONS, DEFAULT_AUDIBLE_REGION } from '../types/audible';
import {
getLanguageForRegion,
stripPrefixes,
buildContainsSelector,
extractByPatterns,
isAcceptedLanguage,
type LanguageConfig,
} from '../constants/language-config';
import { import {
pickUserAgent, pickUserAgent,
getBrowserHeaders, getBrowserHeaders,
@@ -69,6 +77,13 @@ export class AudibleService {
return this.baseUrl; return this.baseUrl;
} }
/**
* Get the language config for the current region
*/
private getLangConfig(): LanguageConfig {
return getLanguageForRegion(this.region);
}
/** /**
* Force re-initialization (used when region config changes) * Force re-initialization (used when region config changes)
*/ */
@@ -106,6 +121,9 @@ export class AudibleService {
logger.info(`Initializing Audible service with region: ${this.region} (${this.baseUrl})`); 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 // Create axios client with region-specific base URL and realistic browser headers
this.client = axios.create({ this.client = axios.create({
baseURL: this.baseUrl, baseURL: this.baseUrl,
@@ -113,7 +131,7 @@ export class AudibleService {
headers: getBrowserHeaders(this.sessionUserAgent), headers: getBrowserHeaders(this.sessionUserAgent),
params: { params: {
ipRedirectOverride: 'true', // Prevent IP-based region redirects ipRedirectOverride: 'true', // Prevent IP-based region redirects
language: 'english', // Force English locale (prevents IP-based language serving for non-English IPs) language: langConfig.scraping.audibleLocaleParam, // Force locale (prevents IP-based language serving)
}, },
}); });
@@ -125,13 +143,16 @@ export class AudibleService {
this.baseUrl = AUDIBLE_REGIONS[this.region].baseUrl; this.baseUrl = AUDIBLE_REGIONS[this.region].baseUrl;
this.sessionUserAgent = pickUserAgent(); this.sessionUserAgent = pickUserAgent();
this.pacer.reset(); this.pacer.reset();
const fallbackLangConfig = getLanguageForRegion(this.region);
this.client = axios.create({ this.client = axios.create({
baseURL: this.baseUrl, baseURL: this.baseUrl,
timeout: 15000, timeout: 15000,
headers: getBrowserHeaders(this.sessionUserAgent), headers: getBrowserHeaders(this.sessionUserAgent),
params: { params: {
ipRedirectOverride: 'true', ipRedirectOverride: 'true',
language: 'english', language: fallbackLangConfig.scraping.audibleLocaleParam,
}, },
}); });
this.initialized = true; this.initialized = true;
@@ -289,12 +310,14 @@ export class AudibleService {
const ratingText = $el.find('.ratingsLabel').text().trim(); const ratingText = $el.find('.ratingsLabel').text().trim();
const rating = ratingText ? parseFloat(ratingText.split(' ')[0]) : undefined; const rating = ratingText ? parseFloat(ratingText.split(' ')[0]) : undefined;
const langConfig = this.getLangConfig();
audiobooks.push({ audiobooks.push({
asin, asin,
title, title,
author: authorText.replace('By:', '').replace('Written by:', '').trim(), author: stripPrefixes(authorText, langConfig.scraping.authorPrefixes),
authorAsin: authorAsinMatch?.[1] || undefined, authorAsin: authorAsinMatch?.[1] || undefined,
narrator: narratorText.replace('Narrated by:', '').trim(), narrator: stripPrefixes(narratorText, langConfig.scraping.narratorPrefixes),
coverArtUrl: coverArtUrl.replace(/\._.*_\./, '._SL500_.'), coverArtUrl: coverArtUrl.replace(/\._.*_\./, '._SL500_.'),
rating, rating,
}); });
@@ -391,12 +414,14 @@ export class AudibleService {
const ratingText = $el.find('.ratingsLabel').text().trim(); const ratingText = $el.find('.ratingsLabel').text().trim();
const rating = ratingText ? parseFloat(ratingText.split(' ')[0]) : undefined; const rating = ratingText ? parseFloat(ratingText.split(' ')[0]) : undefined;
const langConfig = this.getLangConfig();
audiobooks.push({ audiobooks.push({
asin, asin,
title, title,
author: authorText.replace('By:', '').replace('Written by:', '').trim(), author: stripPrefixes(authorText, langConfig.scraping.authorPrefixes),
authorAsin: authorAsinMatch?.[1] || undefined, authorAsin: authorAsinMatch?.[1] || undefined,
narrator: narratorText.replace('Narrated by:', '').trim(), narrator: stripPrefixes(narratorText, langConfig.scraping.narratorPrefixes),
coverArtUrl: coverArtUrl.replace(/\._.*_\./, '._SL500_.'), coverArtUrl: coverArtUrl.replace(/\._.*_\./, '._SL500_.'),
rating, rating,
}); });
@@ -487,9 +512,11 @@ export class AudibleService {
const coverArtUrl = $el.find('img').attr('src') || ''; const coverArtUrl = $el.find('img').attr('src') || '';
const langConfig = this.getLangConfig();
// Extract runtime/duration // Extract runtime/duration
const runtimeText = $el.find('.runtimeLabel').text().trim() || const runtimeText = $el.find('.runtimeLabel').text().trim() ||
$el.find('span:contains("Length:")').text().trim(); $el.find(buildContainsSelector('span', langConfig.scraping.lengthLabels)).text().trim();
const durationMinutes = this.parseRuntime(runtimeText); const durationMinutes = this.parseRuntime(runtimeText);
// Extract rating // Extract rating
@@ -500,9 +527,9 @@ export class AudibleService {
audiobooks.push({ audiobooks.push({
asin, asin,
title, title,
author: authorText.replace('By:', '').replace('Written by:', '').trim(), author: stripPrefixes(authorText, langConfig.scraping.authorPrefixes),
authorAsin: authorAsinMatch?.[1] || undefined, authorAsin: authorAsinMatch?.[1] || undefined,
narrator: narratorText.replace('Narrated by:', '').trim(), narrator: stripPrefixes(narratorText, langConfig.scraping.narratorPrefixes),
coverArtUrl: coverArtUrl.replace(/\._.*_\./, '._SL500_.'), coverArtUrl: coverArtUrl.replace(/\._.*_\./, '._SL500_.'),
durationMinutes, durationMinutes,
rating, rating,
@@ -565,13 +592,15 @@ export class AudibleService {
$('.s-result-item, .productListItem').each((_index, element) => { $('.s-result-item, .productListItem').each((_index, element) => {
const $el = $(element); const $el = $(element);
// --- Language filter: require explicit "English" --- // --- Language filter: require matching language for region ---
const langText = $el.find('span:contains("Language:")').text().trim() || const langConfig = this.getLangConfig();
const langText = $el.find(buildContainsSelector('span', langConfig.scraping.languageLabels)).text().trim() ||
$el.find('.languageLabel').text().trim(); $el.find('.languageLabel').text().trim();
// Extract language value (e.g. "Language: English" "English") // Extract language value (e.g. "Language: English" -> "English", "Sprache: Deutsch" -> "Deutsch")
const langMatch = langText.match(/Language:\s*(.+)/i); 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(); const language = langMatch?.[1]?.trim();
if (!language || language.toLowerCase() !== 'english') return; if (!language || !isAcceptedLanguage(language, langConfig)) return;
// --- Author ASIN filter: verify target ASIN in author links --- // --- Author ASIN filter: verify target ASIN in author links ---
const authorLinks = $el.find('a[href*="/author/"]'); const authorLinks = $el.find('a[href*="/author/"]');
@@ -609,7 +638,7 @@ export class AudibleService {
const coverArtUrl = $el.find('img').attr('src') || ''; const coverArtUrl = $el.find('img').attr('src') || '';
const runtimeText = $el.find('.runtimeLabel').text().trim() || const runtimeText = $el.find('.runtimeLabel').text().trim() ||
$el.find('span:contains("Length:")').text().trim(); $el.find(buildContainsSelector('span', langConfig.scraping.lengthLabels)).text().trim();
const durationMinutes = this.parseRuntime(runtimeText); const durationMinutes = this.parseRuntime(runtimeText);
const ratingText = $el.find('.ratingsLabel').text().trim() || const ratingText = $el.find('.ratingsLabel').text().trim() ||
@@ -619,9 +648,9 @@ export class AudibleService {
allBooks.push({ allBooks.push({
asin: bookAsin, asin: bookAsin,
title, title,
author: authorText.replace('By:', '').replace('Written by:', '').trim(), author: stripPrefixes(authorText, langConfig.scraping.authorPrefixes),
authorAsin, authorAsin,
narrator: narratorText.replace('Narrated by:', '').trim(), narrator: stripPrefixes(narratorText, langConfig.scraping.narratorPrefixes),
coverArtUrl: coverArtUrl.replace(/\._.*_\./, '._SL500_.'), coverArtUrl: coverArtUrl.replace(/\._.*_\./, '._SL500_.'),
durationMinutes, durationMinutes,
rating, rating,
@@ -867,7 +896,8 @@ export class AudibleService {
result.author = [...new Set(authors)].slice(0, 3).join(', '); result.author = [...new Set(authors)].slice(0, 3).join(', ');
} }
result.author = result.author.replace(/^By:\s*/i, '').replace(/^Written by:\s*/i, '').trim(); const authorLangConfig = this.getLangConfig();
result.author = stripPrefixes(result.author, authorLangConfig.scraping.authorPrefixes);
logger.info(` Author from HTML: "${result.author}"`); logger.info(` Author from HTML: "${result.author}"`);
} }
@@ -911,22 +941,16 @@ export class AudibleService {
} }
if (result.narrator) { if (result.narrator) {
result.narrator = result.narrator.replace(/^Narrated by:\s*/i, '').trim(); const detailLangConfig = this.getLangConfig();
result.narrator = stripPrefixes(result.narrator, detailLangConfig.scraping.narratorPrefixes);
} }
logger.info(` Narrator from HTML: "${result.narrator || ''}"`); logger.info(` Narrator from HTML: "${result.narrator || ''}"`);
} }
// Description - try multiple approaches with strict filtering // Description - try multiple approaches with strict filtering
if (!result.description) { if (!result.description) {
const excludePatterns = [ const descLangConfig = this.getLangConfig();
/\$\d+\.\d+/, // Price patterns const excludePatterns = descLangConfig.scraping.descriptionExcludePatterns;
/cancel anytime/i,
/free trial/i,
/membership/i,
/subscribe/i,
/offer.*ends/i,
/^\s*by\s+[\w\s,]+$/i, // Just author names
];
const isValidDescription = (text: string): boolean => { const isValidDescription = (text: string): boolean => {
if (!text || text.length < 50 || text.length > 5000) return false; if (!text || text.length < 50 || text.length > 5000) return false;
@@ -982,18 +1006,20 @@ export class AudibleService {
// Runtime/Duration - try multiple approaches // Runtime/Duration - try multiple approaches
if (!result.durationMinutes) { if (!result.durationMinutes) {
const rtLangConfig = this.getLangConfig();
// Look for runtime text in various places // Look for runtime text in various places
const runtimeText = const runtimeText =
$('li.runtimeLabel span').text().trim() || $('li.runtimeLabel span').text().trim() ||
$('.runtimeLabel').text().trim() || $('.runtimeLabel').text().trim() ||
$('span:contains("Length:")').parent().text().trim() || $(buildContainsSelector('span', rtLangConfig.scraping.lengthLabels)).parent().text().trim() ||
$('li:contains("Length:")').text().trim() || $(buildContainsSelector('li', rtLangConfig.scraping.lengthLabels)).text().trim() ||
(() => { (() => {
// Look for any text matching duration pattern // Look for any text matching duration pattern
let found = ''; let found = '';
$('li, span, div').each((_, elem) => { $('li, span, div').each((_, elem) => {
const text = $(elem).text().trim(); const text = $(elem).text().trim();
if (text.match(/\d+\s*(hr|hour|h)\s*\d*\s*(min|minute|m)?/i) && text.length < 100) { if (text.match(rtLangConfig.scraping.durationDetectionPattern) && text.length < 100) {
found = text; found = text;
return false; // break return false; // break
} }
@@ -1007,41 +1033,55 @@ export class AudibleService {
// Rating - try multiple approaches // Rating - try multiple approaches
if (!result.rating) { if (!result.rating) {
const ratingLangConfig = this.getLangConfig();
const ratingText = const ratingText =
$('.ratingsLabel').text().trim() || $('.ratingsLabel').text().trim() ||
$('[class*="rating"]').first().text().trim() || $('[class*="rating"]').first().text().trim() ||
$('span:contains("out of 5 stars")').parent().text().trim() || $(`span:contains("${ratingLangConfig.scraping.ratingTextSelector}")`).parent().text().trim() ||
(() => { (() => {
// Look for rating pattern // Look for rating pattern using language-specific patterns
let found = ''; let found = '';
$('span, div').each((_, elem) => { $('span, div').each((_, elem) => {
const text = $(elem).text().trim(); const text = $(elem).text().trim();
if (text.match(/\d+\.?\d*\s*out of\s*5/i) && text.length < 50) { if (text.length < 50) {
found = text; for (const pattern of ratingLangConfig.scraping.ratingPatterns) {
return false; if (pattern.test(text)) {
found = text;
return false;
}
}
} }
}); });
return found; return found;
})(); })();
if (ratingText) { if (ratingText) {
const ratingMatch = ratingText.match(/(\d+\.?\d*)\s*out of/i); let ratingValue: number | undefined;
result.rating = ratingMatch ? parseFloat(ratingMatch[1]) : 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}`); logger.info(` Rating from "${ratingText}": ${result.rating}`);
} }
// Release date - try multiple selectors // Release date - try multiple selectors
if (!result.releaseDate) { if (!result.releaseDate) {
const rdLangConfig = this.getLangConfig();
const releaseDateText = const releaseDateText =
$('li:contains("Release date:")').text().trim() || $(buildContainsSelector('li', rdLangConfig.scraping.releaseDateLabels)).text().trim() ||
$('span:contains("Release date:")').parent().text().trim() || $(buildContainsSelector('span', rdLangConfig.scraping.releaseDateLabels)).parent().text().trim() ||
$('[class*="release"]').text().trim(); $('[class*="release"]').text().trim();
const dateMatch = releaseDateText.match(/Release date:\s*(.+)/i) || const dateMatch = extractByPatterns(releaseDateText, rdLangConfig.scraping.releaseDatePatterns) ||
releaseDateText.match(/(\w+ \d{1,2},? \d{4})/); releaseDateText.match(/(\w+ \d{1,2},? \d{4})/)?.[1];
if (dateMatch) { if (dateMatch) {
result.releaseDate = dateMatch[1].trim(); result.releaseDate = dateMatch.trim();
} }
logger.info(` Release date from "${releaseDateText}": ${result.releaseDate}`); logger.info(` Release date from "${releaseDateText}": ${result.releaseDate}`);
} }
@@ -1078,20 +1118,30 @@ export class AudibleService {
} }
/** /**
* Parse runtime text to minutes * Parse runtime text to minutes using language-specific patterns
*/ */
private parseRuntime(runtimeText: string): number | undefined { private parseRuntime(runtimeText: string): number | undefined {
if (!runtimeText) return undefined; if (!runtimeText) return undefined;
const hoursMatch = runtimeText.match(/(\d+)\s*hrs?/i); const langConfig = this.getLangConfig();
const minutesMatch = runtimeText.match(/(\d+)\s*mins?/i);
let totalMinutes = 0; let totalMinutes = 0;
if (hoursMatch) {
totalMinutes += parseInt(hoursMatch[1]) * 60; // Try each hour pattern until one matches
for (const pattern of langConfig.scraping.runtimeHourPatterns) {
const match = runtimeText.match(pattern);
if (match) {
totalMinutes += parseInt(match[1]) * 60;
break;
}
} }
if (minutesMatch) {
totalMinutes += parseInt(minutesMatch[1]); // Try each minute pattern until one matches
for (const pattern of langConfig.scraping.runtimeMinutePatterns) {
const match = runtimeText.match(pattern);
if (match) {
totalMinutes += parseInt(match[1]);
break;
}
} }
return totalMinutes > 0 ? totalMinutes : undefined; return totalMinutes > 0 ? totalMinutes : undefined;
+15 -2
View File
@@ -14,6 +14,8 @@ import { RMABLogger } from '../utils/logger';
import { getProwlarrService } from '../integrations/prowlarr.service'; import { getProwlarrService } from '../integrations/prowlarr.service';
import { rankEbookTorrents, RankedEbookTorrent } from '../utils/ranking-algorithm'; import { rankEbookTorrents, RankedEbookTorrent } from '../utils/ranking-algorithm';
import { groupIndexersByCategories, getGroupDescription } from '../utils/indexer-grouping'; import { groupIndexersByCategories, getGroupDescription } from '../utils/indexer-grouping';
import { getLanguageForRegion } from '../constants/language-config';
import type { AudibleRegion } from '../types/audible';
// Import ebook scraper functions for Anna's Archive // Import ebook scraper functions for Anna's Archive
import { import {
@@ -151,6 +153,11 @@ async function searchAnnasArchive(
const baseUrl = await configService.get('ebook_sidecar_base_url') || 'https://annas-archive.li'; const baseUrl = await configService.get('ebook_sidecar_base_url') || 'https://annas-archive.li';
const flaresolverrUrl = await configService.get('ebook_sidecar_flaresolverr_url') || undefined; const flaresolverrUrl = await configService.get('ebook_sidecar_flaresolverr_url') || undefined;
// Get language code from Audible region config
const region = await configService.getAudibleRegion() as AudibleRegion;
const langConfig = getLanguageForRegion(region);
const languageCode = langConfig.annasArchiveLang;
if (flaresolverrUrl) { if (flaresolverrUrl) {
logger.info(`Using FlareSolverr at ${flaresolverrUrl}`); logger.info(`Using FlareSolverr at ${flaresolverrUrl}`);
} }
@@ -161,7 +168,7 @@ async function searchAnnasArchive(
// Try ASIN search first (exact match - best) // Try ASIN search first (exact match - best)
if (audiobook.asin) { if (audiobook.asin) {
logger.info(`Searching Anna's Archive by ASIN: ${audiobook.asin} (format: ${preferredFormat})...`); logger.info(`Searching Anna's Archive by ASIN: ${audiobook.asin} (format: ${preferredFormat})...`);
md5 = await searchByAsin(audiobook.asin, preferredFormat, baseUrl, logger, flaresolverrUrl); md5 = await searchByAsin(audiobook.asin, preferredFormat, baseUrl, logger, flaresolverrUrl, languageCode);
if (md5) { if (md5) {
logger.info(`Found via ASIN: ${md5}`); logger.info(`Found via ASIN: ${md5}`);
@@ -174,7 +181,7 @@ async function searchAnnasArchive(
// Fallback to title + author search // Fallback to title + author search
if (!md5) { if (!md5) {
logger.info(`Searching Anna's Archive by title + author: "${audiobook.title}" by ${audiobook.author}...`); logger.info(`Searching Anna's Archive by title + author: "${audiobook.title}" by ${audiobook.author}...`);
md5 = await searchByTitle(audiobook.title, audiobook.author, preferredFormat, baseUrl, logger, flaresolverrUrl); md5 = await searchByTitle(audiobook.title, audiobook.author, preferredFormat, baseUrl, logger, flaresolverrUrl, languageCode);
if (md5) { if (md5) {
logger.info(`Found via title search: ${md5}`); logger.info(`Found via title search: ${md5}`);
@@ -301,6 +308,10 @@ async function searchIndexers(
logger.info(`Will filter ${aboveThreshold.length} results > 20 MB (too large for ebooks)`); logger.info(`Will filter ${aboveThreshold.length} results > 20 MB (too large for ebooks)`);
} }
// Get language-specific stop words for ranking
const ebookRegion = await configService.getAudibleRegion() as AudibleRegion;
const ebookLangConfig = getLanguageForRegion(ebookRegion);
// Rank results with ebook-specific scoring // Rank results with ebook-specific scoring
// This filters out > 20MB and uses inverted size scoring // This filters out > 20MB and uses inverted size scoring
const rankedResults = rankEbookTorrents(allResults, { const rankedResults = rankEbookTorrents(allResults, {
@@ -311,6 +322,8 @@ async function searchIndexers(
indexerPriorities, indexerPriorities,
flagConfigs, flagConfigs,
requireAuthor: true, // Automatic mode - prevent wrong authors requireAuthor: true, // Automatic mode - prevent wrong authors
stopWords: ebookLangConfig.stopWords,
characterReplacements: ebookLangConfig.characterReplacements,
}); });
// Log filter results // Log filter results
@@ -9,6 +9,8 @@ import { getProwlarrService } from '../integrations/prowlarr.service';
import { getRankingAlgorithm } from '../utils/ranking-algorithm'; import { getRankingAlgorithm } from '../utils/ranking-algorithm';
import { groupIndexersByCategories, getGroupDescription } from '../utils/indexer-grouping'; import { groupIndexersByCategories, getGroupDescription } from '../utils/indexer-grouping';
import { RMABLogger } from '../utils/logger'; import { RMABLogger } from '../utils/logger';
import { getLanguageForRegion } from '../constants/language-config';
import type { AudibleRegion } from '../types/audible';
/** /**
* Process search indexers job * Process search indexers job
@@ -146,8 +148,10 @@ export async function processSearchIndexers(payload: SearchIndexersPayload): Pro
logger.info(`Will filter ${belowThreshold.length} results < ${sizeMBThreshold} MB (likely ebooks)`); logger.info(`Will filter ${belowThreshold.length} results < ${sizeMBThreshold} MB (likely ebooks)`);
} }
// Get ranking algorithm // Get ranking algorithm and language-specific stop words
const ranker = getRankingAlgorithm(); const ranker = getRankingAlgorithm();
const region = await configService.getAudibleRegion() as AudibleRegion;
const langConfig = getLanguageForRegion(region);
// Rank results with indexer priorities and flag configs // Rank results with indexer priorities and flag configs
// Note: rankTorrents now filters out results < 20 MB internally // Note: rankTorrents now filters out results < 20 MB internally
@@ -159,7 +163,9 @@ export async function processSearchIndexers(payload: SearchIndexersPayload): Pro
}, { }, {
indexerPriorities, indexerPriorities,
flagConfigs, flagConfigs,
requireAuthor: true // Automatic mode - prevent wrong authors requireAuthor: true, // Automatic mode - prevent wrong authors
stopWords: langConfig.stopWords,
characterReplacements: langConfig.characterReplacements,
}); });
// Log filter results // Log filter results
+13 -10
View File
@@ -170,7 +170,8 @@ export async function downloadEbook(
preferredFormat: string = 'epub', preferredFormat: string = 'epub',
baseUrl: string = 'https://annas-archive.li', baseUrl: string = 'https://annas-archive.li',
logger?: RMABLogger, logger?: RMABLogger,
flaresolverrUrl?: string flaresolverrUrl?: string,
languageCode: string = 'en'
): Promise<EbookDownloadResult> { ): Promise<EbookDownloadResult> {
try { try {
let md5: string | null = null; let md5: string | null = null;
@@ -183,7 +184,7 @@ export async function downloadEbook(
// Step 1: Try ASIN search (exact match - best) // Step 1: Try ASIN search (exact match - best)
if (asin) { if (asin) {
await logger?.info(`Searching by ASIN: ${asin} (format: ${preferredFormat})...`); await logger?.info(`Searching by ASIN: ${asin} (format: ${preferredFormat})...`);
md5 = await searchByAsin(asin, preferredFormat, baseUrl, logger, flaresolverrUrl); md5 = await searchByAsin(asin, preferredFormat, baseUrl, logger, flaresolverrUrl, languageCode);
if (md5) { if (md5) {
await logger?.info(`Found via ASIN: ${md5}`); await logger?.info(`Found via ASIN: ${md5}`);
@@ -195,7 +196,7 @@ export async function downloadEbook(
// Step 2: Fallback to title + author search // Step 2: Fallback to title + author search
if (!md5) { if (!md5) {
await logger?.info(`Searching by title + author: "${title}" by ${author}...`); await logger?.info(`Searching by title + author: "${title}" by ${author}...`);
md5 = await searchByTitle(title, author, preferredFormat, baseUrl, logger, flaresolverrUrl); md5 = await searchByTitle(title, author, preferredFormat, baseUrl, logger, flaresolverrUrl, languageCode);
if (md5) { if (md5) {
await logger?.info(`Found via title search: ${md5}`); await logger?.info(`Found via title search: ${md5}`);
@@ -312,10 +313,11 @@ export async function searchByAsin(
format: string, format: string,
baseUrl: string, baseUrl: string,
logger?: RMABLogger, logger?: RMABLogger,
flaresolverrUrl?: string flaresolverrUrl?: string,
languageCode: string = 'en'
): Promise<string | null> { ): Promise<string | null> {
// Check cache first // Check cache first
const cacheKey = `${asin}-${format}`; const cacheKey = `${asin}-${format}-${languageCode}`;
if (md5Cache.has(cacheKey)) { if (md5Cache.has(cacheKey)) {
const cached = md5Cache.get(cacheKey); const cached = md5Cache.get(cacheKey);
if (cached) { if (cached) {
@@ -327,7 +329,7 @@ export async function searchByAsin(
try { try {
// Build search URL with ASIN and optional format filter // Build search URL with ASIN and optional format filter
const formatParam = format && format !== 'any' ? `ext=${format}&` : ''; const formatParam = format && format !== 'any' ? `ext=${format}&` : '';
const searchUrl = `${baseUrl}/search?${formatParam}lang=en&q=%22asin:${asin}%22`; const searchUrl = `${baseUrl}/search?${formatParam}lang=${languageCode}&q=%22asin:${asin}%22`;
moduleLogger.debug(`ASIN search URL: ${searchUrl}`); moduleLogger.debug(`ASIN search URL: ${searchUrl}`);
@@ -404,10 +406,11 @@ export async function searchByTitle(
format: string, format: string,
baseUrl: string, baseUrl: string,
logger?: RMABLogger, logger?: RMABLogger,
flaresolverrUrl?: string flaresolverrUrl?: string,
languageCode: string = 'en'
): Promise<string | null> { ): Promise<string | null> {
// Check cache first // Check cache first
const cacheKey = `title-${title}-${author}-${format}`.toLowerCase(); const cacheKey = `title-${title}-${author}-${format}-${languageCode}`.toLowerCase();
if (md5Cache.has(cacheKey)) { if (md5Cache.has(cacheKey)) {
const cached = md5Cache.get(cacheKey); const cached = md5Cache.get(cacheKey);
if (cached) { if (cached) {
@@ -432,8 +435,8 @@ export async function searchByTitle(
// Add content type filters (books only, all fiction/nonfiction/unknown) // Add content type filters (books only, all fiction/nonfiction/unknown)
searchUrl += '&content=book_nonfiction&content=book_fiction&content=book_unknown'; searchUrl += '&content=book_nonfiction&content=book_fiction&content=book_unknown';
// Add language filter (English) // Add language filter
searchUrl += '&lang=en'; searchUrl += `&lang=${languageCode}`;
// Empty raw query (we're using specific terms instead) // Empty raw query (we're using specific terms instead)
searchUrl += '&q='; searchUrl += '&q=';
+10 -8
View File
@@ -3,6 +3,8 @@
* Documentation: documentation/integrations/audible.md * Documentation: documentation/integrations/audible.md
*/ */
import type { SupportedLanguage } from '../constants/language-config';
export type AudibleRegion = 'us' | 'ca' | 'uk' | 'au' | 'in' | 'de' | 'es'; export type AudibleRegion = 'us' | 'ca' | 'uk' | 'au' | 'in' | 'de' | 'es';
export interface AudibleRegionConfig { export interface AudibleRegionConfig {
@@ -10,7 +12,7 @@ export interface AudibleRegionConfig {
name: string; name: string;
baseUrl: string; baseUrl: string;
audnexusParam: string; audnexusParam: string;
isEnglish: boolean; language: SupportedLanguage;
} }
export const AUDIBLE_REGIONS: Record<AudibleRegion, AudibleRegionConfig> = { export const AUDIBLE_REGIONS: Record<AudibleRegion, AudibleRegionConfig> = {
@@ -19,49 +21,49 @@ export const AUDIBLE_REGIONS: Record<AudibleRegion, AudibleRegionConfig> = {
name: 'United States', name: 'United States',
baseUrl: 'https://www.audible.com', baseUrl: 'https://www.audible.com',
audnexusParam: 'us', audnexusParam: 'us',
isEnglish: true, language: 'en',
}, },
ca: { ca: {
code: 'ca', code: 'ca',
name: 'Canada', name: 'Canada',
baseUrl: 'https://www.audible.ca', baseUrl: 'https://www.audible.ca',
audnexusParam: 'ca', audnexusParam: 'ca',
isEnglish: true, language: 'en',
}, },
uk: { uk: {
code: 'uk', code: 'uk',
name: 'United Kingdom', name: 'United Kingdom',
baseUrl: 'https://www.audible.co.uk', baseUrl: 'https://www.audible.co.uk',
audnexusParam: 'uk', audnexusParam: 'uk',
isEnglish: true, language: 'en',
}, },
au: { au: {
code: 'au', code: 'au',
name: 'Australia', name: 'Australia',
baseUrl: 'https://www.audible.com.au', baseUrl: 'https://www.audible.com.au',
audnexusParam: 'au', audnexusParam: 'au',
isEnglish: true, language: 'en',
}, },
in: { in: {
code: 'in', code: 'in',
name: 'India', name: 'India',
baseUrl: 'https://www.audible.in', baseUrl: 'https://www.audible.in',
audnexusParam: 'in', audnexusParam: 'in',
isEnglish: true, language: 'en',
}, },
de: { de: {
code: 'de', code: 'de',
name: 'Germany', name: 'Germany',
baseUrl: 'https://www.audible.de', baseUrl: 'https://www.audible.de',
audnexusParam: 'de', audnexusParam: 'de',
isEnglish: false, language: 'de',
}, },
es: { es: {
code: 'es', code: 'es',
name: 'Spain', name: 'Spain',
baseUrl: 'https://www.audible.es', baseUrl: 'https://www.audible.es',
audnexusParam: 'es', audnexusParam: 'es',
isEnglish: false, language: 'es',
} }
}; };
+50 -18
View File
@@ -40,6 +40,8 @@ export interface RankTorrentsOptions {
indexerPriorities?: Map<number, number>; // indexerId -> priority (1-25) indexerPriorities?: Map<number, number>; // indexerId -> priority (1-25)
flagConfigs?: IndexerFlagConfig[]; // Flag bonus configurations flagConfigs?: IndexerFlagConfig[]; // Flag bonus configurations
requireAuthor?: boolean; // Enforce author presence check (default: true) requireAuthor?: boolean; // Enforce author presence check (default: true)
stopWords?: string[]; // Language-specific stop words for matching
characterReplacements?: Record<string, string>; // Language-specific char replacements (e.g. ß→ss)
} }
export interface EbookTorrentRequest { export interface EbookTorrentRequest {
@@ -52,6 +54,8 @@ export interface RankEbookTorrentsOptions {
indexerPriorities?: Map<number, number>; // indexerId -> priority (1-25) indexerPriorities?: Map<number, number>; // indexerId -> priority (1-25)
flagConfigs?: IndexerFlagConfig[]; // Flag bonus configurations flagConfigs?: IndexerFlagConfig[]; // Flag bonus configurations
requireAuthor?: boolean; // Enforce author presence check (default: true) requireAuthor?: boolean; // Enforce author presence check (default: true)
stopWords?: string[]; // Language-specific stop words for matching
characterReplacements?: Record<string, string>; // Language-specific char replacements (e.g. ß→ss)
} }
export interface BonusModifier { export interface BonusModifier {
@@ -113,7 +117,9 @@ export class RankingAlgorithm {
const { const {
indexerPriorities, indexerPriorities,
flagConfigs, flagConfigs,
requireAuthor = true // Safe default: require author in automatic mode requireAuthor = true, // Safe default: require author in automatic mode
stopWords,
characterReplacements,
} = options; } = options;
// Filter out files < 20 MB (likely ebooks/samples) // Filter out files < 20 MB (likely ebooks/samples)
const filteredTorrents = torrents.filter((torrent) => { const filteredTorrents = torrents.filter((torrent) => {
@@ -126,7 +132,7 @@ export class RankingAlgorithm {
const formatScore = this.scoreFormat(torrent); const formatScore = this.scoreFormat(torrent);
const sizeScore = this.scoreSize(torrent, audiobook.durationMinutes); const sizeScore = this.scoreSize(torrent, audiobook.durationMinutes);
const seederScore = this.scoreSeeders(torrent.seeders); const seederScore = this.scoreSeeders(torrent.seeders);
const matchScore = this.scoreMatch(torrent, audiobook, requireAuthor); const matchScore = this.scoreMatch(torrent, audiobook, requireAuthor, stopWords, characterReplacements);
const baseScore = formatScore + sizeScore + seederScore + matchScore; const baseScore = formatScore + sizeScore + seederScore + matchScore;
@@ -340,11 +346,22 @@ export class RankingAlgorithm {
* "Twelve.Months-Jim.Butcher" → "twelve months jim butcher" * "Twelve.Months-Jim.Butcher" → "twelve months jim butcher"
* "Author_Name_Book" → "author name book" * "Author_Name_Book" → "author name book"
*/ */
private normalizeForMatching(text: string): string { private normalizeForMatching(text: string, characterReplacements?: Record<string, string>): string {
return text let result = text
// Split CamelCase FIRST (before lowercasing): "TheCorrespondent" → "The Correspondent" // Split CamelCase FIRST (before lowercasing): "TheCorrespondent" → "The Correspondent"
.replace(/([a-z])([A-Z])/g, '$1 $2') .replace(/([a-z])([A-Z])/g, '$1 $2')
.toLowerCase() .toLowerCase();
// Apply language-specific character replacements before NFD (e.g. ß→ss)
if (characterReplacements) {
for (const [from, to] of Object.entries(characterReplacements)) {
result = result.replace(new RegExp(from, 'g'), to);
}
}
return result
// NFD normalization: convert accented chars to ASCII base forms
// e.g. "uber" from "uber", "senor" from "senor", "cafe" from "cafe"
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
// Replace underscores with spaces (must be explicit since \w includes _) // Replace underscores with spaces (must be explicit since \w includes _)
.replace(/_/g, ' ') .replace(/_/g, ' ')
// Replace other punctuation/separators with spaces (preserves apostrophes in contractions) // Replace other punctuation/separators with spaces (preserves apostrophes in contractions)
@@ -362,11 +379,13 @@ export class RankingAlgorithm {
private scoreMatch( private scoreMatch(
torrent: TorrentResult, torrent: TorrentResult,
audiobook: AudiobookRequest, audiobook: AudiobookRequest,
requireAuthor: boolean = true requireAuthor: boolean = true,
customStopWords?: string[],
characterReplacements?: Record<string, string>
): number { ): number {
// Normalize for matching (handles CamelCase, punctuation separators) // Normalize for matching (handles CamelCase, punctuation separators, diacritics)
const torrentTitle = this.normalizeForMatching(torrent.title); const torrentTitle = this.normalizeForMatching(torrent.title, characterReplacements);
const requestTitle = this.normalizeForMatching(audiobook.title); const requestTitle = this.normalizeForMatching(audiobook.title, characterReplacements);
// Parse authors from RAW string first (preserving commas for splitting) // Parse authors from RAW string first (preserving commas for splitting)
// Then normalize individual authors for matching // Then normalize individual authors for matching
@@ -377,19 +396,30 @@ export class RankingAlgorithm {
.filter(a => a.length > 2 && !['translator', 'narrator'].includes(a)); .filter(a => a.length > 2 && !['translator', 'narrator'].includes(a));
// Normalize parsed authors for matching (handles CamelCase in author names) // Normalize parsed authors for matching (handles CamelCase in author names)
const normalizedAuthors = parsedAuthors.map(a => this.normalizeForMatching(a)); const normalizedAuthors = parsedAuthors.map(a => this.normalizeForMatching(a, characterReplacements));
// Combined normalized author string for fuzzy matching // Combined normalized author string for fuzzy matching
const requestAuthorNormalized = normalizedAuthors.join(' '); const requestAuthorNormalized = normalizedAuthors.join(' ');
// ========== STAGE 1: WORD COVERAGE FILTER (MANDATORY) ========== // ========== STAGE 1: WORD COVERAGE FILTER (MANDATORY) ==========
// Extract significant words (filter out common stop words) // Extract significant words (filter out common stop words)
const stopWords = ['the', 'a', 'an', 'of', 'on', 'in', 'at', 'by', 'for']; // Use provided language-specific stop words, or fall back to English defaults
const stopWords = customStopWords || ['the', 'a', 'an', 'of', 'on', 'in', 'at', 'by', 'for'];
const extractWords = (text: string, stopList: string[]): string[] => { const extractWords = (text: string, stopList: string[]): string[] => {
return text let processed = text
// Split CamelCase FIRST: "TheCorrespondent" → "The Correspondent" // Split CamelCase FIRST: "TheCorrespondent" → "The Correspondent"
.replace(/([a-z])([A-Z])/g, '$1 $2') .replace(/([a-z])([A-Z])/g, '$1 $2')
.toLowerCase() .toLowerCase();
// Apply language-specific character replacements before NFD
if (characterReplacements) {
for (const [from, to] of Object.entries(characterReplacements)) {
processed = processed.replace(new RegExp(from, 'g'), to);
}
}
return processed
// NFD normalization for accented characters
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
// Replace underscores with spaces (must be explicit since \w includes _) // Replace underscores with spaces (must be explicit since \w includes _)
.replace(/_/g, ' ') .replace(/_/g, ' ')
// Remove other punctuation (but keep apostrophes for contractions) // Remove other punctuation (but keep apostrophes for contractions)
@@ -431,7 +461,7 @@ export class RankingAlgorithm {
} }
// Normalize the required portion (handles CamelCase, punctuation) // Normalize the required portion (handles CamelCase, punctuation)
const required = this.normalizeForMatching(requiredRaw); const required = this.normalizeForMatching(requiredRaw, characterReplacements);
const optional = optionalMatches.join(' '); const optional = optionalMatches.join(' ');
return { required, optional }; return { required, optional };
@@ -653,7 +683,7 @@ export class RankingAlgorithm {
* @param requestAuthor - Raw author string (will be parsed and normalized internally) * @param requestAuthor - Raw author string (will be parsed and normalized internally)
* @returns true if at least ONE author is present with high confidence * @returns true if at least ONE author is present with high confidence
*/ */
private checkAuthorPresence(torrentTitle: string, requestAuthor: string): boolean { private checkAuthorPresence(torrentTitle: string, requestAuthor: string, characterReplacements?: Record<string, string>): boolean {
// Parse multiple authors (same logic as Stage 3 author matching) // Parse multiple authors (same logic as Stage 3 author matching)
const authors = requestAuthor const authors = requestAuthor
.split(/,|&| and | - /) .split(/,|&| and | - /)
@@ -661,7 +691,7 @@ export class RankingAlgorithm {
.filter(a => a.length > 2 && !['translator', 'narrator'].includes(a)); .filter(a => a.length > 2 && !['translator', 'narrator'].includes(a));
// Normalize each author for matching // Normalize each author for matching
const normalizedAuthors = authors.map(a => this.normalizeForMatching(a)); const normalizedAuthors = authors.map(a => this.normalizeForMatching(a, characterReplacements));
return this.checkAuthorPresenceWithParsed(torrentTitle, normalizedAuthors); return this.checkAuthorPresenceWithParsed(torrentTitle, normalizedAuthors);
} }
@@ -788,7 +818,9 @@ export class RankingAlgorithm {
const { const {
indexerPriorities, indexerPriorities,
flagConfigs, flagConfigs,
requireAuthor = true // Safe default: require author in automatic mode requireAuthor = true, // Safe default: require author in automatic mode
stopWords,
characterReplacements,
} = options; } = options;
// Filter out files > 20 MB (too large for ebooks) // Filter out files > 20 MB (too large for ebooks)
@@ -809,7 +841,7 @@ export class RankingAlgorithm {
const matchScore = this.scoreMatch(torrent, { const matchScore = this.scoreMatch(torrent, {
title: ebook.title, title: ebook.title,
author: ebook.author, author: ebook.author,
}, requireAuthor); }, requireAuthor, stopWords, characterReplacements);
const baseScore = formatScore + sizeScore + seederScore + matchScore; const baseScore = formatScore + sizeScore + seederScore + matchScore;
@@ -10,6 +10,7 @@ let authRequest: any;
const requireAuthMock = vi.hoisted(() => vi.fn()); const requireAuthMock = vi.hoisted(() => vi.fn());
const configServiceMock = vi.hoisted(() => ({ const configServiceMock = vi.hoisted(() => ({
get: vi.fn(), get: vi.fn(),
getAudibleRegion: vi.fn().mockResolvedValue('us'),
})); }));
const prowlarrMock = vi.hoisted(() => ({ const prowlarrMock = vi.hoisted(() => ({
search: vi.fn(), search: vi.fn(),
@@ -43,6 +44,7 @@ vi.mock('@/lib/utils/indexer-grouping', () => ({
describe('Audiobooks search torrents route', () => { describe('Audiobooks search torrents route', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
configServiceMock.getAudibleRegion.mockResolvedValue('us');
authRequest = { authRequest = {
user: { id: 'user-1', role: 'user' }, user: { id: 'user-1', role: 'user' },
json: vi.fn(), json: vi.fn(),
+2 -1
View File
@@ -12,7 +12,7 @@ const prismaMock = createPrismaMock();
const requireAuthMock = vi.hoisted(() => vi.fn()); const requireAuthMock = vi.hoisted(() => vi.fn());
const prowlarrMock = vi.hoisted(() => ({ search: vi.fn(), searchWithVariations: vi.fn() })); const prowlarrMock = vi.hoisted(() => ({ search: vi.fn(), searchWithVariations: vi.fn() }));
const rankTorrentsMock = vi.hoisted(() => vi.fn()); const rankTorrentsMock = vi.hoisted(() => vi.fn());
const configServiceMock = vi.hoisted(() => ({ get: vi.fn() })); const configServiceMock = vi.hoisted(() => ({ get: vi.fn(), getAudibleRegion: vi.fn().mockResolvedValue('us') }));
const groupIndexersMock = vi.hoisted(() => vi.fn()); const groupIndexersMock = vi.hoisted(() => vi.fn());
const groupDescriptionMock = vi.hoisted(() => vi.fn(() => 'Group')); const groupDescriptionMock = vi.hoisted(() => vi.fn(() => 'Group'));
const configState = vi.hoisted(() => ({ const configState = vi.hoisted(() => ({
@@ -75,6 +75,7 @@ vi.mock('fs/promises', () => ({ default: fsMock, ...fsMock, constants: { R_OK: 4
describe('Request action routes', () => { describe('Request action routes', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
configServiceMock.getAudibleRegion.mockResolvedValue('us');
configState.values.clear(); configState.values.clear();
authRequest = { user: { id: 'user-1', role: 'user' }, json: vi.fn() }; authRequest = { user: { id: 'user-1', role: 'user' }, json: vi.fn() };
requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest)); requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest));
@@ -10,6 +10,7 @@ const prismaMock = createPrismaMock();
const configServiceMock = vi.hoisted(() => ({ const configServiceMock = vi.hoisted(() => ({
get: vi.fn(), get: vi.fn(),
getAudibleRegion: vi.fn().mockResolvedValue('us'),
})); }));
const jobQueueMock = vi.hoisted(() => ({ const jobQueueMock = vi.hoisted(() => ({
@@ -39,6 +40,7 @@ vi.mock('@/lib/services/ebook-scraper', () => ebookScraperMock);
describe('processSearchEbook', () => { describe('processSearchEbook', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
configServiceMock.getAudibleRegion.mockResolvedValue('us');
configServiceMock.get.mockImplementation(async (key: string) => { configServiceMock.get.mockImplementation(async (key: string) => {
if (key === 'ebook_sidecar_preferred_format') return 'epub'; if (key === 'ebook_sidecar_preferred_format') return 'epub';
if (key === 'ebook_sidecar_base_url') return 'https://annas-archive.li'; if (key === 'ebook_sidecar_base_url') return 'https://annas-archive.li';
@@ -79,7 +81,8 @@ describe('processSearchEbook', () => {
'epub', 'epub',
'https://annas-archive.li', 'https://annas-archive.li',
expect.anything(), expect.anything(),
undefined undefined,
'en'
); );
expect(jobQueueMock.addStartDirectDownloadJob).toHaveBeenCalledWith( expect(jobQueueMock.addStartDirectDownloadJob).toHaveBeenCalledWith(
'req-1', 'req-1',
@@ -123,7 +126,8 @@ describe('processSearchEbook', () => {
'epub', 'epub',
'https://annas-archive.li', 'https://annas-archive.li',
expect.anything(), expect.anything(),
undefined undefined,
'en'
); );
}); });
@@ -253,7 +257,8 @@ describe('processSearchEbook', () => {
'epub', 'epub',
'https://annas-archive.li', 'https://annas-archive.li',
expect.anything(), expect.anything(),
'http://flaresolverr:8191' 'http://flaresolverr:8191',
'en'
); );
}); });
@@ -8,7 +8,7 @@ import { createPrismaMock } from '../helpers/prisma';
import { createJobQueueMock } from '../helpers/job-queue'; import { createJobQueueMock } from '../helpers/job-queue';
const prismaMock = createPrismaMock(); const prismaMock = createPrismaMock();
const configMock = vi.hoisted(() => ({ get: vi.fn() })); const configMock = vi.hoisted(() => ({ get: vi.fn(), getAudibleRegion: vi.fn().mockResolvedValue('us') }));
const jobQueueMock = createJobQueueMock(); const jobQueueMock = createJobQueueMock();
const prowlarrMock = vi.hoisted(() => ({ search: vi.fn(), searchWithVariations: vi.fn() })); const prowlarrMock = vi.hoisted(() => ({ search: vi.fn(), searchWithVariations: vi.fn() }));
@@ -35,6 +35,7 @@ vi.mock('@/lib/integrations/audible.service', () => ({
describe('processSearchIndexers', () => { describe('processSearchIndexers', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
configMock.getAudibleRegion.mockResolvedValue('us');
}); });
it('marks request awaiting_search when no results found', async () => { it('marks request awaiting_search when no results found', async () => {