diff --git a/prisma/migrations/20260311000000_add_ignored_audiobooks/migration.sql b/prisma/migrations/20260311000000_add_ignored_audiobooks/migration.sql new file mode 100644 index 0000000..99fb2f4 --- /dev/null +++ b/prisma/migrations/20260311000000_add_ignored_audiobooks/migration.sql @@ -0,0 +1,24 @@ +-- CreateTable +CREATE TABLE "ignored_audiobooks" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "asin" TEXT NOT NULL, + "title" TEXT NOT NULL, + "author" TEXT NOT NULL, + "cover_art_url" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "ignored_audiobooks_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "ignored_audiobooks_user_id_idx" ON "ignored_audiobooks"("user_id"); + +-- CreateIndex +CREATE INDEX "ignored_audiobooks_asin_idx" ON "ignored_audiobooks"("asin"); + +-- CreateIndex +CREATE UNIQUE INDEX "ignored_audiobooks_user_id_asin_key" ON "ignored_audiobooks"("user_id", "asin"); + +-- AddForeignKey +ALTER TABLE "ignored_audiobooks" ADD CONSTRAINT "ignored_audiobooks_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 9297990..b841ec6 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -74,6 +74,7 @@ model User { watchedSeries WatchedSeries[] watchedAuthors WatchedAuthor[] homeSections UserHomeSection[] + ignoredAudiobooks IgnoredAudiobook[] @@index([plexId]) @@index([role]) @@ -675,6 +676,32 @@ model WatchedAuthor { @@map("watched_authors") } +// ============================================================================ +// IGNORED AUDIOBOOK TABLE +// Per-user ignore list for auto-request suppression. +// Stores the ASIN the user clicked ignore on; works-system expansion +// happens at check-time in request-creator.service.ts. +// Documentation: documentation/features/ignored-audiobooks.md +// ============================================================================ + +model IgnoredAudiobook { + id String @id @default(uuid()) + userId String @map("user_id") + asin String // Audible ASIN that was explicitly ignored + title String // Display only — snapshot at ignore time + author String // Display only — snapshot at ignore time + coverArtUrl String? @map("cover_art_url") @db.Text + createdAt DateTime @default(now()) @map("created_at") + + // Relations + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([userId, asin]) + @@index([userId]) + @@index([asin]) + @@map("ignored_audiobooks") +} + // ============================================================================ // USER HOME SECTION TABLE // Per-user configurable home page sections (popular, new_releases, category) diff --git a/src/app/api/audiobooks/category/[categoryId]/route.ts b/src/app/api/audiobooks/category/[categoryId]/route.ts index 8b4af4b..11810b3 100644 --- a/src/app/api/audiobooks/category/[categoryId]/route.ts +++ b/src/app/api/audiobooks/category/[categoryId]/route.ts @@ -11,6 +11,7 @@ import { prisma } from '@/lib/db'; import { enrichAudiobooksWithMatches, getAvailableAsins } from '@/lib/utils/audiobook-matcher'; import { getCurrentUser } from '@/lib/middleware/auth'; import { RMABLogger } from '@/lib/utils/logger'; +import { annotateWithIgnoreStatus } from '@/lib/utils/ignored-audiobooks'; const logger = RMABLogger.create('API.Audiobooks.Category'); @@ -129,12 +130,15 @@ export async function GET( const userId = currentUser?.sub || undefined; const enrichedAudiobooks = await enrichAudiobooksWithMatches(audibleBooks, userId); + // Annotate with per-user ignore status + const annotatedAudiobooks = await annotateWithIgnoreStatus(enrichedAudiobooks, userId); + const totalPages = Math.ceil(totalCount / limit); const hasMore = page < totalPages; return NextResponse.json({ success: true, - audiobooks: enrichedAudiobooks, + audiobooks: annotatedAudiobooks, count: enrichedAudiobooks.length, totalCount, page, diff --git a/src/app/api/audiobooks/new-releases/route.ts b/src/app/api/audiobooks/new-releases/route.ts index cfd3545..4d3acc1 100644 --- a/src/app/api/audiobooks/new-releases/route.ts +++ b/src/app/api/audiobooks/new-releases/route.ts @@ -11,6 +11,7 @@ import { prisma } from '@/lib/db'; import { enrichAudiobooksWithMatches, getAvailableAsins } from '@/lib/utils/audiobook-matcher'; import { getCurrentUser } from '@/lib/middleware/auth'; import { RMABLogger } from '@/lib/utils/logger'; +import { annotateWithIgnoreStatus } from '@/lib/utils/ignored-audiobooks'; import { NEW_RELEASES_CATEGORY_ID } from '@/lib/processors/audible-refresh.processor'; const logger = RMABLogger.create('API.Audiobooks.NewReleases'); @@ -136,12 +137,15 @@ export async function GET(request: NextRequest) { // Enrich with real-time Plex library matching and request status const enrichedAudiobooks = await enrichAudiobooksWithMatches(audibleBooks, userId); + // Annotate with per-user ignore status + const annotatedAudiobooks = await annotateWithIgnoreStatus(enrichedAudiobooks, userId); + const totalPages = Math.ceil(totalCount / limit); const hasMore = page < totalPages; return NextResponse.json({ success: true, - audiobooks: enrichedAudiobooks, + audiobooks: annotatedAudiobooks, count: enrichedAudiobooks.length, totalCount, page, diff --git a/src/app/api/audiobooks/popular/route.ts b/src/app/api/audiobooks/popular/route.ts index 3683053..a65f8fd 100644 --- a/src/app/api/audiobooks/popular/route.ts +++ b/src/app/api/audiobooks/popular/route.ts @@ -11,6 +11,7 @@ import { prisma } from '@/lib/db'; import { enrichAudiobooksWithMatches, getAvailableAsins } from '@/lib/utils/audiobook-matcher'; import { getCurrentUser } from '@/lib/middleware/auth'; import { RMABLogger } from '@/lib/utils/logger'; +import { annotateWithIgnoreStatus } from '@/lib/utils/ignored-audiobooks'; import { POPULAR_CATEGORY_ID } from '@/lib/processors/audible-refresh.processor'; const logger = RMABLogger.create('API.Audiobooks.Popular'); @@ -136,12 +137,15 @@ export async function GET(request: NextRequest) { // Enrich with real-time Plex library matching and request status const enrichedAudiobooks = await enrichAudiobooksWithMatches(audibleBooks, userId); + // Annotate with per-user ignore status + const annotatedAudiobooks = await annotateWithIgnoreStatus(enrichedAudiobooks, userId); + const totalPages = Math.ceil(totalCount / limit); const hasMore = page < totalPages; return NextResponse.json({ success: true, - audiobooks: enrichedAudiobooks, + audiobooks: annotatedAudiobooks, count: enrichedAudiobooks.length, totalCount, page, diff --git a/src/app/api/audiobooks/search/route.ts b/src/app/api/audiobooks/search/route.ts index 0641aca..1285e10 100644 --- a/src/app/api/audiobooks/search/route.ts +++ b/src/app/api/audiobooks/search/route.ts @@ -10,6 +10,7 @@ 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'; +import { annotateWithIgnoreStatus } from '@/lib/utils/ignored-audiobooks'; const logger = RMABLogger.create('API.Audiobooks.Search'); @@ -51,10 +52,13 @@ export async function GET(request: NextRequest) { // Enrich search results with availability and request status information const enrichedResults = await enrichAudiobooksWithMatches(dedupedResults, userId); + // Annotate with per-user ignore status + const annotatedResults = await annotateWithIgnoreStatus(enrichedResults, userId); + return NextResponse.json({ success: true, query: results.query, - results: enrichedResults, + results: annotatedResults, 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 414345a..3a27bd5 100644 --- a/src/app/api/authors/[asin]/books/route.ts +++ b/src/app/api/authors/[asin]/books/route.ts @@ -10,6 +10,7 @@ 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'; +import { annotateWithIgnoreStatus } from '@/lib/utils/ignored-audiobooks'; const logger = RMABLogger.create('API.Authors.Books'); @@ -67,11 +68,14 @@ export async function GET( const userId = currentUser.sub || undefined; const enrichedBooks = await enrichAudiobooksWithMatches(dedupedBooks, userId); - logger.info(`Author books complete: "${authorName}" → ${enrichedBooks.length} books (page ${page})`); + // Annotate with per-user ignore status + const annotatedBooks = await annotateWithIgnoreStatus(enrichedBooks, userId); + + logger.info(`Author books complete: "${authorName}" → ${annotatedBooks.length} books (page ${page})`); return NextResponse.json({ success: true, - books: enrichedBooks, + books: annotatedBooks, authorName: authorName.trim(), authorAsin: asin, totalBooks: enrichedBooks.length, diff --git a/src/app/api/requests/route.ts b/src/app/api/requests/route.ts index 8c99361..4b44fd9 100644 --- a/src/app/api/requests/route.ts +++ b/src/app/api/requests/route.ts @@ -53,7 +53,7 @@ export async function POST(request: NextRequest) { narrator: audiobook.narrator, description: audiobook.description, coverArtUrl: audiobook.coverArtUrl, - }, { skipAutoSearch }); + }, { skipAutoSearch, bypassIgnore: true }); if (!result.success) { const statusMap: Record = { @@ -61,6 +61,7 @@ export async function POST(request: NextRequest) { being_processed: { error: 'BeingProcessed', status: 409 }, duplicate: { error: 'DuplicateRequest', status: 409 }, user_not_found: { error: 'UserNotFound', status: 404 }, + ignored: { error: 'Ignored', status: 409 }, }; const mapped = statusMap[result.reason] || { error: 'RequestError', status: 500 }; return NextResponse.json( diff --git a/src/app/api/series/[asin]/route.ts b/src/app/api/series/[asin]/route.ts index 3fe13ab..2a60b6b 100644 --- a/src/app/api/series/[asin]/route.ts +++ b/src/app/api/series/[asin]/route.ts @@ -10,6 +10,7 @@ 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'; +import { annotateWithIgnoreStatus } from '@/lib/utils/ignored-audiobooks'; const logger = RMABLogger.create('API.Series.Detail'); @@ -63,13 +64,16 @@ export async function GET( const userId = currentUser.sub || undefined; const enrichedBooks = await enrichAudiobooksWithMatches(dedupedBooks, userId); - logger.info(`Series detail complete: "${detail.title}" (${enrichedBooks.length} books, page ${page})`); + // Annotate with per-user ignore status + const annotatedBooks = await annotateWithIgnoreStatus(enrichedBooks, userId); + + logger.info(`Series detail complete: "${detail.title}" (${annotatedBooks.length} books, page ${page})`); return NextResponse.json({ success: true, series: { ...detail, - books: enrichedBooks, + books: annotatedBooks, }, hasMore: detail.hasMore, page: detail.page, diff --git a/src/app/api/user/ignored-audiobooks/[id]/route.ts b/src/app/api/user/ignored-audiobooks/[id]/route.ts new file mode 100644 index 0000000..d605f55 --- /dev/null +++ b/src/app/api/user/ignored-audiobooks/[id]/route.ts @@ -0,0 +1,65 @@ +/** + * Component: Ignored Audiobook Delete Route + * Documentation: documentation/features/ignored-audiobooks.md + * + * DELETE removes a single entry from the user's ignore list (un-ignore). + */ + +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.IgnoredAudiobooks'); + +/** + * DELETE /api/user/ignored-audiobooks/[id] + * Remove an audiobook from the user's ignore list + */ +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; + + // Verify ownership before deleting + const existing = await prisma.ignoredAudiobook.findUnique({ + where: { id }, + }); + + if (!existing) { + return NextResponse.json( + { error: 'NotFound', message: 'Ignored audiobook entry not found' }, + { status: 404 } + ); + } + + if (existing.userId !== req.user.id) { + return NextResponse.json( + { error: 'Forbidden', message: 'Cannot modify another user\'s ignore list' }, + { status: 403 } + ); + } + + await prisma.ignoredAudiobook.delete({ where: { id } }); + + logger.info(`User ${req.user.id} un-ignored ASIN ${existing.asin} ("${existing.title}")`); + + return NextResponse.json({ success: true }); + } catch (error) { + logger.error('Failed to remove ignored audiobook', { + error: error instanceof Error ? error.message : String(error), + }); + return NextResponse.json( + { error: 'DeleteError', message: 'Failed to remove ignored audiobook' }, + { status: 500 } + ); + } + }); +} diff --git a/src/app/api/user/ignored-audiobooks/check/[asin]/route.ts b/src/app/api/user/ignored-audiobooks/check/[asin]/route.ts new file mode 100644 index 0000000..631dc89 --- /dev/null +++ b/src/app/api/user/ignored-audiobooks/check/[asin]/route.ts @@ -0,0 +1,79 @@ +/** + * Component: Ignored Audiobook Check Route + * Documentation: documentation/features/ignored-audiobooks.md + * + * Quick check whether a specific ASIN is ignored by the current user. + * Includes works-system expansion to catch sibling ASINs. + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth'; +import { prisma } from '@/lib/db'; +import { getSiblingAsins } from '@/lib/services/works.service'; +import { RMABLogger } from '@/lib/utils/logger'; + +const logger = RMABLogger.create('API.IgnoredAudiobooks.Check'); + +/** + * GET /api/user/ignored-audiobooks/check/[asin] + * Returns { ignored: boolean, ignoredId?: string } for the given ASIN. + * ignoredId is the ID of the matching IgnoredAudiobook record (for un-ignore). + */ +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ asin: string }> } +) { + return requireAuth(request, async (req: AuthenticatedRequest) => { + try { + if (!req.user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const { asin } = await params; + + // Direct check + const directIgnore = await prisma.ignoredAudiobook.findUnique({ + where: { userId_asin: { userId: req.user.id, asin } }, + }); + + if (directIgnore) { + return NextResponse.json({ + ignored: true, + ignoredId: directIgnore.id, + }); + } + + // Works-system expansion: check sibling ASINs + try { + const siblingMap = await getSiblingAsins([asin]); + const siblings = siblingMap.get(asin); + if (siblings && siblings.length > 0) { + const siblingIgnore = await prisma.ignoredAudiobook.findFirst({ + where: { + userId: req.user.id, + asin: { in: siblings }, + }, + }); + if (siblingIgnore) { + return NextResponse.json({ + ignored: true, + ignoredId: siblingIgnore.id, + }); + } + } + } catch { + // Works expansion is best-effort + } + + return NextResponse.json({ ignored: false }); + } catch (error) { + logger.error('Failed to check ignored status', { + error: error instanceof Error ? error.message : String(error), + }); + return NextResponse.json( + { error: 'CheckError', message: 'Failed to check ignored status' }, + { status: 500 } + ); + } + }); +} diff --git a/src/app/api/user/ignored-audiobooks/route.ts b/src/app/api/user/ignored-audiobooks/route.ts new file mode 100644 index 0000000..cf94133 --- /dev/null +++ b/src/app/api/user/ignored-audiobooks/route.ts @@ -0,0 +1,123 @@ +/** + * Component: Ignored Audiobooks API Routes + * Documentation: documentation/features/ignored-audiobooks.md + * + * Per-user ignore list for auto-request suppression. + * GET returns the user's full ignore list; POST adds a new entry. + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth'; +import { prisma } from '@/lib/db'; +import { z } from 'zod'; +import { RMABLogger } from '@/lib/utils/logger'; + +const logger = RMABLogger.create('API.IgnoredAudiobooks'); + +const AddIgnoredSchema = z.object({ + asin: z.string().min(1).max(20), + title: z.string().min(1).max(500), + author: z.string().min(1).max(500), + coverArtUrl: z.string().optional(), +}); + +/** + * GET /api/user/ignored-audiobooks + * List the current user's ignored audiobooks + */ +export async function GET(request: NextRequest) { + return requireAuth(request, async (req: AuthenticatedRequest) => { + try { + if (!req.user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const ignored = await prisma.ignoredAudiobook.findMany({ + where: { userId: req.user.id }, + orderBy: { createdAt: 'desc' }, + }); + + return NextResponse.json({ + success: true, + ignoredAudiobooks: ignored.map((item) => ({ + id: item.id, + asin: item.asin, + title: item.title, + author: item.author, + coverArtUrl: item.coverArtUrl, + createdAt: item.createdAt.toISOString(), + })), + }); + } catch (error) { + logger.error('Failed to list ignored audiobooks', { + error: error instanceof Error ? error.message : String(error), + }); + return NextResponse.json( + { error: 'FetchError', message: 'Failed to fetch ignored audiobooks' }, + { status: 500 } + ); + } + }); +} + +/** + * POST /api/user/ignored-audiobooks + * Add an audiobook to the user's ignore 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 data = AddIgnoredSchema.parse(body); + + // Upsert to handle duplicate gracefully + const ignored = await prisma.ignoredAudiobook.upsert({ + where: { + userId_asin: { userId: req.user.id, asin: data.asin }, + }, + update: {}, // Already exists — no-op + create: { + userId: req.user.id, + asin: data.asin, + title: data.title, + author: data.author, + coverArtUrl: data.coverArtUrl, + }, + }); + + logger.info(`User ${req.user.id} ignored ASIN ${data.asin} ("${data.title}")`); + + return NextResponse.json({ + success: true, + ignoredAudiobook: { + id: ignored.id, + asin: ignored.asin, + title: ignored.title, + author: ignored.author, + coverArtUrl: ignored.coverArtUrl, + createdAt: ignored.createdAt.toISOString(), + }, + }, { status: 201 }); + } catch (error) { + logger.error('Failed to add ignored audiobook', { + 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: 'CreateError', message: 'Failed to ignore audiobook' }, + { status: 500 } + ); + } + }); +} diff --git a/src/components/audiobooks/AudiobookCard.tsx b/src/components/audiobooks/AudiobookCard.tsx index f77048e..26a2f2b 100644 --- a/src/components/audiobooks/AudiobookCard.tsx +++ b/src/components/audiobooks/AudiobookCard.tsx @@ -59,13 +59,15 @@ export function AudiobookCard({ const [error, setError] = useState(null); const [showModal, setShowModal] = useState(false); const [localRequestStatus, setLocalRequestStatus] = useState(undefined); + const [localIsIgnored, setLocalIsIgnored] = useState(undefined); const [coverError, setCoverError] = useState(false); - // Build a display-only audiobook with the local status override + // Build a display-only audiobook with local overrides const displayAudiobook = localRequestStatus !== undefined ? { ...audiobook, requestStatus: localRequestStatus } : audiobook; const status = getStatusConfig(displayAudiobook); + const isIgnored = localIsIgnored !== undefined ? localIsIgnored : audiobook.isIgnored; const handleRequest = async (e: React.MouseEvent) => { e.stopPropagation(); @@ -218,6 +220,19 @@ export function AudiobookCard({ {audiobook.rating.toFixed(1)} )} + + {/* Ignored Indicator - Bottom Left */} + {isIgnored && ( +
+ + + + Ignored +
+ )} @@ -253,6 +268,7 @@ export function AudiobookCard({ onClose={() => setShowModal(false)} onRequestSuccess={onRequestSuccess} onStatusChange={(newStatus) => setLocalRequestStatus(newStatus)} + onIgnoreChange={(ignored) => setLocalIsIgnored(ignored)} isRequested={audiobook.isRequested || localRequestStatus !== undefined} requestStatus={displayAudiobook.requestStatus} isAvailable={audiobook.isAvailable} diff --git a/src/components/audiobooks/AudiobookDetailsModal.tsx b/src/components/audiobooks/AudiobookDetailsModal.tsx index 44e6e66..a3a79fc 100644 --- a/src/components/audiobooks/AudiobookDetailsModal.tsx +++ b/src/components/audiobooks/AudiobookDetailsModal.tsx @@ -19,8 +19,10 @@ import { usePreferences } from '@/contexts/PreferencesContext'; import { InteractiveTorrentSearchModal } from '@/components/requests/InteractiveTorrentSearchModal'; import { ReportIssueModal } from '@/components/audiobooks/ReportIssueModal'; import { ManualImportBrowser } from '@/components/audiobooks/ManualImportBrowser'; -import { FolderArrowDownIcon } from '@heroicons/react/24/outline'; +import { FolderArrowDownIcon, EyeSlashIcon } from '@heroicons/react/24/outline'; +import { EyeSlashIcon as EyeSlashSolidIcon } from '@heroicons/react/24/solid'; import { fetchWithAuth } from '@/lib/utils/api'; +import { useIsIgnored, useToggleIgnore } from '@/lib/hooks/useIgnoredAudiobooks'; interface AudiobookDetailsModalProps { asin: string; @@ -28,6 +30,7 @@ interface AudiobookDetailsModalProps { onClose: () => void; onRequestSuccess?: () => void; onStatusChange?: (newStatus: string) => void; + onIgnoreChange?: (isIgnored: boolean) => void; isRequested?: boolean; requestStatus?: string | null; isAvailable?: boolean; @@ -69,6 +72,7 @@ export function AudiobookDetailsModal({ onClose, onRequestSuccess, onStatusChange, + onIgnoreChange, isRequested = false, requestStatus = null, isAvailable = false, @@ -85,6 +89,9 @@ export function AudiobookDetailsModal({ const { downloadAvailable, requestId } = useDownloadStatus(isOpen ? asin : null); const { fetchEbook, isLoading: isFetchingEbook } = useFetchEbookByAsin(); + const { isIgnored, ignoredId, isLoading: isLoadingIgnore } = useIsIgnored(isOpen ? asin : null); + const { addIgnore, removeIgnore } = useToggleIgnore(); + const [showToast, setShowToast] = useState(false); const [toastMessage, setToastMessage] = useState(''); const [toastType, setToastType] = useState<'success' | 'error'>('success'); @@ -97,6 +104,7 @@ export function AudiobookDetailsModal({ const [localRequestStatus, setLocalRequestStatus] = useState(requestStatus ?? null); const [isDownloading, setIsDownloading] = useState(false); const [coverError, setCoverError] = useState(false); + const [isTogglingIgnore, setIsTogglingIgnore] = useState(false); // Sync local status when the prop changes (e.g. page data refreshes) useEffect(() => { @@ -196,6 +204,31 @@ export function AudiobookDetailsModal({ } }; + const handleToggleIgnore = async () => { + if (!user || !audiobook) return; + setIsTogglingIgnore(true); + try { + if (isIgnored && ignoredId) { + await removeIgnore(ignoredId, asin); + onIgnoreChange?.(false); + showNotification('Removed from ignore list'); + } else { + await addIgnore({ + asin, + title: audiobook.title, + author: audiobook.author, + coverArtUrl: audiobook.coverArtUrl, + }); + onIgnoreChange?.(true); + showNotification('Added to ignore list — auto-requests will skip this book'); + } + } catch (err) { + showNotification(err instanceof Error ? err.message : 'Failed to update ignore status', 'error'); + } finally { + setIsTogglingIgnore(false); + } + }; + const formatDuration = (minutes?: number) => { if (!minutes) return null; const hours = Math.floor(minutes / 60); @@ -685,6 +718,26 @@ export function AudiobookDetailsModal({ )} + {/* Ignore Toggle - always visible when user is logged in */} + {user && !isLoadingIgnore && ( + + )} + )} diff --git a/src/lib/hooks/createShelfHooks.ts b/src/lib/hooks/createShelfHooks.ts index d66643e..dcf4062 100644 --- a/src/lib/hooks/createShelfHooks.ts +++ b/src/lib/hooks/createShelfHooks.ts @@ -46,7 +46,13 @@ export function createShelfHooks(endpoint: string) { const key = accessToken ? endpoint : null; const { data, error, isLoading } = useSWR(key, fetcher, { - refreshInterval: 30000, + refreshInterval: (latestData: { shelves: TShelf[] } | undefined) => { + const shelves = latestData?.shelves || []; + const hasSyncing = shelves.some( + (s) => !(s as Record)['lastSyncAt'], + ); + return hasSyncing ? 3000 : 30000; + }, }); return { diff --git a/src/lib/hooks/useAudiobooks.ts b/src/lib/hooks/useAudiobooks.ts index b181708..9380f40 100644 --- a/src/lib/hooks/useAudiobooks.ts +++ b/src/lib/hooks/useAudiobooks.ts @@ -33,6 +33,7 @@ export interface Audiobook { requestId?: string | null; // ID of request (if any) requestedByUsername?: string | null; // Username who requested (only if not current user) hasReportedIssue?: boolean; // True if an open issue exists for this audiobook + isIgnored?: boolean; // True if this user has ignored this audiobook from auto-requests } export function useAudiobooks(type: 'popular' | 'new-releases', limit: number = 20, page: number = 1, hideAvailable: boolean = false) { diff --git a/src/lib/hooks/useIgnoredAudiobooks.ts b/src/lib/hooks/useIgnoredAudiobooks.ts new file mode 100644 index 0000000..4147038 --- /dev/null +++ b/src/lib/hooks/useIgnoredAudiobooks.ts @@ -0,0 +1,123 @@ +/** + * Component: Ignored Audiobooks Hook + * Documentation: documentation/features/ignored-audiobooks.md + * + * Provides hooks for checking and toggling audiobook ignore status. + * - useIsIgnored(asin): check if a specific book is ignored + * - useToggleIgnore(): toggle ignore on/off for a book + * - useIgnoredList(): list all ignored books for the current user + */ + +'use client'; + +import useSWR, { mutate } from 'swr'; +import { authenticatedFetcher, fetchWithAuth } from '@/lib/utils/api'; + +interface IgnoredAudiobook { + id: string; + asin: string; + title: string; + author: string; + coverArtUrl?: string; + createdAt: string; +} + +interface IgnoreCheckResult { + ignored: boolean; + ignoredId?: string; +} + +/** + * Check if a specific ASIN is ignored by the current user. + * Includes works-system expansion on the server side. + */ +export function useIsIgnored(asin: string | null) { + const endpoint = asin ? `/api/user/ignored-audiobooks/check/${asin}` : null; + + const { data, error, isLoading } = useSWR( + endpoint, + authenticatedFetcher, + { + revalidateOnFocus: false, + dedupingInterval: 30000, + } + ); + + return { + isIgnored: data?.ignored ?? false, + ignoredId: data?.ignoredId ?? null, + isLoading, + error, + }; +} + +/** + * Toggle ignore status for an audiobook. + * Returns { addIgnore, removeIgnore } functions. + */ +export function useToggleIgnore() { + const addIgnore = async (book: { + asin: string; + title: string; + author: string; + coverArtUrl?: string; + }): Promise => { + const res = await fetchWithAuth('/api/user/ignored-audiobooks', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(book), + }); + + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err.message || 'Failed to ignore audiobook'); + } + + const result = await res.json(); + + // Invalidate the check cache for this ASIN + mutate(`/api/user/ignored-audiobooks/check/${book.asin}`); + // Invalidate the full list + mutate('/api/user/ignored-audiobooks'); + + return result.ignoredAudiobook; + }; + + const removeIgnore = async (id: string, asin: string): Promise => { + const res = await fetchWithAuth(`/api/user/ignored-audiobooks/${id}`, { + method: 'DELETE', + }); + + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err.message || 'Failed to un-ignore audiobook'); + } + + // Invalidate the check cache for this ASIN + mutate(`/api/user/ignored-audiobooks/check/${asin}`); + // Invalidate the full list + mutate('/api/user/ignored-audiobooks'); + }; + + return { addIgnore, removeIgnore }; +} + +/** + * List all ignored audiobooks for the current user. + */ +export function useIgnoredList() { + const { data, error, isLoading } = useSWR<{ ignoredAudiobooks: IgnoredAudiobook[] }>( + '/api/user/ignored-audiobooks', + authenticatedFetcher, + { + revalidateOnFocus: false, + dedupingInterval: 60000, + } + ); + + return { + ignoredAudiobooks: data?.ignoredAudiobooks ?? [], + isLoading, + error, + }; +} diff --git a/src/lib/hooks/useShelves.ts b/src/lib/hooks/useShelves.ts index 4da0261..53928f5 100644 --- a/src/lib/hooks/useShelves.ts +++ b/src/lib/hooks/useShelves.ts @@ -30,7 +30,11 @@ export function useShelves() { const endpoint = accessToken ? '/api/user/shelves' : null; const { data, error, isLoading } = useSWR(endpoint, fetcher, { - refreshInterval: 30000, + refreshInterval: (latestData: { shelves: GenericShelf[] } | undefined) => { + const shelves = latestData?.shelves || []; + const hasSyncing = shelves.some((s) => !s.lastSyncAt); + return hasSyncing ? 3000 : 30000; + }, }); return { diff --git a/src/lib/services/request-creator.service.ts b/src/lib/services/request-creator.service.ts index c89eda7..7444a2d 100644 --- a/src/lib/services/request-creator.service.ts +++ b/src/lib/services/request-creator.service.ts @@ -12,7 +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'; +import { seedAsin, getSiblingAsins } from '@/lib/services/works.service'; const logger = RMABLogger.create('RequestCreator'); @@ -27,11 +27,13 @@ export interface CreateRequestInput { export interface CreateRequestOptions { skipAutoSearch?: boolean; + /** When true, skip the per-user ignore list check (used for manual requests) */ + bypassIgnore?: boolean; } export type CreateRequestResult = | { success: true; request: any } - | { success: false; reason: 'already_available' | 'being_processed' | 'duplicate' | 'user_not_found'; message: string }; + | { success: false; reason: 'already_available' | 'being_processed' | 'duplicate' | 'user_not_found' | 'ignored'; message: string }; /** * Create a request for a user, with full duplicate detection, library checks, @@ -42,7 +44,7 @@ export async function createRequestForUser( audiobook: CreateRequestInput, options: CreateRequestOptions = {} ): Promise { - const { skipAutoSearch = false } = options; + const { skipAutoSearch = false, bypassIgnore = false } = options; // Check for existing active request (downloaded/available) for this ASIN const existingActiveRequest = await prisma.request.findFirst({ @@ -81,6 +83,18 @@ export async function createRequestForUser( }; } + // Check per-user ignore list (skipped for manual requests via bypassIgnore) + if (!bypassIgnore) { + const isIgnored = await checkIgnoreList(userId, audiobook.asin); + if (isIgnored) { + return { + success: false, + reason: 'ignored', + message: 'This audiobook is on your ignore list', + }; + } + } + // Fetch full details from Audnexus for year/series let year: number | undefined; let series: string | undefined; @@ -279,3 +293,34 @@ export async function createRequestForUser( return { success: true, request: newRequest }; } + +/** + * Check if an ASIN (or any of its sibling ASINs via the works table) + * is on the user's ignore list. Returns true if the book should be blocked. + */ +async function checkIgnoreList(userId: string, asin: string): Promise { + // Direct check: is this exact ASIN ignored? + const directIgnore = await prisma.ignoredAudiobook.findUnique({ + where: { userId_asin: { userId, asin } }, + }); + if (directIgnore) return true; + + // Works-system expansion: check sibling ASINs + try { + const siblingMap = await getSiblingAsins([asin]); + const siblings = siblingMap.get(asin); + if (siblings && siblings.length > 0) { + const siblingIgnore = await prisma.ignoredAudiobook.findFirst({ + where: { + userId, + asin: { in: siblings }, + }, + }); + if (siblingIgnore) return true; + } + } catch { + // Works expansion is best-effort — if it fails, only direct check applies + } + + return false; +} diff --git a/src/lib/utils/deduplicate-audiobooks.ts b/src/lib/utils/deduplicate-audiobooks.ts index cbaf773..fafa4cb 100644 --- a/src/lib/utils/deduplicate-audiobooks.ts +++ b/src/lib/utils/deduplicate-audiobooks.ts @@ -6,7 +6,7 @@ * under different ASINs (publisher re-listings, rights transfers, etc.). * * Dedup key: normalized title + normalized narrator - * Duration tolerance: max(longerDuration * 0.01, 5) minutes + * Duration tolerance: max(longerDuration * 0.05, 10) minutes * Missing duration treated as compatible (graceful degradation). */ @@ -95,13 +95,13 @@ function normalizeNarrator(narrator?: string): string { /** * Check if two durations are compatible (represent the same recording). - * Tolerance: max(longerDuration * 0.01, 5) minutes. + * Tolerance: max(longerDuration * 0.05, 10) 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); + const tolerance = Math.max(longer * 0.05, 10); return Math.abs(a - b) <= tolerance; } diff --git a/src/lib/utils/ignored-audiobooks.ts b/src/lib/utils/ignored-audiobooks.ts new file mode 100644 index 0000000..c341bf8 --- /dev/null +++ b/src/lib/utils/ignored-audiobooks.ts @@ -0,0 +1,37 @@ +/** + * Component: Ignored Audiobooks Utility + * Documentation: documentation/features/ignored-audiobooks.md + * + * Shared utility for annotating audiobook lists with per-user ignore status. + * Uses a single bulk query for the user's full ignore list, then annotates in-memory. + */ + +import { prisma } from '@/lib/db'; + +/** + * Annotate an array of audiobook objects with `isIgnored: boolean`. + * Fetches the user's full ignore list in one query and matches by ASIN. + * + * If userId is undefined (unauthenticated), all books get `isIgnored: false`. + */ +export async function annotateWithIgnoreStatus( + audiobooks: T[], + userId?: string +): Promise<(T & { isIgnored: boolean })[]> { + if (!userId || audiobooks.length === 0) { + return audiobooks.map((book) => ({ ...book, isIgnored: false })); + } + + // Single query: get all ASINs this user has ignored + const ignoredEntries = await prisma.ignoredAudiobook.findMany({ + where: { userId }, + select: { asin: true }, + }); + + const ignoredAsinSet = new Set(ignoredEntries.map((e) => e.asin)); + + return audiobooks.map((book) => ({ + ...book, + isIgnored: ignoredAsinSet.has(book.asin), + })); +} diff --git a/tests/api/audiobooks-browse.routes.test.ts b/tests/api/audiobooks-browse.routes.test.ts index f4f5e24..baa0a9f 100644 --- a/tests/api/audiobooks-browse.routes.test.ts +++ b/tests/api/audiobooks-browse.routes.test.ts @@ -27,6 +27,13 @@ vi.mock('@/lib/utils/audiobook-matcher', () => ({ enrichAudiobooksWithMatches: enrichMock, })); +// Mock ignore status annotation — pass-through that adds isIgnored: false +vi.mock('@/lib/utils/ignored-audiobooks', () => ({ + annotateWithIgnoreStatus: vi.fn(async (books: any[]) => + books.map((b: any) => ({ ...b, isIgnored: false })) + ), +})); + vi.mock('@/lib/middleware/auth', () => ({ getCurrentUser: currentUserMock, })); diff --git a/tests/helpers/prisma.ts b/tests/helpers/prisma.ts index aa50d8a..b06cf56 100644 --- a/tests/helpers/prisma.ts +++ b/tests/helpers/prisma.ts @@ -57,6 +57,7 @@ export const createPrismaMock = () => ({ watchedAuthor: createModelMock(), userHomeSection: createModelMock(), audibleCacheCategory: createModelMock(), + ignoredAudiobook: createModelMock(), $queryRaw: vi.fn(), $transaction: vi.fn(), $disconnect: vi.fn(), diff --git a/tests/services/request-creator-ignore.test.ts b/tests/services/request-creator-ignore.test.ts new file mode 100644 index 0000000..5b8b795 --- /dev/null +++ b/tests/services/request-creator-ignore.test.ts @@ -0,0 +1,200 @@ +/** + * Component: Request Creator Ignore Tests + * Documentation: documentation/features/ignored-audiobooks.md + * + * Tests the per-user ignore list check in createRequestForUser, + * including direct ASIN match, works-system sibling expansion, + * and the bypassIgnore option. + */ + +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(), + }), + }, +})); + +// Mock findPlexMatch to return null (not in library) +vi.mock('@/lib/utils/audiobook-matcher', () => ({ + findPlexMatch: vi.fn().mockResolvedValue(null), +})); + +// Mock AudibleService +vi.mock('@/lib/integrations/audible.service', () => ({ + getAudibleService: () => ({ + getAudiobookDetails: vi.fn().mockResolvedValue(null), + }), +})); + +// Mock job queue +vi.mock('@/lib/services/job-queue.service', () => ({ + getJobQueueService: () => ({ + addSearchJob: vi.fn().mockResolvedValue(undefined), + addNotificationJob: vi.fn().mockResolvedValue(undefined), + }), +})); + +// Mock getSiblingAsins from works.service +const mockGetSiblingAsins = vi.fn().mockResolvedValue(new Map()); +const mockSeedAsin = vi.fn().mockResolvedValue(undefined); + +vi.mock('@/lib/services/works.service', () => ({ + getSiblingAsins: (...args: any[]) => mockGetSiblingAsins(...args), + seedAsin: (...args: any[]) => mockSeedAsin(...args), +})); + +const TEST_AUDIOBOOK = { + asin: 'B00TEST001', + title: 'Test Book', + author: 'Test Author', +}; + +const TEST_USER_ID = 'user-123'; + +describe('createRequestForUser — ignore list', () => { + beforeEach(() => { + vi.clearAllMocks(); + + // Default: no existing requests, no library matches + prismaMock.request.findFirst.mockResolvedValue(null); + prismaMock.audiobook.findFirst.mockResolvedValue(null); + prismaMock.audiobook.create.mockResolvedValue({ + id: 'audiobook-1', + audibleAsin: TEST_AUDIOBOOK.asin, + title: TEST_AUDIOBOOK.title, + author: TEST_AUDIOBOOK.author, + narrator: null, + }); + prismaMock.request.create.mockResolvedValue({ + id: 'request-1', + userId: TEST_USER_ID, + audiobookId: 'audiobook-1', + status: 'pending', + audiobook: { id: 'audiobook-1', title: 'Test Book' }, + user: { id: TEST_USER_ID, plexUsername: 'testuser' }, + }); + prismaMock.user.findUnique.mockResolvedValue({ + role: 'user', + autoApproveRequests: true, + plexUsername: 'testuser', + }); + + // Default: not ignored + prismaMock.ignoredAudiobook.findUnique.mockResolvedValue(null); + prismaMock.ignoredAudiobook.findFirst.mockResolvedValue(null); + mockGetSiblingAsins.mockResolvedValue(new Map()); + mockSeedAsin.mockResolvedValue(undefined); + }); + + it('blocks auto-request when ASIN is directly ignored', async () => { + prismaMock.ignoredAudiobook.findUnique.mockResolvedValue({ + id: 'ignored-1', + userId: TEST_USER_ID, + asin: TEST_AUDIOBOOK.asin, + }); + + const { createRequestForUser } = await import('@/lib/services/request-creator.service'); + const result = await createRequestForUser(TEST_USER_ID, TEST_AUDIOBOOK); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.reason).toBe('ignored'); + expect(result.message).toContain('ignore list'); + } + + // Should NOT create a request + expect(prismaMock.request.create).not.toHaveBeenCalled(); + }); + + it('blocks auto-request when sibling ASIN is ignored', async () => { + // Direct ASIN not ignored + prismaMock.ignoredAudiobook.findUnique.mockResolvedValue(null); + + // But a sibling is ignored + mockGetSiblingAsins.mockResolvedValue(new Map([ + [TEST_AUDIOBOOK.asin, ['B00SIBLING']], + ])); + prismaMock.ignoredAudiobook.findFirst.mockResolvedValue({ + id: 'ignored-sibling', + userId: TEST_USER_ID, + asin: 'B00SIBLING', + }); + + const { createRequestForUser } = await import('@/lib/services/request-creator.service'); + const result = await createRequestForUser(TEST_USER_ID, TEST_AUDIOBOOK); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.reason).toBe('ignored'); + } + + expect(prismaMock.request.create).not.toHaveBeenCalled(); + }); + + it('allows manual request with bypassIgnore even when ignored', async () => { + prismaMock.ignoredAudiobook.findUnique.mockResolvedValue({ + id: 'ignored-1', + userId: TEST_USER_ID, + asin: TEST_AUDIOBOOK.asin, + }); + + const { createRequestForUser } = await import('@/lib/services/request-creator.service'); + const result = await createRequestForUser(TEST_USER_ID, TEST_AUDIOBOOK, { + bypassIgnore: true, + }); + + expect(result.success).toBe(true); + expect(prismaMock.request.create).toHaveBeenCalled(); + + // Should NOT have even checked the ignore list + expect(prismaMock.ignoredAudiobook.findUnique).not.toHaveBeenCalled(); + }); + + it('allows request when ASIN is not ignored', async () => { + prismaMock.ignoredAudiobook.findUnique.mockResolvedValue(null); + prismaMock.ignoredAudiobook.findFirst.mockResolvedValue(null); + + const { createRequestForUser } = await import('@/lib/services/request-creator.service'); + const result = await createRequestForUser(TEST_USER_ID, TEST_AUDIOBOOK); + + expect(result.success).toBe(true); + expect(prismaMock.request.create).toHaveBeenCalled(); + }); + + it('falls through gracefully when works expansion fails', async () => { + prismaMock.ignoredAudiobook.findUnique.mockResolvedValue(null); + mockGetSiblingAsins.mockRejectedValue(new Error('DB error')); + + const { createRequestForUser } = await import('@/lib/services/request-creator.service'); + const result = await createRequestForUser(TEST_USER_ID, TEST_AUDIOBOOK); + + // Should still succeed since direct check passed and expansion is best-effort + expect(result.success).toBe(true); + expect(prismaMock.request.create).toHaveBeenCalled(); + }); + + it('does not check siblings when no sibling ASINs exist', async () => { + prismaMock.ignoredAudiobook.findUnique.mockResolvedValue(null); + mockGetSiblingAsins.mockResolvedValue(new Map()); + + const { createRequestForUser } = await import('@/lib/services/request-creator.service'); + const result = await createRequestForUser(TEST_USER_ID, TEST_AUDIOBOOK); + + expect(result.success).toBe(true); + // Should not have queried findFirst for sibling check since map was empty + expect(prismaMock.ignoredAudiobook.findFirst).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/utils/deduplicate-audiobooks.test.ts b/tests/utils/deduplicate-audiobooks.test.ts index a84d5ed..5945c59 100644 --- a/tests/utils/deduplicate-audiobooks.test.ts +++ b/tests/utils/deduplicate-audiobooks.test.ts @@ -137,16 +137,18 @@ describe('areDurationsCompatible', () => { 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% of longer duration as tolerance for long books', () => { + // tolerance = max(longer*0.05, 10). When b > a, longer = b, so threshold shifts. + // 2400 vs 2526: longer=2526, tol=126.3, diff=126 → true + expect(areDurationsCompatible(2400, 2526)).toBe(true); + // 2400 vs 2527: longer=2527, tol=126.35, diff=127 → false + expect(areDurationsCompatible(2400, 2527)).toBe(false); }); - 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('uses 10-minute minimum tolerance for short books', () => { + // Two 2-hour books (120 min): tolerance = max(120*0.05, 10) = max(6, 10) = 10 min + expect(areDurationsCompatible(120, 130)).toBe(true); // exactly at 10-min minimum + expect(areDurationsCompatible(120, 131)).toBe(false); // just over }); it('keeps abridged vs unabridged separate (large duration gap)', () => { @@ -155,10 +157,10 @@ describe('areDurationsCompatible', () => { }); 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); + expect(areDurationsCompatible(2400, 2526)).toBe(true); + expect(areDurationsCompatible(2526, 2400)).toBe(true); + expect(areDurationsCompatible(120, 131)).toBe(false); + expect(areDurationsCompatible(131, 120)).toBe(false); }); }); @@ -305,17 +307,17 @@ describe('deduplicateAudiobooks', () => { }); it('uses percentage tolerance for very long audiobooks', () => { - // Two 40-hour books: tolerance = max(2400*0.01, 5) = 24 min + // tolerance = max(longer*0.05, 10). 2400 vs 2526: longer=2526, tol=126.3, diff=126 → same 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 }), + makeBook({ asin: 'A2', title: 'Long Book', author: 'Auth', narrator: 'Nar', durationMinutes: 2526 }), ]; expect(deduplicateAudiobooks(books)).toHaveLength(1); - // Beyond tolerance + // Beyond tolerance: 2400 vs 2600: longer=2600, tol=130, diff=200 → different 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 }), + makeBook({ asin: 'A2', title: 'Long Book', author: 'Auth', narrator: 'Nar', durationMinutes: 2600 }), ]; expect(deduplicateAudiobooks(booksFar)).toHaveLength(2); });