diff --git a/prisma/migrations/20260303000000_add_works_table/migration.sql b/prisma/migrations/20260303000000_add_works_table/migration.sql new file mode 100644 index 0000000..83aa861 --- /dev/null +++ b/prisma/migrations/20260303000000_add_works_table/migration.sql @@ -0,0 +1,42 @@ +-- CreateTable +CREATE TABLE "works" ( + "id" TEXT NOT NULL, + "title" TEXT NOT NULL, + "author" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "works_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "work_asins" ( + "id" TEXT NOT NULL, + "work_id" TEXT NOT NULL, + "asin" TEXT NOT NULL, + "narrator" TEXT, + "duration_minutes" INTEGER, + "is_canonical" BOOLEAN NOT NULL DEFAULT false, + "source" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "work_asins_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "works_title_idx" ON "works"("title"); + +-- CreateIndex +CREATE INDEX "works_author_idx" ON "works"("author"); + +-- CreateIndex +CREATE UNIQUE INDEX "work_asins_asin_key" ON "work_asins"("asin"); + +-- CreateIndex +CREATE INDEX "work_asins_work_id_idx" ON "work_asins"("work_id"); + +-- CreateIndex +CREATE INDEX "work_asins_asin_idx" ON "work_asins"("asin"); + +-- AddForeignKey +ALTER TABLE "work_asins" ADD CONSTRAINT "work_asins_work_id_fkey" FOREIGN KEY ("work_id") REFERENCES "works"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20260303100000_add_watched_series_authors/migration.sql b/prisma/migrations/20260303100000_add_watched_series_authors/migration.sql new file mode 100644 index 0000000..2503569 --- /dev/null +++ b/prisma/migrations/20260303100000_add_watched_series_authors/migration.sql @@ -0,0 +1,51 @@ +-- CreateTable +CREATE TABLE "watched_series" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "series_asin" TEXT NOT NULL, + "series_title" TEXT NOT NULL, + "cover_art_url" TEXT, + "last_checked_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "watched_series_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "watched_authors" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "author_asin" TEXT NOT NULL, + "author_name" TEXT NOT NULL, + "cover_art_url" TEXT, + "last_checked_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "watched_authors_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "watched_series_user_id_idx" ON "watched_series"("user_id"); + +-- CreateIndex +CREATE INDEX "watched_series_series_asin_idx" ON "watched_series"("series_asin"); + +-- CreateIndex +CREATE UNIQUE INDEX "watched_series_user_id_series_asin_key" ON "watched_series"("user_id", "series_asin"); + +-- CreateIndex +CREATE INDEX "watched_authors_user_id_idx" ON "watched_authors"("user_id"); + +-- CreateIndex +CREATE INDEX "watched_authors_author_asin_idx" ON "watched_authors"("author_asin"); + +-- CreateIndex +CREATE UNIQUE INDEX "watched_authors_user_id_author_asin_key" ON "watched_authors"("user_id", "author_asin"); + +-- AddForeignKey +ALTER TABLE "watched_series" ADD CONSTRAINT "watched_series_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "watched_authors" ADD CONSTRAINT "watched_authors_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 2dee339..51f7634 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -69,6 +69,8 @@ model User { hardcoverShelves HardcoverShelf[] reportedIssues ReportedIssue[] @relation("Reporter") resolvedIssues ReportedIssue[] @relation("Resolver") + watchedSeries WatchedSeries[] + watchedAuthors WatchedAuthor[] @@index([plexId]) @@index([role]) @@ -574,3 +576,87 @@ model HardcoverBookMapping { @@index([audibleAsin]) @@map("hardcover_book_mappings") } + +// ============================================================================ +// WORKS TABLE +// Cross-ASIN audiobook identity mapping — links multiple Audible ASINs +// to a single logical work for library matching across editions. +// Documentation: documentation/integrations/audible.md +// ============================================================================ + +model Work { + id String @id @default(uuid()) + title String + author String + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + // Relations + asins WorkAsin[] + + @@index([title]) + @@index([author]) + @@map("works") +} + +model WorkAsin { + id String @id @default(uuid()) + workId String @map("work_id") + asin String @unique + narrator String? + durationMinutes Int? @map("duration_minutes") + isCanonical Boolean @default(false) @map("is_canonical") + source String // 'dedup_auto' | 'admin_manual' + createdAt DateTime @default(now()) @map("created_at") + + // Relations + work Work @relation(fields: [workId], references: [id], onDelete: Cascade) + + @@index([workId]) + @@index([asin]) + @@map("work_asins") +} + +// ============================================================================ +// WATCHED LISTS TABLES +// Per-user series and author subscriptions for automatic new-release requests. +// Documentation: documentation/features/watched-lists.md +// ============================================================================ + +model WatchedSeries { + id String @id @default(uuid()) + userId String @map("user_id") + seriesAsin String @map("series_asin") + seriesTitle String @map("series_title") + coverArtUrl String? @map("cover_art_url") @db.Text + lastCheckedAt DateTime? @map("last_checked_at") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + // Relations + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([userId, seriesAsin]) + @@index([userId]) + @@index([seriesAsin]) + @@map("watched_series") +} + +model WatchedAuthor { + id String @id @default(uuid()) + userId String @map("user_id") + authorAsin String @map("author_asin") + authorName String @map("author_name") + coverArtUrl String? @map("cover_art_url") @db.Text + lastCheckedAt DateTime? @map("last_checked_at") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + // Relations + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([userId, authorAsin]) + @@index([userId]) + @@index([authorAsin]) + @@map("watched_authors") +} diff --git a/src/app/api/audiobooks/new-releases/route.ts b/src/app/api/audiobooks/new-releases/route.ts index 78076de..5bec85d 100644 --- a/src/app/api/audiobooks/new-releases/route.ts +++ b/src/app/api/audiobooks/new-releases/route.ts @@ -7,7 +7,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { prisma } from '@/lib/db'; -import { enrichAudiobooksWithMatches } from '@/lib/utils/audiobook-matcher'; +import { enrichAudiobooksWithMatches, getAvailableAsins } from '@/lib/utils/audiobook-matcher'; import { getCurrentUser } from '@/lib/middleware/auth'; import { RMABLogger } from '@/lib/utils/logger'; @@ -24,6 +24,7 @@ export async function GET(request: NextRequest) { const searchParams = request.nextUrl.searchParams; const page = parseInt(searchParams.get('page') || '1', 10); const limit = parseInt(searchParams.get('limit') || '20', 10); + const hideAvailable = searchParams.get('hideAvailable') === 'true'; // Validate pagination parameters if (page < 1 || limit < 1 || limit > 100) { @@ -38,12 +39,22 @@ export async function GET(request: NextRequest) { const skip = (page - 1) * limit; + // When hideAvailable is enabled, exclude ASINs that are in the library or have completed requests + let excludedAsins: string[] = []; + if (hideAvailable) { + const availableSet = await getAvailableAsins(); + excludedAsins = [...availableSet]; + } + + const whereClause = { + isNewRelease: true, + ...(excludedAsins.length > 0 ? { asin: { notIn: excludedAsins } } : {}), + }; + // Query audible_cache for new release audiobooks const [audiobooks, totalCount] = await Promise.all([ prisma.audibleCache.findMany({ - where: { - isNewRelease: true, - }, + where: whereClause, orderBy: { newReleaseRank: 'asc', }, @@ -66,9 +77,7 @@ export async function GET(request: NextRequest) { }, }), prisma.audibleCache.count({ - where: { - isNewRelease: true, - }, + where: whereClause, }), ]); diff --git a/src/app/api/audiobooks/popular/route.ts b/src/app/api/audiobooks/popular/route.ts index 3bab399..8c46913 100644 --- a/src/app/api/audiobooks/popular/route.ts +++ b/src/app/api/audiobooks/popular/route.ts @@ -7,7 +7,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { prisma } from '@/lib/db'; -import { enrichAudiobooksWithMatches } from '@/lib/utils/audiobook-matcher'; +import { enrichAudiobooksWithMatches, getAvailableAsins } from '@/lib/utils/audiobook-matcher'; import { getCurrentUser } from '@/lib/middleware/auth'; import { RMABLogger } from '@/lib/utils/logger'; @@ -24,6 +24,7 @@ export async function GET(request: NextRequest) { const searchParams = request.nextUrl.searchParams; const page = parseInt(searchParams.get('page') || '1', 10); const limit = parseInt(searchParams.get('limit') || '20', 10); + const hideAvailable = searchParams.get('hideAvailable') === 'true'; // Validate pagination parameters if (page < 1 || limit < 1 || limit > 100) { @@ -38,12 +39,22 @@ export async function GET(request: NextRequest) { const skip = (page - 1) * limit; + // When hideAvailable is enabled, exclude ASINs that are in the library or have completed requests + let excludedAsins: string[] = []; + if (hideAvailable) { + const availableSet = await getAvailableAsins(); + excludedAsins = [...availableSet]; + } + + const whereClause = { + isPopular: true, + ...(excludedAsins.length > 0 ? { asin: { notIn: excludedAsins } } : {}), + }; + // Query audible_cache for popular audiobooks const [audiobooks, totalCount] = await Promise.all([ prisma.audibleCache.findMany({ - where: { - isPopular: true, - }, + where: whereClause, orderBy: { popularRank: 'asc', }, @@ -66,9 +77,7 @@ export async function GET(request: NextRequest) { }, }), prisma.audibleCache.count({ - where: { - isPopular: true, - }, + where: whereClause, }), ]); diff --git a/src/app/api/audiobooks/search/route.ts b/src/app/api/audiobooks/search/route.ts index 4093fcb..0641aca 100644 --- a/src/app/api/audiobooks/search/route.ts +++ b/src/app/api/audiobooks/search/route.ts @@ -6,6 +6,8 @@ import { NextRequest, NextResponse } from 'next/server'; import { getAudibleService } from '@/lib/integrations/audible.service'; import { enrichAudiobooksWithMatches } from '@/lib/utils/audiobook-matcher'; +import { deduplicateAndCollectGroups } from '@/lib/utils/deduplicate-audiobooks'; +import { persistDedupGroups } from '@/lib/services/works.service'; import { getCurrentUser } from '@/lib/middleware/auth'; import { RMABLogger } from '@/lib/utils/logger'; @@ -38,14 +40,22 @@ export async function GET(request: NextRequest) { const currentUser = getCurrentUser(request); const userId = currentUser?.sub || undefined; + // Deduplicate before enrichment to avoid wasted DB queries on duplicate entries + const { books: dedupedResults, groups } = deduplicateAndCollectGroups(results.results); + + // Fire-and-forget: persist dedup groups to works table for cross-ASIN matching + if (groups.length > 0) { + persistDedupGroups(groups).catch(() => {}); + } + // Enrich search results with availability and request status information - const enrichedResults = await enrichAudiobooksWithMatches(results.results, userId); + const enrichedResults = await enrichAudiobooksWithMatches(dedupedResults, userId); return NextResponse.json({ success: true, query: results.query, results: enrichedResults, - totalResults: results.totalResults, + totalResults: enrichedResults.length, page: results.page, hasMore: results.hasMore, }); diff --git a/src/app/api/authors/[asin]/books/route.ts b/src/app/api/authors/[asin]/books/route.ts index 0535d73..414345a 100644 --- a/src/app/api/authors/[asin]/books/route.ts +++ b/src/app/api/authors/[asin]/books/route.ts @@ -6,6 +6,8 @@ import { NextRequest, NextResponse } from 'next/server'; import { getAudibleService } from '@/lib/integrations/audible.service'; import { enrichAudiobooksWithMatches } from '@/lib/utils/audiobook-matcher'; +import { deduplicateAndCollectGroups } from '@/lib/utils/deduplicate-audiobooks'; +import { persistDedupGroups } from '@/lib/services/works.service'; import { getCurrentUser } from '@/lib/middleware/auth'; import { RMABLogger } from '@/lib/utils/logger'; @@ -53,9 +55,17 @@ export async function GET( const audibleService = getAudibleService(); const result = await audibleService.searchByAuthorAsin(authorName.trim(), asin, page); + // Deduplicate before enrichment to avoid wasted DB queries on duplicate entries + const { books: dedupedBooks, groups } = deduplicateAndCollectGroups(result.books); + + // Fire-and-forget: persist dedup groups to works table for cross-ASIN matching + if (groups.length > 0) { + persistDedupGroups(groups).catch(() => {}); + } + // Enrich with library availability and request status const userId = currentUser.sub || undefined; - const enrichedBooks = await enrichAudiobooksWithMatches(result.books, userId); + const enrichedBooks = await enrichAudiobooksWithMatches(dedupedBooks, userId); logger.info(`Author books complete: "${authorName}" → ${enrichedBooks.length} books (page ${page})`); @@ -64,7 +74,7 @@ export async function GET( books: enrichedBooks, authorName: authorName.trim(), authorAsin: asin, - totalBooks: result.totalResults || enrichedBooks.length, + totalBooks: enrichedBooks.length, hasMore: result.hasMore, page: result.page, }); diff --git a/src/app/api/series/[asin]/route.ts b/src/app/api/series/[asin]/route.ts index 43271fb..3fe13ab 100644 --- a/src/app/api/series/[asin]/route.ts +++ b/src/app/api/series/[asin]/route.ts @@ -8,6 +8,8 @@ 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'; +import { deduplicateAndCollectGroups } from '@/lib/utils/deduplicate-audiobooks'; +import { persistDedupGroups } from '@/lib/services/works.service'; const logger = RMABLogger.create('API.Series.Detail'); @@ -49,9 +51,17 @@ export async function GET( ); } + // Deduplicate before enrichment to avoid wasted DB queries on duplicate entries + const { books: dedupedBooks, groups } = deduplicateAndCollectGroups(detail.books); + + // Fire-and-forget: persist dedup groups to works table for cross-ASIN matching + if (groups.length > 0) { + persistDedupGroups(groups).catch(() => {}); + } + // Enrich books with library availability and request status const userId = currentUser.sub || undefined; - const enrichedBooks = await enrichAudiobooksWithMatches(detail.books, userId); + const enrichedBooks = await enrichAudiobooksWithMatches(dedupedBooks, userId); logger.info(`Series detail complete: "${detail.title}" (${enrichedBooks.length} books, page ${page})`); diff --git a/src/app/api/user/watched-authors/[id]/route.ts b/src/app/api/user/watched-authors/[id]/route.ts new file mode 100644 index 0000000..d294f9e --- /dev/null +++ b/src/app/api/user/watched-authors/[id]/route.ts @@ -0,0 +1,52 @@ +/** + * Component: Watched Author Delete Route + * Documentation: documentation/features/watched-lists.md + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth'; +import { prisma } from '@/lib/db'; +import { RMABLogger } from '@/lib/utils/logger'; + +const logger = RMABLogger.create('API.WatchedAuthors'); + +/** + * DELETE /api/user/watched-authors/[id] + * Remove an author from the user's watch list (ownership check) + */ +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + return requireAuth(request, async (req: AuthenticatedRequest) => { + try { + if (!req.user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const { id } = await params; + + const watched = await prisma.watchedAuthor.findUnique({ + where: { id }, + }); + + if (!watched) { + return NextResponse.json({ error: 'Watched author not found' }, { status: 404 }); + } + + // Ownership check + if (watched.userId !== req.user.id) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + await prisma.watchedAuthor.delete({ where: { id } }); + + logger.info(`User ${req.user.id} stopped watching author "${watched.authorName}" (${watched.authorAsin})`); + + return NextResponse.json({ success: true }); + } catch (error) { + logger.error('Failed to delete watched author', { error: error instanceof Error ? error.message : String(error) }); + return NextResponse.json({ error: 'Failed to delete watched author' }, { status: 500 }); + } + }); +} diff --git a/src/app/api/user/watched-authors/route.ts b/src/app/api/user/watched-authors/route.ts new file mode 100644 index 0000000..c267338 --- /dev/null +++ b/src/app/api/user/watched-authors/route.ts @@ -0,0 +1,125 @@ +/** + * Component: Watched Authors API Routes + * Documentation: documentation/features/watched-lists.md + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth'; +import { prisma } from '@/lib/db'; +import { getJobQueueService } from '@/lib/services/job-queue.service'; +import { z } from 'zod'; +import { RMABLogger } from '@/lib/utils/logger'; + +const logger = RMABLogger.create('API.WatchedAuthors'); + +const AddWatchedAuthorSchema = z.object({ + authorAsin: z.string().regex(/^[A-Z0-9]{10}$/, 'Invalid author ASIN'), + authorName: z.string().min(1).max(500), + coverArtUrl: z.string().url().optional(), +}); + +/** + * GET /api/user/watched-authors + * List the current user's watched authors + */ +export async function GET(request: NextRequest) { + return requireAuth(request, async (req: AuthenticatedRequest) => { + try { + if (!req.user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const authors = await prisma.watchedAuthor.findMany({ + where: { userId: req.user.id }, + orderBy: { createdAt: 'desc' }, + }); + + return NextResponse.json({ + success: true, + authors: authors.map((a) => ({ + id: a.id, + authorAsin: a.authorAsin, + authorName: a.authorName, + coverArtUrl: a.coverArtUrl, + lastCheckedAt: a.lastCheckedAt, + createdAt: a.createdAt, + })), + }); + } catch (error) { + logger.error('Failed to list watched authors', { error: error instanceof Error ? error.message : String(error) }); + return NextResponse.json({ error: 'Failed to list watched authors' }, { status: 500 }); + } + }); +} + +/** + * POST /api/user/watched-authors + * Add an author to the user's watch list + */ +export async function POST(request: NextRequest) { + return requireAuth(request, async (req: AuthenticatedRequest) => { + try { + if (!req.user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const body = await req.json(); + const { authorAsin, authorName, coverArtUrl } = AddWatchedAuthorSchema.parse(body); + + // Check for duplicate + const existing = await prisma.watchedAuthor.findUnique({ + where: { userId_authorAsin: { userId: req.user.id, authorAsin } }, + }); + + if (existing) { + return NextResponse.json( + { error: 'AlreadyWatching', message: 'You are already watching this author' }, + { status: 409 } + ); + } + + const watched = await prisma.watchedAuthor.create({ + data: { + userId: req.user.id, + authorAsin, + authorName, + coverArtUrl: coverArtUrl || null, + }, + }); + + logger.info(`User ${req.user.id} started watching author "${authorName}" (${authorAsin})`); + + // Trigger immediate targeted check for this author (fire-and-forget) + try { + const jobQueue = getJobQueueService(); + await jobQueue.addCheckWatchedItemJob(req.user.id, undefined, authorAsin); + logger.info(`Triggered immediate check for watched author "${authorName}" (${authorAsin})`); + } catch (error) { + logger.error('Failed to trigger immediate watched author check', { error: error instanceof Error ? error.message : String(error) }); + } + + return NextResponse.json({ + success: true, + author: { + id: watched.id, + authorAsin: watched.authorAsin, + authorName: watched.authorName, + coverArtUrl: watched.coverArtUrl, + lastCheckedAt: watched.lastCheckedAt, + createdAt: watched.createdAt, + }, + }, { status: 201 }); + } catch (error) { + logger.error('Failed to add watched author', { error: error instanceof Error ? error.message : String(error) }); + + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: 'ValidationError', details: error.errors }, + { status: 400 } + ); + } + + return NextResponse.json({ error: 'Failed to add watched author' }, { status: 500 }); + } + }); +} diff --git a/src/app/api/user/watched-series/[id]/route.ts b/src/app/api/user/watched-series/[id]/route.ts new file mode 100644 index 0000000..6c7507b --- /dev/null +++ b/src/app/api/user/watched-series/[id]/route.ts @@ -0,0 +1,52 @@ +/** + * Component: Watched Series Delete Route + * Documentation: documentation/features/watched-lists.md + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth'; +import { prisma } from '@/lib/db'; +import { RMABLogger } from '@/lib/utils/logger'; + +const logger = RMABLogger.create('API.WatchedSeries'); + +/** + * DELETE /api/user/watched-series/[id] + * Remove a series from the user's watch list (ownership check) + */ +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + return requireAuth(request, async (req: AuthenticatedRequest) => { + try { + if (!req.user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const { id } = await params; + + const watched = await prisma.watchedSeries.findUnique({ + where: { id }, + }); + + if (!watched) { + return NextResponse.json({ error: 'Watched series not found' }, { status: 404 }); + } + + // Ownership check + if (watched.userId !== req.user.id) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + await prisma.watchedSeries.delete({ where: { id } }); + + logger.info(`User ${req.user.id} stopped watching series "${watched.seriesTitle}" (${watched.seriesAsin})`); + + return NextResponse.json({ success: true }); + } catch (error) { + logger.error('Failed to delete watched series', { error: error instanceof Error ? error.message : String(error) }); + return NextResponse.json({ error: 'Failed to delete watched series' }, { status: 500 }); + } + }); +} diff --git a/src/app/api/user/watched-series/route.ts b/src/app/api/user/watched-series/route.ts new file mode 100644 index 0000000..f9239ad --- /dev/null +++ b/src/app/api/user/watched-series/route.ts @@ -0,0 +1,125 @@ +/** + * Component: Watched Series API Routes + * Documentation: documentation/features/watched-lists.md + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth'; +import { prisma } from '@/lib/db'; +import { getJobQueueService } from '@/lib/services/job-queue.service'; +import { z } from 'zod'; +import { RMABLogger } from '@/lib/utils/logger'; + +const logger = RMABLogger.create('API.WatchedSeries'); + +const AddWatchedSeriesSchema = z.object({ + seriesAsin: z.string().regex(/^[A-Z0-9]{10}$/, 'Invalid series ASIN'), + seriesTitle: z.string().min(1).max(500), + coverArtUrl: z.string().url().optional(), +}); + +/** + * GET /api/user/watched-series + * List the current user's watched series + */ +export async function GET(request: NextRequest) { + return requireAuth(request, async (req: AuthenticatedRequest) => { + try { + if (!req.user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const series = await prisma.watchedSeries.findMany({ + where: { userId: req.user.id }, + orderBy: { createdAt: 'desc' }, + }); + + return NextResponse.json({ + success: true, + series: series.map((s) => ({ + id: s.id, + seriesAsin: s.seriesAsin, + seriesTitle: s.seriesTitle, + coverArtUrl: s.coverArtUrl, + lastCheckedAt: s.lastCheckedAt, + createdAt: s.createdAt, + })), + }); + } catch (error) { + logger.error('Failed to list watched series', { error: error instanceof Error ? error.message : String(error) }); + return NextResponse.json({ error: 'Failed to list watched series' }, { status: 500 }); + } + }); +} + +/** + * POST /api/user/watched-series + * Add a series to the user's watch list + */ +export async function POST(request: NextRequest) { + return requireAuth(request, async (req: AuthenticatedRequest) => { + try { + if (!req.user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const body = await req.json(); + const { seriesAsin, seriesTitle, coverArtUrl } = AddWatchedSeriesSchema.parse(body); + + // Check for duplicate + const existing = await prisma.watchedSeries.findUnique({ + where: { userId_seriesAsin: { userId: req.user.id, seriesAsin } }, + }); + + if (existing) { + return NextResponse.json( + { error: 'AlreadyWatching', message: 'You are already watching this series' }, + { status: 409 } + ); + } + + const watched = await prisma.watchedSeries.create({ + data: { + userId: req.user.id, + seriesAsin, + seriesTitle, + coverArtUrl: coverArtUrl || null, + }, + }); + + logger.info(`User ${req.user.id} started watching series "${seriesTitle}" (${seriesAsin})`); + + // Trigger immediate targeted check for this series (fire-and-forget) + try { + const jobQueue = getJobQueueService(); + await jobQueue.addCheckWatchedItemJob(req.user.id, seriesAsin); + logger.info(`Triggered immediate check for watched series "${seriesTitle}" (${seriesAsin})`); + } catch (error) { + logger.error('Failed to trigger immediate watched series check', { error: error instanceof Error ? error.message : String(error) }); + } + + return NextResponse.json({ + success: true, + series: { + id: watched.id, + seriesAsin: watched.seriesAsin, + seriesTitle: watched.seriesTitle, + coverArtUrl: watched.coverArtUrl, + lastCheckedAt: watched.lastCheckedAt, + createdAt: watched.createdAt, + }, + }, { status: 201 }); + } catch (error) { + logger.error('Failed to add watched series', { error: error instanceof Error ? error.message : String(error) }); + + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: 'ValidationError', details: error.errors }, + { status: 400 } + ); + } + + return NextResponse.json({ error: 'Failed to add watched series' }, { status: 500 }); + } + }); +} diff --git a/src/app/page.tsx b/src/app/page.tsx index af8429d..da71faa 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -5,12 +5,12 @@ 'use client'; -import { useState, useRef, useMemo } from 'react'; +import { useState, useRef, useEffect } from 'react'; import { Header } from '@/components/layout/Header'; import { AudiobookGrid } from '@/components/audiobooks/AudiobookGrid'; -import { useAudiobooks, Audiobook } from '@/lib/hooks/useAudiobooks'; +import { useAudiobooks } from '@/lib/hooks/useAudiobooks'; import { ProtectedRoute } from '@/components/auth/ProtectedRoute'; -import { StickyPagination } from '@/components/ui/StickyPagination'; +import { UnifiedPagination } from '@/components/ui/UnifiedPagination'; import { SectionToolbar } from '@/components/ui/SectionToolbar'; import { usePreferences } from '@/contexts/PreferencesContext'; @@ -29,24 +29,20 @@ export default function HomePage() { isLoading: loadingPopular, totalPages: popularTotalPages, message: popularMessage, - } = useAudiobooks('popular', 20, popularPage); + } = useAudiobooks('popular', 20, popularPage, hideAvailable); const { audiobooks: newReleases, isLoading: loadingNewReleases, totalPages: newReleasesTotalPages, message: newReleasesMessage, - } = useAudiobooks('new-releases', 20, newReleasesPage); + } = useAudiobooks('new-releases', 20, newReleasesPage, hideAvailable); - // Filter out available titles when hideAvailable is enabled - const filteredPopular = useMemo( - () => hideAvailable ? popular.filter((b: Audiobook) => !b.isAvailable && b.requestStatus !== 'completed') : popular, - [popular, hideAvailable] - ); - const filteredNewReleases = useMemo( - () => hideAvailable ? newReleases.filter((b: Audiobook) => !b.isAvailable && b.requestStatus !== 'completed') : newReleases, - [newReleases, hideAvailable] - ); + // Reset to page 1 when hideAvailable changes (total pages may differ) + useEffect(() => { + setPopularPage(1); + setNewReleasesPage(1); + }, [hideAvailable]); // Handle page changes with auto-scroll to section top const handlePopularPageChange = (page: number) => { @@ -100,7 +96,7 @@ export default function HomePage() { ) : ( ) : ( - {/* Sticky Pagination Controls */} - - + popularSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }), + }, + { + label: 'New Releases', + accentColor: 'bg-emerald-500', + currentPage: newReleasesPage, + totalPages: newReleasesTotalPages, + onPageChange: handleNewReleasesPageChange, + sectionRef: newReleasesSectionRef, + onScrollToSection: () => + newReleasesSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }), + }, + ]} /> diff --git a/src/app/profile/page.tsx b/src/app/profile/page.tsx index 5d69501..9530e9a 100644 --- a/src/app/profile/page.tsx +++ b/src/app/profile/page.tsx @@ -12,6 +12,7 @@ import { useAuth } from '@/contexts/AuthContext'; import { useRequests } from '@/lib/hooks/useRequests'; import { cn } from '@/lib/utils/cn'; import { ShelvesSection } from '@/components/profile/ShelvesSection'; +import { WatchedSeriesSection, WatchedAuthorsSection } from '@/components/profile/WatchedListsSection'; const statConfig = [ { key: 'total', label: 'Total', color: 'text-gray-900 dark:text-white' }, @@ -142,6 +143,12 @@ export default function ProfilePage() { {/* Generic Shelves Section */} + {/* Watched Series */} + + + {/* Watched Authors */} + + {/* Active Downloads */} {activeDownloads.length > 0 && (
diff --git a/src/components/authors/AuthorDetailCard.tsx b/src/components/authors/AuthorDetailCard.tsx index 9acccce..00577b8 100644 --- a/src/components/authors/AuthorDetailCard.tsx +++ b/src/components/authors/AuthorDetailCard.tsx @@ -11,6 +11,7 @@ import React, { useState } from 'react'; import Image from 'next/image'; import { AuthorDetail } from '@/lib/hooks/useAuthors'; +import { WatchAuthorButton } from '@/components/ui/WatchButton'; interface AuthorDetailCardProps { author: AuthorDetail; @@ -64,20 +65,27 @@ export function AuthorDetailCard({ author }: AuthorDetailCardProps) { )} - {/* Audible Link */} - {author.audibleUrl && ( - - View on Audible - - - - - )} + {/* Actions row: Audible link + Watch button */} +
+ {author.audibleUrl && ( + + View on Audible + + + + + )} + +
{/* Description */} {author.description && ( diff --git a/src/components/profile/WatchedListsSection.tsx b/src/components/profile/WatchedListsSection.tsx new file mode 100644 index 0000000..48b16e2 --- /dev/null +++ b/src/components/profile/WatchedListsSection.tsx @@ -0,0 +1,323 @@ +/** + * Component: Watched Lists Section (Profile Page) + * Documentation: documentation/features/watched-lists.md + * + * Shows the user's watched series and watched authors on their profile page + * with the ability to remove items. + */ + +'use client'; + +import React, { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import Image from 'next/image'; +import { useWatchedSeries, useDeleteWatchedSeries, WatchedSeriesItem } from '@/lib/hooks/useWatchedSeries'; +import { useWatchedAuthors, useDeleteWatchedAuthor, WatchedAuthorItem } from '@/lib/hooks/useWatchedAuthors'; +import { usePreferences } from '@/contexts/PreferencesContext'; + +function formatRelativeTime(dateStr: string | null): string { + if (!dateStr) return 'Never'; + const date = new Date(dateStr); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / 60000); + if (diffMins < 1) return 'just now'; + if (diffMins < 60) return `${diffMins}m ago`; + const diffHours = Math.floor(diffMins / 60); + if (diffHours < 24) return `${diffHours}h ago`; + const diffDays = Math.floor(diffHours / 24); + return `${diffDays}d ago`; +} + +// --------------------------------------------------------------------------- +// Watched Series Section +// --------------------------------------------------------------------------- + +export function WatchedSeriesSection() { + const router = useRouter(); + const { series, isLoading } = useWatchedSeries(); + const { deleteSeries, isLoading: isDeleting } = useDeleteWatchedSeries(); + const { squareCovers } = usePreferences(); + const [confirmDeleteId, setConfirmDeleteId] = useState(null); + + const handleDelete = async (id: string) => { + try { + await deleteSeries(id); + setConfirmDeleteId(null); + } catch { + // Error handled by hook + } + }; + + if (isLoading) { + return ( +
+ +
+ {[1, 2].map((i) => )} +
+
+ ); + } + + if (series.length === 0) return null; + + return ( +
+ +
+ {series.map((item) => ( + router.push(`/series/${item.seriesAsin}`)} + onConfirmDelete={() => setConfirmDeleteId(item.id)} + onCancelDelete={() => setConfirmDeleteId(null)} + onDelete={() => handleDelete(item.id)} + /> + ))} +
+
+ ); +} + +function WatchedSeriesCard({ + item, squareCovers, isDeleting, confirmingDelete, onNavigate, onConfirmDelete, onCancelDelete, onDelete, +}: { + item: WatchedSeriesItem; + squareCovers: boolean; + isDeleting: boolean; + confirmingDelete: boolean; + onNavigate: () => void; + onConfirmDelete: () => void; + onCancelDelete: () => void; + onDelete: () => void; +}) { + return ( +
+ {/* Cover */} + + + {/* Info */} +
+ +

+ Last checked: {formatRelativeTime(item.lastCheckedAt)} +

+
+ + {/* Delete */} +
+ {confirmingDelete ? ( +
+ + +
+ ) : ( + + )} +
+
+ ); +} + +// --------------------------------------------------------------------------- +// Watched Authors Section +// --------------------------------------------------------------------------- + +export function WatchedAuthorsSection() { + const router = useRouter(); + const { authors, isLoading } = useWatchedAuthors(); + const { deleteAuthor, isLoading: isDeleting } = useDeleteWatchedAuthor(); + const [confirmDeleteId, setConfirmDeleteId] = useState(null); + + const handleDelete = async (id: string) => { + try { + await deleteAuthor(id); + setConfirmDeleteId(null); + } catch { + // Error handled by hook + } + }; + + if (isLoading) { + return ( +
+ +
+ {[1, 2].map((i) => )} +
+
+ ); + } + + if (authors.length === 0) return null; + + return ( +
+ +
+ {authors.map((item) => ( + router.push(`/authors/${item.authorAsin}`)} + onConfirmDelete={() => setConfirmDeleteId(item.id)} + onCancelDelete={() => setConfirmDeleteId(null)} + onDelete={() => handleDelete(item.id)} + /> + ))} +
+
+ ); +} + +function WatchedAuthorCard({ + item, isDeleting, confirmingDelete, onNavigate, onConfirmDelete, onCancelDelete, onDelete, +}: { + item: WatchedAuthorItem; + isDeleting: boolean; + confirmingDelete: boolean; + onNavigate: () => void; + onConfirmDelete: () => void; + onCancelDelete: () => void; + onDelete: () => void; +}) { + return ( +
+ {/* Avatar */} + + + {/* Info */} +
+
+ +

+ Last checked: {formatRelativeTime(item.lastCheckedAt)} +

+
+
+ + {/* Delete */} +
+ {confirmingDelete ? ( +
+ + +
+ ) : ( + + )} +
+
+ ); +} + +// --------------------------------------------------------------------------- +// Shared Components +// --------------------------------------------------------------------------- + +function SectionHeader({ title, icon, count }: { title: string; icon: 'series' | 'author'; count: number | null }) { + const gradientColors = icon === 'series' + ? 'from-emerald-500 to-teal-500' + : 'from-blue-500 to-indigo-500'; + + return ( +
+
+

+ {title} +

+ {count !== null && ( + ({count}) + )} +
+ ); +} + +function CardSkeleton({ squareCovers }: { squareCovers?: boolean }) { + return ( +
+
+
+
+
+
+
+ ); +} diff --git a/src/components/series/SeriesDetailCard.tsx b/src/components/series/SeriesDetailCard.tsx index d5afaa2..158b304 100644 --- a/src/components/series/SeriesDetailCard.tsx +++ b/src/components/series/SeriesDetailCard.tsx @@ -11,6 +11,7 @@ import React, { useState } from 'react'; import Image from 'next/image'; import { SeriesDetail } from '@/lib/hooks/useSeries'; +import { WatchSeriesButton } from '@/components/ui/WatchButton'; interface SeriesDetailCardProps { series: SeriesDetail; @@ -91,20 +92,27 @@ export function SeriesDetailCard({ series, squareCovers = false }: SeriesDetailC
)} - {/* Audible Link */} - {series.audibleUrl && ( - - View on Audible - - - - - )} + {/* Actions row: Audible link + Watch button */} +
+ {series.audibleUrl && ( + + View on Audible + + + + + )} + +
{/* Description */} {series.description && ( diff --git a/src/components/ui/StickyPagination.tsx b/src/components/ui/StickyPagination.tsx deleted file mode 100644 index a4c7e2a..0000000 --- a/src/components/ui/StickyPagination.tsx +++ /dev/null @@ -1,170 +0,0 @@ -/** - * Component: Sticky Pagination with Progress Bar - * Documentation: documentation/frontend/components.md - */ - -'use client'; - -import React, { useState, useEffect, useRef } from 'react'; -import { ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/24/outline'; - -interface StickyPaginationProps { - currentPage: number; - totalPages: number; - onPageChange: (page: number) => void; - sectionRef: React.RefObject; - label: string; // e.g., "Popular Audiobooks" - footerRef?: React.RefObject; // Optional footer ref to avoid overlap -} - -export function StickyPagination({ - currentPage, - totalPages, - onPageChange, - sectionRef, - label, - footerRef, -}: StickyPaginationProps) { - const [isVisible, setIsVisible] = useState(false); - const [isFooterVisible, setIsFooterVisible] = useState(false); - const [jumpPage, setJumpPage] = useState(currentPage.toString()); - - // Update jump page input when current page changes externally - useEffect(() => { - setJumpPage(currentPage.toString()); - }, [currentPage]); - - // Intersection Observer to show/hide pagination based on section visibility - useEffect(() => { - if (!sectionRef.current) return; - - const observer = new IntersectionObserver( - ([entry]) => { - // Show pagination when section is in viewport - setIsVisible(entry.isIntersecting && entry.intersectionRatio > 0.1); - }, - { - threshold: [0, 0.1, 0.5, 1], - rootMargin: '-60px 0px -60px 0px', // Account for header/footer - } - ); - - observer.observe(sectionRef.current); - - return () => observer.disconnect(); - }, [sectionRef]); - - // Footer observer to hide pagination when footer is visible - useEffect(() => { - if (!footerRef?.current) return; - - const observer = new IntersectionObserver( - ([entry]) => { - // Hide pagination when footer is in viewport - setIsFooterVisible(entry.isIntersecting); - }, - { - threshold: [0, 0.1], - rootMargin: '0px', - } - ); - - observer.observe(footerRef.current); - - return () => observer.disconnect(); - }, [footerRef]); - - if (totalPages <= 1) { - return null; - } - - const handlePrevious = () => { - if (currentPage > 1) { - onPageChange(currentPage - 1); - } - }; - - const handleNext = () => { - if (currentPage < totalPages) { - onPageChange(currentPage + 1); - } - }; - - const handleJumpSubmit = (e: React.FormEvent) => { - e.preventDefault(); - const page = parseInt(jumpPage, 10); - if (!isNaN(page) && page >= 1 && page <= totalPages) { - onPageChange(page); - } else { - // Reset to current page if invalid - setJumpPage(currentPage.toString()); - } - }; - - // Final visibility: show when section is visible AND footer is not visible - const shouldShow = isVisible && !isFooterVisible; - - return ( -
-
-
- {/* Section Label - Hidden on small screens */} -
- {label} -
- - {/* Previous Button */} - - - {/* Page Info & Jump */} -
- - Page - -
- setJumpPage(e.target.value)} - onBlur={handleJumpSubmit} - className="w-10 px-1.5 py-0.5 text-center text-sm font-medium rounded - bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100 - border border-gray-300 dark:border-gray-600 - focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-transparent" - aria-label="Current page" - /> -
- - of {totalPages} - -
- - {/* Next Button */} - -
-
-
- ); -} diff --git a/src/components/ui/UnifiedPagination.tsx b/src/components/ui/UnifiedPagination.tsx new file mode 100644 index 0000000..b44230b --- /dev/null +++ b/src/components/ui/UnifiedPagination.tsx @@ -0,0 +1,325 @@ +/** + * Component: Unified Pagination — context-aware floating paginator + * Documentation: documentation/frontend/components.md + * + * Replaces two overlapping StickyPagination instances with a single pill + * that automatically tracks which section dominates the viewport and shows + * controls for that section. Transitions smoothly when the dominant section + * changes. Includes a two-dot section indicator for manual switching. + */ + +'use client'; + +import React, { useState, useEffect, useRef, useCallback } from 'react'; +import { ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/24/outline'; + +export interface PaginationSection { + /** Display label, e.g. "Popular Audiobooks" */ + label: string; + /** Tailwind color class applied to the active accent dot, e.g. "bg-blue-500" */ + accentColor: string; + currentPage: number; + totalPages: number; + onPageChange: (page: number) => void; + /** Ref to the section element — used for intersection tracking */ + sectionRef: React.RefObject; + /** Called when user clicks this section's dot while it's inactive — should scroll to section */ + onScrollToSection: () => void; +} + +interface UnifiedPaginationProps { + sections: [PaginationSection, PaginationSection]; + footerRef?: React.RefObject; +} + +// --------------------------------------------------------------------------- +// Small page-jump form — isolated to prevent key re-mounts on section switch +// --------------------------------------------------------------------------- + +interface PageJumpProps { + currentPage: number; + totalPages: number; + onPageChange: (page: number) => void; +} + +function PageJump({ currentPage, totalPages, onPageChange }: PageJumpProps) { + const [value, setValue] = useState(currentPage.toString()); + + // Sync when page changes externally (e.g. after scrollIntoView + setState) + useEffect(() => { + setValue(currentPage.toString()); + }, [currentPage]); + + const commit = useCallback( + (e?: React.FormEvent) => { + e?.preventDefault(); + const parsed = parseInt(value, 10); + if (!isNaN(parsed) && parsed >= 1 && parsed <= totalPages) { + onPageChange(parsed); + } else { + setValue(currentPage.toString()); + } + }, + [value, currentPage, totalPages, onPageChange] + ); + + return ( +
+ + Page + +
+ setValue(e.target.value)} + onBlur={commit} + className="w-10 px-1.5 py-0.5 text-center text-sm font-medium rounded-md + bg-black/[0.04] dark:bg-white/[0.08] + text-gray-900 dark:text-gray-100 + border border-gray-300/60 dark:border-white/10 + focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-transparent + transition-all duration-150" + aria-label="Jump to page" + /> +
+ + of {totalPages} + +
+ ); +} + +// --------------------------------------------------------------------------- +// Main component +// --------------------------------------------------------------------------- + +export function UnifiedPagination({ sections, footerRef }: UnifiedPaginationProps) { + // Index of the currently dominant section (0 or 1) + const [activeIndex, setActiveIndex] = useState<0 | 1>(0); + // Whether the label+controls area is mid-transition (drives opacity fade) + const [isTransitioning, setIsTransitioning] = useState(false); + + const [footerVisible, setFooterVisible] = useState(false); + // Per-section raw intersection ratios [0,1] + const ratiosRef = useRef<[number, number]>([0, 0]); + // Whether each section has any meaningful intersection + const [sectionVisible, setSectionVisible] = useState<[boolean, boolean]>([false, false]); + + const transitionTimerRef = useRef | null>(null); + + // Determine if the pill should be shown at all: + // - at least one section is meaningfully visible + // - footer is not visible + // - the active section has >1 page + const activeSectionHasPages = sections[activeIndex].totalPages > 1; + const eitherSectionVisible = sectionVisible[0] || sectionVisible[1]; + const shouldShow = eitherSectionVisible && !footerVisible && activeSectionHasPages; + + // ------------------------------------------------------------------ + // Track which section each instance belongs to via intersection ratio + // ------------------------------------------------------------------ + useEffect(() => { + const observers: IntersectionObserver[] = []; + + sections.forEach((section, idx) => { + if (!section.sectionRef.current) return; + + const observer = new IntersectionObserver( + ([entry]) => { + ratiosRef.current[idx as 0 | 1] = entry.intersectionRatio; + const isVisible = entry.isIntersecting && entry.intersectionRatio > 0.05; + + setSectionVisible((prev) => { + const next: [boolean, boolean] = [...prev] as [boolean, boolean]; + next[idx as 0 | 1] = isVisible; + return next; + }); + + // Determine dominant section (whichever has more viewport coverage) + const [r0, r1] = ratiosRef.current; + const dominant: 0 | 1 = r0 >= r1 ? 0 : 1; + + setActiveIndex((current) => { + if (current !== dominant) { + // Trigger cross-fade transition + setIsTransitioning(true); + + if (transitionTimerRef.current) { + clearTimeout(transitionTimerRef.current); + } + transitionTimerRef.current = setTimeout(() => { + setIsTransitioning(false); + }, 320); + + return dominant; + } + return current; + }); + }, + { + // Dense threshold array gives us smooth ratio tracking + threshold: Array.from({ length: 21 }, (_, i) => i / 20), + rootMargin: '-60px 0px -80px 0px', + } + ); + + observer.observe(section.sectionRef.current); + observers.push(observer); + }); + + return () => { + observers.forEach((o) => o.disconnect()); + if (transitionTimerRef.current) clearTimeout(transitionTimerRef.current); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [sections[0].sectionRef, sections[1].sectionRef]); + + // ------------------------------------------------------------------ + // Footer observer + // ------------------------------------------------------------------ + useEffect(() => { + if (!footerRef?.current) return; + const observer = new IntersectionObserver( + ([entry]) => setFooterVisible(entry.isIntersecting), + { threshold: [0, 0.01] } + ); + observer.observe(footerRef.current); + return () => observer.disconnect(); + }, [footerRef]); + + // ------------------------------------------------------------------ + // Derived values for the currently active section + // ------------------------------------------------------------------ + const active = sections[activeIndex]; + + const handlePrev = () => { + if (active.currentPage > 1) active.onPageChange(active.currentPage - 1); + }; + const handleNext = () => { + if (active.currentPage < active.totalPages) active.onPageChange(active.currentPage + 1); + }; + + // ------------------------------------------------------------------ + // Render + // ------------------------------------------------------------------ + return ( +
+ {/* Pill surface */} +
+ {/* Section selector dots — left side */} +
+ {sections.map((section, idx) => { + const isActive = idx === activeIndex; + return ( +
+ + {/* Divider */} +
+ + {/* Label + controls — cross-fades on section switch */} +
+ {/* Section label — hidden on small screens */} + + {active.label} + + + {/* Previous */} + + + {/* Page jump */} + + + {/* Next */} + +
+ + {/* Right padding balance */} +
+
+
+ ); +} diff --git a/src/components/ui/WatchButton.tsx b/src/components/ui/WatchButton.tsx new file mode 100644 index 0000000..e39a8d9 --- /dev/null +++ b/src/components/ui/WatchButton.tsx @@ -0,0 +1,186 @@ +/** + * Component: Watch Button (Series / Author) + * Documentation: documentation/features/watched-lists.md + * + * Reusable toggle button for watching/unwatching a series or author. + * Shows a confirmation modal before watching. Unwatching is instant. + */ + +'use client'; + +import React, { useState } from 'react'; +import { useWatchedSeries, useAddWatchedSeries, useDeleteWatchedSeries } from '@/lib/hooks/useWatchedSeries'; +import { useWatchedAuthors, useAddWatchedAuthor, useDeleteWatchedAuthor } from '@/lib/hooks/useWatchedAuthors'; +import { ConfirmModal } from './ConfirmModal'; + +interface WatchSeriesButtonProps { + seriesAsin: string; + seriesTitle: string; + coverArtUrl?: string; +} + +export function WatchSeriesButton({ seriesAsin, seriesTitle, coverArtUrl }: WatchSeriesButtonProps) { + const { series } = useWatchedSeries(); + const { addSeries, isLoading: isAdding } = useAddWatchedSeries(); + const { deleteSeries, isLoading: isDeleting } = useDeleteWatchedSeries(); + const [error, setError] = useState(null); + const [showConfirm, setShowConfirm] = useState(false); + + const watchedEntry = series.find((s) => s.seriesAsin === seriesAsin); + const isWatching = !!watchedEntry; + const isLoading = isAdding || isDeleting; + + const handleClick = async () => { + setError(null); + if (isWatching && watchedEntry) { + // Unwatch immediately (no confirmation needed) + try { + await deleteSeries(watchedEntry.id); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed'); + } + } else { + // Show confirmation before watching + setShowConfirm(true); + } + }; + + const handleConfirmWatch = async () => { + setShowConfirm(false); + setError(null); + try { + await addSeries(seriesAsin, seriesTitle, coverArtUrl); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed'); + } + }; + + return ( +
+ + {error && ( + {error} + )} + setShowConfirm(false)} + onConfirm={handleConfirmWatch} + title={`Watch "${seriesTitle}"?`} + message={`This will request all books in "${seriesTitle}" that aren't already in your library, and automatically request new releases as they're added to the series. Continue?`} + confirmText="Watch" + isLoading={isAdding} + /> +
+ ); +} + +interface WatchAuthorButtonProps { + authorAsin: string; + authorName: string; + coverArtUrl?: string; +} + +export function WatchAuthorButton({ authorAsin, authorName, coverArtUrl }: WatchAuthorButtonProps) { + const { authors } = useWatchedAuthors(); + const { addAuthor, isLoading: isAdding } = useAddWatchedAuthor(); + const { deleteAuthor, isLoading: isDeleting } = useDeleteWatchedAuthor(); + const [error, setError] = useState(null); + const [showConfirm, setShowConfirm] = useState(false); + + const watchedEntry = authors.find((a) => a.authorAsin === authorAsin); + const isWatching = !!watchedEntry; + const isLoading = isAdding || isDeleting; + + const handleClick = async () => { + setError(null); + if (isWatching && watchedEntry) { + // Unwatch immediately (no confirmation needed) + try { + await deleteAuthor(watchedEntry.id); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed'); + } + } else { + // Show confirmation before watching + setShowConfirm(true); + } + }; + + const handleConfirmWatch = async () => { + setShowConfirm(false); + setError(null); + try { + await addAuthor(authorAsin, authorName, coverArtUrl); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed'); + } + }; + + return ( +
+ + {error && ( + {error} + )} + setShowConfirm(false)} + onConfirm={handleConfirmWatch} + title={`Watch "${authorName}"?`} + message={`This will request all books by "${authorName}" that aren't already in your library, and automatically request new releases. Continue?`} + confirmText="Watch" + isLoading={isAdding} + /> +
+ ); +} diff --git a/src/lib/hooks/useAudiobooks.ts b/src/lib/hooks/useAudiobooks.ts index 8018ab4..b181708 100644 --- a/src/lib/hooks/useAudiobooks.ts +++ b/src/lib/hooks/useAudiobooks.ts @@ -35,11 +35,12 @@ export interface Audiobook { hasReportedIssue?: boolean; // True if an open issue exists for this audiobook } -export function useAudiobooks(type: 'popular' | 'new-releases', limit: number = 20, page: number = 1) { +export function useAudiobooks(type: 'popular' | 'new-releases', limit: number = 20, page: number = 1, hideAvailable: boolean = false) { + const hideParam = hideAvailable ? '&hideAvailable=true' : ''; const endpoint = type === 'popular' - ? `/api/audiobooks/popular?page=${page}&limit=${limit}` - : `/api/audiobooks/new-releases?page=${page}&limit=${limit}`; + ? `/api/audiobooks/popular?page=${page}&limit=${limit}${hideParam}` + : `/api/audiobooks/new-releases?page=${page}&limit=${limit}${hideParam}`; const { data, error, isLoading } = useSWR(endpoint, authenticatedFetcher, { revalidateOnFocus: false, diff --git a/src/lib/hooks/useWatchedAuthors.ts b/src/lib/hooks/useWatchedAuthors.ts new file mode 100644 index 0000000..9a76ab7 --- /dev/null +++ b/src/lib/hooks/useWatchedAuthors.ts @@ -0,0 +1,119 @@ +/** + * Component: Watched Authors Hook + * Documentation: documentation/features/watched-lists.md + */ + +'use client'; + +import { useState } from 'react'; +import useSWR, { mutate } from 'swr'; +import { useAuth } from '@/contexts/AuthContext'; +import { fetchWithAuth } from '@/lib/utils/api'; + +export interface WatchedAuthorItem { + id: string; + authorAsin: string; + authorName: string; + coverArtUrl: string | null; + lastCheckedAt: string | null; + createdAt: string; +} + +const fetcher = (url: string) => + fetchWithAuth(url).then((res) => res.json()); + +export function useWatchedAuthors() { + const { accessToken } = useAuth(); + + const endpoint = accessToken ? '/api/user/watched-authors' : null; + + const { data, error, isLoading } = useSWR( + endpoint, + fetcher, + { refreshInterval: 60000 } + ); + + return { + authors: (data?.authors || []) as WatchedAuthorItem[], + isLoading, + error, + }; +} + +export function useAddWatchedAuthor() { + const { accessToken } = useAuth(); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const addAuthor = async (authorAsin: string, authorName: string, coverArtUrl?: string) => { + if (!accessToken) throw new Error('Not authenticated'); + + setIsLoading(true); + setError(null); + + try { + const response = await fetchWithAuth('/api/user/watched-authors', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ authorAsin, authorName, coverArtUrl }), + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.message || data.error || 'Failed to watch author'); + } + + // Revalidate watched authors list + mutate((key) => typeof key === 'string' && key.includes('/api/user/watched-authors')); + + return data.author as WatchedAuthorItem; + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error'; + setError(message); + throw err; + } finally { + setIsLoading(false); + } + }; + + return { addAuthor, isLoading, error }; +} + +export function useDeleteWatchedAuthor() { + const { accessToken } = useAuth(); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const deleteAuthor = async (id: string) => { + if (!accessToken) throw new Error('Not authenticated'); + + setIsLoading(true); + setError(null); + + try { + const response = await fetchWithAuth(`/api/user/watched-authors/${id}`, { + method: 'DELETE', + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.message || data.error || 'Failed to unwatch author'); + } + + // Revalidate watched authors list + mutate((key) => typeof key === 'string' && key.includes('/api/user/watched-authors')); + + return true; + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error'; + setError(message); + throw err; + } finally { + setIsLoading(false); + } + }; + + return { deleteAuthor, isLoading, error }; +} diff --git a/src/lib/hooks/useWatchedSeries.ts b/src/lib/hooks/useWatchedSeries.ts new file mode 100644 index 0000000..5b09d72 --- /dev/null +++ b/src/lib/hooks/useWatchedSeries.ts @@ -0,0 +1,119 @@ +/** + * Component: Watched Series Hook + * Documentation: documentation/features/watched-lists.md + */ + +'use client'; + +import { useState } from 'react'; +import useSWR, { mutate } from 'swr'; +import { useAuth } from '@/contexts/AuthContext'; +import { fetchWithAuth } from '@/lib/utils/api'; + +export interface WatchedSeriesItem { + id: string; + seriesAsin: string; + seriesTitle: string; + coverArtUrl: string | null; + lastCheckedAt: string | null; + createdAt: string; +} + +const fetcher = (url: string) => + fetchWithAuth(url).then((res) => res.json()); + +export function useWatchedSeries() { + const { accessToken } = useAuth(); + + const endpoint = accessToken ? '/api/user/watched-series' : null; + + const { data, error, isLoading } = useSWR( + endpoint, + fetcher, + { refreshInterval: 60000 } + ); + + return { + series: (data?.series || []) as WatchedSeriesItem[], + isLoading, + error, + }; +} + +export function useAddWatchedSeries() { + const { accessToken } = useAuth(); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const addSeries = async (seriesAsin: string, seriesTitle: string, coverArtUrl?: string) => { + if (!accessToken) throw new Error('Not authenticated'); + + setIsLoading(true); + setError(null); + + try { + const response = await fetchWithAuth('/api/user/watched-series', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ seriesAsin, seriesTitle, coverArtUrl }), + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.message || data.error || 'Failed to watch series'); + } + + // Revalidate watched series list + mutate((key) => typeof key === 'string' && key.includes('/api/user/watched-series')); + + return data.series as WatchedSeriesItem; + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error'; + setError(message); + throw err; + } finally { + setIsLoading(false); + } + }; + + return { addSeries, isLoading, error }; +} + +export function useDeleteWatchedSeries() { + const { accessToken } = useAuth(); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const deleteSeries = async (id: string) => { + if (!accessToken) throw new Error('Not authenticated'); + + setIsLoading(true); + setError(null); + + try { + const response = await fetchWithAuth(`/api/user/watched-series/${id}`, { + method: 'DELETE', + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.message || data.error || 'Failed to unwatch series'); + } + + // Revalidate watched series list + mutate((key) => typeof key === 'string' && key.includes('/api/user/watched-series')); + + return true; + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error'; + setError(message); + throw err; + } finally { + setIsLoading(false); + } + }; + + return { deleteSeries, isLoading, error }; +} diff --git a/src/lib/integrations/audible-series.ts b/src/lib/integrations/audible-series.ts index f5df693..7cf976b 100644 --- a/src/lib/integrations/audible-series.ts +++ b/src/lib/integrations/audible-series.ts @@ -14,8 +14,10 @@ import { getLanguageForRegion, buildContainsSelector, stripPrefixes, + type LanguageConfig, } from '../constants/language-config'; import { RMABLogger } from '../utils/logger'; +import { parseRuntime } from '../utils/parse-runtime'; import { randomDelay } from '../utils/scrape-resilience'; const logger = RMABLogger.create('Audible.Series'); @@ -311,7 +313,7 @@ export async function scrapeSeriesPage(asin: string, page: number = 1): Promise< undefined; // Parse all books from the series page - const books = parseSeriesBooks($, langConfig.scraping.authorPrefixes, langConfig.scraping.narratorPrefixes); + const books = parseSeriesBooks($, langConfig.scraping.authorPrefixes, langConfig.scraping.narratorPrefixes, langConfig); // Use actual book count if we got more from scraping const bookCount = Math.max(summary.bookCount, books.length); @@ -403,7 +405,8 @@ function parseSeriesRating($: cheerio.CheerioAPI): { rating?: number; ratingCoun function parseSeriesBooks( $: cheerio.CheerioAPI, authorPrefixes: string[], - narratorPrefixes: string[] + narratorPrefixes: string[], + langConfig: LanguageConfig ): AudibleAudiobook[] { const books: AudibleAudiobook[] = []; const seenAsins = new Set(); @@ -453,6 +456,11 @@ function parseSeriesBooks( const ratingMatch = ratingText ? ratingText.match(/(\d+[.,]?\d*)/) : null; const rating = ratingMatch ? parseFloat(ratingMatch[1].replace(',', '.')) : undefined; + // Duration + const runtimeText = $el.find('.runtimeLabel').text().trim() || + $el.find(buildContainsSelector('span', langConfig.scraping.lengthLabels)).text().trim(); + const durationMinutes = parseRuntime(runtimeText, langConfig); + books.push({ asin: bookAsin, title, @@ -461,6 +469,7 @@ function parseSeriesBooks( narrator: stripPrefixes(narratorText, narratorPrefixes), coverArtUrl, rating, + durationMinutes, }); }); diff --git a/src/lib/integrations/audible.service.ts b/src/lib/integrations/audible.service.ts index 229c421..de32076 100644 --- a/src/lib/integrations/audible.service.ts +++ b/src/lib/integrations/audible.service.ts @@ -23,6 +23,7 @@ import { AdaptivePacer, FetchResultMeta, } from '../utils/scrape-resilience'; +import { parseRuntime as parseRuntimeUtil } from '../utils/parse-runtime'; // Module-level logger const logger = RMABLogger.create('Audible'); @@ -1134,33 +1135,11 @@ export class AudibleService { } /** - * Parse runtime text to minutes using language-specific patterns + * Parse runtime text to minutes using language-specific patterns. + * Delegates to shared utility in src/lib/utils/parse-runtime.ts. */ private parseRuntime(runtimeText: string): number | undefined { - if (!runtimeText) return undefined; - - const langConfig = this.getLangConfig(); - let totalMinutes = 0; - - // Try each hour pattern until one matches - for (const pattern of langConfig.scraping.runtimeHourPatterns) { - const match = runtimeText.match(pattern); - if (match) { - totalMinutes += parseInt(match[1]) * 60; - break; - } - } - - // Try each minute pattern until one matches - for (const pattern of langConfig.scraping.runtimeMinutePatterns) { - const match = runtimeText.match(pattern); - if (match) { - totalMinutes += parseInt(match[1]); - break; - } - } - - return totalMinutes > 0 ? totalMinutes : undefined; + return parseRuntimeUtil(runtimeText, this.getLangConfig()); } /** diff --git a/src/lib/processors/check-watched-lists.processor.ts b/src/lib/processors/check-watched-lists.processor.ts new file mode 100644 index 0000000..e462568 --- /dev/null +++ b/src/lib/processors/check-watched-lists.processor.ts @@ -0,0 +1,43 @@ +/** + * Component: Check Watched Lists Processor + * Documentation: documentation/features/watched-lists.md + * + * Dedicated processor for checking watched series and watched authors + * for new releases and auto-creating requests. + * Supports targeted processing of a single series/author for immediate sync. + */ + +import { RMABLogger } from '../utils/logger'; + +export interface CheckWatchedListsPayload { + jobId?: string; + scheduledJobId?: string; + /** If set, only process watched items for this user */ + userId?: string; + /** If set, only process this specific series */ + seriesAsin?: string; + /** If set, only process this specific author */ + authorAsin?: string; +} + +export async function processCheckWatchedLists(payload: CheckWatchedListsPayload): Promise { + const { jobId, userId, seriesAsin, authorAsin } = payload; + const logger = RMABLogger.forJob(jobId, 'CheckWatchedLists'); + + const isTargeted = !!(userId && (seriesAsin || authorAsin)); + logger.info(isTargeted + ? `Starting targeted watched lists check (user: ${userId}, series: ${seriesAsin || 'n/a'}, author: ${authorAsin || 'n/a'})...` + : 'Starting watched lists check...' + ); + + const { processWatchedLists } = await import('../services/watched-lists.service'); + const stats = await processWatchedLists(logger, { userId, seriesAsin, authorAsin }); + + logger.info('Watched lists check complete', { stats }); + + return { + success: true, + message: isTargeted ? 'Targeted watched item checked' : 'Watched lists checked', + ...stats, + }; +} diff --git a/src/lib/services/job-queue.service.ts b/src/lib/services/job-queue.service.ts index e729762..8e9bcc4 100644 --- a/src/lib/services/job-queue.service.ts +++ b/src/lib/services/job-queue.service.ts @@ -27,6 +27,7 @@ export type JobType = | 'cleanup_seeded_torrents' | 'monitor_rss_feeds' | 'sync_reading_shelves' + | 'check_watched_lists' | 'send_notification' // Ebook-specific job types | 'search_ebook' @@ -114,6 +115,16 @@ export interface SyncShelvesPayload extends JobPayload { maxLookupsPerShelf?: number; } +export interface CheckWatchedListsPayload extends JobPayload { + scheduledJobId?: string; + /** If set, only process watched items for this user */ + userId?: string; + /** If set, only process this specific series */ + seriesAsin?: string; + /** If set, only process this specific author */ + authorAsin?: string; +} + // Ebook-specific payload interfaces export interface SearchEbookPayload extends JobPayload { requestId: string; @@ -385,6 +396,12 @@ export class JobQueueService { return await processSyncShelves(payloadWithJobId); }); + this.queue.process('check_watched_lists', 1, async (job: BullJob) => { + const { processCheckWatchedLists } = await import('../processors/check-watched-lists.processor'); + const payloadWithJobId = await this.ensureJobRecord(job, 'check_watched_lists'); + return await processCheckWatchedLists(payloadWithJobId); + }); + // Send notification processor this.queue.process('send_notification', 2, async (job: BullJob) => { const { processSendNotification } = await import('../processors/send-notification.processor'); @@ -768,6 +785,39 @@ export class JobQueueService { ); } + /** + * Add check watched lists job (watched series + watched authors) + */ + async addCheckWatchedListsJob(scheduledJobId?: string): Promise { + return await this.addJob( + 'check_watched_lists', + { + scheduledJobId, + } as CheckWatchedListsPayload, + { + priority: 7, + } + ); + } + + /** + * Add a targeted check for a specific watched series or author for a specific user. + * Used for immediate processing when a user adds a new watch. + */ + async addCheckWatchedItemJob(userId: string, seriesAsin?: string, authorAsin?: string): Promise { + return await this.addJob( + 'check_watched_lists', + { + userId, + seriesAsin, + authorAsin, + } as CheckWatchedListsPayload, + { + priority: 8, // Higher than scheduled (7) since user-initiated + } + ); + } + // ========================================================================= // EBOOK-SPECIFIC JOB METHODS // ========================================================================= diff --git a/src/lib/services/request-creator.service.ts b/src/lib/services/request-creator.service.ts index 864c233..c89eda7 100644 --- a/src/lib/services/request-creator.service.ts +++ b/src/lib/services/request-creator.service.ts @@ -12,6 +12,7 @@ import { getJobQueueService } from '@/lib/services/job-queue.service'; import { findPlexMatch } from '@/lib/utils/audiobook-matcher'; import { getAudibleService } from '@/lib/integrations/audible.service'; import { RMABLogger } from '@/lib/utils/logger'; +import { seedAsin } from '@/lib/services/works.service'; const logger = RMABLogger.create('RequestCreator'); @@ -147,6 +148,15 @@ export async function createRequestForUser( } } + // Seed works table for cross-ASIN matching (Layer 2: request-time seeding) + seedAsin( + audiobook.asin, + audiobookRecord.title, + audiobookRecord.author, + audiobookRecord.narrator || undefined, + undefined // duration not available at request time + ).catch(() => {}); + // Check if user already has an active request for this audiobook const existingRequest = await prisma.request.findFirst({ where: { diff --git a/src/lib/services/scheduler.service.ts b/src/lib/services/scheduler.service.ts index a6f8436..785af60 100644 --- a/src/lib/services/scheduler.service.ts +++ b/src/lib/services/scheduler.service.ts @@ -10,7 +10,7 @@ import { RMABLogger } from '../utils/logger'; const logger = RMABLogger.create('Scheduler'); -export type ScheduledJobType = 'plex_library_scan' | 'plex_recently_added_check' | 'audible_refresh' | 'retry_missing_torrents' | 'retry_failed_imports' | 'cleanup_seeded_torrents' | 'monitor_rss_feeds' | 'sync_reading_shelves'; +export type ScheduledJobType = 'plex_library_scan' | 'plex_recently_added_check' | 'audible_refresh' | 'retry_missing_torrents' | 'retry_failed_imports' | 'cleanup_seeded_torrents' | 'monitor_rss_feeds' | 'sync_reading_shelves' | 'check_watched_lists'; export interface ScheduledJob { id: string; @@ -136,6 +136,13 @@ export class SchedulerService { enabled: true, // Enable by default payload: {}, }, + { + name: 'Check Watched Lists', + type: 'check_watched_lists' as ScheduledJobType, + schedule: '0 0 * * *', // Daily at midnight (every 24 hours) + enabled: true, // Enable by default + payload: {}, + }, ]; let created = 0; @@ -381,6 +388,9 @@ export class SchedulerService { case 'sync_reading_shelves': bullJobId = await this.triggerSyncShelves(job); break; + case 'check_watched_lists': + bullJobId = await this.triggerCheckWatchedLists(job); + break; default: throw new Error(`Unknown job type: ${job.type}`); } @@ -655,6 +665,13 @@ export class SchedulerService { private async triggerSyncShelves(job: any): Promise { return await this.jobQueue.addSyncShelvesJob(job.id); } + + /** + * Trigger watched lists check (watched series + watched authors) + */ + private async triggerCheckWatchedLists(job: any): Promise { + return await this.jobQueue.addCheckWatchedListsJob(job.id); + } } // Singleton instance diff --git a/src/lib/services/watched-lists.service.ts b/src/lib/services/watched-lists.service.ts new file mode 100644 index 0000000..35f4ed3 --- /dev/null +++ b/src/lib/services/watched-lists.service.ts @@ -0,0 +1,414 @@ +/** + * Component: Watched Lists Service + * Documentation: documentation/features/watched-lists.md + * + * Checks watched series and watched authors for new releases. + * Deduplicates results using the works table, checks against user's library, + * and auto-creates requests via the shared request-creator service. + * Follows the same pattern as goodreads-sync.service.ts. + */ + +import { prisma } from '@/lib/db'; +import { getAudibleService, AudibleAudiobook } from '@/lib/integrations/audible.service'; +import { scrapeSeriesPage } from '@/lib/integrations/audible-series'; +import { deduplicateAndCollectGroups } from '@/lib/utils/deduplicate-audiobooks'; +import { persistDedupGroups } from '@/lib/services/works.service'; +import { createRequestForUser } from '@/lib/services/request-creator.service'; +import { findPlexMatch } from '@/lib/utils/audiobook-matcher'; +import { getSiblingAsins } from '@/lib/services/works.service'; +import { RMABLogger } from '@/lib/utils/logger'; + +const logger = RMABLogger.create('WatchedLists'); + +/** Max books to process per series (avoid excessively long runs) */ +const MAX_BOOKS_PER_SERIES = 200; + +/** Max author book pages to scrape */ +const MAX_AUTHOR_PAGES = 4; + +/** Delay between scrapes to avoid rate limiting (ms) */ +const SCRAPE_DELAY_MS = 2000; + +export interface WatchedListsSyncStats { + seriesChecked: number; + authorsChecked: number; + booksFound: number; + requestsCreated: number; + skippedOwned: number; + skippedExisting: number; + errors: number; +} + +export interface WatchedListsSyncOptions { + /** Process only this specific user (for targeted sync) */ + userId?: string; + /** Process only this specific series (for immediate sync on watch) */ + seriesAsin?: string; + /** Process only this specific author (for immediate sync on watch) */ + authorAsin?: string; +} + +/** + * Process all watched series and authors: scrape for new releases, + * deduplicate, check library ownership, and create requests. + * Called from the check_watched_lists processor. + */ +export async function processWatchedLists( + jobLogger?: ReturnType, + options: WatchedListsSyncOptions = {} +): Promise { + const log = jobLogger || logger; + const stats: WatchedListsSyncStats = { + seriesChecked: 0, + authorsChecked: 0, + booksFound: 0, + requestsCreated: 0, + skippedOwned: 0, + skippedExisting: 0, + errors: 0, + }; + + // ---- Watched Series ---- + await processAllWatchedSeries(log, stats, options); + + // ---- Watched Authors ---- + await processAllWatchedAuthors(log, stats, options); + + log.info('Watched lists sync complete', { + seriesChecked: stats.seriesChecked, + authorsChecked: stats.authorsChecked, + booksFound: stats.booksFound, + requestsCreated: stats.requestsCreated, + skippedOwned: stats.skippedOwned, + skippedExisting: stats.skippedExisting, + errors: stats.errors, + }); + + return stats; +} + +// --------------------------------------------------------------------------- +// Watched Series +// --------------------------------------------------------------------------- + +async function processAllWatchedSeries( + log: ReturnType | ReturnType, + stats: WatchedListsSyncStats, + options: WatchedListsSyncOptions +): Promise { + const whereClause: any = {}; + if (options.userId) whereClause.userId = options.userId; + if (options.seriesAsin) whereClause.seriesAsin = options.seriesAsin; + const watchedSeries = await prisma.watchedSeries.findMany({ + where: whereClause, + include: { user: { select: { id: true, plexUsername: true } } }, + }); + + if (watchedSeries.length === 0) { + log.info('No watched series to process'); + return; + } + + // Group by seriesAsin to avoid re-scraping the same series for multiple users + const seriesByAsin = new Map(); + for (const ws of watchedSeries) { + const list = seriesByAsin.get(ws.seriesAsin) || []; + list.push(ws); + seriesByAsin.set(ws.seriesAsin, list); + } + + log.info(`Processing ${seriesByAsin.size} unique watched series (${watchedSeries.length} total subscriptions)`); + + for (const [seriesAsin, subscriptions] of seriesByAsin) { + try { + await processSeriesForUsers(seriesAsin, subscriptions, log, stats); + } catch (error) { + stats.errors++; + log.error(`Failed to process watched series ${seriesAsin}`, { + error: error instanceof Error ? error.message : String(error), + }); + } + + // Rate limit between series + await delay(SCRAPE_DELAY_MS); + } +} + +async function processSeriesForUsers( + seriesAsin: string, + subscriptions: Array<{ id: string; seriesTitle: string; user: { id: string; plexUsername: string } }>, + log: ReturnType | ReturnType, + stats: WatchedListsSyncStats +): Promise { + const title = subscriptions[0].seriesTitle; + log.info(`Scraping watched series: "${title}" (${seriesAsin})`); + + // Scrape all pages of the series (up to MAX_BOOKS_PER_SERIES) + const allBooks: AudibleAudiobook[] = []; + let page = 1; + let hasMore = true; + + while (hasMore && allBooks.length < MAX_BOOKS_PER_SERIES) { + const result = await scrapeSeriesPage(seriesAsin, page); + if (!result || result.books.length === 0) break; + + allBooks.push(...result.books); + hasMore = result.hasMore; + page++; + + if (hasMore) await delay(1000); + } + + if (allBooks.length === 0) { + log.info(`No books found for series "${title}"`); + stats.seriesChecked++; + return; + } + + stats.booksFound += allBooks.length; + + // Deduplicate + const { books: dedupedBooks, groups } = deduplicateAndCollectGroups(allBooks); + + // Persist dedup groups (fire-and-forget) + if (groups.length > 0) { + persistDedupGroups(groups).catch(() => {}); + } + + // For each user watching this series, create requests for new books + for (const subscription of subscriptions) { + await createRequestsForUser( + subscription.user.id, + subscription.user.plexUsername, + dedupedBooks, + log, + stats + ); + + // Update lastCheckedAt + await prisma.watchedSeries.update({ + where: { id: subscription.id }, + data: { lastCheckedAt: new Date() }, + }).catch(() => {}); + } + + stats.seriesChecked++; +} + +// --------------------------------------------------------------------------- +// Watched Authors +// --------------------------------------------------------------------------- + +async function processAllWatchedAuthors( + log: ReturnType | ReturnType, + stats: WatchedListsSyncStats, + options: WatchedListsSyncOptions +): Promise { + const whereClause: any = {}; + if (options.userId) whereClause.userId = options.userId; + if (options.authorAsin) whereClause.authorAsin = options.authorAsin; + const watchedAuthors = await prisma.watchedAuthor.findMany({ + where: whereClause, + include: { user: { select: { id: true, plexUsername: true } } }, + }); + + if (watchedAuthors.length === 0) { + log.info('No watched authors to process'); + return; + } + + // Group by authorAsin to avoid re-scraping the same author for multiple users + const authorsByAsin = new Map(); + for (const wa of watchedAuthors) { + const list = authorsByAsin.get(wa.authorAsin) || []; + list.push(wa); + authorsByAsin.set(wa.authorAsin, list); + } + + log.info(`Processing ${authorsByAsin.size} unique watched authors (${watchedAuthors.length} total subscriptions)`); + + for (const [authorAsin, subscriptions] of authorsByAsin) { + try { + await processAuthorForUsers(authorAsin, subscriptions, log, stats); + } catch (error) { + stats.errors++; + log.error(`Failed to process watched author ${authorAsin}`, { + error: error instanceof Error ? error.message : String(error), + }); + } + + // Rate limit between authors + await delay(SCRAPE_DELAY_MS); + } +} + +async function processAuthorForUsers( + authorAsin: string, + subscriptions: Array<{ id: string; authorName: string; user: { id: string; plexUsername: string } }>, + log: ReturnType | ReturnType, + stats: WatchedListsSyncStats +): Promise { + const authorName = subscriptions[0].authorName; + log.info(`Scraping watched author: "${authorName}" (${authorAsin})`); + + const audibleService = getAudibleService(); + const allBooks: AudibleAudiobook[] = []; + let page = 1; + let hasMore = true; + + while (hasMore && page <= MAX_AUTHOR_PAGES) { + try { + const result = await audibleService.searchByAuthorAsin(authorName, authorAsin, page); + if (result.books.length === 0) break; + + allBooks.push(...result.books); + hasMore = result.hasMore; + page++; + + if (hasMore) await delay(1000); + } catch (error) { + log.error(`Failed to scrape author page ${page} for "${authorName}"`, { + error: error instanceof Error ? error.message : String(error), + }); + break; + } + } + + if (allBooks.length === 0) { + log.info(`No books found for author "${authorName}"`); + stats.authorsChecked++; + return; + } + + stats.booksFound += allBooks.length; + + // Deduplicate + const { books: dedupedBooks, groups } = deduplicateAndCollectGroups(allBooks); + + // Persist dedup groups (fire-and-forget) + if (groups.length > 0) { + persistDedupGroups(groups).catch(() => {}); + } + + // For each user watching this author, create requests for new books + for (const subscription of subscriptions) { + await createRequestsForUser( + subscription.user.id, + subscription.user.plexUsername, + dedupedBooks, + log, + stats + ); + + // Update lastCheckedAt + await prisma.watchedAuthor.update({ + where: { id: subscription.id }, + data: { lastCheckedAt: new Date() }, + }).catch(() => {}); + } + + stats.authorsChecked++; +} + +// --------------------------------------------------------------------------- +// Shared: Create requests for a user from a list of books +// --------------------------------------------------------------------------- + +async function createRequestsForUser( + userId: string, + username: string, + books: AudibleAudiobook[], + log: ReturnType | ReturnType, + stats: WatchedListsSyncStats +): Promise { + // Filter to books that have an ASIN + const booksWithAsin = books.filter(b => b.asin); + if (booksWithAsin.length === 0) return; + + // Batch check: which ASINs are already in library (direct + sibling expansion) + const ownedAsins = await getOwnedAsins(booksWithAsin.map(b => b.asin)); + + for (const book of booksWithAsin) { + // Skip if user already owns this (direct or via sibling ASIN) + if (ownedAsins.has(book.asin)) { + stats.skippedOwned++; + continue; + } + + try { + const result = await createRequestForUser(userId, { + asin: book.asin, + title: book.title, + author: book.author, + narrator: book.narrator, + description: book.description, + coverArtUrl: book.coverArtUrl, + }); + + if (result.success) { + stats.requestsCreated++; + log.info(`Auto-requested "${book.title}" by ${book.author} for ${username}`); + } else { + // already_available, being_processed, duplicate — all expected + stats.skippedExisting++; + } + } catch (error) { + log.error(`Failed to create request for "${book.title}" for ${username}`, { + error: error instanceof Error ? error.message : String(error), + }); + } + } +} + +/** + * Get the set of ASINs that are already in the library (direct match + sibling expansion). + */ +async function getOwnedAsins(asins: string[]): Promise> { + const owned = new Set(); + + // Direct library lookup + const libraryItems = await prisma.plexLibrary.findMany({ + where: { asin: { in: asins } }, + select: { asin: true }, + }); + for (const item of libraryItems) { + if (item.asin) owned.add(item.asin); + } + + // Sibling expansion via works table + try { + const siblingMap = await getSiblingAsins(asins); + if (siblingMap.size > 0) { + const allSiblings = new Set(); + for (const siblings of siblingMap.values()) { + for (const s of siblings) allSiblings.add(s); + } + + if (allSiblings.size > 0) { + const siblingLibrary = await prisma.plexLibrary.findMany({ + where: { asin: { in: [...allSiblings] } }, + select: { asin: true }, + }); + + for (const item of siblingLibrary) { + if (item.asin) { + // Mark the original ASIN as owned (not the sibling) + for (const [originalAsin, siblings] of siblingMap) { + if (siblings.includes(item.asin)) { + owned.add(originalAsin); + } + } + } + } + } + } + } catch { + // Works table expansion is best-effort + } + + return owned; +} + +function delay(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} diff --git a/src/lib/services/works.service.ts b/src/lib/services/works.service.ts new file mode 100644 index 0000000..45d989d --- /dev/null +++ b/src/lib/services/works.service.ts @@ -0,0 +1,248 @@ +/** + * Component: Works Service + * Documentation: documentation/integrations/audible.md + * + * Manages the works table — persistent cross-ASIN audiobook identity mapping. + * Layer 1: Auto-populated from dedup logic when users browse search/author/series pages. + * Layer 2: Seeded at request time to ensure requested ASINs are tracked. + */ + +import { prisma } from '@/lib/db'; +import { RMABLogger } from '@/lib/utils/logger'; +import type { DedupGroup } from '@/lib/utils/deduplicate-audiobooks'; + +const logger = RMABLogger.create('WorksService'); + +// --------------------------------------------------------------------------- +// Layer 1: Persist dedup groups (fire-and-forget from API routes) +// --------------------------------------------------------------------------- + +/** + * Persist dedup groups to the works table. For each group of 2+ ASINs that + * were identified as the same audiobook, create or update a Work record + * linking all ASINs together. + * + * Safe to call fire-and-forget — never throws. + */ +export async function persistDedupGroups(groups: DedupGroup[]): Promise { + try { + for (const group of groups) { + await persistSingleGroup(group); + } + } catch (error) { + logger.error('Failed to persist dedup groups', { + error: error instanceof Error ? error.message : String(error), + groupCount: groups.length, + }); + } +} + +/** + * Persist a single dedup group. Handles merging when ASINs span multiple + * existing works. + */ +async function persistSingleGroup(group: DedupGroup): Promise { + const { canonicalAsin, allAsins, title, author, narrator, durationMinutes } = group; + + // Find which of these ASINs already exist in work_asins + const existingEntries = await prisma.workAsin.findMany({ + where: { asin: { in: allAsins } }, + select: { asin: true, workId: true }, + }); + + // Collect unique work IDs that already contain any of our ASINs + const existingWorkIds = [...new Set(existingEntries.map(e => e.workId))]; + const existingAsinSet = new Set(existingEntries.map(e => e.asin)); + + if (existingWorkIds.length === 0) { + // No existing works — create a new one with all ASINs + const work = await prisma.work.create({ + data: { title, author }, + }); + + await Promise.all( + allAsins.map(asin => + prisma.workAsin.create({ + data: { + workId: work.id, + asin, + narrator: asin === canonicalAsin ? narrator : undefined, + durationMinutes: asin === canonicalAsin ? durationMinutes : undefined, + isCanonical: asin === canonicalAsin, + source: 'dedup_auto', + }, + }) + ) + ); + + logger.debug('Created new work', { workId: work.id, asinCount: allAsins.length }); + } else { + // Use the first existing work as the target + const targetWorkId = existingWorkIds[0]; + + // If multiple existing works, merge them into the target + if (existingWorkIds.length > 1) { + const mergeWorkIds = existingWorkIds.slice(1); + + // Move all ASINs from other works to the target + await prisma.workAsin.updateMany({ + where: { workId: { in: mergeWorkIds } }, + data: { workId: targetWorkId }, + }); + + // Delete the now-empty works + await prisma.work.deleteMany({ + where: { id: { in: mergeWorkIds } }, + }); + + logger.debug('Merged works', { + targetWorkId, + mergedWorkIds: mergeWorkIds, + }); + } + + // Add any new ASINs that don't already exist + const newAsins = allAsins.filter(a => !existingAsinSet.has(a)); + if (newAsins.length > 0) { + await Promise.all( + newAsins.map(asin => + prisma.workAsin.create({ + data: { + workId: targetWorkId, + asin, + narrator: asin === canonicalAsin ? narrator : undefined, + durationMinutes: asin === canonicalAsin ? durationMinutes : undefined, + isCanonical: asin === canonicalAsin, + source: 'dedup_auto', + }, + }) + ) + ); + + logger.debug('Added ASINs to existing work', { + workId: targetWorkId, + newAsinCount: newAsins.length, + }); + } + + // Update canonical status: ensure the canonical ASIN is marked + await prisma.workAsin.updateMany({ + where: { workId: targetWorkId, asin: canonicalAsin }, + data: { isCanonical: true }, + }); + } +} + +// --------------------------------------------------------------------------- +// Layer 2: Seed ASIN at request time +// --------------------------------------------------------------------------- + +/** + * Ensure an ASIN is tracked in the works table. Creates a single-ASIN work + * if the ASIN isn't already present. Called at request creation time. + * + * Safe to call fire-and-forget — never throws. + */ +export async function seedAsin( + asin: string, + title: string, + author: string, + narrator?: string, + durationMinutes?: number +): Promise { + try { + // Check if ASIN already tracked + const existing = await prisma.workAsin.findUnique({ + where: { asin }, + }); + if (existing) return; + + // Create a new single-ASIN work + const work = await prisma.work.create({ + data: { title, author }, + }); + + await prisma.workAsin.create({ + data: { + workId: work.id, + asin, + narrator, + durationMinutes, + isCanonical: true, + source: 'dedup_auto', + }, + }); + + logger.debug('Seeded ASIN', { workId: work.id, asin }); + } catch (error) { + logger.error('Failed to seed ASIN', { + error: error instanceof Error ? error.message : String(error), + asin, + }); + } +} + +// --------------------------------------------------------------------------- +// Sibling ASIN lookup (for library matching expansion) +// --------------------------------------------------------------------------- + +/** + * Given a list of ASINs, return a map of each input ASIN to its sibling ASINs + * (other ASINs in the same work, NOT including the input ASIN itself). + * + * ASINs not found in the works table are simply omitted from the result. + */ +export async function getSiblingAsins( + asins: string[] +): Promise> { + const result = new Map(); + if (asins.length === 0) return result; + + // Step 1: Find which input ASINs are in work_asins and their work IDs + const inputEntries = await prisma.workAsin.findMany({ + where: { asin: { in: asins } }, + select: { asin: true, workId: true }, + }); + + if (inputEntries.length === 0) return result; + + // Build map of workId -> input ASINs in that work + const workIdToInputAsins = new Map(); + for (const entry of inputEntries) { + const list = workIdToInputAsins.get(entry.workId); + if (list) { + list.push(entry.asin); + } else { + workIdToInputAsins.set(entry.workId, [entry.asin]); + } + } + + // Step 2: Get ALL ASINs in those works + const workIds = [...workIdToInputAsins.keys()]; + const allWorkAsins = await prisma.workAsin.findMany({ + where: { workId: { in: workIds } }, + select: { asin: true, workId: true }, + }); + + // Build map of workId -> all ASINs + const workIdToAllAsins = new Map(); + for (const entry of allWorkAsins) { + const list = workIdToAllAsins.get(entry.workId); + if (list) { + list.push(entry.asin); + } else { + workIdToAllAsins.set(entry.workId, [entry.asin]); + } + } + + // Step 3: For each input ASIN, compute siblings (all ASINs in same work minus self) + for (const entry of inputEntries) { + const allInWork = workIdToAllAsins.get(entry.workId) || []; + const siblings = allInWork.filter(a => a !== entry.asin); + if (siblings.length > 0) { + result.set(entry.asin, siblings); + } + } + + return result; +} diff --git a/src/lib/utils/audiobook-matcher.ts b/src/lib/utils/audiobook-matcher.ts index ec86f45..ee49ff7 100644 --- a/src/lib/utils/audiobook-matcher.ts +++ b/src/lib/utils/audiobook-matcher.ts @@ -8,6 +8,7 @@ import { prisma } from '@/lib/db'; import { LibraryItem } from '@/lib/services/library'; +import { getSiblingAsins } from '@/lib/services/works.service'; import { RMABLogger } from './logger'; // Module-level logger @@ -178,6 +179,61 @@ export async function enrichAudiobooksWithMatches( } } + // Works-table sibling expansion: check if unmatched ASINs have siblings in the library + try { + const unmatchedAsins = results.filter(r => !r.isAvailable).map(r => r.asin); + if (unmatchedAsins.length > 0) { + const siblingMap = await getSiblingAsins(unmatchedAsins); + if (siblingMap.size > 0) { + // Collect all sibling ASINs for a single batch library query + const allSiblingAsins = new Set(); + for (const siblings of siblingMap.values()) { + for (const s of siblings) allSiblingAsins.add(s); + } + + if (allSiblingAsins.size > 0) { + const siblingLibraryMatches = await prisma.plexLibrary.findMany({ + where: { asin: { in: [...allSiblingAsins] } }, + select: { asin: true, plexGuid: true }, + }); + const libraryAsinSet = new Set( + siblingLibraryMatches.filter(m => m.asin).map(m => m.asin!.toLowerCase()) + ); + + // Update results where a sibling ASIN is found in the library + for (const result of results) { + if (result.isAvailable) continue; + const siblings = siblingMap.get(result.asin); + if (!siblings) continue; + const matchedSiblingAsin = siblings.find(s => libraryAsinSet.has(s.toLowerCase())); + if (matchedSiblingAsin) { + const libMatch = siblingLibraryMatches.find( + m => m.asin?.toLowerCase() === matchedSiblingAsin.toLowerCase() + ); + (result as any).isAvailable = true; + (result as any).plexGuid = libMatch?.plexGuid || null; + } + } + + const siblingMatchCount = results.filter(r => { + if (!r.isAvailable) return false; + return siblingMap.has(r.asin); + }).length; + logger.debug('Sibling expansion', { + unmatchedCount: unmatchedAsins.length, + siblingGroupsFound: siblingMap.size, + siblingMatches: siblingMatchCount, + }); + } + } + } + } catch (error) { + // Works table expansion is best-effort — direct matches still work + logger.error('Sibling ASIN expansion failed', { + error: error instanceof Error ? error.message : String(error), + }); + } + // Always enrich with request status (check ANY user's requests) const asins = audiobooks.map(book => book.asin); @@ -272,6 +328,57 @@ export async function enrichAudiobooksWithMatches( return results; } +/** + * Get all ASINs that are considered "available" — present in library or have completed requests. + * Used by paginated API routes to exclude available items at the DB level. + */ +export async function getAvailableAsins(): Promise> { + const [libraryItems, completedRequests] = await Promise.all([ + // ASINs present in the library (Plex or Audiobookshelf) + prisma.plexLibrary.findMany({ + where: { asin: { not: null } }, + select: { asin: true }, + distinct: ['asin'], + }), + // ASINs with completed audiobook requests + prisma.audiobook.findMany({ + where: { + audibleAsin: { not: null }, + requests: { + some: { + status: 'completed', + type: 'audiobook', + deletedAt: null, + }, + }, + }, + select: { audibleAsin: true }, + }), + ]); + + const asins = new Set(); + for (const item of libraryItems) { + if (item.asin) asins.add(item.asin); + } + for (const item of completedRequests) { + if (item.audibleAsin) asins.add(item.audibleAsin); + } + + // Expand with works-table sibling ASINs + try { + if (asins.size > 0) { + const siblingMap = await getSiblingAsins([...asins]); + for (const siblings of siblingMap.values()) { + for (const s of siblings) asins.add(s); + } + } + } catch { + // Works table expansion is best-effort + } + + return asins; +} + /** * Normalize ISBN for comparison (remove dashes and spaces) */ diff --git a/src/lib/utils/deduplicate-audiobooks.ts b/src/lib/utils/deduplicate-audiobooks.ts new file mode 100644 index 0000000..1cfe6f2 --- /dev/null +++ b/src/lib/utils/deduplicate-audiobooks.ts @@ -0,0 +1,203 @@ +/** + * Component: Audiobook Deduplication Utility + * Documentation: documentation/integrations/audible.md + * + * Deduplicates audiobook listings that represent the same recording + * under different ASINs (publisher re-listings, rights transfers, etc.). + * + * Dedup key: normalized title + normalized narrator + * Duration tolerance: max(longerDuration * 0.01, 5) minutes + * Missing duration treated as compatible (graceful degradation). + */ + +import type { AudibleAudiobook } from '../integrations/audible.service'; + +// --------------------------------------------------------------------------- +// Title / narrator normalization +// --------------------------------------------------------------------------- + +/** Patterns in parentheses or brackets to strip (edition markers, format labels) */ +const EDITION_PAREN_RE = /[([][^)\]]*?(?:unabridged|abridged|edition|remaster(?:ed)?|anniversary|complete|original|version|narrat(?:ed|or)?|audio(?:book)?|full cast|dramatiz(?:ed|ation))[^)\]]*[)\]]/gi; + +/** Trailing subtitle after colon or long dash */ +const SUBTITLE_RE = /\s*[:]\s+.+$/; +const LONG_DASH_SUBTITLE_RE = /\s+[-\u2013\u2014]\s+.+$/; + +/** Trailing descriptors like "A Novel", "A Memoir" */ +const TRAILING_DESCRIPTOR_RE = /\s*[-:,]?\s+a\s+(novel|memoir|thriller|mystery|romance|story|tale|novella)\s*$/i; + +/** + * Normalize a title for dedup comparison. + * Strips subtitles, edition markers, and trailing descriptors. + */ +export function normalizeTitle(title: string): string { + let t = title.toLowerCase(); + // Remove parenthesized/bracketed edition markers + t = t.replace(EDITION_PAREN_RE, ''); + // Remove trailing descriptors before subtitle stripping + t = t.replace(TRAILING_DESCRIPTOR_RE, ''); + // Remove subtitle after colon + t = t.replace(SUBTITLE_RE, ''); + // Remove subtitle after long dash (but not short hyphenated words) + t = t.replace(LONG_DASH_SUBTITLE_RE, ''); + // Collapse whitespace and trim + return t.replace(/\s+/g, ' ').trim(); +} + +/** Normalize narrator for comparison. Sorts individual names so order doesn't matter. */ +function normalizeNarrator(narrator?: string): string { + const raw = (narrator || '').toLowerCase().trim(); + if (!raw) return raw; + return raw.split(',').map(n => n.trim()).filter(Boolean).sort().join(', '); +} + +// --------------------------------------------------------------------------- +// Duration compatibility +// --------------------------------------------------------------------------- + +/** + * Check if two durations are compatible (represent the same recording). + * Tolerance: max(longerDuration * 0.01, 5) minutes. + * Missing duration on either side is treated as compatible. + */ +export function areDurationsCompatible(a?: number, b?: number): boolean { + if (a == null || b == null) return true; + const longer = Math.max(a, b); + const tolerance = Math.max(longer * 0.01, 5); + return Math.abs(a - b) <= tolerance; +} + +// --------------------------------------------------------------------------- +// Metadata scoring (for picking best representative) +// --------------------------------------------------------------------------- + +function metadataScore(book: AudibleAudiobook): number { + let score = 0; + if (book.coverArtUrl) score++; + if (book.rating != null) score++; + if (book.durationMinutes != null) score++; + if (book.description) score++; + if (book.narrator) score++; + if (book.releaseDate) score++; + if (book.genres && book.genres.length > 0) score++; + return score; +} + +// --------------------------------------------------------------------------- +// Dedup group types (for works-table persistence) +// --------------------------------------------------------------------------- + +/** Metadata about a group of ASINs that were collapsed during dedup. */ +export interface DedupGroup { + canonicalAsin: string; // ASIN of the "winner" (best metadata score) + allAsins: string[]; // All ASINs in this group (including canonical) + title: string; // Author from the canonical entry + author: string; // Author from the canonical entry + narrator?: string; // Narrator from the canonical entry + durationMinutes?: number; // Duration from the canonical entry +} + +/** Result of deduplication with group collection. */ +export interface DeduplicateResult { + books: AudibleAudiobook[]; // The deduped list (same as deduplicateAudiobooks returns) + groups: DedupGroup[]; // Groups where 2+ ASINs were collapsed +} + +// --------------------------------------------------------------------------- +// Main dedup functions +// --------------------------------------------------------------------------- + +/** + * Deduplicate audiobook listings by normalized title + narrator + duration. + * + * Same narrator + compatible duration + similar title = same recording -> collapse. + * Different narrator = different production -> keep both. + * Duration outside tolerance = different content (abridged vs unabridged) -> keep both. + * + * Preserves original ordering (position of first appearance). + */ +export function deduplicateAudiobooks(books: AudibleAudiobook[]): AudibleAudiobook[] { + return deduplicateAndCollectGroups(books).books; +} + +/** + * Deduplicate audiobooks AND return grouping metadata for works-table persistence. + * Returns both the deduped list and the groups where 2+ ASINs were collapsed. + */ +export function deduplicateAndCollectGroups(books: AudibleAudiobook[]): DeduplicateResult { + if (books.length <= 1) return { books: [...books], groups: [] }; + + // Group by normalized title + narrator + const titleNarratorGroups = new Map(); + const insertionOrder: string[] = []; + + for (const book of books) { + const key = `${normalizeTitle(book.title)}|||${normalizeNarrator(book.narrator)}`; + const group = titleNarratorGroups.get(key); + if (group) { + group.push(book); + } else { + titleNarratorGroups.set(key, [book]); + insertionOrder.push(key); + } + } + + const result: AudibleAudiobook[] = []; + const dedupGroups: DedupGroup[] = []; + + for (const key of insertionOrder) { + const group = titleNarratorGroups.get(key)!; + if (group.length === 1) { + result.push(group[0]); + continue; + } + + // Within a title+narrator group, further split by duration compatibility. + // Build sub-groups where all members are duration-compatible with the + // representative (first member). A book joins the first compatible sub-group. + const subGroups: AudibleAudiobook[][] = []; + + for (const book of group) { + let placed = false; + for (const sg of subGroups) { + // Check compatibility against the representative (first member) + if (areDurationsCompatible(sg[0].durationMinutes, book.durationMinutes)) { + sg.push(book); + placed = true; + break; + } + } + if (!placed) { + subGroups.push([book]); + } + } + + // From each sub-group, pick the best representative and collect group metadata + for (const sg of subGroups) { + let best = sg[0]; + let bestScore = metadataScore(best); + for (let i = 1; i < sg.length; i++) { + const score = metadataScore(sg[i]); + if (score > bestScore) { + best = sg[i]; + bestScore = score; + } + } + result.push(best); + + // Collect group metadata for works-table persistence (only multi-ASIN groups) + if (sg.length >= 2) { + dedupGroups.push({ + canonicalAsin: best.asin, + allAsins: sg.map(b => b.asin), + title: best.title, + author: best.author, + narrator: best.narrator, + durationMinutes: best.durationMinutes, + }); + } + } + } + + return { books: result, groups: dedupGroups }; +} diff --git a/src/lib/utils/parse-runtime.ts b/src/lib/utils/parse-runtime.ts new file mode 100644 index 0000000..148dcb7 --- /dev/null +++ b/src/lib/utils/parse-runtime.ts @@ -0,0 +1,44 @@ +/** + * Component: Runtime Parsing Utility + * Documentation: documentation/integrations/audible.md + * + * Shared runtime/duration text parser extracted from AudibleService. + * Handles all i18n patterns (English, German, Spanish, French) via + * language-specific regex patterns in LanguageConfig. + */ + +import type { LanguageConfig } from '../constants/language-config'; + +/** + * Parse runtime text (e.g. "12 hrs and 30 mins", "5 Std. 20 Min.") + * into total minutes using language-specific patterns. + * + * @param runtimeText - Raw runtime string from Audible HTML + * @param langConfig - Language configuration with hour/minute regex patterns + * @returns Total minutes, or undefined if no duration could be parsed + */ +export function parseRuntime(runtimeText: string, langConfig: LanguageConfig): number | undefined { + if (!runtimeText) return undefined; + + let totalMinutes = 0; + + // Try each hour pattern until one matches + for (const pattern of langConfig.scraping.runtimeHourPatterns) { + const match = runtimeText.match(pattern); + if (match) { + totalMinutes += parseInt(match[1]) * 60; + break; + } + } + + // Try each minute pattern until one matches + for (const pattern of langConfig.scraping.runtimeMinutePatterns) { + const match = runtimeText.match(pattern); + if (match) { + totalMinutes += parseInt(match[1]); + break; + } + } + + return totalMinutes > 0 ? totalMinutes : undefined; +} diff --git a/tests/app/home.page.test.tsx b/tests/app/home.page.test.tsx index f84f7c0..6a009e4 100644 --- a/tests/app/home.page.test.tsx +++ b/tests/app/home.page.test.tsx @@ -47,17 +47,22 @@ vi.mock('@/components/ui/CardSizeControls', () => ({ CardSizeControls: ({ size }: { size: number }) =>
, })); -vi.mock('@/components/ui/StickyPagination', () => ({ - StickyPagination: ({ - label, - onPageChange, +vi.mock('@/components/ui/UnifiedPagination', () => ({ + UnifiedPagination: ({ + sections, }: { - label: string; - onPageChange: (page: number) => void; + sections: Array<{ + label: string; + onPageChange: (page: number) => void; + }>; }) => ( - +
+ {sections.map((s) => ( + + ))} +
), })); @@ -113,7 +118,7 @@ describe('HomePage', () => { fireEvent.click(screen.getByRole('button', { name: 'Popular Audiobooks next' })); await waitFor(() => { - expect(useAudiobooksMock).toHaveBeenCalledWith('popular', 20, 2); + expect(useAudiobooksMock).toHaveBeenCalledWith('popular', 20, 2, undefined); }); }); }); diff --git a/tests/components/ui/StickyPagination.test.tsx b/tests/components/ui/StickyPagination.test.tsx deleted file mode 100644 index 4c2206a..0000000 --- a/tests/components/ui/StickyPagination.test.tsx +++ /dev/null @@ -1,133 +0,0 @@ -/** - * Component: Sticky Pagination Tests - * Documentation: documentation/frontend/components.md - */ - -// @vitest-environment jsdom - -import React from 'react'; -import { act, fireEvent, render, screen } from '@testing-library/react'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { StickyPagination } from '@/components/ui/StickyPagination'; - -type ObserverEntry = { - isIntersecting: boolean; - intersectionRatio: number; - target: Element; -}; - -describe('StickyPagination', () => { - const observers: { callback: IntersectionObserverCallback }[] = []; - - beforeEach(() => { - observers.length = 0; - class MockIntersectionObserver { - callback: IntersectionObserverCallback; - observe = vi.fn(); - unobserve = vi.fn(); - disconnect = vi.fn(); - takeRecords = vi.fn(); - - constructor(callback: IntersectionObserverCallback) { - this.callback = callback; - observers.push(this); - } - } - - (global as any).IntersectionObserver = MockIntersectionObserver; - }); - - it('returns null when there is only one page', () => { - const sectionRef = { current: document.createElement('div') }; - const { container } = render( - - ); - - expect(container.firstChild).toBeNull(); - }); - - it('shows and hides based on section and footer visibility', () => { - const sectionRef = { current: document.createElement('div') }; - const footerRef = { current: document.createElement('div') }; - - const { container } = render( - - ); - - const root = container.querySelector('div.fixed') as HTMLElement; - expect(root).toHaveClass('opacity-0'); - - act(() => { - observers[0].callback( - [ - { - isIntersecting: true, - intersectionRatio: 0.2, - target: sectionRef.current as Element, - } as ObserverEntry, - ], - observers[0] as unknown as IntersectionObserver - ); - }); - - expect(root).toHaveClass('opacity-100'); - - act(() => { - observers[1].callback( - [ - { - isIntersecting: true, - intersectionRatio: 0.2, - target: footerRef.current as Element, - } as ObserverEntry, - ], - observers[1] as unknown as IntersectionObserver - ); - }); - - expect(root).toHaveClass('opacity-0'); - }); - - it('handles navigation and jump input updates', () => { - const sectionRef = { current: document.createElement('div') }; - const onPageChange = vi.fn(); - - render( - - ); - - fireEvent.click(screen.getByLabelText('Next page')); - expect(onPageChange).toHaveBeenCalledWith(3); - - fireEvent.click(screen.getByLabelText('Previous page')); - expect(onPageChange).toHaveBeenCalledWith(1); - - const input = screen.getByLabelText('Current page') as HTMLInputElement; - fireEvent.change(input, { target: { value: '4' } }); - fireEvent.blur(input); - expect(onPageChange).toHaveBeenCalledWith(4); - - fireEvent.change(input, { target: { value: '99' } }); - fireEvent.blur(input); - expect(input.value).toBe('2'); - }); -}); diff --git a/tests/components/ui/UnifiedPagination.test.tsx b/tests/components/ui/UnifiedPagination.test.tsx new file mode 100644 index 0000000..6d38ba8 --- /dev/null +++ b/tests/components/ui/UnifiedPagination.test.tsx @@ -0,0 +1,203 @@ +/** + * Component: Unified Pagination Tests + * Documentation: documentation/frontend/components.md + */ + +// @vitest-environment jsdom + +import React from 'react'; +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { UnifiedPagination, PaginationSection } from '@/components/ui/UnifiedPagination'; + +type ObserverEntry = { + isIntersecting: boolean; + intersectionRatio: number; + target: Element; +}; + +function makeSections( + overrides?: Partial[] +): [PaginationSection, PaginationSection] { + const defaults: [PaginationSection, PaginationSection] = [ + { + label: 'Popular', + accentColor: 'bg-blue-500', + currentPage: 1, + totalPages: 3, + onPageChange: vi.fn(), + sectionRef: { current: document.createElement('section') }, + onScrollToSection: vi.fn(), + }, + { + label: 'New Releases', + accentColor: 'bg-emerald-500', + currentPage: 1, + totalPages: 2, + onPageChange: vi.fn(), + sectionRef: { current: document.createElement('section') }, + onScrollToSection: vi.fn(), + }, + ]; + + if (overrides) { + overrides.forEach((o, i) => { + if (o) Object.assign(defaults[i], o); + }); + } + + return defaults; +} + +describe('UnifiedPagination', () => { + const observers: { callback: IntersectionObserverCallback; observe: ReturnType; disconnect: ReturnType }[] = []; + + beforeEach(() => { + observers.length = 0; + + class MockIntersectionObserver { + callback: IntersectionObserverCallback; + observe = vi.fn(); + unobserve = vi.fn(); + disconnect = vi.fn(); + takeRecords = vi.fn(); + + constructor(callback: IntersectionObserverCallback) { + this.callback = callback; + observers.push(this); + } + } + + (global as any).IntersectionObserver = MockIntersectionObserver; + }); + + it('renders nothing when both sections have only one page', () => { + const sections = makeSections([{ totalPages: 1 }, { totalPages: 1 }]); + const { container } = render(); + // The pill should be hidden (pointer-events-none, opacity-0) + const root = container.querySelector('div.fixed') as HTMLElement; + expect(root).toHaveClass('pointer-events-none'); + }); + + it('shows pagination when the dominant section is visible and has pages', () => { + const sections = makeSections(); + const { container } = render(); + + const root = container.querySelector('div.fixed') as HTMLElement; + expect(root).toHaveClass('opacity-0'); + + // Simulate first section becoming visible with high ratio + act(() => { + observers[0].callback( + [ + { + isIntersecting: true, + intersectionRatio: 0.5, + target: sections[0].sectionRef.current as Element, + } as ObserverEntry, + ], + observers[0] as unknown as IntersectionObserver + ); + }); + + expect(root).toHaveClass('opacity-100'); + }); + + it('hides when footer becomes visible', () => { + const sections = makeSections(); + const footerRef = { current: document.createElement('footer') }; + const { container } = render( + + ); + + const root = container.querySelector('div.fixed') as HTMLElement; + + // Make section visible + act(() => { + observers[0].callback( + [ + { + isIntersecting: true, + intersectionRatio: 0.5, + target: sections[0].sectionRef.current as Element, + } as ObserverEntry, + ], + observers[0] as unknown as IntersectionObserver + ); + }); + + expect(root).toHaveClass('opacity-100'); + + // Footer observer is the 3rd (index 2): section0, section1, footer + act(() => { + observers[2].callback( + [ + { + isIntersecting: true, + intersectionRatio: 0.1, + target: footerRef.current as Element, + } as ObserverEntry, + ], + observers[2] as unknown as IntersectionObserver + ); + }); + + expect(root).toHaveClass('opacity-0'); + }); + + it('calls onPageChange for prev/next buttons', () => { + const sections = makeSections([{ currentPage: 2, totalPages: 4 }]); + const { container } = render(); + + // Make section visible so controls render interactably + act(() => { + observers[0].callback( + [ + { + isIntersecting: true, + intersectionRatio: 0.5, + target: sections[0].sectionRef.current as Element, + } as ObserverEntry, + ], + observers[0] as unknown as IntersectionObserver + ); + }); + + fireEvent.click(screen.getByLabelText('Next page')); + expect(sections[0].onPageChange).toHaveBeenCalledWith(3); + + fireEvent.click(screen.getByLabelText('Previous page')); + expect(sections[0].onPageChange).toHaveBeenCalledWith(1); + }); + + it('handles page jump input', () => { + const sections = makeSections([{ currentPage: 2, totalPages: 5 }]); + render(); + + // Make section visible + act(() => { + observers[0].callback( + [ + { + isIntersecting: true, + intersectionRatio: 0.5, + target: sections[0].sectionRef.current as Element, + } as ObserverEntry, + ], + observers[0] as unknown as IntersectionObserver + ); + }); + + const input = screen.getByLabelText('Jump to page') as HTMLInputElement; + fireEvent.change(input, { target: { value: '4' } }); + fireEvent.blur(input); + expect(sections[0].onPageChange).toHaveBeenCalledWith(4); + }); + + it('uses pointer-events-none when hidden', () => { + const sections = makeSections(); + const { container } = render(); + const root = container.querySelector('div.fixed') as HTMLElement; + expect(root).toHaveClass('pointer-events-none'); + }); +}); diff --git a/tests/helpers/prisma.ts b/tests/helpers/prisma.ts index 6dfc5a1..90cd6e2 100644 --- a/tests/helpers/prisma.ts +++ b/tests/helpers/prisma.ts @@ -48,6 +48,10 @@ export const createPrismaMock = () => ({ goodreadsShelf: createModelMock(), goodreadsBookMapping: createModelMock(), hardcoverShelf: createModelMock(), + work: createModelMock(), + workAsin: createModelMock(), + watchedSeries: createModelMock(), + watchedAuthor: createModelMock(), $queryRaw: vi.fn(), $disconnect: vi.fn(), }); diff --git a/tests/services/job-queue.service.test.ts b/tests/services/job-queue.service.test.ts index 922584c..247e8c0 100644 --- a/tests/services/job-queue.service.test.ts +++ b/tests/services/job-queue.service.test.ts @@ -22,6 +22,7 @@ const processorsMock = vi.hoisted(() => ({ processRetryFailedImports: vi.fn().mockResolvedValue('ok'), processCleanupSeededTorrents: vi.fn().mockResolvedValue('ok'), processSyncShelves: vi.fn().mockResolvedValue('ok'), + processCheckWatchedLists: vi.fn().mockResolvedValue('ok'), // Ebook processors processSearchEbook: vi.fn().mockResolvedValue('ok'), processStartDirectDownload: vi.fn().mockResolvedValue('ok'), @@ -120,6 +121,10 @@ vi.mock('@/lib/processors/sync-shelves.processor', () => ({ processSyncShelves: processorsMock.processSyncShelves, })); +vi.mock('@/lib/processors/check-watched-lists.processor', () => ({ + processCheckWatchedLists: processorsMock.processCheckWatchedLists, +})); + // Ebook processors vi.mock('@/lib/processors/search-ebook.processor', () => ({ processSearchEbook: processorsMock.processSearchEbook, @@ -565,6 +570,7 @@ describe('JobQueueService', () => { expect(processorsMock.processRetryFailedImports).toHaveBeenCalled(); expect(processorsMock.processCleanupSeededTorrents).toHaveBeenCalled(); expect(processorsMock.processSyncShelves).toHaveBeenCalled(); + expect(processorsMock.processCheckWatchedLists).toHaveBeenCalled(); }); it('returns repeatable jobs from the queue', async () => { diff --git a/tests/services/scheduler.service.test.ts b/tests/services/scheduler.service.test.ts index ff852b7..8ff7adb 100644 --- a/tests/services/scheduler.service.test.ts +++ b/tests/services/scheduler.service.test.ts @@ -81,7 +81,7 @@ describe('SchedulerService', () => { const service = new SchedulerService(); await service.start(); - expect(prismaMock.scheduledJob.create).toHaveBeenCalledTimes(8); + expect(prismaMock.scheduledJob.create).toHaveBeenCalledTimes(9); expect(jobQueueMock.addRepeatableJob).toHaveBeenCalledWith( 'audible_refresh', { scheduledJobId: 'job-1' }, diff --git a/tests/services/watched-lists.service.test.ts b/tests/services/watched-lists.service.test.ts new file mode 100644 index 0000000..e4835d4 --- /dev/null +++ b/tests/services/watched-lists.service.test.ts @@ -0,0 +1,588 @@ +/** + * Component: Watched Lists Service Tests + * Documentation: documentation/features/watched-lists.md + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createPrismaMock } from '../helpers/prisma'; + +const prismaMock = createPrismaMock(); + +vi.mock('@/lib/db', () => ({ + prisma: prismaMock, +})); + +vi.mock('@/lib/utils/logger', () => ({ + RMABLogger: { + create: () => ({ + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }), + forJob: () => ({ + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }), + }, +})); + +// Mock scrapeSeriesPage +const mockScrapeSeriesPage = vi.fn(); +vi.mock('@/lib/integrations/audible-series', () => ({ + scrapeSeriesPage: (...args: any[]) => mockScrapeSeriesPage(...args), +})); + +// Mock AudibleService +const mockSearchByAuthorAsin = vi.fn(); +vi.mock('@/lib/integrations/audible.service', () => ({ + getAudibleService: () => ({ + searchByAuthorAsin: mockSearchByAuthorAsin, + }), +})); + +// Mock deduplicateAndCollectGroups +const mockDeduplicateAndCollectGroups = vi.fn(); +vi.mock('@/lib/utils/deduplicate-audiobooks', () => ({ + deduplicateAndCollectGroups: (...args: any[]) => mockDeduplicateAndCollectGroups(...args), +})); + +// Mock works service +const mockPersistDedupGroups = vi.fn(); +const mockGetSiblingAsins = vi.fn(); +vi.mock('@/lib/services/works.service', () => ({ + persistDedupGroups: (...args: any[]) => mockPersistDedupGroups(...args), + getSiblingAsins: (...args: any[]) => mockGetSiblingAsins(...args), +})); + +// Mock request creator +const mockCreateRequestForUser = vi.fn(); +vi.mock('@/lib/services/request-creator.service', () => ({ + createRequestForUser: (...args: any[]) => mockCreateRequestForUser(...args), +})); + +// Mock findPlexMatch +vi.mock('@/lib/utils/audiobook-matcher', () => ({ + findPlexMatch: vi.fn().mockResolvedValue(null), +})); + +describe('processWatchedLists', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.resetModules(); + + // Default: empty library, no siblings + prismaMock.plexLibrary.findMany.mockResolvedValue([]); + mockGetSiblingAsins.mockResolvedValue(new Map()); + mockPersistDedupGroups.mockResolvedValue(undefined); + }); + + it('processes watched series and creates requests for new books', async () => { + // Setup: one user watching one series + prismaMock.watchedSeries.findMany.mockResolvedValue([ + { + id: 'ws-1', + userId: 'user-1', + seriesAsin: 'B001SERIES1', + seriesTitle: 'Test Series', + coverArtUrl: null, + lastCheckedAt: null, + user: { id: 'user-1', plexUsername: 'testuser' }, + }, + ]); + + prismaMock.watchedAuthor.findMany.mockResolvedValue([]); + prismaMock.watchedSeries.update.mockResolvedValue({}); + + // Series page returns 2 books + mockScrapeSeriesPage.mockResolvedValueOnce({ + asin: 'B001SERIES1', + title: 'Test Series', + bookCount: 2, + books: [ + { asin: 'B001BOOK01', title: 'Book One', author: 'Author A', narrator: 'Narrator' }, + { asin: 'B001BOOK02', title: 'Book Two', author: 'Author A', narrator: 'Narrator' }, + ], + hasMore: false, + page: 1, + }); + + // No dedup (each book is unique) + mockDeduplicateAndCollectGroups.mockReturnValue({ + books: [ + { asin: 'B001BOOK01', title: 'Book One', author: 'Author A', narrator: 'Narrator' }, + { asin: 'B001BOOK02', title: 'Book Two', author: 'Author A', narrator: 'Narrator' }, + ], + groups: [], + }); + + // Both requests succeed + mockCreateRequestForUser.mockResolvedValue({ success: true, request: {} }); + + const { processWatchedLists } = await import('@/lib/services/watched-lists.service'); + const stats = await processWatchedLists(); + + expect(stats.seriesChecked).toBe(1); + expect(stats.requestsCreated).toBe(2); + expect(mockCreateRequestForUser).toHaveBeenCalledTimes(2); + expect(prismaMock.watchedSeries.update).toHaveBeenCalledWith({ + where: { id: 'ws-1' }, + data: { lastCheckedAt: expect.any(Date) }, + }); + }); + + it('skips books already in the library', async () => { + prismaMock.watchedSeries.findMany.mockResolvedValue([ + { + id: 'ws-1', + userId: 'user-1', + seriesAsin: 'B001SERIES1', + seriesTitle: 'Test Series', + coverArtUrl: null, + lastCheckedAt: null, + user: { id: 'user-1', plexUsername: 'testuser' }, + }, + ]); + + prismaMock.watchedAuthor.findMany.mockResolvedValue([]); + prismaMock.watchedSeries.update.mockResolvedValue({}); + + mockScrapeSeriesPage.mockResolvedValueOnce({ + asin: 'B001SERIES1', + title: 'Test Series', + bookCount: 2, + books: [ + { asin: 'B001BOOK01', title: 'Book One', author: 'Author A' }, + { asin: 'B001BOOK02', title: 'Book Two', author: 'Author A' }, + ], + hasMore: false, + page: 1, + }); + + mockDeduplicateAndCollectGroups.mockReturnValue({ + books: [ + { asin: 'B001BOOK01', title: 'Book One', author: 'Author A' }, + { asin: 'B001BOOK02', title: 'Book Two', author: 'Author A' }, + ], + groups: [], + }); + + // Book One is already in library + prismaMock.plexLibrary.findMany.mockResolvedValue([ + { asin: 'B001BOOK01' }, + ]); + + mockCreateRequestForUser.mockResolvedValue({ success: true, request: {} }); + + const { processWatchedLists } = await import('@/lib/services/watched-lists.service'); + const stats = await processWatchedLists(); + + expect(stats.skippedOwned).toBe(1); + expect(stats.requestsCreated).toBe(1); + expect(mockCreateRequestForUser).toHaveBeenCalledTimes(1); + expect(mockCreateRequestForUser).toHaveBeenCalledWith('user-1', expect.objectContaining({ asin: 'B001BOOK02' })); + }); + + it('processes watched authors and creates requests', async () => { + prismaMock.watchedSeries.findMany.mockResolvedValue([]); + + prismaMock.watchedAuthor.findMany.mockResolvedValue([ + { + id: 'wa-1', + userId: 'user-1', + authorAsin: 'B001AUTH001', + authorName: 'Author A', + coverArtUrl: null, + lastCheckedAt: null, + user: { id: 'user-1', plexUsername: 'testuser' }, + }, + ]); + + prismaMock.watchedAuthor.update.mockResolvedValue({}); + + // Author has 1 book + mockSearchByAuthorAsin.mockResolvedValueOnce({ + books: [ + { asin: 'B001BOOK01', title: 'Author Book', author: 'Author A' }, + ], + hasMore: false, + page: 1, + totalResults: 1, + }); + + mockDeduplicateAndCollectGroups.mockReturnValue({ + books: [ + { asin: 'B001BOOK01', title: 'Author Book', author: 'Author A' }, + ], + groups: [], + }); + + mockCreateRequestForUser.mockResolvedValue({ success: true, request: {} }); + + const { processWatchedLists } = await import('@/lib/services/watched-lists.service'); + const stats = await processWatchedLists(); + + expect(stats.authorsChecked).toBe(1); + expect(stats.requestsCreated).toBe(1); + expect(mockSearchByAuthorAsin).toHaveBeenCalledWith('Author A', 'B001AUTH001', 1); + }); + + it('counts duplicate/already-available books as skippedExisting', async () => { + prismaMock.watchedSeries.findMany.mockResolvedValue([ + { + id: 'ws-1', + userId: 'user-1', + seriesAsin: 'B001SERIES1', + seriesTitle: 'Test Series', + coverArtUrl: null, + lastCheckedAt: null, + user: { id: 'user-1', plexUsername: 'testuser' }, + }, + ]); + + prismaMock.watchedAuthor.findMany.mockResolvedValue([]); + prismaMock.watchedSeries.update.mockResolvedValue({}); + + mockScrapeSeriesPage.mockResolvedValueOnce({ + asin: 'B001SERIES1', + title: 'Test Series', + bookCount: 1, + books: [ + { asin: 'B001BOOK01', title: 'Book One', author: 'Author A' }, + ], + hasMore: false, + page: 1, + }); + + mockDeduplicateAndCollectGroups.mockReturnValue({ + books: [ + { asin: 'B001BOOK01', title: 'Book One', author: 'Author A' }, + ], + groups: [], + }); + + // Request creation returns duplicate + mockCreateRequestForUser.mockResolvedValue({ + success: false, + reason: 'duplicate', + message: 'Already requested', + }); + + const { processWatchedLists } = await import('@/lib/services/watched-lists.service'); + const stats = await processWatchedLists(); + + expect(stats.skippedExisting).toBe(1); + expect(stats.requestsCreated).toBe(0); + }); + + it('deduplicates scraping when multiple users watch same series', async () => { + prismaMock.watchedSeries.findMany.mockResolvedValue([ + { + id: 'ws-1', + userId: 'user-1', + seriesAsin: 'B001SERIES1', + seriesTitle: 'Same Series', + coverArtUrl: null, + lastCheckedAt: null, + user: { id: 'user-1', plexUsername: 'user1' }, + }, + { + id: 'ws-2', + userId: 'user-2', + seriesAsin: 'B001SERIES1', + seriesTitle: 'Same Series', + coverArtUrl: null, + lastCheckedAt: null, + user: { id: 'user-2', plexUsername: 'user2' }, + }, + ]); + + prismaMock.watchedAuthor.findMany.mockResolvedValue([]); + prismaMock.watchedSeries.update.mockResolvedValue({}); + + // Should only scrape once despite 2 subscriptions + mockScrapeSeriesPage.mockResolvedValueOnce({ + asin: 'B001SERIES1', + title: 'Same Series', + bookCount: 1, + books: [ + { asin: 'B001BOOK01', title: 'Book One', author: 'Author A' }, + ], + hasMore: false, + page: 1, + }); + + mockDeduplicateAndCollectGroups.mockReturnValue({ + books: [ + { asin: 'B001BOOK01', title: 'Book One', author: 'Author A' }, + ], + groups: [], + }); + + mockCreateRequestForUser.mockResolvedValue({ success: true, request: {} }); + + const { processWatchedLists } = await import('@/lib/services/watched-lists.service'); + const stats = await processWatchedLists(); + + // Scraped once, but created requests for both users + expect(mockScrapeSeriesPage).toHaveBeenCalledTimes(1); + expect(mockCreateRequestForUser).toHaveBeenCalledTimes(2); + expect(stats.requestsCreated).toBe(2); + }); + + it('handles empty series page gracefully', async () => { + prismaMock.watchedSeries.findMany.mockResolvedValue([ + { + id: 'ws-1', + userId: 'user-1', + seriesAsin: 'B001SERIES1', + seriesTitle: 'Empty Series', + coverArtUrl: null, + lastCheckedAt: null, + user: { id: 'user-1', plexUsername: 'testuser' }, + }, + ]); + + prismaMock.watchedAuthor.findMany.mockResolvedValue([]); + + mockScrapeSeriesPage.mockResolvedValueOnce(null); + + const { processWatchedLists } = await import('@/lib/services/watched-lists.service'); + const stats = await processWatchedLists(); + + expect(stats.seriesChecked).toBe(1); + expect(stats.booksFound).toBe(0); + expect(stats.requestsCreated).toBe(0); + expect(mockCreateRequestForUser).not.toHaveBeenCalled(); + }); + + it('returns empty stats when no watched items exist', async () => { + prismaMock.watchedSeries.findMany.mockResolvedValue([]); + prismaMock.watchedAuthor.findMany.mockResolvedValue([]); + + const { processWatchedLists } = await import('@/lib/services/watched-lists.service'); + const stats = await processWatchedLists(); + + expect(stats.seriesChecked).toBe(0); + expect(stats.authorsChecked).toBe(0); + expect(stats.booksFound).toBe(0); + expect(stats.requestsCreated).toBe(0); + expect(stats.errors).toBe(0); + }); + + it('persists dedup groups to works table', async () => { + prismaMock.watchedSeries.findMany.mockResolvedValue([ + { + id: 'ws-1', + userId: 'user-1', + seriesAsin: 'B001SERIES1', + seriesTitle: 'Test Series', + coverArtUrl: null, + lastCheckedAt: null, + user: { id: 'user-1', plexUsername: 'testuser' }, + }, + ]); + + prismaMock.watchedAuthor.findMany.mockResolvedValue([]); + prismaMock.watchedSeries.update.mockResolvedValue({}); + + mockScrapeSeriesPage.mockResolvedValueOnce({ + asin: 'B001SERIES1', + title: 'Test Series', + bookCount: 2, + books: [ + { asin: 'B001BOOK01', title: 'Book One', author: 'Author A' }, + { asin: 'B001BOOK02', title: 'Book One (Remastered)', author: 'Author A' }, + ], + hasMore: false, + page: 1, + }); + + const dedupGroup = { + canonicalAsin: 'B001BOOK01', + allAsins: ['B001BOOK01', 'B001BOOK02'], + title: 'Book One', + author: 'Author A', + }; + + mockDeduplicateAndCollectGroups.mockReturnValue({ + books: [{ asin: 'B001BOOK01', title: 'Book One', author: 'Author A' }], + groups: [dedupGroup], + }); + + mockCreateRequestForUser.mockResolvedValue({ success: true, request: {} }); + + const { processWatchedLists } = await import('@/lib/services/watched-lists.service'); + await processWatchedLists(); + + expect(mockPersistDedupGroups).toHaveBeenCalledWith([dedupGroup]); + }); + + // ---- Targeted processing tests ---- + + it('filters by seriesAsin when provided in options', async () => { + // Two series exist, but we only want to process one + prismaMock.watchedSeries.findMany.mockResolvedValue([ + { + id: 'ws-1', + userId: 'user-1', + seriesAsin: 'B001SERIES1', + seriesTitle: 'Target Series', + coverArtUrl: null, + lastCheckedAt: null, + user: { id: 'user-1', plexUsername: 'testuser' }, + }, + ]); + + prismaMock.watchedAuthor.findMany.mockResolvedValue([]); + prismaMock.watchedSeries.update.mockResolvedValue({}); + + mockScrapeSeriesPage.mockResolvedValueOnce({ + asin: 'B001SERIES1', + title: 'Target Series', + bookCount: 1, + books: [ + { asin: 'B001BOOK01', title: 'Book One', author: 'Author A' }, + ], + hasMore: false, + page: 1, + }); + + mockDeduplicateAndCollectGroups.mockReturnValue({ + books: [ + { asin: 'B001BOOK01', title: 'Book One', author: 'Author A' }, + ], + groups: [], + }); + + mockCreateRequestForUser.mockResolvedValue({ success: true, request: {} }); + + const { processWatchedLists } = await import('@/lib/services/watched-lists.service'); + const stats = await processWatchedLists(undefined, { + userId: 'user-1', + seriesAsin: 'B001SERIES1', + }); + + // Should have passed both userId and seriesAsin to the Prisma query + expect(prismaMock.watchedSeries.findMany).toHaveBeenCalledWith({ + where: { userId: 'user-1', seriesAsin: 'B001SERIES1' }, + include: { user: { select: { id: true, plexUsername: true } } }, + }); + + expect(stats.seriesChecked).toBe(1); + expect(stats.requestsCreated).toBe(1); + }); + + it('filters by authorAsin when provided in options', async () => { + prismaMock.watchedSeries.findMany.mockResolvedValue([]); + + prismaMock.watchedAuthor.findMany.mockResolvedValue([ + { + id: 'wa-1', + userId: 'user-1', + authorAsin: 'B001AUTH001', + authorName: 'Target Author', + coverArtUrl: null, + lastCheckedAt: null, + user: { id: 'user-1', plexUsername: 'testuser' }, + }, + ]); + + prismaMock.watchedAuthor.update.mockResolvedValue({}); + + mockSearchByAuthorAsin.mockResolvedValueOnce({ + books: [ + { asin: 'B001BOOK01', title: 'Author Book', author: 'Target Author' }, + ], + hasMore: false, + page: 1, + totalResults: 1, + }); + + mockDeduplicateAndCollectGroups.mockReturnValue({ + books: [ + { asin: 'B001BOOK01', title: 'Author Book', author: 'Target Author' }, + ], + groups: [], + }); + + mockCreateRequestForUser.mockResolvedValue({ success: true, request: {} }); + + const { processWatchedLists } = await import('@/lib/services/watched-lists.service'); + const stats = await processWatchedLists(undefined, { + userId: 'user-1', + authorAsin: 'B001AUTH001', + }); + + // Should have passed both userId and authorAsin to the Prisma query + expect(prismaMock.watchedAuthor.findMany).toHaveBeenCalledWith({ + where: { userId: 'user-1', authorAsin: 'B001AUTH001' }, + include: { user: { select: { id: true, plexUsername: true } } }, + }); + + expect(stats.authorsChecked).toBe(1); + expect(stats.requestsCreated).toBe(1); + }); + + it('skips authors when targeted for a specific series only', async () => { + // When seriesAsin is provided but no authorAsin, authors should still be queried + // but with no authorAsin filter (only userId), so they run normally. + // The key behavior: seriesAsin filter applies to series, not authors. + prismaMock.watchedSeries.findMany.mockResolvedValue([ + { + id: 'ws-1', + userId: 'user-1', + seriesAsin: 'B001SERIES1', + seriesTitle: 'Target Series', + coverArtUrl: null, + lastCheckedAt: null, + user: { id: 'user-1', plexUsername: 'testuser' }, + }, + ]); + + prismaMock.watchedAuthor.findMany.mockResolvedValue([]); + prismaMock.watchedSeries.update.mockResolvedValue({}); + + mockScrapeSeriesPage.mockResolvedValueOnce({ + asin: 'B001SERIES1', + title: 'Target Series', + bookCount: 1, + books: [ + { asin: 'B001BOOK01', title: 'Book One', author: 'Author A' }, + ], + hasMore: false, + page: 1, + }); + + mockDeduplicateAndCollectGroups.mockReturnValue({ + books: [ + { asin: 'B001BOOK01', title: 'Book One', author: 'Author A' }, + ], + groups: [], + }); + + mockCreateRequestForUser.mockResolvedValue({ success: true, request: {} }); + + const { processWatchedLists } = await import('@/lib/services/watched-lists.service'); + const stats = await processWatchedLists(undefined, { + userId: 'user-1', + seriesAsin: 'B001SERIES1', + }); + + // Series should be filtered by seriesAsin + expect(prismaMock.watchedSeries.findMany).toHaveBeenCalledWith({ + where: { userId: 'user-1', seriesAsin: 'B001SERIES1' }, + include: { user: { select: { id: true, plexUsername: true } } }, + }); + + // Authors query should only filter by userId (no authorAsin filter) + expect(prismaMock.watchedAuthor.findMany).toHaveBeenCalledWith({ + where: { userId: 'user-1' }, + include: { user: { select: { id: true, plexUsername: true } } }, + }); + + expect(stats.seriesChecked).toBe(1); + }); +}); diff --git a/tests/services/works.service.test.ts b/tests/services/works.service.test.ts new file mode 100644 index 0000000..5efca96 --- /dev/null +++ b/tests/services/works.service.test.ts @@ -0,0 +1,306 @@ +/** + * Component: Works Service Tests + * Documentation: documentation/integrations/audible.md + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createPrismaMock } from '../helpers/prisma'; +import type { DedupGroup } from '@/lib/utils/deduplicate-audiobooks'; + +const prismaMock = createPrismaMock(); + +vi.mock('@/lib/db', () => ({ + prisma: prismaMock, +})); + +vi.mock('@/lib/utils/logger', () => ({ + RMABLogger: { + create: () => ({ + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }), + }, +})); + +describe('persistDedupGroups', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.resetModules(); + }); + + it('creates new work + work_asins for a fresh group', async () => { + prismaMock.workAsin.findMany.mockResolvedValue([]); + prismaMock.work.create.mockResolvedValue({ id: 'work-1' }); + prismaMock.workAsin.create.mockResolvedValue({}); + prismaMock.workAsin.updateMany.mockResolvedValue({ count: 0 }); + + const { persistDedupGroups } = await import('@/lib/services/works.service'); + + const groups: DedupGroup[] = [{ + canonicalAsin: 'ASIN_A', + allAsins: ['ASIN_A', 'ASIN_B'], + title: 'Test Book', + author: 'Test Author', + narrator: 'Test Narrator', + durationMinutes: 600, + }]; + + await persistDedupGroups(groups); + + expect(prismaMock.work.create).toHaveBeenCalledWith({ + data: { title: 'Test Book', author: 'Test Author' }, + }); + expect(prismaMock.workAsin.create).toHaveBeenCalledTimes(2); + + // Canonical ASIN should have narrator, duration, isCanonical=true + expect(prismaMock.workAsin.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + workId: 'work-1', + asin: 'ASIN_A', + narrator: 'Test Narrator', + durationMinutes: 600, + isCanonical: true, + source: 'dedup_auto', + }), + }); + + // Non-canonical ASIN should have isCanonical=false + expect(prismaMock.workAsin.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + workId: 'work-1', + asin: 'ASIN_B', + isCanonical: false, + source: 'dedup_auto', + }), + }); + }); + + it('adds new ASINs to existing work when canonical already exists', async () => { + prismaMock.workAsin.findMany.mockResolvedValue([ + { asin: 'ASIN_A', workId: 'existing-work' }, + ]); + prismaMock.workAsin.create.mockResolvedValue({}); + prismaMock.workAsin.updateMany.mockResolvedValue({ count: 1 }); + + const { persistDedupGroups } = await import('@/lib/services/works.service'); + + const groups: DedupGroup[] = [{ + canonicalAsin: 'ASIN_A', + allAsins: ['ASIN_A', 'ASIN_B', 'ASIN_C'], + title: 'Test Book', + author: 'Test Author', + narrator: 'Narrator', + durationMinutes: 500, + }]; + + await persistDedupGroups(groups); + + // Should NOT create a new work + expect(prismaMock.work.create).not.toHaveBeenCalled(); + + // Should create entries for ASIN_B and ASIN_C only (ASIN_A already exists) + expect(prismaMock.workAsin.create).toHaveBeenCalledTimes(2); + expect(prismaMock.workAsin.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + workId: 'existing-work', + asin: 'ASIN_B', + }), + }); + expect(prismaMock.workAsin.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + workId: 'existing-work', + asin: 'ASIN_C', + }), + }); + }); + + it('merges two separate works when dedup groups them together', async () => { + // ASIN_A is in work-1, ASIN_B is in work-2 + prismaMock.workAsin.findMany.mockResolvedValue([ + { asin: 'ASIN_A', workId: 'work-1' }, + { asin: 'ASIN_B', workId: 'work-2' }, + ]); + prismaMock.workAsin.updateMany.mockResolvedValue({ count: 1 }); + prismaMock.work.deleteMany.mockResolvedValue({ count: 1 }); + + const { persistDedupGroups } = await import('@/lib/services/works.service'); + + const groups: DedupGroup[] = [{ + canonicalAsin: 'ASIN_A', + allAsins: ['ASIN_A', 'ASIN_B'], + title: 'Merged Book', + author: 'Author', + }]; + + await persistDedupGroups(groups); + + // Should move work-2 ASINs to work-1 + expect(prismaMock.workAsin.updateMany).toHaveBeenCalledWith({ + where: { workId: { in: ['work-2'] } }, + data: { workId: 'work-1' }, + }); + + // Should delete work-2 + expect(prismaMock.work.deleteMany).toHaveBeenCalledWith({ + where: { id: { in: ['work-2'] } }, + }); + }); + + it('silently catches and logs errors without throwing', async () => { + prismaMock.workAsin.findMany.mockRejectedValue(new Error('DB connection failed')); + + const { persistDedupGroups } = await import('@/lib/services/works.service'); + + const groups: DedupGroup[] = [{ + canonicalAsin: 'ASIN_A', + allAsins: ['ASIN_A', 'ASIN_B'], + title: 'Test', + author: 'Auth', + }]; + + // Should not throw + await expect(persistDedupGroups(groups)).resolves.toBeUndefined(); + }); +}); + +describe('seedAsin', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.resetModules(); + }); + + it('creates single-ASIN work for new ASIN', async () => { + prismaMock.workAsin.findUnique.mockResolvedValue(null); + prismaMock.work.create.mockResolvedValue({ id: 'new-work' }); + prismaMock.workAsin.create.mockResolvedValue({}); + + const { seedAsin } = await import('@/lib/services/works.service'); + + await seedAsin('NEW_ASIN', 'New Book', 'Author', 'Narrator', 300); + + expect(prismaMock.work.create).toHaveBeenCalledWith({ + data: { title: 'New Book', author: 'Author' }, + }); + expect(prismaMock.workAsin.create).toHaveBeenCalledWith({ + data: { + workId: 'new-work', + asin: 'NEW_ASIN', + narrator: 'Narrator', + durationMinutes: 300, + isCanonical: true, + source: 'dedup_auto', + }, + }); + }); + + it('does nothing for already-tracked ASIN', async () => { + prismaMock.workAsin.findUnique.mockResolvedValue({ + id: 'existing', + asin: 'EXISTING_ASIN', + workId: 'work-1', + }); + + const { seedAsin } = await import('@/lib/services/works.service'); + + await seedAsin('EXISTING_ASIN', 'Book', 'Author'); + + expect(prismaMock.work.create).not.toHaveBeenCalled(); + expect(prismaMock.workAsin.create).not.toHaveBeenCalled(); + }); + + it('silently catches and logs errors without throwing', async () => { + prismaMock.workAsin.findUnique.mockRejectedValue(new Error('DB error')); + + const { seedAsin } = await import('@/lib/services/works.service'); + + await expect(seedAsin('ASIN', 'Book', 'Auth')).resolves.toBeUndefined(); + }); +}); + +describe('getSiblingAsins', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.resetModules(); + }); + + it('returns sibling ASINs correctly', async () => { + // First query: find input ASINs and their work IDs + prismaMock.workAsin.findMany + .mockResolvedValueOnce([ + { asin: 'ASIN_A', workId: 'work-1' }, + { asin: 'ASIN_C', workId: 'work-2' }, + ]) + // Second query: all ASINs in those works + .mockResolvedValueOnce([ + { asin: 'ASIN_A', workId: 'work-1' }, + { asin: 'ASIN_B', workId: 'work-1' }, + { asin: 'ASIN_C', workId: 'work-2' }, + { asin: 'ASIN_D', workId: 'work-2' }, + { asin: 'ASIN_E', workId: 'work-2' }, + ]); + + const { getSiblingAsins } = await import('@/lib/services/works.service'); + + const result = await getSiblingAsins(['ASIN_A', 'ASIN_C']); + + expect(result.get('ASIN_A')).toEqual(['ASIN_B']); + expect(result.get('ASIN_C')).toEqual(['ASIN_D', 'ASIN_E']); + }); + + it('returns empty map for unknown ASINs', async () => { + prismaMock.workAsin.findMany.mockResolvedValue([]); + + const { getSiblingAsins } = await import('@/lib/services/works.service'); + + const result = await getSiblingAsins(['UNKNOWN']); + + expect(result.size).toBe(0); + }); + + it('returns empty map for empty input', async () => { + const { getSiblingAsins } = await import('@/lib/services/works.service'); + + const result = await getSiblingAsins([]); + + expect(result.size).toBe(0); + // Should not query DB + expect(prismaMock.workAsin.findMany).not.toHaveBeenCalled(); + }); + + it('excludes the input ASIN itself from siblings', async () => { + prismaMock.workAsin.findMany + .mockResolvedValueOnce([ + { asin: 'ASIN_A', workId: 'work-1' }, + ]) + .mockResolvedValueOnce([ + { asin: 'ASIN_A', workId: 'work-1' }, + { asin: 'ASIN_B', workId: 'work-1' }, + ]); + + const { getSiblingAsins } = await import('@/lib/services/works.service'); + + const result = await getSiblingAsins(['ASIN_A']); + + expect(result.get('ASIN_A')).toEqual(['ASIN_B']); + expect(result.get('ASIN_A')).not.toContain('ASIN_A'); + }); + + it('omits ASINs with no siblings (single-ASIN works)', async () => { + prismaMock.workAsin.findMany + .mockResolvedValueOnce([ + { asin: 'ASIN_LONELY', workId: 'work-solo' }, + ]) + .mockResolvedValueOnce([ + { asin: 'ASIN_LONELY', workId: 'work-solo' }, + ]); + + const { getSiblingAsins } = await import('@/lib/services/works.service'); + + const result = await getSiblingAsins(['ASIN_LONELY']); + + // No siblings means it shouldn't be in the map at all + expect(result.has('ASIN_LONELY')).toBe(false); + }); +}); diff --git a/tests/utils/deduplicate-audiobooks.test.ts b/tests/utils/deduplicate-audiobooks.test.ts new file mode 100644 index 0000000..a535e15 --- /dev/null +++ b/tests/utils/deduplicate-audiobooks.test.ts @@ -0,0 +1,451 @@ +/** + * Component: Audiobook Deduplication Tests + * Documentation: documentation/integrations/audible.md + */ + +import { describe, expect, it } from 'vitest'; +import { + deduplicateAudiobooks, + deduplicateAndCollectGroups, + normalizeTitle, + areDurationsCompatible, +} from '@/lib/utils/deduplicate-audiobooks'; +import type { AudibleAudiobook } from '@/lib/integrations/audible.service'; + +// --------------------------------------------------------------------------- +// Helper: minimal AudibleAudiobook factory +// --------------------------------------------------------------------------- + +function makeBook(overrides: Partial & { asin: string; title: string; author: string }): AudibleAudiobook { + return { + narrator: undefined, + coverArtUrl: undefined, + durationMinutes: undefined, + rating: undefined, + description: undefined, + releaseDate: undefined, + genres: undefined, + series: undefined, + seriesPart: undefined, + seriesAsin: undefined, + authorAsin: undefined, + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// normalizeTitle +// --------------------------------------------------------------------------- + +describe('normalizeTitle', () => { + it('lowercases', () => { + expect(normalizeTitle('The Black Prism')).toBe('the black prism'); + }); + + it('strips (Unabridged)', () => { + expect(normalizeTitle('The Black Prism (Unabridged)')).toBe('the black prism'); + }); + + it('strips [Abridged Edition]', () => { + expect(normalizeTitle('The Black Prism [Abridged Edition]')).toBe('the black prism'); + }); + + it('strips (2024 Remastered Edition)', () => { + expect(normalizeTitle('The Hobbit (2024 Remastered Edition)')).toBe('the hobbit'); + }); + + it('strips subtitle after colon', () => { + expect(normalizeTitle('The Black Prism: Lightbringer, Book 1')).toBe('the black prism'); + }); + + it('strips subtitle after long dash', () => { + expect(normalizeTitle('The Black Prism \u2014 A Lightbringer Novel')).toBe('the black prism'); + }); + + it('strips trailing "A Novel"', () => { + expect(normalizeTitle('The Black Prism: A Novel')).toBe('the black prism'); + }); + + it('strips (Audiobook)', () => { + expect(normalizeTitle('The Hobbit (Audiobook)')).toBe('the hobbit'); + }); + + it('strips (Dramatized Adaptation)', () => { + expect(normalizeTitle('The Black Prism (Dramatized Adaptation)')).toBe('the black prism'); + }); + + it('strips (Full Cast Narration)', () => { + expect(normalizeTitle('The Black Prism (Full Cast Narration)')).toBe('the black prism'); + }); + + it('collapses whitespace', () => { + expect(normalizeTitle(' The Black Prism ')).toBe('the black prism'); + }); + + it('handles empty string', () => { + expect(normalizeTitle('')).toBe(''); + }); + + it('preserves hyphenated words (not subtitles)', () => { + // "well-known" has a short dash, not a subtitle separator + expect(normalizeTitle('A Well-Known Book')).toBe('a well-known book'); + }); +}); + +// --------------------------------------------------------------------------- +// areDurationsCompatible +// --------------------------------------------------------------------------- + +describe('areDurationsCompatible', () => { + it('returns true when both undefined', () => { + expect(areDurationsCompatible(undefined, undefined)).toBe(true); + }); + + it('returns true when one undefined', () => { + expect(areDurationsCompatible(600, undefined)).toBe(true); + expect(areDurationsCompatible(undefined, 600)).toBe(true); + }); + + it('returns true for identical durations', () => { + expect(areDurationsCompatible(600, 600)).toBe(true); + }); + + it('uses 1% of longer duration as tolerance for long books', () => { + // Two 40-hour books (2400 min): tolerance = max(2400*0.01, 5) = 24 min + expect(areDurationsCompatible(2400, 2424)).toBe(true); // exactly at tolerance + expect(areDurationsCompatible(2400, 2425)).toBe(false); // just over + }); + + it('uses 5-minute minimum tolerance for short books', () => { + // Two 2-hour books (120 min): tolerance = max(120*0.01, 5) = max(1.2, 5) = 5 min + expect(areDurationsCompatible(120, 125)).toBe(true); // exactly at 5-min minimum + expect(areDurationsCompatible(120, 126)).toBe(false); // just over + }); + + it('keeps abridged vs unabridged separate (large duration gap)', () => { + // Unabridged: 720 min (12 hrs), Abridged: 360 min (6 hrs) + expect(areDurationsCompatible(720, 360)).toBe(false); + }); + + it('symmetry: order does not matter', () => { + expect(areDurationsCompatible(2400, 2424)).toBe(true); + expect(areDurationsCompatible(2424, 2400)).toBe(true); + expect(areDurationsCompatible(120, 126)).toBe(false); + expect(areDurationsCompatible(126, 120)).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// deduplicateAudiobooks +// --------------------------------------------------------------------------- + +describe('deduplicateAudiobooks', () => { + it('returns empty array for empty input', () => { + expect(deduplicateAudiobooks([])).toEqual([]); + }); + + it('returns single book unchanged', () => { + const book = makeBook({ asin: 'A1', title: 'Book One', author: 'Author' }); + expect(deduplicateAudiobooks([book])).toEqual([book]); + }); + + it('passes through all-unique books unchanged', () => { + const books = [ + makeBook({ asin: 'A1', title: 'Book One', author: 'Auth', narrator: 'Nar A', durationMinutes: 600 }), + makeBook({ asin: 'A2', title: 'Book Two', author: 'Auth', narrator: 'Nar A', durationMinutes: 500 }), + makeBook({ asin: 'A3', title: 'Book Three', author: 'Auth', narrator: 'Nar B', durationMinutes: 700 }), + ]; + expect(deduplicateAudiobooks(books)).toHaveLength(3); + }); + + it('collapses simple duplicates (same title + narrator + similar duration)', () => { + const books = [ + makeBook({ asin: 'A1', title: 'The Black Prism', author: 'Brent Weeks', narrator: 'Simon Vance', durationMinutes: 1260 }), + makeBook({ asin: 'A2', title: 'The Black Prism', author: 'Brent Weeks', narrator: 'Simon Vance', durationMinutes: 1262 }), + ]; + const result = deduplicateAudiobooks(books); + expect(result).toHaveLength(1); + }); + + it('keeps books with different narrators (different production)', () => { + const books = [ + makeBook({ asin: 'A1', title: 'The Black Prism', author: 'Brent Weeks', narrator: 'Simon Vance', durationMinutes: 1260 }), + makeBook({ asin: 'A2', title: 'The Black Prism', author: 'Brent Weeks', narrator: 'Full Cast', durationMinutes: 480 }), + ]; + const result = deduplicateAudiobooks(books); + expect(result).toHaveLength(2); + }); + + it('keeps abridged vs unabridged (same narrator, very different duration)', () => { + const books = [ + makeBook({ asin: 'A1', title: 'The Hobbit', author: 'Tolkien', narrator: 'Andy Serkis', durationMinutes: 660 }), + makeBook({ asin: 'A2', title: 'The Hobbit', author: 'Tolkien', narrator: 'Andy Serkis', durationMinutes: 330 }), + ]; + const result = deduplicateAudiobooks(books); + expect(result).toHaveLength(2); + }); + + it('collapses when one book has missing duration', () => { + const books = [ + makeBook({ asin: 'A1', title: 'The Black Prism', author: 'Brent Weeks', narrator: 'Simon Vance', durationMinutes: 1260 }), + makeBook({ asin: 'A2', title: 'The Black Prism', author: 'Brent Weeks', narrator: 'Simon Vance', durationMinutes: undefined }), + ]; + const result = deduplicateAudiobooks(books); + expect(result).toHaveLength(1); + }); + + it('collapses when both books have missing duration', () => { + const books = [ + makeBook({ asin: 'A1', title: 'The Black Prism', author: 'Brent Weeks', narrator: 'Simon Vance' }), + makeBook({ asin: 'A2', title: 'The Black Prism', author: 'Brent Weeks', narrator: 'Simon Vance' }), + ]; + const result = deduplicateAudiobooks(books); + expect(result).toHaveLength(1); + }); + + it('collapses title variants with edition markers', () => { + const books = [ + makeBook({ asin: 'A1', title: 'The Black Prism (Unabridged)', author: 'Brent Weeks', narrator: 'Simon Vance', durationMinutes: 1260 }), + makeBook({ asin: 'A2', title: 'The Black Prism', author: 'Brent Weeks', narrator: 'Simon Vance', durationMinutes: 1258 }), + ]; + const result = deduplicateAudiobooks(books); + expect(result).toHaveLength(1); + }); + + it('collapses title variants with subtitles', () => { + const books = [ + makeBook({ asin: 'A1', title: 'The Black Prism: Lightbringer, Book 1', author: 'Brent Weeks', narrator: 'Simon Vance', durationMinutes: 1260 }), + makeBook({ asin: 'A2', title: 'The Black Prism', author: 'Brent Weeks', narrator: 'Simon Vance', durationMinutes: 1262 }), + ]; + const result = deduplicateAudiobooks(books); + expect(result).toHaveLength(1); + }); + + it('picks the representative with most metadata', () => { + const sparse = makeBook({ + asin: 'A1', title: 'The Black Prism', author: 'Brent Weeks', + narrator: 'Simon Vance', durationMinutes: 1260, + }); + const rich = makeBook({ + asin: 'A2', title: 'The Black Prism', author: 'Brent Weeks', + narrator: 'Simon Vance', durationMinutes: 1262, + coverArtUrl: 'https://img.jpg', rating: 4.5, description: 'Great book', + }); + const result = deduplicateAudiobooks([sparse, rich]); + expect(result).toHaveLength(1); + expect(result[0].asin).toBe('A2'); // rich entry wins + }); + + it('preserves original order (first-seen position)', () => { + const books = [ + makeBook({ asin: 'A1', title: 'Alpha', author: 'Auth', narrator: 'Nar', durationMinutes: 300 }), + makeBook({ asin: 'B1', title: 'Beta', author: 'Auth', narrator: 'Nar', durationMinutes: 400 }), + makeBook({ asin: 'A2', title: 'Alpha', author: 'Auth', narrator: 'Nar', durationMinutes: 302 }), + makeBook({ asin: 'C1', title: 'Charlie', author: 'Auth', narrator: 'Nar', durationMinutes: 500 }), + ]; + const result = deduplicateAudiobooks(books); + expect(result).toHaveLength(3); + expect(result.map(b => b.title)).toEqual(['Alpha', 'Beta', 'Charlie']); + }); + + it('handles Lightbringer-style scenario: unabridged + dramatized', () => { + // Simon Vance full narration (long) + const vance1 = makeBook({ + asin: 'SV1', title: 'The Black Prism', author: 'Brent Weeks', + narrator: 'Simon Vance', durationMinutes: 1260, + coverArtUrl: 'cover1.jpg', rating: 4.7, + }); + // Re-listed Simon Vance (same duration, different ASIN) + const vance2 = makeBook({ + asin: 'SV2', title: 'The Black Prism: Lightbringer Book 1', author: 'Brent Weeks', + narrator: 'Simon Vance', durationMinutes: 1262, + }); + // Dramatized with full cast (shorter, different narrator) + const drama = makeBook({ + asin: 'DR1', title: 'The Black Prism (Dramatized Adaptation)', author: 'Brent Weeks', + narrator: 'Full Cast', durationMinutes: 480, + coverArtUrl: 'cover-drama.jpg', + }); + + const result = deduplicateAudiobooks([vance1, vance2, drama]); + expect(result).toHaveLength(2); + // Simon Vance should collapse to 1, Full Cast stays + expect(result.find(b => b.narrator === 'Simon Vance')).toBeTruthy(); + expect(result.find(b => b.narrator === 'Full Cast')).toBeTruthy(); + // Should pick the richer entry for Simon Vance + const svResult = result.find(b => b.narrator === 'Simon Vance')!; + expect(svResult.asin).toBe('SV1'); // has cover + rating + }); + + it('uses percentage tolerance for very long audiobooks', () => { + // Two 40-hour books: tolerance = max(2400*0.01, 5) = 24 min + const books = [ + makeBook({ asin: 'A1', title: 'Long Book', author: 'Auth', narrator: 'Nar', durationMinutes: 2400 }), + makeBook({ asin: 'A2', title: 'Long Book', author: 'Auth', narrator: 'Nar', durationMinutes: 2420 }), + ]; + expect(deduplicateAudiobooks(books)).toHaveLength(1); + + // Beyond tolerance + const booksFar = [ + makeBook({ asin: 'A1', title: 'Long Book', author: 'Auth', narrator: 'Nar', durationMinutes: 2400 }), + makeBook({ asin: 'A2', title: 'Long Book', author: 'Auth', narrator: 'Nar', durationMinutes: 2430 }), + ]; + expect(deduplicateAudiobooks(booksFar)).toHaveLength(2); + }); + + it('treats missing narrator as its own group', () => { + // Two entries with same title but no narrator - should collapse + const books = [ + makeBook({ asin: 'A1', title: 'Test Book', author: 'Auth', narrator: undefined, durationMinutes: 300 }), + makeBook({ asin: 'A2', title: 'Test Book', author: 'Auth', narrator: undefined, durationMinutes: 302 }), + ]; + expect(deduplicateAudiobooks(books)).toHaveLength(1); + }); + + it('does not collapse empty-narrator with named narrator', () => { + const books = [ + makeBook({ asin: 'A1', title: 'Test Book', author: 'Auth', narrator: undefined, durationMinutes: 300 }), + makeBook({ asin: 'A2', title: 'Test Book', author: 'Auth', narrator: 'John Smith', durationMinutes: 302 }), + ]; + expect(deduplicateAudiobooks(books)).toHaveLength(2); + }); + + it('collapses duplicates when narrators are listed in different order', () => { + const books = [ + makeBook({ + asin: 'A1', title: 'The Passengers', author: 'John Marrs', + narrator: 'Kristin Atherton, Roy McMillan, Clare Corbett, Tom Bateman, Patience Tomlinson, Shaheen Khan', + durationMinutes: 600, + }), + makeBook({ + asin: 'A2', title: 'The Passengers', author: 'John Marrs', + narrator: 'Clare Corbett, Roy McMillan, Tom Bateman, Shaheen Khan, Kristin Atherton, Patience Tomlinson', + durationMinutes: 602, + }), + ]; + const result = deduplicateAudiobooks(books); + expect(result).toHaveLength(1); + }); +}); + +// --------------------------------------------------------------------------- +// deduplicateAndCollectGroups +// --------------------------------------------------------------------------- + +describe('deduplicateAndCollectGroups', () => { + it('returns empty groups array when no duplicates', () => { + const books = [ + makeBook({ asin: 'A1', title: 'Book One', author: 'Auth', narrator: 'Nar A', durationMinutes: 600 }), + makeBook({ asin: 'A2', title: 'Book Two', author: 'Auth', narrator: 'Nar A', durationMinutes: 500 }), + ]; + const { books: result, groups } = deduplicateAndCollectGroups(books); + expect(result).toHaveLength(2); + expect(groups).toHaveLength(0); + }); + + it('returns empty groups for empty input', () => { + const { books: result, groups } = deduplicateAndCollectGroups([]); + expect(result).toHaveLength(0); + expect(groups).toHaveLength(0); + }); + + it('returns empty groups for single book', () => { + const book = makeBook({ asin: 'A1', title: 'Book One', author: 'Auth' }); + const { books: result, groups } = deduplicateAndCollectGroups([book]); + expect(result).toHaveLength(1); + expect(groups).toHaveLength(0); + }); + + it('returns group with 2 ASINs when 2 books match', () => { + const books = [ + makeBook({ asin: 'A1', title: 'The Black Prism', author: 'Brent Weeks', narrator: 'Simon Vance', durationMinutes: 1260 }), + makeBook({ asin: 'A2', title: 'The Black Prism', author: 'Brent Weeks', narrator: 'Simon Vance', durationMinutes: 1262 }), + ]; + const { books: result, groups } = deduplicateAndCollectGroups(books); + expect(result).toHaveLength(1); + expect(groups).toHaveLength(1); + expect(groups[0].allAsins).toHaveLength(2); + expect(groups[0].allAsins).toContain('A1'); + expect(groups[0].allAsins).toContain('A2'); + }); + + it('returns group with 3+ ASINs for multi-duplicate scenario', () => { + const books = [ + makeBook({ asin: 'A1', title: 'The Hobbit', author: 'Tolkien', narrator: 'Andy Serkis', durationMinutes: 660 }), + makeBook({ asin: 'A2', title: 'The Hobbit', author: 'Tolkien', narrator: 'Andy Serkis', durationMinutes: 662 }), + makeBook({ asin: 'A3', title: 'The Hobbit (Unabridged)', author: 'Tolkien', narrator: 'Andy Serkis', durationMinutes: 658 }), + ]; + const { books: result, groups } = deduplicateAndCollectGroups(books); + expect(result).toHaveLength(1); + expect(groups).toHaveLength(1); + expect(groups[0].allAsins).toHaveLength(3); + expect(groups[0].allAsins).toContain('A1'); + expect(groups[0].allAsins).toContain('A2'); + expect(groups[0].allAsins).toContain('A3'); + }); + + it('canonicalAsin is the one with highest metadata score', () => { + const sparse = makeBook({ + asin: 'SPARSE', title: 'The Black Prism', author: 'Brent Weeks', + narrator: 'Simon Vance', durationMinutes: 1260, + }); + const rich = makeBook({ + asin: 'RICH', title: 'The Black Prism', author: 'Brent Weeks', + narrator: 'Simon Vance', durationMinutes: 1262, + coverArtUrl: 'https://img.jpg', rating: 4.5, description: 'Great book', + }); + const { groups } = deduplicateAndCollectGroups([sparse, rich]); + expect(groups).toHaveLength(1); + expect(groups[0].canonicalAsin).toBe('RICH'); + }); + + it('groups only include entries with 2+ ASINs', () => { + const books = [ + makeBook({ asin: 'A1', title: 'Alpha', author: 'Auth', narrator: 'Nar', durationMinutes: 300 }), + makeBook({ asin: 'A2', title: 'Alpha', author: 'Auth', narrator: 'Nar', durationMinutes: 302 }), + makeBook({ asin: 'B1', title: 'Beta', author: 'Auth', narrator: 'Nar', durationMinutes: 500 }), + ]; + const { groups } = deduplicateAndCollectGroups(books); + // Only Alpha group should appear (Beta is a singleton) + expect(groups).toHaveLength(1); + expect(groups[0].allAsins).toContain('A1'); + expect(groups[0].allAsins).toContain('A2'); + }); + + it('duration-incompatible books produce separate entries (no group for singletons)', () => { + // Same title/narrator but very different durations (abridged vs unabridged) + const books = [ + makeBook({ asin: 'A1', title: 'The Hobbit', author: 'Tolkien', narrator: 'Andy Serkis', durationMinutes: 660 }), + makeBook({ asin: 'A2', title: 'The Hobbit', author: 'Tolkien', narrator: 'Andy Serkis', durationMinutes: 330 }), + ]; + const { books: result, groups } = deduplicateAndCollectGroups(books); + expect(result).toHaveLength(2); // Not collapsed + expect(groups).toHaveLength(0); // No multi-ASIN groups + }); + + it('books field matches what deduplicateAudiobooks returns', () => { + const books = [ + makeBook({ asin: 'A1', title: 'Alpha', author: 'Auth', narrator: 'Nar', durationMinutes: 300, coverArtUrl: 'img.jpg', rating: 4.5 }), + makeBook({ asin: 'A2', title: 'Alpha', author: 'Auth', narrator: 'Nar', durationMinutes: 302 }), + makeBook({ asin: 'B1', title: 'Beta', author: 'Auth', narrator: 'Nar', durationMinutes: 500 }), + makeBook({ asin: 'C1', title: 'Charlie', author: 'Auth', narrator: 'Nar', durationMinutes: 600 }), + makeBook({ asin: 'C2', title: 'Charlie', author: 'Auth', narrator: 'Nar', durationMinutes: 601 }), + ]; + const dedupOnly = deduplicateAudiobooks(books); + const { books: withGroups } = deduplicateAndCollectGroups(books); + expect(withGroups.map(b => b.asin)).toEqual(dedupOnly.map(b => b.asin)); + }); + + it('includes narrator and durationMinutes from canonical entry in group', () => { + const books = [ + makeBook({ asin: 'A1', title: 'Test Book', author: 'Auth', narrator: 'Jane Doe', durationMinutes: 480 }), + makeBook({ asin: 'A2', title: 'Test Book', author: 'Auth', narrator: 'Jane Doe', durationMinutes: 482, coverArtUrl: 'img.jpg', rating: 4.0 }), + ]; + const { groups } = deduplicateAndCollectGroups(books); + expect(groups).toHaveLength(1); + expect(groups[0].canonicalAsin).toBe('A2'); // richer metadata + expect(groups[0].narrator).toBe('Jane Doe'); + expect(groups[0].durationMinutes).toBe(482); + expect(groups[0].author).toBe('Auth'); + }); +});