From cbf02d3e24cd6757a110dab8cdd8519508262930 Mon Sep 17 00:00:00 2001 From: kikootwo Date: Tue, 3 Mar 2026 21:57:38 -0500 Subject: [PATCH] Add watched series/authors feature Introduce watched lists for series and authors end-to-end. - Add DB migration to create watched_series and watched_authors tables with indexes and foreign keys. - Implement API routes: GET/POST for listing/adding and DELETE by id for both /api/user/watched-series and /api/user/watched-authors. Validation, ownership checks, and immediate targeted job triggers are included. - Add client hooks (useWatchedSeries, useWatchedAuthors) with add/delete helpers and SWR revalidation. - Add UI components: WatchButton (toggle/confirm) and WatchedListsSection for profile display and removal UX. - Add processor (check-watched-lists.processor) and service (watched-lists.service) to scrape Audible, deduplicate, check library ownership, and auto-create requests; supports targeted checks for newly watched items. - Include tests for the watched-lists service. These changes implement the watched-lists feature to let users watch series/authors and have the system automatically detect and request new releases. --- .../migration.sql | 51 ++ prisma/schema.prisma | 46 ++ .../api/user/watched-authors/[id]/route.ts | 52 ++ src/app/api/user/watched-authors/route.ts | 125 ++++ src/app/api/user/watched-series/[id]/route.ts | 52 ++ src/app/api/user/watched-series/route.ts | 125 ++++ src/app/profile/page.tsx | 7 + src/components/authors/AuthorDetailCard.tsx | 36 +- .../profile/WatchedListsSection.tsx | 323 ++++++++++ src/components/series/SeriesDetailCard.tsx | 36 +- src/components/ui/WatchButton.tsx | 186 ++++++ src/lib/hooks/useWatchedAuthors.ts | 119 ++++ src/lib/hooks/useWatchedSeries.ts | 119 ++++ .../check-watched-lists.processor.ts | 43 ++ src/lib/services/job-queue.service.ts | 50 ++ src/lib/services/scheduler.service.ts | 19 +- src/lib/services/watched-lists.service.ts | 414 ++++++++++++ src/lib/utils/deduplicate-audiobooks.ts | 6 +- tests/helpers/prisma.ts | 2 + tests/services/job-queue.service.test.ts | 6 + tests/services/scheduler.service.test.ts | 2 +- tests/services/watched-lists.service.test.ts | 588 ++++++++++++++++++ tests/utils/deduplicate-audiobooks.test.ts | 17 + 23 files changed, 2392 insertions(+), 32 deletions(-) create mode 100644 prisma/migrations/20260303100000_add_watched_series_authors/migration.sql create mode 100644 src/app/api/user/watched-authors/[id]/route.ts create mode 100644 src/app/api/user/watched-authors/route.ts create mode 100644 src/app/api/user/watched-series/[id]/route.ts create mode 100644 src/app/api/user/watched-series/route.ts create mode 100644 src/components/profile/WatchedListsSection.tsx create mode 100644 src/components/ui/WatchButton.tsx create mode 100644 src/lib/hooks/useWatchedAuthors.ts create mode 100644 src/lib/hooks/useWatchedSeries.ts create mode 100644 src/lib/processors/check-watched-lists.processor.ts create mode 100644 src/lib/services/watched-lists.service.ts create mode 100644 tests/services/watched-lists.service.test.ts 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 bcb900a..2decb53 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -68,6 +68,8 @@ model User { goodreadsShelves GoodreadsShelf[] reportedIssues ReportedIssue[] @relation("Reporter") resolvedIssues ReportedIssue[] @relation("Resolver") + watchedSeries WatchedSeries[] + watchedAuthors WatchedAuthor[] @@index([plexId]) @@index([role]) @@ -571,3 +573,47 @@ model WorkAsin { @@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/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/profile/page.tsx b/src/app/profile/page.tsx index 1d88640..0d496fa 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 { GoodreadsShelvesSection } from '@/components/profile/GoodreadsShelvesSection'; +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() { {/* Goodreads Shelves */} + {/* 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/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/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/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 6617a63..900b221 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_goodreads_shelves' + | 'check_watched_lists' | 'send_notification' // Ebook-specific job types | 'search_ebook' @@ -113,6 +114,16 @@ export interface SyncGoodreadsShelvesPayload 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; @@ -384,6 +395,12 @@ export class JobQueueService { return await processSyncGoodreadsShelves(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'); @@ -766,6 +783,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/scheduler.service.ts b/src/lib/services/scheduler.service.ts index f64fd6a..28d7690 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_goodreads_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_goodreads_shelves' | 'check_watched_lists'; export interface ScheduledJob { id: string; @@ -133,6 +133,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; @@ -353,6 +360,9 @@ export class SchedulerService { case 'sync_goodreads_shelves': bullJobId = await this.triggerSyncGoodreadsShelves(job); break; + case 'check_watched_lists': + bullJobId = await this.triggerCheckWatchedLists(job); + break; default: throw new Error(`Unknown job type: ${job.type}`); } @@ -627,6 +637,13 @@ export class SchedulerService { private async triggerSyncGoodreadsShelves(job: any): Promise { return await this.jobQueue.addSyncGoodreadsShelvesJob(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/utils/deduplicate-audiobooks.ts b/src/lib/utils/deduplicate-audiobooks.ts index 1bc426f..1cfe6f2 100644 --- a/src/lib/utils/deduplicate-audiobooks.ts +++ b/src/lib/utils/deduplicate-audiobooks.ts @@ -44,9 +44,11 @@ export function normalizeTitle(title: string): string { return t.replace(/\s+/g, ' ').trim(); } -/** Normalize narrator for comparison. */ +/** Normalize narrator for comparison. Sorts individual names so order doesn't matter. */ function normalizeNarrator(narrator?: string): string { - return (narrator || '').toLowerCase().trim(); + const raw = (narrator || '').toLowerCase().trim(); + if (!raw) return raw; + return raw.split(',').map(n => n.trim()).filter(Boolean).sort().join(', '); } // --------------------------------------------------------------------------- diff --git a/tests/helpers/prisma.ts b/tests/helpers/prisma.ts index dfcb5ac..e52b73b 100644 --- a/tests/helpers/prisma.ts +++ b/tests/helpers/prisma.ts @@ -49,6 +49,8 @@ export const createPrismaMock = () => ({ goodreadsBookMapping: 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 78e3998..fd23199 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'), processSyncGoodreadsShelves: 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-goodreads-shelves.processor', () => ({ processSyncGoodreadsShelves: processorsMock.processSyncGoodreadsShelves, })); +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.processSyncGoodreadsShelves).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 a64b021..b86349f 100644 --- a/tests/services/scheduler.service.test.ts +++ b/tests/services/scheduler.service.test.ts @@ -78,7 +78,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/utils/deduplicate-audiobooks.test.ts b/tests/utils/deduplicate-audiobooks.test.ts index c60a4a5..a535e15 100644 --- a/tests/utils/deduplicate-audiobooks.test.ts +++ b/tests/utils/deduplicate-audiobooks.test.ts @@ -309,6 +309,23 @@ describe('deduplicateAudiobooks', () => { ]; 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); + }); }); // ---------------------------------------------------------------------------