From cb9f1b81bcd6f00f232da546d43110afba8f47c0 Mon Sep 17 00:00:00 2001
From: kikootwo
Date: Fri, 20 Feb 2026 10:19:30 -0500
Subject: [PATCH] Add series browsing, search, and detail UI
Introduce full support for Audible series exploration: API routes, frontend pages, components, hooks, and integrations. Key changes:
- Prisma: add Audiobook.seriesAsin for linking audiobooks to series detail pages.
- Backend: add /api/series/search and /api/series/[asin] routes that require auth; scrape Audible series data and enrich books with library availability.
- Integrations/services: add audible-series integration and update request/HTTP services to support the workflow.
- Frontend: add /series and /series/[asin] pages, new components (SeriesCard, SeriesGrid, SeriesDetailCard, SimilarSeriesRow) and wire them to a new useSeries hook; update AudiobookDetailsModal to show/link series; add Series link to Header.
- Misc: extend audiobook types with series fields and add seriesLabels to language-config for scraping.
These changes enable users to search for series, view series metadata and books, and navigate between audiobook and series detail pages.
---
prisma/schema.prisma | 1 +
src/app/api/series/[asin]/route.ts | 72 +++
src/app/api/series/search/route.ts | 57 ++
src/app/series/[asin]/page.tsx | 117 ++++
src/app/series/page.tsx | 179 ++++++
.../audiobooks/AudiobookDetailsModal.tsx | 18 +
src/components/layout/Header.tsx | 13 +
src/components/series/SeriesCard.tsx | 153 ++++++
src/components/series/SeriesDetailCard.tsx | 164 ++++++
src/components/series/SeriesGrid.tsx | 98 ++++
src/components/series/SimilarSeriesRow.tsx | 169 ++++++
src/lib/constants/language-config.ts | 5 +
src/lib/hooks/useAudiobooks.ts | 3 +
src/lib/hooks/useSeries.ts | 75 +++
src/lib/integrations/audible-series.ts | 515 ++++++++++++++++++
src/lib/integrations/audible.service.ts | 21 +-
src/lib/services/request-creator.service.ts | 4 +
17 files changed, 1663 insertions(+), 1 deletion(-)
create mode 100644 src/app/api/series/[asin]/route.ts
create mode 100644 src/app/api/series/search/route.ts
create mode 100644 src/app/series/[asin]/page.tsx
create mode 100644 src/app/series/page.tsx
create mode 100644 src/components/series/SeriesCard.tsx
create mode 100644 src/components/series/SeriesDetailCard.tsx
create mode 100644 src/components/series/SeriesGrid.tsx
create mode 100644 src/components/series/SimilarSeriesRow.tsx
create mode 100644 src/lib/hooks/useSeries.ts
create mode 100644 src/lib/integrations/audible-series.ts
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 5780358..817f568 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -176,6 +176,7 @@ model Audiobook {
year Int? // Release year extracted from releaseDate
series String? // Book series name (e.g., "The Mistborn Saga")
seriesPart String? @map("series_part") // Series position (e.g., "1", "1.5", "Book 1")
+ seriesAsin String? @map("series_asin") // Audible series ASIN for linking to series detail page
// Request tracking
status String @default("requested") // requested, downloading, processing, completed, failed
diff --git a/src/app/api/series/[asin]/route.ts b/src/app/api/series/[asin]/route.ts
new file mode 100644
index 0000000..e277c06
--- /dev/null
+++ b/src/app/api/series/[asin]/route.ts
@@ -0,0 +1,72 @@
+/**
+ * Component: Series Detail API Route
+ * Documentation: documentation/integrations/audible.md
+ */
+
+import { NextRequest, NextResponse } from 'next/server';
+import { getCurrentUser } from '@/lib/middleware/auth';
+import { RMABLogger } from '@/lib/utils/logger';
+import { scrapeSeriesPage } from '@/lib/integrations/audible-series';
+import { enrichAudiobooksWithMatches } from '@/lib/utils/audiobook-matcher';
+
+const logger = RMABLogger.create('API.Series.Detail');
+
+/**
+ * GET /api/series/{asin}
+ * Fetch series detail: metadata + books (enriched with availability) + similar series
+ */
+export async function GET(
+ request: NextRequest,
+ { params }: { params: Promise<{ asin: string }> }
+) {
+ try {
+ const currentUser = getCurrentUser(request);
+ if (!currentUser) {
+ return NextResponse.json(
+ { error: 'Unauthorized', message: 'Authentication required' },
+ { status: 401 }
+ );
+ }
+
+ const { asin } = await params;
+
+ if (!asin || !/^[A-Z0-9]{10}$/.test(asin)) {
+ return NextResponse.json(
+ { error: 'ValidationError', message: 'Valid series ASIN is required' },
+ { status: 400 }
+ );
+ }
+
+ logger.info(`Fetching series detail: ${asin}`);
+
+ const detail = await scrapeSeriesPage(asin);
+ if (!detail) {
+ return NextResponse.json(
+ { error: 'NotFound', message: 'Series not found' },
+ { status: 404 }
+ );
+ }
+
+ // Enrich books with library availability and request status
+ const userId = currentUser.sub || undefined;
+ const enrichedBooks = await enrichAudiobooksWithMatches(detail.books, userId);
+
+ logger.info(`Series detail complete: "${detail.title}" (${enrichedBooks.length} books)`);
+
+ return NextResponse.json({
+ success: true,
+ series: {
+ ...detail,
+ books: enrichedBooks,
+ },
+ });
+ } catch (error) {
+ logger.error('Failed to fetch series detail', {
+ error: error instanceof Error ? error.message : String(error),
+ });
+ return NextResponse.json(
+ { error: 'FetchError', message: 'Failed to fetch series details' },
+ { status: 500 }
+ );
+ }
+}
diff --git a/src/app/api/series/search/route.ts b/src/app/api/series/search/route.ts
new file mode 100644
index 0000000..0341fa1
--- /dev/null
+++ b/src/app/api/series/search/route.ts
@@ -0,0 +1,57 @@
+/**
+ * Component: Series Search API Route
+ * Documentation: documentation/integrations/audible.md
+ */
+
+import { NextRequest, NextResponse } from 'next/server';
+import { getCurrentUser } from '@/lib/middleware/auth';
+import { RMABLogger } from '@/lib/utils/logger';
+import { searchForSeries } from '@/lib/integrations/audible-series';
+
+const logger = RMABLogger.create('API.Series.Search');
+
+/**
+ * GET /api/series/search?q=game+of+thrones
+ * Search for audiobook series on Audible, de-duplicate, and return enriched summaries
+ */
+export async function GET(request: NextRequest) {
+ try {
+ // Require authentication
+ const currentUser = getCurrentUser(request);
+ if (!currentUser) {
+ return NextResponse.json(
+ { error: 'Unauthorized', message: 'Authentication required' },
+ { status: 401 }
+ );
+ }
+
+ const query = request.nextUrl.searchParams.get('q');
+
+ if (!query || query.trim().length === 0) {
+ return NextResponse.json(
+ { error: 'ValidationError', message: 'Search query is required' },
+ { status: 400 }
+ );
+ }
+
+ logger.info(`Searching series: "${query}"`);
+
+ const series = await searchForSeries(query.trim());
+
+ logger.info(`Series search complete: "${query}" -> ${series.length} results`);
+
+ return NextResponse.json({
+ success: true,
+ series,
+ query: query.trim(),
+ });
+ } catch (error) {
+ logger.error('Failed to search series', {
+ error: error instanceof Error ? error.message : String(error),
+ });
+ return NextResponse.json(
+ { error: 'SearchError', message: 'Failed to search series' },
+ { status: 500 }
+ );
+ }
+}
diff --git a/src/app/series/[asin]/page.tsx b/src/app/series/[asin]/page.tsx
new file mode 100644
index 0000000..951824e
--- /dev/null
+++ b/src/app/series/[asin]/page.tsx
@@ -0,0 +1,117 @@
+/**
+ * Component: Series Detail Page
+ * Documentation: documentation/frontend/components.md
+ */
+
+'use client';
+
+import { use, useCallback } from 'react';
+import { useRouter, useSearchParams } from 'next/navigation';
+import { Header } from '@/components/layout/Header';
+import { AudiobookGrid } from '@/components/audiobooks/AudiobookGrid';
+import { SeriesDetailCard, SeriesDetailSkeleton } from '@/components/series/SeriesDetailCard';
+import { SimilarSeriesRow, SimilarSeriesSkeleton } from '@/components/series/SimilarSeriesRow';
+import { useSeriesDetail } from '@/lib/hooks/useSeries';
+import { ProtectedRoute } from '@/components/auth/ProtectedRoute';
+import { CardSizeControls } from '@/components/ui/CardSizeControls';
+import { SquareCoversToggle } from '@/components/ui/SquareCoversToggle';
+import { usePreferences } from '@/contexts/PreferencesContext';
+
+export default function SeriesDetailPage({
+ params,
+}: {
+ params: Promise<{ asin: string }>;
+}) {
+ const { asin } = use(params);
+ const router = useRouter();
+ const searchParams = useSearchParams();
+ const fromSeriesTitle = searchParams.get('from');
+ const { series, isLoading: seriesLoading } = useSeriesDetail(asin);
+ const { cardSize, setCardSize, squareCovers, setSquareCovers } = usePreferences();
+
+ const handleBack = useCallback(() => {
+ // Use browser back if we came from within the app, otherwise fallback to /series
+ if (window.history.length > 1) {
+ router.back();
+ } else {
+ router.push('/series');
+ }
+ }, [router]);
+
+ return (
+
+
+
+
+
+ {/* Back navigation */}
+
+
+ {/* Series Detail Card */}
+ {seriesLoading ? (
+
+ ) : series ? (
+
+ ) : (
+
+
+
Series not found
+
+ )}
+
+ {/* Similar Series */}
+ {seriesLoading ? (
+
+ ) : series && series.similarSeries.length > 0 ? (
+
+ ) : null}
+
+ {/* Books Section */}
+ {series && (
+
+ {/* Sticky Books Header */}
+
+
+
+
+
+ Books in Series
+
+ {series.books.length > 0 && (
+
+ ({series.books.length} title{series.books.length !== 1 ? 's' : ''})
+
+ )}
+
+
+
+
+
+
+
+
+ {/* Books Grid */}
+
+
+ )}
+
+
+
+ );
+}
diff --git a/src/app/series/page.tsx b/src/app/series/page.tsx
new file mode 100644
index 0000000..d4eaaaa
--- /dev/null
+++ b/src/app/series/page.tsx
@@ -0,0 +1,179 @@
+/**
+ * Component: Series Page
+ * Documentation: documentation/frontend/components.md
+ */
+
+'use client';
+
+import { Suspense, useState, useEffect, useCallback } from 'react';
+import { useSearchParams, useRouter } from 'next/navigation';
+import { Header } from '@/components/layout/Header';
+import { SeriesGrid } from '@/components/series/SeriesGrid';
+import { useSeriesSearch } from '@/lib/hooks/useSeries';
+import { ProtectedRoute } from '@/components/auth/ProtectedRoute';
+import { CardSizeControls } from '@/components/ui/CardSizeControls';
+import { SquareCoversToggle } from '@/components/ui/SquareCoversToggle';
+import { usePreferences } from '@/contexts/PreferencesContext';
+
+function SeriesPageContent() {
+ const searchParams = useSearchParams();
+ const router = useRouter();
+ const initialQuery = searchParams.get('q') || '';
+
+ const [query, setQuery] = useState(initialQuery);
+ const [debouncedQuery, setDebouncedQuery] = useState(initialQuery);
+ const { cardSize, setCardSize, squareCovers, setSquareCovers } = usePreferences();
+
+ // Debounce search query and sync to URL
+ useEffect(() => {
+ const timer = setTimeout(() => {
+ setDebouncedQuery(query);
+ // Update URL without adding history entries
+ const trimmed = query.trim();
+ if (trimmed) {
+ router.replace(`/series?q=${encodeURIComponent(trimmed)}`, { scroll: false });
+ } else {
+ router.replace('/series', { scroll: false });
+ }
+ }, 500);
+
+ return () => clearTimeout(timer);
+ }, [query, router]);
+
+ const { series, isLoading } = useSeriesSearch(debouncedQuery);
+
+ const handleSearch = useCallback((e: React.FormEvent) => {
+ e.preventDefault();
+ }, []);
+
+ return (
+
+
+
+
+
+ {/* Page Header */}
+
+
+ Browse Series
+
+
+ Search for your favorite audiobook series
+
+
+
+ {/* Search Form */}
+
+
+ {/* Results */}
+ {debouncedQuery ? (
+
+ {/* Sticky Results Header */}
+
+
+
+
+
+ Series
+
+ {!isLoading && series.length > 0 && (
+
+ ({series.length} result{series.length !== 1 ? 's' : ''})
+
+ )}
+
+
+
+
+
+
+
+
+ {/* Series Grid */}
+
+
+ ) : (
+ /* Empty State */
+
+
+
+ Start typing to search for series
+
+
+ Search by series name to discover audiobook collections
+
+
+ )}
+
+
+
+ );
+}
+
+export default function SeriesPage() {
+ return (
+
+
+
+ );
+}
diff --git a/src/components/audiobooks/AudiobookDetailsModal.tsx b/src/components/audiobooks/AudiobookDetailsModal.tsx
index 2d19a1f..2b6121d 100644
--- a/src/components/audiobooks/AudiobookDetailsModal.tsx
+++ b/src/components/audiobooks/AudiobookDetailsModal.tsx
@@ -307,6 +307,24 @@ export function AudiobookDetailsModal({
Narrated by {audiobook.narrator}
)}
+ {audiobook.series && (
+
+ {audiobook.seriesAsin ? (
+ {
+ e.stopPropagation();
+ onClose();
+ }}
+ className="hover:text-emerald-600 dark:hover:text-emerald-400 transition-colors"
+ >
+ {audiobook.series}{audiobook.seriesPart ? `, Book ${audiobook.seriesPart}` : ''}
+
+ ) : (
+ {audiobook.series}{audiobook.seriesPart ? `, Book ${audiobook.seriesPart}` : ''}
+ )}
+
+ )}
{/* Status Badge */}
{status.type !== 'none' && (
diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx
index 869e85e..eff6201 100644
--- a/src/components/layout/Header.tsx
+++ b/src/components/layout/Header.tsx
@@ -166,6 +166,12 @@ export function Header() {
>
Authors
+
+ Series
+
{showBookDate && (
Authors
+ setShowMobileMenu(false)}
+ className="px-3 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md transition-colors"
+ >
+ Series
+
{showBookDate && (
0;
+ const hasRating = series.rating != null && series.rating > 0;
+
+ return (
+
+ {/* Cover Container — The Hero */}
+
+ {/* Cover Art or Fallback */}
+ {series.coverArtUrl ? (
+
+ ) : (
+
+ )}
+
+ {/* Top-row badges — Rating (left) + Book count (right) */}
+ {/* Rating Badge — top-left, matches AudiobookCard pattern exactly */}
+ {hasRating && (
+
+
+
{series.rating!.toFixed(1)}
+
+ )}
+
+ {/* Book count badge — top-right */}
+ {series.bookCount > 0 && (
+
+ {series.bookCount} {series.bookCount === 1 ? 'Book' : 'Books'}
+
+ )}
+
+ {/* Bottom gradient overlay — always present, deepens on hover */}
+
+
+ {/* Tag pills — pinned to bottom of cover, inside gradient */}
+ {hasTags && (
+
+ {visibleTags.map(tag => (
+
+ {tag}
+
+ ))}
+
+ )}
+
+
+ {/* Below-cover: title only — fixed, predictable height across all cards */}
+
+
+ {series.title}
+
+
+
+ );
+}
diff --git a/src/components/series/SeriesDetailCard.tsx b/src/components/series/SeriesDetailCard.tsx
new file mode 100644
index 0000000..d5afaa2
--- /dev/null
+++ b/src/components/series/SeriesDetailCard.tsx
@@ -0,0 +1,164 @@
+/**
+ * Component: Series Detail Card
+ * Documentation: documentation/frontend/components.md
+ *
+ * Hero section for the series detail page with rectangular cover image,
+ * title, book count, rating, collapsible description, and tag pills.
+ */
+
+'use client';
+
+import React, { useState } from 'react';
+import Image from 'next/image';
+import { SeriesDetail } from '@/lib/hooks/useSeries';
+
+interface SeriesDetailCardProps {
+ series: SeriesDetail;
+ squareCovers?: boolean;
+}
+
+export function SeriesDetailCard({ series, squareCovers = false }: SeriesDetailCardProps) {
+ const [expanded, setExpanded] = useState(false);
+ const hasLongDescription = (series.description?.length || 0) > 300;
+
+ return (
+
+ {/* Rectangular Cover */}
+
+
+ {series.books[0]?.coverArtUrl ? (
+
+ ) : (
+
+ )}
+
+
+
+ {/* Series Info */}
+
+
+ {series.title}
+
+
+ {/* Meta row: book count + rating */}
+
+ {series.bookCount > 0 && (
+
+
+ {series.bookCount} Book{series.bookCount !== 1 ? 's' : ''}
+
+ )}
+
+ {series.rating != null && series.rating > 0 && (
+
+
+ {series.rating.toFixed(1)}
+ {series.ratingCount != null && series.ratingCount > 0 && (
+
+ ({series.ratingCount.toLocaleString()})
+
+ )}
+
+ )}
+
+
+ {/* Tag Pills */}
+ {series.tags.length > 0 && (
+
+ {series.tags.map(tag => (
+
+ {tag}
+
+ ))}
+
+ )}
+
+ {/* Audible Link */}
+ {series.audibleUrl && (
+
+ View on Audible
+
+
+ )}
+
+ {/* Description */}
+ {series.description && (
+
+
+ {series.description}
+
+ {hasLongDescription && (
+
+ )}
+
+ )}
+
+
+ );
+}
+
+export function SeriesDetailSkeleton({ squareCovers = false }: { squareCovers?: boolean }) {
+ return (
+
+ {/* Cover skeleton */}
+
+
+ {/* Info skeleton */}
+
+
+ );
+}
diff --git a/src/components/series/SeriesGrid.tsx b/src/components/series/SeriesGrid.tsx
new file mode 100644
index 0000000..d73fabe
--- /dev/null
+++ b/src/components/series/SeriesGrid.tsx
@@ -0,0 +1,98 @@
+/**
+ * Component: Series Grid
+ * Documentation: documentation/frontend/components.md
+ *
+ * Grid layout for series cards with loading skeletons and empty state.
+ * Uses the same responsive column system as AudiobookGrid since
+ * series cards use rectangular (2:3) aspect ratios like book covers.
+ */
+
+'use client';
+
+import React from 'react';
+import { SeriesCard } from './SeriesCard';
+import { SeriesSummary } from '@/lib/hooks/useSeries';
+
+interface SeriesGridProps {
+ series: SeriesSummary[];
+ isLoading?: boolean;
+ emptyMessage?: string;
+ cardSize?: number;
+ squareCovers?: boolean;
+}
+
+function getGridClasses(size: number): string {
+ const sizeMap: Record = {
+ 1: 'grid-cols-4 sm:grid-cols-5 md:grid-cols-6 lg:grid-cols-8 xl:grid-cols-10',
+ 2: 'grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-7 xl:grid-cols-9',
+ 3: 'grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-8',
+ 4: 'grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-7',
+ 5: 'grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6',
+ 6: 'grid-cols-2 sm:grid-cols-3 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5',
+ 7: 'grid-cols-2 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4',
+ 8: 'grid-cols-1 sm:grid-cols-2 md:grid-cols-3',
+ 9: 'grid-cols-1 sm:grid-cols-2',
+ };
+ return sizeMap[size] || sizeMap[5];
+}
+
+export function SeriesGrid({
+ series,
+ isLoading = false,
+ emptyMessage = 'No series found',
+ cardSize = 5,
+ squareCovers = false,
+}: SeriesGridProps) {
+ const gridClasses = getGridClasses(cardSize);
+
+ if (isLoading) {
+ return (
+
+ {Array.from({ length: 10 }).map((_, i) => (
+
+ ))}
+
+ );
+ }
+
+ if (series.length === 0) {
+ return (
+
+ );
+ }
+
+ return (
+
+ {series.map(s => (
+
+ ))}
+
+ );
+}
+
+function SeriesSkeletonCard({ index = 0, squareCovers = false }: { index?: number; squareCovers?: boolean }) {
+ return (
+
+ {/* Rectangular cover skeleton */}
+
+
+ {/* Text skeleton */}
+
+
+ );
+}
diff --git a/src/components/series/SimilarSeriesRow.tsx b/src/components/series/SimilarSeriesRow.tsx
new file mode 100644
index 0000000..6b60152
--- /dev/null
+++ b/src/components/series/SimilarSeriesRow.tsx
@@ -0,0 +1,169 @@
+/**
+ * Component: Similar Series Row
+ * Documentation: documentation/frontend/components.md
+ *
+ * Horizontal scrollable carousel of similar series cards.
+ * Desktop: left/right nav arrows. Mobile: drag-to-scroll.
+ * Each card navigates to the series detail page.
+ */
+
+'use client';
+
+import React, { useRef, useState, useEffect, useCallback } from 'react';
+import Image from 'next/image';
+import Link from 'next/link';
+import { SimilarSeries } from '@/lib/hooks/useSeries';
+
+interface SimilarSeriesRowProps {
+ series: SimilarSeries[];
+ currentSeriesTitle?: string;
+ squareCovers?: boolean;
+}
+
+export function SimilarSeriesRow({ series, currentSeriesTitle, squareCovers = false }: SimilarSeriesRowProps) {
+ const scrollRef = useRef(null);
+ const [canScrollLeft, setCanScrollLeft] = useState(false);
+ const [canScrollRight, setCanScrollRight] = useState(false);
+
+ const checkScroll = useCallback(() => {
+ const el = scrollRef.current;
+ if (!el) return;
+ setCanScrollLeft(el.scrollLeft > 4);
+ setCanScrollRight(el.scrollLeft < el.scrollWidth - el.clientWidth - 4);
+ }, []);
+
+ useEffect(() => {
+ checkScroll();
+ const el = scrollRef.current;
+ if (!el) return;
+ el.addEventListener('scroll', checkScroll, { passive: true });
+ const observer = new ResizeObserver(checkScroll);
+ observer.observe(el);
+ return () => {
+ el.removeEventListener('scroll', checkScroll);
+ observer.disconnect();
+ };
+ }, [checkScroll, series]);
+
+ const scroll = (direction: 'left' | 'right') => {
+ const el = scrollRef.current;
+ if (!el) return;
+ const scrollAmount = el.clientWidth * 0.7;
+ el.scrollBy({
+ left: direction === 'left' ? -scrollAmount : scrollAmount,
+ behavior: 'smooth',
+ });
+ };
+
+ if (series.length === 0) return null;
+
+ return (
+
+
+
+
+ Similar Series
+
+
+ ({series.length})
+
+
+
+
+ {/* Left arrow */}
+ {canScrollLeft && (
+
+ )}
+
+ {/* Scrollable row */}
+
+ {series.map(s => (
+
+ {/* Cover */}
+
+ {s.coverArtUrl ? (
+
+ ) : (
+
+
+ {s.title.charAt(0).toUpperCase()}
+
+
+ )}
+
+
+ {/* Title */}
+
+ {s.title}
+
+
+ ))}
+
+
+ {/* Right arrow */}
+ {canScrollRight && (
+
+ )}
+
+ {/* Fade edges */}
+ {canScrollLeft && (
+
+ )}
+ {canScrollRight && (
+
+ )}
+
+
+ );
+}
+
+export function SimilarSeriesSkeleton({ squareCovers = false }: { squareCovers?: boolean }) {
+ return (
+
+
+
+ {Array.from({ length: 8 }).map((_, i) => (
+
+ ))}
+
+
+ );
+}
diff --git a/src/lib/constants/language-config.ts b/src/lib/constants/language-config.ts
index 920a29e..f7ab586 100644
--- a/src/lib/constants/language-config.ts
+++ b/src/lib/constants/language-config.ts
@@ -31,6 +31,8 @@ export interface ScrapingConfig {
languageLabels: string[];
/** Release date field labels */
releaseDateLabels: string[];
+ /** Series label prefixes used to find series links in search results */
+ seriesLabels: string[];
/** Accepted language values for filtering (lowercase) */
acceptedLanguageValues: string[];
/** Regex patterns that match hour portions in runtime strings */
@@ -80,6 +82,7 @@ const ENGLISH_CONFIG: LanguageConfig = {
lengthLabels: ['Length:'],
languageLabels: ['Language:'],
releaseDateLabels: ['Release date:'],
+ seriesLabels: ['Series:'],
acceptedLanguageValues: ['english'],
runtimeHourPatterns: [/(\d+)\s*hrs?/i, /(\d+)\s*hours?/i],
runtimeMinutePatterns: [/(\d+)\s*mins?/i, /(\d+)\s*minutes?/i],
@@ -112,6 +115,7 @@ const GERMAN_CONFIG: LanguageConfig = {
lengthLabels: ['Spieldauer:', 'Dauer:', 'L\u00e4nge:'],
languageLabels: ['Sprache:'],
releaseDateLabels: ['Erscheinungsdatum:'],
+ seriesLabels: ['Serie:', 'Reihe:'],
acceptedLanguageValues: ['deutsch', 'german'],
runtimeHourPatterns: [/(\d+)\s*Std\.?/i, /(\d+)\s*Stunden?/i],
runtimeMinutePatterns: [/(\d+)\s*Min\.?/i, /(\d+)\s*Minuten?/i],
@@ -145,6 +149,7 @@ const SPANISH_CONFIG: LanguageConfig = {
lengthLabels: ['Duraci\u00f3n:'],
languageLabels: ['Idioma:'],
releaseDateLabels: ['Fecha de lanzamiento:'],
+ seriesLabels: ['Serie:'],
acceptedLanguageValues: ['espa\u00f1ol', 'spanish'],
runtimeHourPatterns: [/(\d+)\s*h\b/i, /(\d+)\s*horas?/i],
runtimeMinutePatterns: [/(\d+)\s*min/i, /(\d+)\s*minutos?/i],
diff --git a/src/lib/hooks/useAudiobooks.ts b/src/lib/hooks/useAudiobooks.ts
index 65e416c..fa2b8cf 100644
--- a/src/lib/hooks/useAudiobooks.ts
+++ b/src/lib/hooks/useAudiobooks.ts
@@ -20,6 +20,9 @@ export interface Audiobook {
releaseDate?: string;
rating?: number;
genres?: string[];
+ series?: string; // Series name (e.g., "A Song of Ice and Fire")
+ seriesPart?: string; // Position in series (e.g., "1", "1.5")
+ seriesAsin?: string; // Audible ASIN for the series (links to /series/{asin})
isAvailable?: boolean; // Set by real-time matching against plex_library
plexGuid?: string | null;
dbId?: string | null;
diff --git a/src/lib/hooks/useSeries.ts b/src/lib/hooks/useSeries.ts
new file mode 100644
index 0000000..b8660f2
--- /dev/null
+++ b/src/lib/hooks/useSeries.ts
@@ -0,0 +1,75 @@
+/**
+ * Component: Series Fetching Hooks
+ * Documentation: documentation/frontend/components.md
+ */
+
+'use client';
+
+import useSWR from 'swr';
+import { authenticatedFetcher } from '@/lib/utils/api';
+import { Audiobook } from './useAudiobooks';
+
+export interface SeriesSummary {
+ asin: string;
+ title: string;
+ bookCount: number;
+ rating?: number;
+ ratingCount?: number;
+ tags: string[];
+ coverArtUrl?: string;
+ audibleUrl: string;
+}
+
+export interface SimilarSeries {
+ asin: string;
+ title: string;
+ bookCount?: number;
+ coverArtUrl?: string;
+}
+
+export interface SeriesDetail {
+ asin: string;
+ title: string;
+ bookCount: number;
+ rating?: number;
+ ratingCount?: number;
+ description?: string;
+ tags: string[];
+ books: Audiobook[];
+ similarSeries: SimilarSeries[];
+ audibleUrl: string;
+}
+
+export function useSeriesSearch(query: string) {
+ const shouldFetch = query && query.length > 0;
+ const endpoint = shouldFetch
+ ? `/api/series/search?q=${encodeURIComponent(query)}`
+ : null;
+
+ const { data, error, isLoading } = useSWR(endpoint, authenticatedFetcher, {
+ revalidateOnFocus: false,
+ dedupingInterval: 30000,
+ });
+
+ return {
+ series: (data?.series || []) as SeriesSummary[],
+ query: data?.query || '',
+ isLoading: shouldFetch && isLoading,
+ error,
+ };
+}
+
+export function useSeriesDetail(asin: string | null) {
+ const endpoint = asin ? `/api/series/${asin}` : null;
+
+ const { data, error, isLoading } = useSWR(endpoint, authenticatedFetcher, {
+ revalidateOnFocus: false,
+ dedupingInterval: 300000, // Cache for 5 minutes
+ });
+
+ return {
+ series: (data?.series || null) as SeriesDetail | null,
+ isLoading,
+ error,
+ };
+}
diff --git a/src/lib/integrations/audible-series.ts b/src/lib/integrations/audible-series.ts
new file mode 100644
index 0000000..3f5754a
--- /dev/null
+++ b/src/lib/integrations/audible-series.ts
@@ -0,0 +1,515 @@
+/**
+ * Component: Audible Series Scraping
+ * Documentation: documentation/integrations/audible.md
+ *
+ * Standalone series scraping module. Uses the AudibleService fetch wrapper
+ * for HTTP requests and Cheerio for HTML parsing.
+ * Kept separate from audible.service.ts to avoid bloating the main service.
+ */
+
+import * as cheerio from 'cheerio';
+import { getAudibleService, AudibleAudiobook } from './audible.service';
+import { AUDIBLE_REGIONS } from '../types/audible';
+import {
+ getLanguageForRegion,
+ buildContainsSelector,
+ stripPrefixes,
+} from '../constants/language-config';
+import { RMABLogger } from '../utils/logger';
+import { randomDelay } from '../utils/scrape-resilience';
+
+const logger = RMABLogger.create('Audible.Series');
+
+const AUDIBLE_PAGE_SIZE = 50;
+const MAX_SERIES_RESULTS = 15;
+
+// ---------------------------------------------------------------------------
+// Types
+// ---------------------------------------------------------------------------
+
+export interface SeriesSummary {
+ asin: string;
+ title: string;
+ bookCount: number;
+ rating?: number;
+ ratingCount?: number;
+ tags: string[];
+ coverArtUrl?: string;
+ audibleUrl: string;
+}
+
+export interface SimilarSeries {
+ asin: string;
+ title: string;
+ bookCount?: number;
+ coverArtUrl?: string;
+}
+
+export interface SeriesDetail {
+ asin: string;
+ title: string;
+ bookCount: number;
+ rating?: number;
+ ratingCount?: number;
+ description?: string;
+ tags: string[];
+ books: AudibleAudiobook[];
+ similarSeries: SimilarSeries[];
+ audibleUrl: string;
+}
+
+// ---------------------------------------------------------------------------
+// Search: extract series links from Audible search results
+// ---------------------------------------------------------------------------
+
+/**
+ * Search for series by scraping Audible search results and extracting
+ * series links. De-duplicates by ASIN, then scrapes each unique series
+ * page in parallel (capped at MAX_SERIES_RESULTS).
+ */
+export async function searchForSeries(query: string): Promise {
+ const service = getAudibleService();
+ const region = service.getRegion();
+ const baseUrl = service.getBaseUrl();
+ const langConfig = getLanguageForRegion(region);
+ const seriesLabels = langConfig.scraping.seriesLabels;
+
+ logger.info(`Searching series for "${query}" (region: ${region})`);
+
+ // Step 1: Fetch search results page
+ let $: cheerio.CheerioAPI;
+ try {
+ const { data: response } = await service.fetch('/search', {
+ params: {
+ ipRedirectOverride: 'true',
+ keywords: query,
+ pageSize: AUDIBLE_PAGE_SIZE,
+ },
+ });
+ $ = cheerio.load(response.data);
+ } catch (error) {
+ logger.error('Series search fetch failed', {
+ error: error instanceof Error ? error.message : String(error),
+ });
+ return [];
+ }
+
+ // Step 2: Extract unique series ASINs from search results
+ // Series links appear inside spans containing locale-specific "Series:" text
+ const seriesMap = new Map();
+
+ $('.s-result-item, .productListItem').each((_index, element) => {
+ if (seriesMap.size >= MAX_SERIES_RESULTS) return false;
+
+ const $el = $(element);
+
+ // Find the span containing a series label (e.g. "Series:")
+ const seriesSelector = buildContainsSelector('span', seriesLabels);
+ const seriesContainer = $el.find(seriesSelector).first();
+ if (seriesContainer.length === 0) return;
+
+ // Look for series link within or near the series label container
+ // The series link is a child or sibling:
+ const parentEl = seriesContainer.parent();
+ const seriesLink = parentEl.find('a[href*="/series/"]').first();
+ if (seriesLink.length === 0) return;
+
+ const href = seriesLink.attr('href') || '';
+ const asinMatch = href.match(/\/series\/[^/]*\/([A-Z0-9]{10})/);
+ if (!asinMatch) return;
+
+ const asin = asinMatch[1];
+ if (seriesMap.has(asin)) return;
+
+ const title = seriesLink.text().trim();
+ if (!title) return;
+
+ // Use the first book's cover as representative image
+ const coverArtUrl = $el.find('img').first().attr('src')?.replace(/\._.*_\./, '._SL500_.') || undefined;
+
+ seriesMap.set(asin, { title, coverArtUrl });
+ });
+
+ if (seriesMap.size === 0) {
+ logger.info(`No series found for "${query}"`);
+ return [];
+ }
+
+ logger.info(`Found ${seriesMap.size} unique series, scraping detail pages...`);
+
+ // Step 3: Scrape each series page in parallel (with rate limiting)
+ const entries = Array.from(seriesMap.entries());
+ const BATCH_SIZE = 5;
+ const results: SeriesSummary[] = [];
+
+ for (let i = 0; i < entries.length; i += BATCH_SIZE) {
+ const batch = entries.slice(i, i + BATCH_SIZE);
+ const batchResults = await Promise.all(
+ batch.map(async ([asin, meta]) => {
+ try {
+ const detail = await scrapeSeriesPageSummary(asin);
+ if (!detail) return null;
+ return {
+ ...detail,
+ coverArtUrl: detail.coverArtUrl || meta.coverArtUrl,
+ audibleUrl: `${baseUrl}/series/${asin}`,
+ } as SeriesSummary;
+ } catch (error) {
+ logger.warn(`Failed to scrape series ${asin}`, {
+ error: error instanceof Error ? error.message : String(error),
+ });
+ // Return a minimal result from search data
+ return {
+ asin,
+ title: meta.title,
+ bookCount: 0,
+ tags: [],
+ coverArtUrl: meta.coverArtUrl,
+ audibleUrl: `${baseUrl}/series/${asin}`,
+ } as SeriesSummary;
+ }
+ })
+ );
+
+ results.push(...batchResults.filter((r): r is SeriesSummary => r !== null));
+
+ // Rate limit between batches
+ if (i + BATCH_SIZE < entries.length) {
+ await new Promise(resolve => setTimeout(resolve, randomDelay(1500, 3000)));
+ }
+ }
+
+ logger.info(`Series search complete: "${query}" -> ${results.length} results`);
+ return results;
+}
+
+// ---------------------------------------------------------------------------
+// Series page scraping (summary - for search results)
+// ---------------------------------------------------------------------------
+
+/**
+ * Scrape a series page for summary data (title, book count, rating, tags).
+ * Used during search to enrich each series result.
+ */
+async function scrapeSeriesPageSummary(asin: string): Promise | null> {
+ const service = getAudibleService();
+
+ try {
+ const { data: response } = await service.fetch(`/series/${asin}`, {
+ params: { ipRedirectOverride: 'true' },
+ });
+ const $ = cheerio.load(response.data);
+
+ return parseSeriesPageSummary($, asin);
+ } catch (error) {
+ logger.warn(`Failed to fetch series page ${asin}`, {
+ error: error instanceof Error ? error.message : String(error),
+ });
+ return null;
+ }
+}
+
+/**
+ * Parse summary fields from a series page's Cheerio document.
+ */
+function parseSeriesPageSummary(
+ $: cheerio.CheerioAPI,
+ asin: string
+): Omit {
+ // Title - from h1
+ const title = $('h1').first().text().trim() || '';
+
+ // Book count - multiple strategies, most specific first
+ let bookCount = 0;
+
+ // Primary: adbl-metadata[slot="child-count"] in the page header (NOT inside carousels)
+ // Filter out carousel items by excluding those inside adbl-product-carousel
+ $('adbl-metadata[slot="child-count"]').each((_i, el) => {
+ if (bookCount > 0) return false;
+ const $el = $(el);
+ // Skip if inside a carousel (those are similar-series counts)
+ if ($el.closest('adbl-product-carousel').length > 0) return;
+ const text = $el.text().trim();
+ const match = text.match(/(\d+)/);
+ if (match) bookCount = parseInt(match[1]);
+ });
+
+ // Secondary: text matching in spans/headings for "X books/titles/Titel/libros/Bucher"
+ if (bookCount === 0) {
+ const countText = $('span:contains("book"), span:contains("title"), span:contains("Titel"), span:contains("libro"), span:contains("Buch"), span:contains("B\u00fccher")')
+ .text().trim();
+ const countMatch = countText.match(/(\d+)\s*(books?|titles?|Titel|libros?|B(?:uch|\u00fccher))/i);
+ if (countMatch) {
+ bookCount = parseInt(countMatch[1]);
+ }
+ }
+
+ // Fallback: count product items on the page
+ if (bookCount === 0) {
+ bookCount = $('.productListItem, .bc-list-item[data-asin]').length;
+ }
+
+ // Rating
+ const { rating, ratingCount } = parseSeriesRating($);
+
+ // Tags/genres: primary from adbl-chip web components, fallback to legacy links
+ const tags: string[] = [];
+ const addTag = (text: string) => {
+ const tag = text.trim();
+ if (tag && tag.length >= 2 && tag.length <= 50 && !tags.includes(tag)) {
+ tags.push(tag);
+ }
+ };
+
+ // Primary: adbl-chip.related-tag elements (modern Audible layout)
+ $('adbl-chip.related-tag').each((_i, el) => {
+ addTag($(el).text());
+ });
+
+ // Fallback: legacy category and tag links
+ if (tags.length === 0) {
+ $('a[href*="/cat/"], a[href*="/tag/"]').each((_i, el) => {
+ addTag($(el).text());
+ });
+ }
+
+ // Cover art from first book image
+ const coverArtUrl = $('.productListItem img, .bc-list-item img').first()
+ .attr('src')?.replace(/\._.*_\./, '._SL500_.') || undefined;
+
+ return { asin, title, bookCount, rating, ratingCount, tags: tags.slice(0, 5), coverArtUrl };
+}
+
+// ---------------------------------------------------------------------------
+// Series page scraping (full detail)
+// ---------------------------------------------------------------------------
+
+/**
+ * Scrape a series page for full detail data including books and similar series.
+ * Used by the detail API endpoint.
+ */
+export async function scrapeSeriesPage(asin: string): Promise {
+ const service = getAudibleService();
+ const region = service.getRegion();
+ const baseUrl = service.getBaseUrl();
+ const langConfig = getLanguageForRegion(region);
+
+ logger.info(`Scraping series detail page: ${asin}`);
+
+ try {
+ const { data: response } = await service.fetch(`/series/${asin}`, {
+ params: { ipRedirectOverride: 'true', pageSize: AUDIBLE_PAGE_SIZE },
+ });
+ const $ = cheerio.load(response.data);
+
+ // Parse summary fields
+ const summary = parseSeriesPageSummary($, asin);
+
+ // Description
+ const description = $('.bc-expander-content').first().text().trim() ||
+ $('[class*="productPublisherSummary"]').first().text().trim() ||
+ undefined;
+
+ // Parse all books from the series page
+ const books = parseSeriesBooks($, langConfig.scraping.authorPrefixes, langConfig.scraping.narratorPrefixes);
+
+ // Use actual book count if we got more from scraping
+ const bookCount = Math.max(summary.bookCount, books.length);
+
+ // Parse similar series ("Listeners also enjoyed" or similar section)
+ const similarSeries = parseSimilarSeries($);
+
+ logger.info(`Series detail complete: "${summary.title}" (${books.length} books, ${similarSeries.length} similar)`);
+
+ return {
+ asin,
+ title: summary.title,
+ bookCount,
+ rating: summary.rating,
+ ratingCount: summary.ratingCount,
+ description,
+ tags: summary.tags,
+ books,
+ similarSeries,
+ audibleUrl: `${baseUrl}/series/${asin}`,
+ };
+ } catch (error) {
+ logger.error(`Failed to scrape series detail ${asin}`, {
+ error: error instanceof Error ? error.message : String(error),
+ });
+ return null;
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Parsing helpers
+// ---------------------------------------------------------------------------
+
+/**
+ * Extract rating and rating count from a series page.
+ *
+ * Real HTML uses:
+ *
+ *
8,704 ratings
+ */
+function parseSeriesRating($: cheerio.CheerioAPI): { rating?: number; ratingCount?: number } {
+ let rating: number | undefined;
+ let ratingCount: number | undefined;
+
+ // Primary: aria-label on div.bc-review-stars (e.g. "4.5 out of 5 stars")
+ const starsDiv = $('div.bc-review-stars');
+ let ariaLabel = starsDiv.attr('aria-label') || '';
+
+ // Fallback: any element with aria-label containing rating pattern
+ if (!ariaLabel) {
+ const fallbackEl = $('[aria-label*="out of"], [aria-label*="von 5"], [aria-label*="de 5"]').first();
+ ariaLabel = fallbackEl.attr('aria-label') || '';
+ }
+
+ // Extract numeric rating from aria-label (handles "4.5 out of 5", "4,5 von 5", "4,5 de 5")
+ const ratingMatch = ariaLabel.match(/(\d+[.,]?\d*)\s*(?:out of|von|de)\s*5/i);
+ if (ratingMatch) {
+ rating = parseFloat(ratingMatch[1].replace(',', '.'));
+ }
+
+ // Rating count from span.series-rating (e.g. "8,704 ratings")
+ const seriesRatingSpan = $('span.series-rating').first();
+ let countText = seriesRatingSpan.text().trim();
+
+ // Fallback: look in broader context for rating count text
+ if (!countText) {
+ const fallbackContainer = $('[class*="rating"], .ratingsLabel').first();
+ countText = fallbackContainer.text().trim();
+ }
+
+ const countMatch = countText.match(/([\d,.]+)\s*(?:ratings?|Bewertungen?|calificaciones?)/i);
+ if (countMatch) {
+ ratingCount = parseInt(countMatch[1].replace(/[.,]/g, ''));
+ }
+
+ return { rating, ratingCount };
+}
+
+/**
+ * Parse all books from a series page's product list items.
+ */
+function parseSeriesBooks(
+ $: cheerio.CheerioAPI,
+ authorPrefixes: string[],
+ narratorPrefixes: string[]
+): AudibleAudiobook[] {
+ const books: AudibleAudiobook[] = [];
+ const seenAsins = new Set
();
+
+ $('.productListItem, .bc-list-item').each((_index, element) => {
+ const $el = $(element);
+
+ // Extract ASIN
+ const bookAsin = $el.attr('data-asin') ||
+ $el.find('li').attr('data-asin') ||
+ $el.find('a[href*="/pd/"]').attr('href')?.match(/\/pd\/[^/]+\/([A-Z0-9]{10})/)?.[1] ||
+ $el.find('a[href*="/ac/"]').attr('href')?.match(/\/ac\/[^/]+\/([A-Z0-9]{10})/)?.[1] ||
+ $el.find('a').attr('href')?.match(/\/(?:pd|ac)\/[^/]+\/([A-Z0-9]{10})/)?.[1] || '';
+
+ if (!bookAsin || seenAsins.has(bookAsin)) return;
+ seenAsins.add(bookAsin);
+
+ // Title
+ const title = $el.find('h2').first().text().trim() ||
+ $el.find('h3 a').first().text().trim() ||
+ $el.find('.bc-heading a').first().text().trim() ||
+ '';
+
+ if (!title) return;
+
+ // Author
+ const authorLink = $el.find('a[href*="/author/"]').first();
+ const authorText = authorLink.text().trim() ||
+ $el.find('.authorLabel').text().trim() ||
+ '';
+ const authorHref = authorLink.attr('href') || '';
+ const authorAsinMatch = authorHref.match(/\/author\/[^/]+\/([A-Z0-9]{10})/);
+
+ // Narrator
+ const narratorText = $el.find('a[href*="searchNarrator="]').first().text().trim() ||
+ $el.find('.narratorLabel').text().trim() ||
+ '';
+
+ // Cover art
+ const coverArtUrl = $el.find('img').first().attr('src')?.replace(/\._.*_\./, '._SL500_.') || '';
+
+ // Rating
+ const ratingText = $el.find('.ratingsLabel').text().trim() ||
+ $el.find('.a-icon-star span').first().text().trim();
+ const ratingMatch = ratingText ? ratingText.match(/(\d+[.,]?\d*)/) : null;
+ const rating = ratingMatch ? parseFloat(ratingMatch[1].replace(',', '.')) : undefined;
+
+ books.push({
+ asin: bookAsin,
+ title,
+ author: stripPrefixes(authorText, authorPrefixes),
+ authorAsin: authorAsinMatch?.[1] || undefined,
+ narrator: stripPrefixes(narratorText, narratorPrefixes),
+ coverArtUrl,
+ rating,
+ });
+ });
+
+ return books;
+}
+
+/**
+ * Parse similar series from the "Listeners also enjoyed" carousel.
+ *
+ * Real HTML uses web components:
+ *
+ *
+ *
+ *
Hockey Guys
+ *
3 titles
+ *
+ */
+function parseSimilarSeries($: cheerio.CheerioAPI): SimilarSeries[] {
+ const similar: SimilarSeries[] = [];
+ const seenAsins = new Set
();
+
+ // Scope to the SeriestoSeries carousel to avoid picking up other series links
+ const carousel = $('adbl-product-carousel#SeriestoSeries');
+ if (carousel.length === 0) return similar;
+
+ carousel.find('adbl-product-grid-item').each((_i, el) => {
+ if (similar.length >= 15) return false;
+
+ const $el = $(el);
+
+ // Extract ASIN: prefer data-asin on impression div, fallback to series href
+ let asin = $el.find('.adbl-impression-emitted, .adbl-asin-impression').first().attr('data-asin') || '';
+ if (!asin) {
+ const seriesHref = $el.find('a[href*="/series/"]').first().attr('href') || '';
+ const hrefMatch = seriesHref.match(/\/series\/[^/]*\/([A-Z0-9]{10})/);
+ if (hrefMatch) asin = hrefMatch[1];
+ }
+ if (!asin || !/^[A-Z0-9]{10}$/.test(asin)) return;
+ if (seenAsins.has(asin)) return;
+ seenAsins.add(asin);
+
+ // Title from metadata slot
+ const title = $el.find('adbl-metadata[slot="title"] a').first().text().trim() ||
+ $el.find('adbl-metadata[slot="title"]').first().text().trim() || '';
+ if (!title || title.length > 200) return;
+
+ // Book count from child-count slot (e.g. "3 titles")
+ const countText = $el.find('adbl-metadata[slot="child-count"]').first().text().trim();
+ const countMatch = countText.match(/(\d+)/);
+ const bookCount = countMatch ? parseInt(countMatch[1]) : undefined;
+
+ // Cover image from adbl-collection-image
+ const coverArtUrl = $el.find('adbl-collection-image img').first().attr('src')?.replace(/\._.*_\./, '._SL500_.') ||
+ $el.find('img').first().attr('src')?.replace(/\._.*_\./, '._SL500_.') ||
+ undefined;
+
+ similar.push({ asin, title, bookCount, coverArtUrl });
+ });
+
+ return similar;
+}
diff --git a/src/lib/integrations/audible.service.ts b/src/lib/integrations/audible.service.ts
index cef42ed..6d548fd 100644
--- a/src/lib/integrations/audible.service.ts
+++ b/src/lib/integrations/audible.service.ts
@@ -48,6 +48,7 @@ export interface AudibleAudiobook {
genres?: string[];
series?: string;
seriesPart?: string;
+ seriesAsin?: string;
}
export interface AudibleSearchResult {
@@ -77,6 +78,22 @@ export class AudibleService {
return this.baseUrl;
}
+ /**
+ * Get the current Audible region code
+ */
+ public getRegion(): AudibleRegion {
+ return this.region;
+ }
+
+ /**
+ * Public fetch wrapper for external scraping modules (e.g. audible-series.ts).
+ * Ensures the service is initialized and delegates to fetchWithRetry.
+ */
+ public async fetch(url: string, config: any = {}): Promise<{ data: any; meta: FetchResultMeta }> {
+ await this.initialize();
+ return this.fetchWithRetry(url, config);
+ }
+
/**
* Get the language config for the current region
*/
@@ -749,6 +766,7 @@ export class AudibleService {
genres: data.genres?.map((g: any) => typeof g === 'string' ? g : g.name).slice(0, 5) || undefined,
series: data.seriesPrimary?.name || undefined,
seriesPart: data.seriesPrimary?.position || undefined,
+ seriesAsin: data.seriesPrimary?.asin || undefined,
};
// Ensure cover art URL is high quality
@@ -765,7 +783,8 @@ export class AudibleService {
rating: result.rating,
genreCount: result.genres?.length || 0,
series: result.series,
- seriesPart: result.seriesPart
+ seriesPart: result.seriesPart,
+ seriesAsin: result.seriesAsin
});
return result;
diff --git a/src/lib/services/request-creator.service.ts b/src/lib/services/request-creator.service.ts
index ed068e4..864c233 100644
--- a/src/lib/services/request-creator.service.ts
+++ b/src/lib/services/request-creator.service.ts
@@ -84,6 +84,7 @@ export async function createRequestForUser(
let year: number | undefined;
let series: string | undefined;
let seriesPart: string | undefined;
+ let seriesAsin: string | undefined;
try {
const audibleService = getAudibleService();
const audnexusData = await audibleService.getAudiobookDetails(audiobook.asin);
@@ -100,6 +101,7 @@ export async function createRequestForUser(
}
if (audnexusData?.series) series = audnexusData.series;
if (audnexusData?.seriesPart) seriesPart = audnexusData.seriesPart;
+ if (audnexusData?.seriesAsin) seriesAsin = audnexusData.seriesAsin;
} catch (error) {
logger.warn(`Failed to fetch Audnexus data for ASIN ${audiobook.asin}: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
@@ -121,6 +123,7 @@ export async function createRequestForUser(
year,
series,
seriesPart,
+ seriesAsin,
status: 'requested',
},
});
@@ -134,6 +137,7 @@ export async function createRequestForUser(
if (year) updates.year = year;
if (series) updates.series = series;
if (seriesPart) updates.seriesPart = seriesPart;
+ if (seriesAsin) updates.seriesAsin = seriesAsin;
if (Object.keys(updates).length > 0) {
audiobookRecord = await prisma.audiobook.update({