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

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

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

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

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