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 */} +
+
+
+ + + +
+ setQuery(e.target.value)} + placeholder="Search by series name..." + className="w-full pl-12 pr-12 py-4 text-lg border-2 border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-400" + autoFocus + /> + {query && ( + + )} +
+
+ + {/* 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.title} + ) : ( +
+ + + +
+ )} +
+
+ + {/* 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 ( +
+
+ + + +
+

{emptyMessage}

+
+ ); + } + + 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({