mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +00:00
Merge branch 'main' into feature/bulk-import-folder-fallback
Resolves conflicts in src/lib/integrations/audible.service.ts. main switched the ASIN-detail fallback from HTML scraping to the JSON catalog API (fetchAudibleDetailsFromApi), removing scrapeAudibleDetails. The PR's lookupAsinFast was a fail-fast variant of the same pattern that getAudiobookDetails now performs (Audnexus -> catalog API), so it's redundant. - Drop the lookupAsinFast method (delete entire HEAD-side conflict block) - Take main's fetchAudibleDetailsFromApi verbatim (the scrapeAudibleDetails maxRetries parameterization is moot) - In bulk-import scan route, swap lookupAsinFast for getAudiobookDetails Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
/**
|
||||
/**
|
||||
* Component: Notification Event Constants
|
||||
* Documentation: documentation/backend/services/notifications.md
|
||||
*
|
||||
@@ -10,16 +10,28 @@ export type NotificationSeverity = 'info' | 'success' | 'error' | 'warning';
|
||||
export type NotificationPriority = 'normal' | 'high';
|
||||
|
||||
/**
|
||||
* Central registry of notification events.
|
||||
* Normalized interface for event metadata.
|
||||
* Each entry in NOTIFICATION_EVENTS is structurally validated against this via `satisfies`.
|
||||
*
|
||||
* Each entry defines:
|
||||
* - `label`: Human-readable name shown in the UI
|
||||
* - `title`: Default title used in notification messages
|
||||
* - `titleByRequestType`: Optional map of request-type-specific titles (e.g. audiobook → "Audiobook Available")
|
||||
* - `emoji`: Emoji prefix for notification titles
|
||||
* - `severity`: Drives provider formatting (colors, Apprise types, ntfy tags)
|
||||
* - `priority`: Drives notification urgency (Pushover/ntfy priority levels)
|
||||
* - `messageLabel`: Optional label for the `message` payload field (defaults to "Error" if omitted)
|
||||
*/
|
||||
export interface NotificationEventConfig {
|
||||
label: string;
|
||||
title: string;
|
||||
titleByRequestType?: Record<string, string>;
|
||||
emoji: string;
|
||||
severity: NotificationSeverity;
|
||||
priority: NotificationPriority;
|
||||
messageLabel?: string;
|
||||
}
|
||||
|
||||
/** Central registry of notification events. */
|
||||
export const NOTIFICATION_EVENTS = {
|
||||
request_pending_approval: {
|
||||
label: 'Request Pending Approval',
|
||||
@@ -31,17 +43,29 @@ export const NOTIFICATION_EVENTS = {
|
||||
request_approved: {
|
||||
label: 'Request Approved',
|
||||
title: 'Request Approved',
|
||||
emoji: '\u2705',
|
||||
emoji: '✅',
|
||||
severity: 'success' as const,
|
||||
priority: 'normal' as const,
|
||||
},
|
||||
request_grabbed: {
|
||||
label: 'Request Grabbed',
|
||||
title: 'Download Grabbed',
|
||||
titleByRequestType: {
|
||||
audiobook: 'Audiobook Grabbed',
|
||||
ebook: 'Ebook Grabbed',
|
||||
},
|
||||
emoji: '\u{1F4E5}',
|
||||
severity: 'info' as const,
|
||||
priority: 'normal' as const,
|
||||
messageLabel: 'Details',
|
||||
},
|
||||
request_available: {
|
||||
label: 'Request Available',
|
||||
title: 'Request Available',
|
||||
titleByRequestType: {
|
||||
audiobook: 'Audiobook Available',
|
||||
ebook: 'Ebook Available',
|
||||
} as Record<string, string>,
|
||||
},
|
||||
emoji: '\u{1F389}',
|
||||
severity: 'success' as const,
|
||||
priority: 'high' as const,
|
||||
@@ -49,7 +73,7 @@ export const NOTIFICATION_EVENTS = {
|
||||
request_error: {
|
||||
label: 'Request Error',
|
||||
title: 'Request Error',
|
||||
emoji: '\u274C',
|
||||
emoji: '❌',
|
||||
severity: 'error' as const,
|
||||
priority: 'high' as const,
|
||||
},
|
||||
@@ -59,8 +83,9 @@ export const NOTIFICATION_EVENTS = {
|
||||
emoji: '\u{1F6A9}',
|
||||
severity: 'warning' as const,
|
||||
priority: 'high' as const,
|
||||
messageLabel: 'Reason',
|
||||
},
|
||||
} as const;
|
||||
} satisfies Record<string, NotificationEventConfig>;
|
||||
|
||||
/** Union type of all valid notification event keys */
|
||||
export type NotificationEvent = keyof typeof NOTIFICATION_EVENTS;
|
||||
@@ -72,7 +97,7 @@ export const NOTIFICATION_EVENT_KEYS = Object.keys(NOTIFICATION_EVENTS) as [Noti
|
||||
export type NotificationEventMeta = (typeof NOTIFICATION_EVENTS)[NotificationEvent];
|
||||
|
||||
/** Helper: get event metadata by key */
|
||||
export function getEventMeta(event: NotificationEvent) {
|
||||
export function getEventMeta(event: NotificationEvent): NotificationEventConfig {
|
||||
return NOTIFICATION_EVENTS[event];
|
||||
}
|
||||
|
||||
@@ -82,9 +107,9 @@ export function getEventMeta(event: NotificationEvent) {
|
||||
* returns the type-specific title. Otherwise falls back to the default `title`.
|
||||
*/
|
||||
export function getEventTitle(event: NotificationEvent, requestType?: string): string {
|
||||
const meta = NOTIFICATION_EVENTS[event];
|
||||
if (requestType && 'titleByRequestType' in meta) {
|
||||
const typeTitle = (meta as typeof meta & { titleByRequestType: Record<string, string> }).titleByRequestType[requestType];
|
||||
const meta = getEventMeta(event);
|
||||
if (requestType && meta.titleByRequestType) {
|
||||
const typeTitle = meta.titleByRequestType[requestType];
|
||||
if (typeTitle) return typeTitle;
|
||||
}
|
||||
return meta.title;
|
||||
|
||||
@@ -34,6 +34,9 @@ export interface Audiobook {
|
||||
requestedByUsername?: string | null; // Username who requested (only if not current user)
|
||||
hasReportedIssue?: boolean; // True if an open issue exists for this audiobook
|
||||
isIgnored?: boolean; // True if this user has ignored this audiobook from auto-requests
|
||||
language?: string;
|
||||
formatType?: string;
|
||||
publisherName?: string;
|
||||
}
|
||||
|
||||
export function useAudiobooks(type: 'popular' | 'new-releases', limit: number = 20, page: number = 1, hideAvailable: boolean = false) {
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
import { RMABLogger } from '../utils/logger';
|
||||
import { parseRuntime } from '../utils/parse-runtime';
|
||||
import { randomDelay } from '../utils/scrape-resilience';
|
||||
import { extractAllNarrators } from '../utils/extract-narrator';
|
||||
|
||||
const logger = RMABLogger.create('Audible.Series');
|
||||
|
||||
@@ -442,10 +443,8 @@ function parseSeriesBooks(
|
||||
const authorHref = authorLink.attr('href') || '';
|
||||
const authorAsinMatch = authorHref.match(/\/author\/[^/]+\/([A-Z0-9]{10})/);
|
||||
|
||||
// Narrator
|
||||
const narratorText = $el.find('a[href*="searchNarrator="]').first().text().trim() ||
|
||||
$el.find('.narratorLabel').text().trim() ||
|
||||
'';
|
||||
// Narrator — capture all narrator links (multi-narrator productions are common)
|
||||
const narratorText = extractAllNarrators($, $el);
|
||||
|
||||
// Cover art
|
||||
const coverArtUrl = $el.find('img').first().attr('src')?.replace(/\._.*_\./, '._SL500_.') || '';
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -138,16 +138,37 @@ async function persistSectionBooks(
|
||||
logger: ReturnType<typeof RMABLogger.forJob>,
|
||||
labelForErrors: string,
|
||||
): Promise<number> {
|
||||
// Defensive dedup: the (asin, categoryId) unique constraint means a duplicate ASIN
|
||||
// in `books` crashes the second .create() with P2002. The HTML parser already dedupes
|
||||
// per page and across pages against the cumulative accumulator, but a warn-on-fire
|
||||
// signal here lets us detect upstream surprises (e.g. Audible serving the same item
|
||||
// in both a carousel and the main grid) without the noisy duplicate-key Postgres
|
||||
// errors. Keep the first occurrence so Audible's editorial ordering is preserved.
|
||||
const seenAsins = new Set<string>();
|
||||
const dedupedBooks = books.filter((b) => {
|
||||
if (!b?.asin || seenAsins.has(b.asin)) return false;
|
||||
seenAsins.add(b.asin);
|
||||
return true;
|
||||
});
|
||||
const droppedCount = books.length - dedupedBooks.length;
|
||||
if (droppedCount > 0) {
|
||||
logger.warn(
|
||||
`Dropped ${droppedCount} duplicate ASIN(s) from ${categoryId} input list before persist`,
|
||||
);
|
||||
}
|
||||
|
||||
// Wipe previous entries for this section
|
||||
logger.info(`Clearing previous data for ${categoryId}...`);
|
||||
await prisma.audibleCacheCategory.deleteMany({
|
||||
where: { categoryId },
|
||||
});
|
||||
logger.info(`Cleared previous entries for ${categoryId}, saving ${books.length} books...`);
|
||||
logger.info(
|
||||
`Cleared previous entries for ${categoryId}, saving ${dedupedBooks.length} books...`,
|
||||
);
|
||||
|
||||
let saved = 0;
|
||||
for (let i = 0; i < books.length; i++) {
|
||||
const book = books[i];
|
||||
for (let i = 0; i < dedupedBooks.length; i++) {
|
||||
const book = dedupedBooks[i];
|
||||
try {
|
||||
// Cache thumbnail if coverArtUrl exists
|
||||
let cachedCoverPath: string | null = null;
|
||||
|
||||
@@ -31,13 +31,16 @@ export async function processDownloadTorrent(payload: DownloadTorrentPayload): P
|
||||
|
||||
try {
|
||||
// Update request status to downloading
|
||||
await prisma.request.update({
|
||||
const request = await prisma.request.update({
|
||||
where: { id: requestId },
|
||||
data: {
|
||||
status: 'downloading',
|
||||
progress: 0,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
include: {
|
||||
user: { select: { plexUsername: true } },
|
||||
},
|
||||
});
|
||||
|
||||
// Detect protocol from result and get appropriate client
|
||||
@@ -103,8 +106,22 @@ export async function processDownloadTorrent(payload: DownloadTorrentPayload): P
|
||||
|
||||
logger.info(`Created download history record: ${downloadHistory.id}`);
|
||||
|
||||
// Trigger monitor download job with initial delay
|
||||
// Send grab notification (non-blocking — failures here don't fail the download)
|
||||
const jobQueue = getJobQueueService();
|
||||
const grabMessage = `${torrent.title} via ${torrent.indexer} (${client.clientType})`;
|
||||
await jobQueue.addNotificationJob(
|
||||
'request_grabbed',
|
||||
requestId,
|
||||
audiobook.title,
|
||||
audiobook.author,
|
||||
request.user.plexUsername || 'Unknown User',
|
||||
grabMessage,
|
||||
request.type
|
||||
).catch((error) => {
|
||||
logger.error('Failed to queue grab notification', { error: error instanceof Error ? error.message : String(error) });
|
||||
});
|
||||
|
||||
// Trigger monitor download job with initial delay
|
||||
await jobQueue.addMonitorJob(
|
||||
requestId,
|
||||
downloadHistory.id,
|
||||
|
||||
@@ -127,6 +127,7 @@ export class AppriseProvider implements INotificationProvider {
|
||||
|
||||
private formatMessage(payload: NotificationPayload): { title: string; body: string } {
|
||||
const { event, title, author, userName, message, requestType } = payload;
|
||||
const meta = getEventMeta(event);
|
||||
|
||||
const isIssue = event === 'issue_reported';
|
||||
const messageLines = [
|
||||
@@ -136,7 +137,9 @@ export class AppriseProvider implements INotificationProvider {
|
||||
];
|
||||
|
||||
if (message) {
|
||||
messageLines.push(isIssue ? `\u{1F4DD} Reason: ${message}` : `\u26A0\uFE0F Error: ${message}`);
|
||||
const messageLabel = meta.messageLabel ?? 'Error';
|
||||
const msgEmoji = meta.severity === 'error' ? '\u26A0\uFE0F' : '\u{1F4DD}';
|
||||
messageLines.push(`${msgEmoji} ${messageLabel}: ${message}`);
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -71,7 +71,7 @@ export class DiscordProvider implements INotificationProvider {
|
||||
];
|
||||
|
||||
if (message) {
|
||||
fields.push({ name: isIssue ? 'Reason' : 'Error', value: message, inline: false });
|
||||
fields.push({ name: meta.messageLabel ?? 'Error', value: message, inline: false });
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -84,6 +84,7 @@ export class NtfyProvider implements INotificationProvider {
|
||||
|
||||
private formatMessage(payload: NotificationPayload): { title: string; message: string } {
|
||||
const { event, title, author, userName, message, requestType } = payload;
|
||||
const meta = getEventMeta(event);
|
||||
|
||||
const isIssue = event === 'issue_reported';
|
||||
const messageLines = [
|
||||
@@ -93,7 +94,9 @@ export class NtfyProvider implements INotificationProvider {
|
||||
];
|
||||
|
||||
if (message) {
|
||||
messageLines.push(isIssue ? `\u{1F4DD} Reason: ${message}` : `\u26A0\uFE0F Error: ${message}`);
|
||||
const messageLabel = meta.messageLabel ?? 'Error';
|
||||
const msgEmoji = meta.severity === 'error' ? '\u26A0\uFE0F' : '\u{1F4DD}';
|
||||
messageLines.push(`${msgEmoji} ${messageLabel}: ${message}`);
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -91,7 +91,9 @@ export class PushoverProvider implements INotificationProvider {
|
||||
];
|
||||
|
||||
if (message) {
|
||||
messageLines.push('', isIssue ? `\u{1F4DD} Reason: ${message}` : `\u26A0\uFE0F Error: ${message}`);
|
||||
const messageLabel = meta.messageLabel ?? 'Error';
|
||||
const msgEmoji = meta.severity === 'error' ? '\u26A0\uFE0F' : '\u{1F4DD}';
|
||||
messageLines.push('', `${msgEmoji} ${messageLabel}: ${message}`);
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
|
||||
import { prisma } from '@/lib/db';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import type { DedupGroup } from '@/lib/utils/deduplicate-audiobooks';
|
||||
import { metadataScore, type DedupGroup } from '@/lib/utils/deduplicate-audiobooks';
|
||||
import type { AudibleAudiobook } from '@/lib/integrations/audible.service';
|
||||
|
||||
const logger = RMABLogger.create('WorksService');
|
||||
|
||||
@@ -182,6 +183,96 @@ export async function seedAsin(
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// View-level collapse (consult the works table after local dedup)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Collapse books that already share a Work record according to the works table.
|
||||
*
|
||||
* The local `deduplicateAndCollectGroups()` pass is title/narrator/duration-based
|
||||
* and stateless — it can fail to merge ASINs whose source metadata diverges (e.g.
|
||||
* a series-page scrape captures different "first narrators" for two ASINs of the
|
||||
* same recording, or two paginated pages each contain one ASIN and never compare
|
||||
* them). The works table is the durable source of truth for "same book" identity,
|
||||
* populated by every prior dedup pass and by request-time seeding. This pass
|
||||
* applies that knowledge to the current view.
|
||||
*
|
||||
* Behavior:
|
||||
* - Books whose ASINs map to a shared workId collapse to a single representative
|
||||
* chosen by `metadataScore()` (same ranking as local dedup).
|
||||
* - Books not present in any work, or in single-ASIN works, pass through untouched.
|
||||
* - Original ordering is preserved (the kept representative sits at the position
|
||||
* of the first occurrence of its work in the input list).
|
||||
* - DB failure is non-fatal: the input list is returned unchanged so the view
|
||||
* still renders (degrades to local-dedup-only behavior).
|
||||
*/
|
||||
export async function collapseByExistingWorks(
|
||||
books: AudibleAudiobook[],
|
||||
): Promise<AudibleAudiobook[]> {
|
||||
if (books.length <= 1) return books;
|
||||
|
||||
try {
|
||||
const asins = books.map(b => b.asin);
|
||||
const entries = await prisma.workAsin.findMany({
|
||||
where: { asin: { in: asins } },
|
||||
select: { asin: true, workId: true },
|
||||
});
|
||||
|
||||
if (entries.length === 0) return books;
|
||||
|
||||
// Map ASIN → workId for fast lookup in the loop below
|
||||
const asinToWorkId = new Map<string, string>();
|
||||
for (const entry of entries) {
|
||||
asinToWorkId.set(entry.asin, entry.workId);
|
||||
}
|
||||
|
||||
// Walk the input once, preserving position. For each work seen, keep a
|
||||
// running "best" book; for books not in any work, emit immediately.
|
||||
const result: AudibleAudiobook[] = [];
|
||||
const workIdToResultIndex = new Map<string, number>();
|
||||
|
||||
for (const book of books) {
|
||||
const workId = asinToWorkId.get(book.asin);
|
||||
if (!workId) {
|
||||
result.push(book);
|
||||
continue;
|
||||
}
|
||||
|
||||
const existingIndex = workIdToResultIndex.get(workId);
|
||||
if (existingIndex === undefined) {
|
||||
workIdToResultIndex.set(workId, result.length);
|
||||
result.push(book);
|
||||
continue;
|
||||
}
|
||||
|
||||
// A sibling from this work is already in the result. Keep whichever
|
||||
// has the richer metadata; on tie, keep the earlier entry (already there).
|
||||
const existing = result[existingIndex];
|
||||
if (metadataScore(book) > metadataScore(existing)) {
|
||||
result[existingIndex] = book;
|
||||
}
|
||||
}
|
||||
|
||||
const collapsed = books.length - result.length;
|
||||
if (collapsed > 0) {
|
||||
logger.debug('Collapsed books via works table', {
|
||||
inputCount: books.length,
|
||||
outputCount: result.length,
|
||||
collapsed,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error('collapseByExistingWorks failed; returning input unchanged', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
bookCount: books.length,
|
||||
});
|
||||
return books;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sibling ASIN lookup (for library matching expansion)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -11,6 +11,7 @@ export interface AudibleRegionConfig {
|
||||
code: AudibleRegion;
|
||||
name: string;
|
||||
baseUrl: string;
|
||||
apiBaseUrl: string;
|
||||
audnexusParam: string;
|
||||
language: SupportedLanguage;
|
||||
}
|
||||
@@ -20,6 +21,7 @@ export const AUDIBLE_REGIONS: Record<AudibleRegion, AudibleRegionConfig> = {
|
||||
code: 'us',
|
||||
name: 'United States',
|
||||
baseUrl: 'https://www.audible.com',
|
||||
apiBaseUrl: 'https://api.audible.com',
|
||||
audnexusParam: 'us',
|
||||
language: 'en',
|
||||
},
|
||||
@@ -27,6 +29,7 @@ export const AUDIBLE_REGIONS: Record<AudibleRegion, AudibleRegionConfig> = {
|
||||
code: 'ca',
|
||||
name: 'Canada',
|
||||
baseUrl: 'https://www.audible.ca',
|
||||
apiBaseUrl: 'https://api.audible.ca',
|
||||
audnexusParam: 'ca',
|
||||
language: 'en',
|
||||
},
|
||||
@@ -34,6 +37,7 @@ export const AUDIBLE_REGIONS: Record<AudibleRegion, AudibleRegionConfig> = {
|
||||
code: 'uk',
|
||||
name: 'United Kingdom',
|
||||
baseUrl: 'https://www.audible.co.uk',
|
||||
apiBaseUrl: 'https://api.audible.co.uk',
|
||||
audnexusParam: 'uk',
|
||||
language: 'en',
|
||||
},
|
||||
@@ -41,6 +45,7 @@ export const AUDIBLE_REGIONS: Record<AudibleRegion, AudibleRegionConfig> = {
|
||||
code: 'au',
|
||||
name: 'Australia',
|
||||
baseUrl: 'https://www.audible.com.au',
|
||||
apiBaseUrl: 'https://api.audible.com.au',
|
||||
audnexusParam: 'au',
|
||||
language: 'en',
|
||||
},
|
||||
@@ -48,6 +53,7 @@ export const AUDIBLE_REGIONS: Record<AudibleRegion, AudibleRegionConfig> = {
|
||||
code: 'in',
|
||||
name: 'India',
|
||||
baseUrl: 'https://www.audible.in',
|
||||
apiBaseUrl: 'https://api.audible.in',
|
||||
audnexusParam: 'in',
|
||||
language: 'en',
|
||||
},
|
||||
@@ -55,6 +61,7 @@ export const AUDIBLE_REGIONS: Record<AudibleRegion, AudibleRegionConfig> = {
|
||||
code: 'de',
|
||||
name: 'Germany',
|
||||
baseUrl: 'https://www.audible.de',
|
||||
apiBaseUrl: 'https://api.audible.de',
|
||||
audnexusParam: 'de',
|
||||
language: 'de',
|
||||
},
|
||||
@@ -62,6 +69,7 @@ export const AUDIBLE_REGIONS: Record<AudibleRegion, AudibleRegionConfig> = {
|
||||
code: 'es',
|
||||
name: 'Spain',
|
||||
baseUrl: 'https://www.audible.es',
|
||||
apiBaseUrl: 'https://api.audible.es',
|
||||
audnexusParam: 'es',
|
||||
language: 'es',
|
||||
},
|
||||
@@ -69,9 +77,10 @@ export const AUDIBLE_REGIONS: Record<AudibleRegion, AudibleRegionConfig> = {
|
||||
code: 'fr',
|
||||
name: 'France',
|
||||
baseUrl: 'https://www.audible.fr',
|
||||
apiBaseUrl: 'https://api.audible.fr',
|
||||
audnexusParam: 'fr',
|
||||
language: 'fr',
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export const DEFAULT_AUDIBLE_REGION: AudibleRegion = 'us';
|
||||
|
||||
@@ -109,7 +109,12 @@ export function areDurationsCompatible(a?: number, b?: number): boolean {
|
||||
// Metadata scoring (for picking best representative)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function metadataScore(book: AudibleAudiobook): number {
|
||||
/**
|
||||
* Score a book by how much metadata it carries. Used as the tie-breaker when
|
||||
* collapsing duplicates — the entry with the richest metadata wins. Exported
|
||||
* so the works-table collapse pass can apply the same ranking.
|
||||
*/
|
||||
export function metadataScore(book: AudibleAudiobook): number {
|
||||
let score = 0;
|
||||
if (book.coverArtUrl) score++;
|
||||
if (book.rating != null) score++;
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Component: Narrator Extraction Utility
|
||||
* Documentation: documentation/integrations/audible.md
|
||||
*
|
||||
* Shared helper for Audible HTML scrapers. Audible product listings render
|
||||
* each narrator as a separate `<a href="?searchNarrator=...">` link; using
|
||||
* `.first()` on that selector silently drops co-narrators and breaks dedup
|
||||
* for multi-narrator productions (e.g. full-cast audiobooks). This helper
|
||||
* captures every narrator link and joins them, falling back to the
|
||||
* `.narratorLabel` span when no anchor links are present.
|
||||
*/
|
||||
|
||||
import type * as cheerio from 'cheerio';
|
||||
import type { AnyNode } from 'domhandler';
|
||||
|
||||
/**
|
||||
* Extract a comma-joined narrator string from an Audible product list item.
|
||||
*
|
||||
* Order is not semantically significant — downstream `normalizeNarrator()`
|
||||
* sorts before comparison — but document-order preserves a stable, legible
|
||||
* value for caching and logging.
|
||||
*/
|
||||
export function extractAllNarrators(
|
||||
$: cheerio.CheerioAPI,
|
||||
$el: cheerio.Cheerio<AnyNode>,
|
||||
): string {
|
||||
const links = $el.find('a[href*="searchNarrator="]');
|
||||
if (links.length > 0) {
|
||||
const names: string[] = [];
|
||||
links.each((_, link) => {
|
||||
const name = $(link).text().trim();
|
||||
if (name) names.push(name);
|
||||
});
|
||||
if (names.length > 0) return names.join(', ');
|
||||
}
|
||||
return $el.find('.narratorLabel').text().trim();
|
||||
}
|
||||
@@ -38,12 +38,18 @@ export function getBrowserHeaders(userAgent: string): Record<string, string> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Jittered exponential backoff: 2^attempt * baseMs * random(0.5, 1.5)
|
||||
* Jittered exponential backoff: 2^attempt * baseMs * random(0.5, 1.5),
|
||||
* optionally capped so high attempt counts don't produce absurd waits.
|
||||
* Avoids predictable retry timing that is trivially fingerprinted.
|
||||
*/
|
||||
export function jitteredBackoff(attempt: number, baseMs: number = 1000): number {
|
||||
export function jitteredBackoff(
|
||||
attempt: number,
|
||||
baseMs: number = 1000,
|
||||
maxBackoffMs: number = Number.POSITIVE_INFINITY,
|
||||
): number {
|
||||
const jitter = 0.5 + Math.random(); // 0.5 – 1.5
|
||||
return Math.round(Math.pow(2, attempt) * baseMs * jitter);
|
||||
const raw = Math.pow(2, attempt) * baseMs * jitter;
|
||||
return Math.round(Math.min(raw, maxBackoffMs));
|
||||
}
|
||||
|
||||
/** Random integer in [minMs, maxMs] */
|
||||
|
||||
Reference in New Issue
Block a user