From ff80d995c5462142d8dcc09f3a86a45dd25ce2f5 Mon Sep 17 00:00:00 2001 From: kikootwo Date: Tue, 3 Mar 2026 12:36:03 -0500 Subject: [PATCH 1/5] Add hideAvailable filter and unified pagination Add support for hiding audiobooks that are already available by introducing a hideAvailable query flag and excluding matching ASINs at the DB level. Implemented getAvailableAsins() in audiobook-matcher to gather ASINs from the library and completed requests, and wired it into the popular and new-releases API routes to apply a notIn filter. Propagated the hideAvailable flag through useAudiobooks so client requests include the parameter, and adjusted the homepage to reset pagination when the flag changes. Replaced two StickyPagination instances with a new UnifiedPagination component (new file) that provides a single context-aware floating paginator which tracks the dominant section and allows switching between Popular and New Releases. Also removed client-side filtering in favor of server-side exclusion and made small imports/cleanup in page.tsx. --- src/app/api/audiobooks/new-releases/route.ts | 23 +- src/app/api/audiobooks/popular/route.ts | 23 +- src/app/page.tsx | 67 ++-- src/components/ui/StickyPagination.tsx | 170 --------- src/components/ui/UnifiedPagination.tsx | 325 ++++++++++++++++++ src/lib/hooks/useAudiobooks.ts | 7 +- src/lib/utils/audiobook-matcher.ts | 38 ++ tests/app/home.page.test.tsx | 25 +- tests/components/ui/StickyPagination.test.tsx | 133 ------- .../components/ui/UnifiedPagination.test.tsx | 203 +++++++++++ 10 files changed, 653 insertions(+), 361 deletions(-) delete mode 100644 src/components/ui/StickyPagination.tsx create mode 100644 src/components/ui/UnifiedPagination.tsx delete mode 100644 tests/components/ui/StickyPagination.test.tsx create mode 100644 tests/components/ui/UnifiedPagination.test.tsx diff --git a/src/app/api/audiobooks/new-releases/route.ts b/src/app/api/audiobooks/new-releases/route.ts index 78076de..5bec85d 100644 --- a/src/app/api/audiobooks/new-releases/route.ts +++ b/src/app/api/audiobooks/new-releases/route.ts @@ -7,7 +7,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { prisma } from '@/lib/db'; -import { enrichAudiobooksWithMatches } from '@/lib/utils/audiobook-matcher'; +import { enrichAudiobooksWithMatches, getAvailableAsins } from '@/lib/utils/audiobook-matcher'; import { getCurrentUser } from '@/lib/middleware/auth'; import { RMABLogger } from '@/lib/utils/logger'; @@ -24,6 +24,7 @@ export async function GET(request: NextRequest) { const searchParams = request.nextUrl.searchParams; const page = parseInt(searchParams.get('page') || '1', 10); const limit = parseInt(searchParams.get('limit') || '20', 10); + const hideAvailable = searchParams.get('hideAvailable') === 'true'; // Validate pagination parameters if (page < 1 || limit < 1 || limit > 100) { @@ -38,12 +39,22 @@ export async function GET(request: NextRequest) { const skip = (page - 1) * limit; + // When hideAvailable is enabled, exclude ASINs that are in the library or have completed requests + let excludedAsins: string[] = []; + if (hideAvailable) { + const availableSet = await getAvailableAsins(); + excludedAsins = [...availableSet]; + } + + const whereClause = { + isNewRelease: true, + ...(excludedAsins.length > 0 ? { asin: { notIn: excludedAsins } } : {}), + }; + // Query audible_cache for new release audiobooks const [audiobooks, totalCount] = await Promise.all([ prisma.audibleCache.findMany({ - where: { - isNewRelease: true, - }, + where: whereClause, orderBy: { newReleaseRank: 'asc', }, @@ -66,9 +77,7 @@ export async function GET(request: NextRequest) { }, }), prisma.audibleCache.count({ - where: { - isNewRelease: true, - }, + where: whereClause, }), ]); diff --git a/src/app/api/audiobooks/popular/route.ts b/src/app/api/audiobooks/popular/route.ts index 3bab399..8c46913 100644 --- a/src/app/api/audiobooks/popular/route.ts +++ b/src/app/api/audiobooks/popular/route.ts @@ -7,7 +7,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { prisma } from '@/lib/db'; -import { enrichAudiobooksWithMatches } from '@/lib/utils/audiobook-matcher'; +import { enrichAudiobooksWithMatches, getAvailableAsins } from '@/lib/utils/audiobook-matcher'; import { getCurrentUser } from '@/lib/middleware/auth'; import { RMABLogger } from '@/lib/utils/logger'; @@ -24,6 +24,7 @@ export async function GET(request: NextRequest) { const searchParams = request.nextUrl.searchParams; const page = parseInt(searchParams.get('page') || '1', 10); const limit = parseInt(searchParams.get('limit') || '20', 10); + const hideAvailable = searchParams.get('hideAvailable') === 'true'; // Validate pagination parameters if (page < 1 || limit < 1 || limit > 100) { @@ -38,12 +39,22 @@ export async function GET(request: NextRequest) { const skip = (page - 1) * limit; + // When hideAvailable is enabled, exclude ASINs that are in the library or have completed requests + let excludedAsins: string[] = []; + if (hideAvailable) { + const availableSet = await getAvailableAsins(); + excludedAsins = [...availableSet]; + } + + const whereClause = { + isPopular: true, + ...(excludedAsins.length > 0 ? { asin: { notIn: excludedAsins } } : {}), + }; + // Query audible_cache for popular audiobooks const [audiobooks, totalCount] = await Promise.all([ prisma.audibleCache.findMany({ - where: { - isPopular: true, - }, + where: whereClause, orderBy: { popularRank: 'asc', }, @@ -66,9 +77,7 @@ export async function GET(request: NextRequest) { }, }), prisma.audibleCache.count({ - where: { - isPopular: true, - }, + where: whereClause, }), ]); diff --git a/src/app/page.tsx b/src/app/page.tsx index af8429d..da71faa 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -5,12 +5,12 @@ 'use client'; -import { useState, useRef, useMemo } from 'react'; +import { useState, useRef, useEffect } from 'react'; import { Header } from '@/components/layout/Header'; import { AudiobookGrid } from '@/components/audiobooks/AudiobookGrid'; -import { useAudiobooks, Audiobook } from '@/lib/hooks/useAudiobooks'; +import { useAudiobooks } from '@/lib/hooks/useAudiobooks'; import { ProtectedRoute } from '@/components/auth/ProtectedRoute'; -import { StickyPagination } from '@/components/ui/StickyPagination'; +import { UnifiedPagination } from '@/components/ui/UnifiedPagination'; import { SectionToolbar } from '@/components/ui/SectionToolbar'; import { usePreferences } from '@/contexts/PreferencesContext'; @@ -29,24 +29,20 @@ export default function HomePage() { isLoading: loadingPopular, totalPages: popularTotalPages, message: popularMessage, - } = useAudiobooks('popular', 20, popularPage); + } = useAudiobooks('popular', 20, popularPage, hideAvailable); const { audiobooks: newReleases, isLoading: loadingNewReleases, totalPages: newReleasesTotalPages, message: newReleasesMessage, - } = useAudiobooks('new-releases', 20, newReleasesPage); + } = useAudiobooks('new-releases', 20, newReleasesPage, hideAvailable); - // Filter out available titles when hideAvailable is enabled - const filteredPopular = useMemo( - () => hideAvailable ? popular.filter((b: Audiobook) => !b.isAvailable && b.requestStatus !== 'completed') : popular, - [popular, hideAvailable] - ); - const filteredNewReleases = useMemo( - () => hideAvailable ? newReleases.filter((b: Audiobook) => !b.isAvailable && b.requestStatus !== 'completed') : newReleases, - [newReleases, hideAvailable] - ); + // Reset to page 1 when hideAvailable changes (total pages may differ) + useEffect(() => { + setPopularPage(1); + setNewReleasesPage(1); + }, [hideAvailable]); // Handle page changes with auto-scroll to section top const handlePopularPageChange = (page: number) => { @@ -100,7 +96,7 @@ export default function HomePage() { ) : ( ) : ( - {/* Sticky Pagination Controls */} - - + popularSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }), + }, + { + label: 'New Releases', + accentColor: 'bg-emerald-500', + currentPage: newReleasesPage, + totalPages: newReleasesTotalPages, + onPageChange: handleNewReleasesPageChange, + sectionRef: newReleasesSectionRef, + onScrollToSection: () => + newReleasesSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }), + }, + ]} /> diff --git a/src/components/ui/StickyPagination.tsx b/src/components/ui/StickyPagination.tsx deleted file mode 100644 index a4c7e2a..0000000 --- a/src/components/ui/StickyPagination.tsx +++ /dev/null @@ -1,170 +0,0 @@ -/** - * Component: Sticky Pagination with Progress Bar - * Documentation: documentation/frontend/components.md - */ - -'use client'; - -import React, { useState, useEffect, useRef } from 'react'; -import { ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/24/outline'; - -interface StickyPaginationProps { - currentPage: number; - totalPages: number; - onPageChange: (page: number) => void; - sectionRef: React.RefObject; - label: string; // e.g., "Popular Audiobooks" - footerRef?: React.RefObject; // Optional footer ref to avoid overlap -} - -export function StickyPagination({ - currentPage, - totalPages, - onPageChange, - sectionRef, - label, - footerRef, -}: StickyPaginationProps) { - const [isVisible, setIsVisible] = useState(false); - const [isFooterVisible, setIsFooterVisible] = useState(false); - const [jumpPage, setJumpPage] = useState(currentPage.toString()); - - // Update jump page input when current page changes externally - useEffect(() => { - setJumpPage(currentPage.toString()); - }, [currentPage]); - - // Intersection Observer to show/hide pagination based on section visibility - useEffect(() => { - if (!sectionRef.current) return; - - const observer = new IntersectionObserver( - ([entry]) => { - // Show pagination when section is in viewport - setIsVisible(entry.isIntersecting && entry.intersectionRatio > 0.1); - }, - { - threshold: [0, 0.1, 0.5, 1], - rootMargin: '-60px 0px -60px 0px', // Account for header/footer - } - ); - - observer.observe(sectionRef.current); - - return () => observer.disconnect(); - }, [sectionRef]); - - // Footer observer to hide pagination when footer is visible - useEffect(() => { - if (!footerRef?.current) return; - - const observer = new IntersectionObserver( - ([entry]) => { - // Hide pagination when footer is in viewport - setIsFooterVisible(entry.isIntersecting); - }, - { - threshold: [0, 0.1], - rootMargin: '0px', - } - ); - - observer.observe(footerRef.current); - - return () => observer.disconnect(); - }, [footerRef]); - - if (totalPages <= 1) { - return null; - } - - const handlePrevious = () => { - if (currentPage > 1) { - onPageChange(currentPage - 1); - } - }; - - const handleNext = () => { - if (currentPage < totalPages) { - onPageChange(currentPage + 1); - } - }; - - const handleJumpSubmit = (e: React.FormEvent) => { - e.preventDefault(); - const page = parseInt(jumpPage, 10); - if (!isNaN(page) && page >= 1 && page <= totalPages) { - onPageChange(page); - } else { - // Reset to current page if invalid - setJumpPage(currentPage.toString()); - } - }; - - // Final visibility: show when section is visible AND footer is not visible - const shouldShow = isVisible && !isFooterVisible; - - return ( -
-
-
- {/* Section Label - Hidden on small screens */} -
- {label} -
- - {/* Previous Button */} - - - {/* Page Info & Jump */} -
- - Page - -
- setJumpPage(e.target.value)} - onBlur={handleJumpSubmit} - className="w-10 px-1.5 py-0.5 text-center text-sm font-medium rounded - bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100 - border border-gray-300 dark:border-gray-600 - focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-transparent" - aria-label="Current page" - /> -
- - of {totalPages} - -
- - {/* Next Button */} - -
-
-
- ); -} diff --git a/src/components/ui/UnifiedPagination.tsx b/src/components/ui/UnifiedPagination.tsx new file mode 100644 index 0000000..b44230b --- /dev/null +++ b/src/components/ui/UnifiedPagination.tsx @@ -0,0 +1,325 @@ +/** + * Component: Unified Pagination — context-aware floating paginator + * Documentation: documentation/frontend/components.md + * + * Replaces two overlapping StickyPagination instances with a single pill + * that automatically tracks which section dominates the viewport and shows + * controls for that section. Transitions smoothly when the dominant section + * changes. Includes a two-dot section indicator for manual switching. + */ + +'use client'; + +import React, { useState, useEffect, useRef, useCallback } from 'react'; +import { ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/24/outline'; + +export interface PaginationSection { + /** Display label, e.g. "Popular Audiobooks" */ + label: string; + /** Tailwind color class applied to the active accent dot, e.g. "bg-blue-500" */ + accentColor: string; + currentPage: number; + totalPages: number; + onPageChange: (page: number) => void; + /** Ref to the section element — used for intersection tracking */ + sectionRef: React.RefObject; + /** Called when user clicks this section's dot while it's inactive — should scroll to section */ + onScrollToSection: () => void; +} + +interface UnifiedPaginationProps { + sections: [PaginationSection, PaginationSection]; + footerRef?: React.RefObject; +} + +// --------------------------------------------------------------------------- +// Small page-jump form — isolated to prevent key re-mounts on section switch +// --------------------------------------------------------------------------- + +interface PageJumpProps { + currentPage: number; + totalPages: number; + onPageChange: (page: number) => void; +} + +function PageJump({ currentPage, totalPages, onPageChange }: PageJumpProps) { + const [value, setValue] = useState(currentPage.toString()); + + // Sync when page changes externally (e.g. after scrollIntoView + setState) + useEffect(() => { + setValue(currentPage.toString()); + }, [currentPage]); + + const commit = useCallback( + (e?: React.FormEvent) => { + e?.preventDefault(); + const parsed = parseInt(value, 10); + if (!isNaN(parsed) && parsed >= 1 && parsed <= totalPages) { + onPageChange(parsed); + } else { + setValue(currentPage.toString()); + } + }, + [value, currentPage, totalPages, onPageChange] + ); + + return ( +
+ + Page + +
+ setValue(e.target.value)} + onBlur={commit} + className="w-10 px-1.5 py-0.5 text-center text-sm font-medium rounded-md + bg-black/[0.04] dark:bg-white/[0.08] + text-gray-900 dark:text-gray-100 + border border-gray-300/60 dark:border-white/10 + focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-transparent + transition-all duration-150" + aria-label="Jump to page" + /> +
+ + of {totalPages} + +
+ ); +} + +// --------------------------------------------------------------------------- +// Main component +// --------------------------------------------------------------------------- + +export function UnifiedPagination({ sections, footerRef }: UnifiedPaginationProps) { + // Index of the currently dominant section (0 or 1) + const [activeIndex, setActiveIndex] = useState<0 | 1>(0); + // Whether the label+controls area is mid-transition (drives opacity fade) + const [isTransitioning, setIsTransitioning] = useState(false); + + const [footerVisible, setFooterVisible] = useState(false); + // Per-section raw intersection ratios [0,1] + const ratiosRef = useRef<[number, number]>([0, 0]); + // Whether each section has any meaningful intersection + const [sectionVisible, setSectionVisible] = useState<[boolean, boolean]>([false, false]); + + const transitionTimerRef = useRef | null>(null); + + // Determine if the pill should be shown at all: + // - at least one section is meaningfully visible + // - footer is not visible + // - the active section has >1 page + const activeSectionHasPages = sections[activeIndex].totalPages > 1; + const eitherSectionVisible = sectionVisible[0] || sectionVisible[1]; + const shouldShow = eitherSectionVisible && !footerVisible && activeSectionHasPages; + + // ------------------------------------------------------------------ + // Track which section each instance belongs to via intersection ratio + // ------------------------------------------------------------------ + useEffect(() => { + const observers: IntersectionObserver[] = []; + + sections.forEach((section, idx) => { + if (!section.sectionRef.current) return; + + const observer = new IntersectionObserver( + ([entry]) => { + ratiosRef.current[idx as 0 | 1] = entry.intersectionRatio; + const isVisible = entry.isIntersecting && entry.intersectionRatio > 0.05; + + setSectionVisible((prev) => { + const next: [boolean, boolean] = [...prev] as [boolean, boolean]; + next[idx as 0 | 1] = isVisible; + return next; + }); + + // Determine dominant section (whichever has more viewport coverage) + const [r0, r1] = ratiosRef.current; + const dominant: 0 | 1 = r0 >= r1 ? 0 : 1; + + setActiveIndex((current) => { + if (current !== dominant) { + // Trigger cross-fade transition + setIsTransitioning(true); + + if (transitionTimerRef.current) { + clearTimeout(transitionTimerRef.current); + } + transitionTimerRef.current = setTimeout(() => { + setIsTransitioning(false); + }, 320); + + return dominant; + } + return current; + }); + }, + { + // Dense threshold array gives us smooth ratio tracking + threshold: Array.from({ length: 21 }, (_, i) => i / 20), + rootMargin: '-60px 0px -80px 0px', + } + ); + + observer.observe(section.sectionRef.current); + observers.push(observer); + }); + + return () => { + observers.forEach((o) => o.disconnect()); + if (transitionTimerRef.current) clearTimeout(transitionTimerRef.current); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [sections[0].sectionRef, sections[1].sectionRef]); + + // ------------------------------------------------------------------ + // Footer observer + // ------------------------------------------------------------------ + useEffect(() => { + if (!footerRef?.current) return; + const observer = new IntersectionObserver( + ([entry]) => setFooterVisible(entry.isIntersecting), + { threshold: [0, 0.01] } + ); + observer.observe(footerRef.current); + return () => observer.disconnect(); + }, [footerRef]); + + // ------------------------------------------------------------------ + // Derived values for the currently active section + // ------------------------------------------------------------------ + const active = sections[activeIndex]; + + const handlePrev = () => { + if (active.currentPage > 1) active.onPageChange(active.currentPage - 1); + }; + const handleNext = () => { + if (active.currentPage < active.totalPages) active.onPageChange(active.currentPage + 1); + }; + + // ------------------------------------------------------------------ + // Render + // ------------------------------------------------------------------ + return ( +
+ {/* Pill surface */} +
+ {/* Section selector dots — left side */} +
+ {sections.map((section, idx) => { + const isActive = idx === activeIndex; + return ( +
+ + {/* Divider */} +
+ + {/* Label + controls — cross-fades on section switch */} +
+ {/* Section label — hidden on small screens */} + + {active.label} + + + {/* Previous */} + + + {/* Page jump */} + + + {/* Next */} + +
+ + {/* Right padding balance */} +
+
+
+ ); +} diff --git a/src/lib/hooks/useAudiobooks.ts b/src/lib/hooks/useAudiobooks.ts index 8018ab4..b181708 100644 --- a/src/lib/hooks/useAudiobooks.ts +++ b/src/lib/hooks/useAudiobooks.ts @@ -35,11 +35,12 @@ export interface Audiobook { hasReportedIssue?: boolean; // True if an open issue exists for this audiobook } -export function useAudiobooks(type: 'popular' | 'new-releases', limit: number = 20, page: number = 1) { +export function useAudiobooks(type: 'popular' | 'new-releases', limit: number = 20, page: number = 1, hideAvailable: boolean = false) { + const hideParam = hideAvailable ? '&hideAvailable=true' : ''; const endpoint = type === 'popular' - ? `/api/audiobooks/popular?page=${page}&limit=${limit}` - : `/api/audiobooks/new-releases?page=${page}&limit=${limit}`; + ? `/api/audiobooks/popular?page=${page}&limit=${limit}${hideParam}` + : `/api/audiobooks/new-releases?page=${page}&limit=${limit}${hideParam}`; const { data, error, isLoading } = useSWR(endpoint, authenticatedFetcher, { revalidateOnFocus: false, diff --git a/src/lib/utils/audiobook-matcher.ts b/src/lib/utils/audiobook-matcher.ts index ec86f45..1181552 100644 --- a/src/lib/utils/audiobook-matcher.ts +++ b/src/lib/utils/audiobook-matcher.ts @@ -272,6 +272,44 @@ export async function enrichAudiobooksWithMatches( return results; } +/** + * Get all ASINs that are considered "available" — present in library or have completed requests. + * Used by paginated API routes to exclude available items at the DB level. + */ +export async function getAvailableAsins(): Promise> { + const [libraryItems, completedRequests] = await Promise.all([ + // ASINs present in the library (Plex or Audiobookshelf) + prisma.plexLibrary.findMany({ + where: { asin: { not: null } }, + select: { asin: true }, + distinct: ['asin'], + }), + // ASINs with completed audiobook requests + prisma.audiobook.findMany({ + where: { + audibleAsin: { not: null }, + requests: { + some: { + status: 'completed', + type: 'audiobook', + deletedAt: null, + }, + }, + }, + select: { audibleAsin: true }, + }), + ]); + + const asins = new Set(); + for (const item of libraryItems) { + if (item.asin) asins.add(item.asin); + } + for (const item of completedRequests) { + if (item.audibleAsin) asins.add(item.audibleAsin); + } + return asins; +} + /** * Normalize ISBN for comparison (remove dashes and spaces) */ diff --git a/tests/app/home.page.test.tsx b/tests/app/home.page.test.tsx index f84f7c0..6a009e4 100644 --- a/tests/app/home.page.test.tsx +++ b/tests/app/home.page.test.tsx @@ -47,17 +47,22 @@ vi.mock('@/components/ui/CardSizeControls', () => ({ CardSizeControls: ({ size }: { size: number }) =>
, })); -vi.mock('@/components/ui/StickyPagination', () => ({ - StickyPagination: ({ - label, - onPageChange, +vi.mock('@/components/ui/UnifiedPagination', () => ({ + UnifiedPagination: ({ + sections, }: { - label: string; - onPageChange: (page: number) => void; + sections: Array<{ + label: string; + onPageChange: (page: number) => void; + }>; }) => ( - +
+ {sections.map((s) => ( + + ))} +
), })); @@ -113,7 +118,7 @@ describe('HomePage', () => { fireEvent.click(screen.getByRole('button', { name: 'Popular Audiobooks next' })); await waitFor(() => { - expect(useAudiobooksMock).toHaveBeenCalledWith('popular', 20, 2); + expect(useAudiobooksMock).toHaveBeenCalledWith('popular', 20, 2, undefined); }); }); }); diff --git a/tests/components/ui/StickyPagination.test.tsx b/tests/components/ui/StickyPagination.test.tsx deleted file mode 100644 index 4c2206a..0000000 --- a/tests/components/ui/StickyPagination.test.tsx +++ /dev/null @@ -1,133 +0,0 @@ -/** - * Component: Sticky Pagination Tests - * Documentation: documentation/frontend/components.md - */ - -// @vitest-environment jsdom - -import React from 'react'; -import { act, fireEvent, render, screen } from '@testing-library/react'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { StickyPagination } from '@/components/ui/StickyPagination'; - -type ObserverEntry = { - isIntersecting: boolean; - intersectionRatio: number; - target: Element; -}; - -describe('StickyPagination', () => { - const observers: { callback: IntersectionObserverCallback }[] = []; - - beforeEach(() => { - observers.length = 0; - class MockIntersectionObserver { - callback: IntersectionObserverCallback; - observe = vi.fn(); - unobserve = vi.fn(); - disconnect = vi.fn(); - takeRecords = vi.fn(); - - constructor(callback: IntersectionObserverCallback) { - this.callback = callback; - observers.push(this); - } - } - - (global as any).IntersectionObserver = MockIntersectionObserver; - }); - - it('returns null when there is only one page', () => { - const sectionRef = { current: document.createElement('div') }; - const { container } = render( - - ); - - expect(container.firstChild).toBeNull(); - }); - - it('shows and hides based on section and footer visibility', () => { - const sectionRef = { current: document.createElement('div') }; - const footerRef = { current: document.createElement('div') }; - - const { container } = render( - - ); - - const root = container.querySelector('div.fixed') as HTMLElement; - expect(root).toHaveClass('opacity-0'); - - act(() => { - observers[0].callback( - [ - { - isIntersecting: true, - intersectionRatio: 0.2, - target: sectionRef.current as Element, - } as ObserverEntry, - ], - observers[0] as unknown as IntersectionObserver - ); - }); - - expect(root).toHaveClass('opacity-100'); - - act(() => { - observers[1].callback( - [ - { - isIntersecting: true, - intersectionRatio: 0.2, - target: footerRef.current as Element, - } as ObserverEntry, - ], - observers[1] as unknown as IntersectionObserver - ); - }); - - expect(root).toHaveClass('opacity-0'); - }); - - it('handles navigation and jump input updates', () => { - const sectionRef = { current: document.createElement('div') }; - const onPageChange = vi.fn(); - - render( - - ); - - fireEvent.click(screen.getByLabelText('Next page')); - expect(onPageChange).toHaveBeenCalledWith(3); - - fireEvent.click(screen.getByLabelText('Previous page')); - expect(onPageChange).toHaveBeenCalledWith(1); - - const input = screen.getByLabelText('Current page') as HTMLInputElement; - fireEvent.change(input, { target: { value: '4' } }); - fireEvent.blur(input); - expect(onPageChange).toHaveBeenCalledWith(4); - - fireEvent.change(input, { target: { value: '99' } }); - fireEvent.blur(input); - expect(input.value).toBe('2'); - }); -}); diff --git a/tests/components/ui/UnifiedPagination.test.tsx b/tests/components/ui/UnifiedPagination.test.tsx new file mode 100644 index 0000000..6d38ba8 --- /dev/null +++ b/tests/components/ui/UnifiedPagination.test.tsx @@ -0,0 +1,203 @@ +/** + * Component: Unified Pagination Tests + * Documentation: documentation/frontend/components.md + */ + +// @vitest-environment jsdom + +import React from 'react'; +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { UnifiedPagination, PaginationSection } from '@/components/ui/UnifiedPagination'; + +type ObserverEntry = { + isIntersecting: boolean; + intersectionRatio: number; + target: Element; +}; + +function makeSections( + overrides?: Partial[] +): [PaginationSection, PaginationSection] { + const defaults: [PaginationSection, PaginationSection] = [ + { + label: 'Popular', + accentColor: 'bg-blue-500', + currentPage: 1, + totalPages: 3, + onPageChange: vi.fn(), + sectionRef: { current: document.createElement('section') }, + onScrollToSection: vi.fn(), + }, + { + label: 'New Releases', + accentColor: 'bg-emerald-500', + currentPage: 1, + totalPages: 2, + onPageChange: vi.fn(), + sectionRef: { current: document.createElement('section') }, + onScrollToSection: vi.fn(), + }, + ]; + + if (overrides) { + overrides.forEach((o, i) => { + if (o) Object.assign(defaults[i], o); + }); + } + + return defaults; +} + +describe('UnifiedPagination', () => { + const observers: { callback: IntersectionObserverCallback; observe: ReturnType; disconnect: ReturnType }[] = []; + + beforeEach(() => { + observers.length = 0; + + class MockIntersectionObserver { + callback: IntersectionObserverCallback; + observe = vi.fn(); + unobserve = vi.fn(); + disconnect = vi.fn(); + takeRecords = vi.fn(); + + constructor(callback: IntersectionObserverCallback) { + this.callback = callback; + observers.push(this); + } + } + + (global as any).IntersectionObserver = MockIntersectionObserver; + }); + + it('renders nothing when both sections have only one page', () => { + const sections = makeSections([{ totalPages: 1 }, { totalPages: 1 }]); + const { container } = render(); + // The pill should be hidden (pointer-events-none, opacity-0) + const root = container.querySelector('div.fixed') as HTMLElement; + expect(root).toHaveClass('pointer-events-none'); + }); + + it('shows pagination when the dominant section is visible and has pages', () => { + const sections = makeSections(); + const { container } = render(); + + const root = container.querySelector('div.fixed') as HTMLElement; + expect(root).toHaveClass('opacity-0'); + + // Simulate first section becoming visible with high ratio + act(() => { + observers[0].callback( + [ + { + isIntersecting: true, + intersectionRatio: 0.5, + target: sections[0].sectionRef.current as Element, + } as ObserverEntry, + ], + observers[0] as unknown as IntersectionObserver + ); + }); + + expect(root).toHaveClass('opacity-100'); + }); + + it('hides when footer becomes visible', () => { + const sections = makeSections(); + const footerRef = { current: document.createElement('footer') }; + const { container } = render( + + ); + + const root = container.querySelector('div.fixed') as HTMLElement; + + // Make section visible + act(() => { + observers[0].callback( + [ + { + isIntersecting: true, + intersectionRatio: 0.5, + target: sections[0].sectionRef.current as Element, + } as ObserverEntry, + ], + observers[0] as unknown as IntersectionObserver + ); + }); + + expect(root).toHaveClass('opacity-100'); + + // Footer observer is the 3rd (index 2): section0, section1, footer + act(() => { + observers[2].callback( + [ + { + isIntersecting: true, + intersectionRatio: 0.1, + target: footerRef.current as Element, + } as ObserverEntry, + ], + observers[2] as unknown as IntersectionObserver + ); + }); + + expect(root).toHaveClass('opacity-0'); + }); + + it('calls onPageChange for prev/next buttons', () => { + const sections = makeSections([{ currentPage: 2, totalPages: 4 }]); + const { container } = render(); + + // Make section visible so controls render interactably + act(() => { + observers[0].callback( + [ + { + isIntersecting: true, + intersectionRatio: 0.5, + target: sections[0].sectionRef.current as Element, + } as ObserverEntry, + ], + observers[0] as unknown as IntersectionObserver + ); + }); + + fireEvent.click(screen.getByLabelText('Next page')); + expect(sections[0].onPageChange).toHaveBeenCalledWith(3); + + fireEvent.click(screen.getByLabelText('Previous page')); + expect(sections[0].onPageChange).toHaveBeenCalledWith(1); + }); + + it('handles page jump input', () => { + const sections = makeSections([{ currentPage: 2, totalPages: 5 }]); + render(); + + // Make section visible + act(() => { + observers[0].callback( + [ + { + isIntersecting: true, + intersectionRatio: 0.5, + target: sections[0].sectionRef.current as Element, + } as ObserverEntry, + ], + observers[0] as unknown as IntersectionObserver + ); + }); + + const input = screen.getByLabelText('Jump to page') as HTMLInputElement; + fireEvent.change(input, { target: { value: '4' } }); + fireEvent.blur(input); + expect(sections[0].onPageChange).toHaveBeenCalledWith(4); + }); + + it('uses pointer-events-none when hidden', () => { + const sections = makeSections(); + const { container } = render(); + const root = container.querySelector('div.fixed') as HTMLElement; + expect(root).toHaveClass('pointer-events-none'); + }); +}); From 610873af6ba90ebc424be4fd4460174a12ba536e Mon Sep 17 00:00:00 2001 From: kikootwo Date: Tue, 3 Mar 2026 13:31:46 -0500 Subject: [PATCH 2/5] Add works table and ASIN deduping Add persistent cross-ASIN "works" mapping and client-side deduplication to improve library matching. Introduces a Prisma migration and models (Work, WorkAsin) plus src/lib/services/works.service for persisting dedup groups, seeding ASINs at request time, and sibling lookup. Adds a deduplication utility (deduplicate-audiobooks) that normalizes titles/narrators, compares durations, and returns grouping metadata; API routes (search, author, series) now deduplicate results before enrichment and fire-and-forget persist groups. Adds sibling-ASIN expansion into audiobook matcher and expands getAvailableAsins accordingly. Extracts runtime parsing into a shared parse-runtime util and updates audible scrapers/services to use it. Includes unit tests for dedup logic and works service and updates test Prisma mocks. --- .../migration.sql | 42 ++ prisma/schema.prisma | 40 ++ src/app/api/audiobooks/search/route.ts | 14 +- src/app/api/authors/[asin]/books/route.ts | 14 +- src/app/api/series/[asin]/route.ts | 12 +- src/lib/integrations/audible-series.ts | 13 +- src/lib/integrations/audible.service.ts | 29 +- src/lib/services/request-creator.service.ts | 10 + src/lib/services/works.service.ts | 248 ++++++++++ src/lib/utils/audiobook-matcher.ts | 69 +++ src/lib/utils/deduplicate-audiobooks.ts | 201 ++++++++ src/lib/utils/parse-runtime.ts | 44 ++ tests/helpers/prisma.ts | 2 + tests/services/works.service.test.ts | 306 ++++++++++++ tests/utils/deduplicate-audiobooks.test.ts | 434 ++++++++++++++++++ 15 files changed, 1446 insertions(+), 32 deletions(-) create mode 100644 prisma/migrations/20260303000000_add_works_table/migration.sql create mode 100644 src/lib/services/works.service.ts create mode 100644 src/lib/utils/deduplicate-audiobooks.ts create mode 100644 src/lib/utils/parse-runtime.ts create mode 100644 tests/services/works.service.test.ts create mode 100644 tests/utils/deduplicate-audiobooks.test.ts diff --git a/prisma/migrations/20260303000000_add_works_table/migration.sql b/prisma/migrations/20260303000000_add_works_table/migration.sql new file mode 100644 index 0000000..83aa861 --- /dev/null +++ b/prisma/migrations/20260303000000_add_works_table/migration.sql @@ -0,0 +1,42 @@ +-- CreateTable +CREATE TABLE "works" ( + "id" TEXT NOT NULL, + "title" TEXT NOT NULL, + "author" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "works_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "work_asins" ( + "id" TEXT NOT NULL, + "work_id" TEXT NOT NULL, + "asin" TEXT NOT NULL, + "narrator" TEXT, + "duration_minutes" INTEGER, + "is_canonical" BOOLEAN NOT NULL DEFAULT false, + "source" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "work_asins_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "works_title_idx" ON "works"("title"); + +-- CreateIndex +CREATE INDEX "works_author_idx" ON "works"("author"); + +-- CreateIndex +CREATE UNIQUE INDEX "work_asins_asin_key" ON "work_asins"("asin"); + +-- CreateIndex +CREATE INDEX "work_asins_work_id_idx" ON "work_asins"("work_id"); + +-- CreateIndex +CREATE INDEX "work_asins_asin_idx" ON "work_asins"("asin"); + +-- AddForeignKey +ALTER TABLE "work_asins" ADD CONSTRAINT "work_asins_work_id_fkey" FOREIGN KEY ("work_id") REFERENCES "works"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6a39100..bcb900a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -531,3 +531,43 @@ model GoodreadsBookMapping { @@index([audibleAsin]) @@map("goodreads_book_mappings") } + +// ============================================================================ +// WORKS TABLE +// Cross-ASIN audiobook identity mapping — links multiple Audible ASINs +// to a single logical work for library matching across editions. +// Documentation: documentation/integrations/audible.md +// ============================================================================ + +model Work { + id String @id @default(uuid()) + title String + author String + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + // Relations + asins WorkAsin[] + + @@index([title]) + @@index([author]) + @@map("works") +} + +model WorkAsin { + id String @id @default(uuid()) + workId String @map("work_id") + asin String @unique + narrator String? + durationMinutes Int? @map("duration_minutes") + isCanonical Boolean @default(false) @map("is_canonical") + source String // 'dedup_auto' | 'admin_manual' + createdAt DateTime @default(now()) @map("created_at") + + // Relations + work Work @relation(fields: [workId], references: [id], onDelete: Cascade) + + @@index([workId]) + @@index([asin]) + @@map("work_asins") +} diff --git a/src/app/api/audiobooks/search/route.ts b/src/app/api/audiobooks/search/route.ts index 4093fcb..0641aca 100644 --- a/src/app/api/audiobooks/search/route.ts +++ b/src/app/api/audiobooks/search/route.ts @@ -6,6 +6,8 @@ import { NextRequest, NextResponse } from 'next/server'; import { getAudibleService } from '@/lib/integrations/audible.service'; import { enrichAudiobooksWithMatches } from '@/lib/utils/audiobook-matcher'; +import { deduplicateAndCollectGroups } from '@/lib/utils/deduplicate-audiobooks'; +import { persistDedupGroups } from '@/lib/services/works.service'; import { getCurrentUser } from '@/lib/middleware/auth'; import { RMABLogger } from '@/lib/utils/logger'; @@ -38,14 +40,22 @@ export async function GET(request: NextRequest) { const currentUser = getCurrentUser(request); const userId = currentUser?.sub || undefined; + // Deduplicate before enrichment to avoid wasted DB queries on duplicate entries + const { books: dedupedResults, groups } = deduplicateAndCollectGroups(results.results); + + // Fire-and-forget: persist dedup groups to works table for cross-ASIN matching + if (groups.length > 0) { + persistDedupGroups(groups).catch(() => {}); + } + // Enrich search results with availability and request status information - const enrichedResults = await enrichAudiobooksWithMatches(results.results, userId); + const enrichedResults = await enrichAudiobooksWithMatches(dedupedResults, userId); return NextResponse.json({ success: true, query: results.query, results: enrichedResults, - totalResults: results.totalResults, + totalResults: enrichedResults.length, page: results.page, hasMore: results.hasMore, }); diff --git a/src/app/api/authors/[asin]/books/route.ts b/src/app/api/authors/[asin]/books/route.ts index 0535d73..414345a 100644 --- a/src/app/api/authors/[asin]/books/route.ts +++ b/src/app/api/authors/[asin]/books/route.ts @@ -6,6 +6,8 @@ import { NextRequest, NextResponse } from 'next/server'; import { getAudibleService } from '@/lib/integrations/audible.service'; import { enrichAudiobooksWithMatches } from '@/lib/utils/audiobook-matcher'; +import { deduplicateAndCollectGroups } from '@/lib/utils/deduplicate-audiobooks'; +import { persistDedupGroups } from '@/lib/services/works.service'; import { getCurrentUser } from '@/lib/middleware/auth'; import { RMABLogger } from '@/lib/utils/logger'; @@ -53,9 +55,17 @@ export async function GET( const audibleService = getAudibleService(); const result = await audibleService.searchByAuthorAsin(authorName.trim(), asin, page); + // Deduplicate before enrichment to avoid wasted DB queries on duplicate entries + const { books: dedupedBooks, groups } = deduplicateAndCollectGroups(result.books); + + // Fire-and-forget: persist dedup groups to works table for cross-ASIN matching + if (groups.length > 0) { + persistDedupGroups(groups).catch(() => {}); + } + // Enrich with library availability and request status const userId = currentUser.sub || undefined; - const enrichedBooks = await enrichAudiobooksWithMatches(result.books, userId); + const enrichedBooks = await enrichAudiobooksWithMatches(dedupedBooks, userId); logger.info(`Author books complete: "${authorName}" → ${enrichedBooks.length} books (page ${page})`); @@ -64,7 +74,7 @@ export async function GET( books: enrichedBooks, authorName: authorName.trim(), authorAsin: asin, - totalBooks: result.totalResults || enrichedBooks.length, + totalBooks: enrichedBooks.length, hasMore: result.hasMore, page: result.page, }); diff --git a/src/app/api/series/[asin]/route.ts b/src/app/api/series/[asin]/route.ts index 43271fb..3fe13ab 100644 --- a/src/app/api/series/[asin]/route.ts +++ b/src/app/api/series/[asin]/route.ts @@ -8,6 +8,8 @@ import { getCurrentUser } from '@/lib/middleware/auth'; import { RMABLogger } from '@/lib/utils/logger'; import { scrapeSeriesPage } from '@/lib/integrations/audible-series'; import { enrichAudiobooksWithMatches } from '@/lib/utils/audiobook-matcher'; +import { deduplicateAndCollectGroups } from '@/lib/utils/deduplicate-audiobooks'; +import { persistDedupGroups } from '@/lib/services/works.service'; const logger = RMABLogger.create('API.Series.Detail'); @@ -49,9 +51,17 @@ export async function GET( ); } + // Deduplicate before enrichment to avoid wasted DB queries on duplicate entries + const { books: dedupedBooks, groups } = deduplicateAndCollectGroups(detail.books); + + // Fire-and-forget: persist dedup groups to works table for cross-ASIN matching + if (groups.length > 0) { + persistDedupGroups(groups).catch(() => {}); + } + // Enrich books with library availability and request status const userId = currentUser.sub || undefined; - const enrichedBooks = await enrichAudiobooksWithMatches(detail.books, userId); + const enrichedBooks = await enrichAudiobooksWithMatches(dedupedBooks, userId); logger.info(`Series detail complete: "${detail.title}" (${enrichedBooks.length} books, page ${page})`); diff --git a/src/lib/integrations/audible-series.ts b/src/lib/integrations/audible-series.ts index f5df693..7cf976b 100644 --- a/src/lib/integrations/audible-series.ts +++ b/src/lib/integrations/audible-series.ts @@ -14,8 +14,10 @@ import { getLanguageForRegion, buildContainsSelector, stripPrefixes, + type LanguageConfig, } from '../constants/language-config'; import { RMABLogger } from '../utils/logger'; +import { parseRuntime } from '../utils/parse-runtime'; import { randomDelay } from '../utils/scrape-resilience'; const logger = RMABLogger.create('Audible.Series'); @@ -311,7 +313,7 @@ export async function scrapeSeriesPage(asin: string, page: number = 1): Promise< undefined; // Parse all books from the series page - const books = parseSeriesBooks($, langConfig.scraping.authorPrefixes, langConfig.scraping.narratorPrefixes); + const books = parseSeriesBooks($, langConfig.scraping.authorPrefixes, langConfig.scraping.narratorPrefixes, langConfig); // Use actual book count if we got more from scraping const bookCount = Math.max(summary.bookCount, books.length); @@ -403,7 +405,8 @@ function parseSeriesRating($: cheerio.CheerioAPI): { rating?: number; ratingCoun function parseSeriesBooks( $: cheerio.CheerioAPI, authorPrefixes: string[], - narratorPrefixes: string[] + narratorPrefixes: string[], + langConfig: LanguageConfig ): AudibleAudiobook[] { const books: AudibleAudiobook[] = []; const seenAsins = new Set(); @@ -453,6 +456,11 @@ function parseSeriesBooks( const ratingMatch = ratingText ? ratingText.match(/(\d+[.,]?\d*)/) : null; const rating = ratingMatch ? parseFloat(ratingMatch[1].replace(',', '.')) : undefined; + // Duration + const runtimeText = $el.find('.runtimeLabel').text().trim() || + $el.find(buildContainsSelector('span', langConfig.scraping.lengthLabels)).text().trim(); + const durationMinutes = parseRuntime(runtimeText, langConfig); + books.push({ asin: bookAsin, title, @@ -461,6 +469,7 @@ function parseSeriesBooks( narrator: stripPrefixes(narratorText, narratorPrefixes), coverArtUrl, rating, + durationMinutes, }); }); diff --git a/src/lib/integrations/audible.service.ts b/src/lib/integrations/audible.service.ts index 229c421..de32076 100644 --- a/src/lib/integrations/audible.service.ts +++ b/src/lib/integrations/audible.service.ts @@ -23,6 +23,7 @@ import { AdaptivePacer, FetchResultMeta, } from '../utils/scrape-resilience'; +import { parseRuntime as parseRuntimeUtil } from '../utils/parse-runtime'; // Module-level logger const logger = RMABLogger.create('Audible'); @@ -1134,33 +1135,11 @@ export class AudibleService { } /** - * Parse runtime text to minutes using language-specific patterns + * Parse runtime text to minutes using language-specific patterns. + * Delegates to shared utility in src/lib/utils/parse-runtime.ts. */ private parseRuntime(runtimeText: string): number | undefined { - if (!runtimeText) return undefined; - - const langConfig = this.getLangConfig(); - let totalMinutes = 0; - - // Try each hour pattern until one matches - for (const pattern of langConfig.scraping.runtimeHourPatterns) { - const match = runtimeText.match(pattern); - if (match) { - totalMinutes += parseInt(match[1]) * 60; - break; - } - } - - // Try each minute pattern until one matches - for (const pattern of langConfig.scraping.runtimeMinutePatterns) { - const match = runtimeText.match(pattern); - if (match) { - totalMinutes += parseInt(match[1]); - break; - } - } - - return totalMinutes > 0 ? totalMinutes : undefined; + return parseRuntimeUtil(runtimeText, this.getLangConfig()); } /** diff --git a/src/lib/services/request-creator.service.ts b/src/lib/services/request-creator.service.ts index 864c233..c89eda7 100644 --- a/src/lib/services/request-creator.service.ts +++ b/src/lib/services/request-creator.service.ts @@ -12,6 +12,7 @@ import { getJobQueueService } from '@/lib/services/job-queue.service'; import { findPlexMatch } from '@/lib/utils/audiobook-matcher'; import { getAudibleService } from '@/lib/integrations/audible.service'; import { RMABLogger } from '@/lib/utils/logger'; +import { seedAsin } from '@/lib/services/works.service'; const logger = RMABLogger.create('RequestCreator'); @@ -147,6 +148,15 @@ export async function createRequestForUser( } } + // Seed works table for cross-ASIN matching (Layer 2: request-time seeding) + seedAsin( + audiobook.asin, + audiobookRecord.title, + audiobookRecord.author, + audiobookRecord.narrator || undefined, + undefined // duration not available at request time + ).catch(() => {}); + // Check if user already has an active request for this audiobook const existingRequest = await prisma.request.findFirst({ where: { diff --git a/src/lib/services/works.service.ts b/src/lib/services/works.service.ts new file mode 100644 index 0000000..45d989d --- /dev/null +++ b/src/lib/services/works.service.ts @@ -0,0 +1,248 @@ +/** + * Component: Works Service + * Documentation: documentation/integrations/audible.md + * + * Manages the works table — persistent cross-ASIN audiobook identity mapping. + * Layer 1: Auto-populated from dedup logic when users browse search/author/series pages. + * Layer 2: Seeded at request time to ensure requested ASINs are tracked. + */ + +import { prisma } from '@/lib/db'; +import { RMABLogger } from '@/lib/utils/logger'; +import type { DedupGroup } from '@/lib/utils/deduplicate-audiobooks'; + +const logger = RMABLogger.create('WorksService'); + +// --------------------------------------------------------------------------- +// Layer 1: Persist dedup groups (fire-and-forget from API routes) +// --------------------------------------------------------------------------- + +/** + * Persist dedup groups to the works table. For each group of 2+ ASINs that + * were identified as the same audiobook, create or update a Work record + * linking all ASINs together. + * + * Safe to call fire-and-forget — never throws. + */ +export async function persistDedupGroups(groups: DedupGroup[]): Promise { + try { + for (const group of groups) { + await persistSingleGroup(group); + } + } catch (error) { + logger.error('Failed to persist dedup groups', { + error: error instanceof Error ? error.message : String(error), + groupCount: groups.length, + }); + } +} + +/** + * Persist a single dedup group. Handles merging when ASINs span multiple + * existing works. + */ +async function persistSingleGroup(group: DedupGroup): Promise { + const { canonicalAsin, allAsins, title, author, narrator, durationMinutes } = group; + + // Find which of these ASINs already exist in work_asins + const existingEntries = await prisma.workAsin.findMany({ + where: { asin: { in: allAsins } }, + select: { asin: true, workId: true }, + }); + + // Collect unique work IDs that already contain any of our ASINs + const existingWorkIds = [...new Set(existingEntries.map(e => e.workId))]; + const existingAsinSet = new Set(existingEntries.map(e => e.asin)); + + if (existingWorkIds.length === 0) { + // No existing works — create a new one with all ASINs + const work = await prisma.work.create({ + data: { title, author }, + }); + + await Promise.all( + allAsins.map(asin => + prisma.workAsin.create({ + data: { + workId: work.id, + asin, + narrator: asin === canonicalAsin ? narrator : undefined, + durationMinutes: asin === canonicalAsin ? durationMinutes : undefined, + isCanonical: asin === canonicalAsin, + source: 'dedup_auto', + }, + }) + ) + ); + + logger.debug('Created new work', { workId: work.id, asinCount: allAsins.length }); + } else { + // Use the first existing work as the target + const targetWorkId = existingWorkIds[0]; + + // If multiple existing works, merge them into the target + if (existingWorkIds.length > 1) { + const mergeWorkIds = existingWorkIds.slice(1); + + // Move all ASINs from other works to the target + await prisma.workAsin.updateMany({ + where: { workId: { in: mergeWorkIds } }, + data: { workId: targetWorkId }, + }); + + // Delete the now-empty works + await prisma.work.deleteMany({ + where: { id: { in: mergeWorkIds } }, + }); + + logger.debug('Merged works', { + targetWorkId, + mergedWorkIds: mergeWorkIds, + }); + } + + // Add any new ASINs that don't already exist + const newAsins = allAsins.filter(a => !existingAsinSet.has(a)); + if (newAsins.length > 0) { + await Promise.all( + newAsins.map(asin => + prisma.workAsin.create({ + data: { + workId: targetWorkId, + asin, + narrator: asin === canonicalAsin ? narrator : undefined, + durationMinutes: asin === canonicalAsin ? durationMinutes : undefined, + isCanonical: asin === canonicalAsin, + source: 'dedup_auto', + }, + }) + ) + ); + + logger.debug('Added ASINs to existing work', { + workId: targetWorkId, + newAsinCount: newAsins.length, + }); + } + + // Update canonical status: ensure the canonical ASIN is marked + await prisma.workAsin.updateMany({ + where: { workId: targetWorkId, asin: canonicalAsin }, + data: { isCanonical: true }, + }); + } +} + +// --------------------------------------------------------------------------- +// Layer 2: Seed ASIN at request time +// --------------------------------------------------------------------------- + +/** + * Ensure an ASIN is tracked in the works table. Creates a single-ASIN work + * if the ASIN isn't already present. Called at request creation time. + * + * Safe to call fire-and-forget — never throws. + */ +export async function seedAsin( + asin: string, + title: string, + author: string, + narrator?: string, + durationMinutes?: number +): Promise { + try { + // Check if ASIN already tracked + const existing = await prisma.workAsin.findUnique({ + where: { asin }, + }); + if (existing) return; + + // Create a new single-ASIN work + const work = await prisma.work.create({ + data: { title, author }, + }); + + await prisma.workAsin.create({ + data: { + workId: work.id, + asin, + narrator, + durationMinutes, + isCanonical: true, + source: 'dedup_auto', + }, + }); + + logger.debug('Seeded ASIN', { workId: work.id, asin }); + } catch (error) { + logger.error('Failed to seed ASIN', { + error: error instanceof Error ? error.message : String(error), + asin, + }); + } +} + +// --------------------------------------------------------------------------- +// Sibling ASIN lookup (for library matching expansion) +// --------------------------------------------------------------------------- + +/** + * Given a list of ASINs, return a map of each input ASIN to its sibling ASINs + * (other ASINs in the same work, NOT including the input ASIN itself). + * + * ASINs not found in the works table are simply omitted from the result. + */ +export async function getSiblingAsins( + asins: string[] +): Promise> { + const result = new Map(); + if (asins.length === 0) return result; + + // Step 1: Find which input ASINs are in work_asins and their work IDs + const inputEntries = await prisma.workAsin.findMany({ + where: { asin: { in: asins } }, + select: { asin: true, workId: true }, + }); + + if (inputEntries.length === 0) return result; + + // Build map of workId -> input ASINs in that work + const workIdToInputAsins = new Map(); + for (const entry of inputEntries) { + const list = workIdToInputAsins.get(entry.workId); + if (list) { + list.push(entry.asin); + } else { + workIdToInputAsins.set(entry.workId, [entry.asin]); + } + } + + // Step 2: Get ALL ASINs in those works + const workIds = [...workIdToInputAsins.keys()]; + const allWorkAsins = await prisma.workAsin.findMany({ + where: { workId: { in: workIds } }, + select: { asin: true, workId: true }, + }); + + // Build map of workId -> all ASINs + const workIdToAllAsins = new Map(); + for (const entry of allWorkAsins) { + const list = workIdToAllAsins.get(entry.workId); + if (list) { + list.push(entry.asin); + } else { + workIdToAllAsins.set(entry.workId, [entry.asin]); + } + } + + // Step 3: For each input ASIN, compute siblings (all ASINs in same work minus self) + for (const entry of inputEntries) { + const allInWork = workIdToAllAsins.get(entry.workId) || []; + const siblings = allInWork.filter(a => a !== entry.asin); + if (siblings.length > 0) { + result.set(entry.asin, siblings); + } + } + + return result; +} diff --git a/src/lib/utils/audiobook-matcher.ts b/src/lib/utils/audiobook-matcher.ts index 1181552..ee49ff7 100644 --- a/src/lib/utils/audiobook-matcher.ts +++ b/src/lib/utils/audiobook-matcher.ts @@ -8,6 +8,7 @@ import { prisma } from '@/lib/db'; import { LibraryItem } from '@/lib/services/library'; +import { getSiblingAsins } from '@/lib/services/works.service'; import { RMABLogger } from './logger'; // Module-level logger @@ -178,6 +179,61 @@ export async function enrichAudiobooksWithMatches( } } + // Works-table sibling expansion: check if unmatched ASINs have siblings in the library + try { + const unmatchedAsins = results.filter(r => !r.isAvailable).map(r => r.asin); + if (unmatchedAsins.length > 0) { + const siblingMap = await getSiblingAsins(unmatchedAsins); + if (siblingMap.size > 0) { + // Collect all sibling ASINs for a single batch library query + const allSiblingAsins = new Set(); + for (const siblings of siblingMap.values()) { + for (const s of siblings) allSiblingAsins.add(s); + } + + if (allSiblingAsins.size > 0) { + const siblingLibraryMatches = await prisma.plexLibrary.findMany({ + where: { asin: { in: [...allSiblingAsins] } }, + select: { asin: true, plexGuid: true }, + }); + const libraryAsinSet = new Set( + siblingLibraryMatches.filter(m => m.asin).map(m => m.asin!.toLowerCase()) + ); + + // Update results where a sibling ASIN is found in the library + for (const result of results) { + if (result.isAvailable) continue; + const siblings = siblingMap.get(result.asin); + if (!siblings) continue; + const matchedSiblingAsin = siblings.find(s => libraryAsinSet.has(s.toLowerCase())); + if (matchedSiblingAsin) { + const libMatch = siblingLibraryMatches.find( + m => m.asin?.toLowerCase() === matchedSiblingAsin.toLowerCase() + ); + (result as any).isAvailable = true; + (result as any).plexGuid = libMatch?.plexGuid || null; + } + } + + const siblingMatchCount = results.filter(r => { + if (!r.isAvailable) return false; + return siblingMap.has(r.asin); + }).length; + logger.debug('Sibling expansion', { + unmatchedCount: unmatchedAsins.length, + siblingGroupsFound: siblingMap.size, + siblingMatches: siblingMatchCount, + }); + } + } + } + } catch (error) { + // Works table expansion is best-effort — direct matches still work + logger.error('Sibling ASIN expansion failed', { + error: error instanceof Error ? error.message : String(error), + }); + } + // Always enrich with request status (check ANY user's requests) const asins = audiobooks.map(book => book.asin); @@ -307,6 +363,19 @@ export async function getAvailableAsins(): Promise> { for (const item of completedRequests) { if (item.audibleAsin) asins.add(item.audibleAsin); } + + // Expand with works-table sibling ASINs + try { + if (asins.size > 0) { + const siblingMap = await getSiblingAsins([...asins]); + for (const siblings of siblingMap.values()) { + for (const s of siblings) asins.add(s); + } + } + } catch { + // Works table expansion is best-effort + } + return asins; } diff --git a/src/lib/utils/deduplicate-audiobooks.ts b/src/lib/utils/deduplicate-audiobooks.ts new file mode 100644 index 0000000..1bc426f --- /dev/null +++ b/src/lib/utils/deduplicate-audiobooks.ts @@ -0,0 +1,201 @@ +/** + * Component: Audiobook Deduplication Utility + * Documentation: documentation/integrations/audible.md + * + * Deduplicates audiobook listings that represent the same recording + * under different ASINs (publisher re-listings, rights transfers, etc.). + * + * Dedup key: normalized title + normalized narrator + * Duration tolerance: max(longerDuration * 0.01, 5) minutes + * Missing duration treated as compatible (graceful degradation). + */ + +import type { AudibleAudiobook } from '../integrations/audible.service'; + +// --------------------------------------------------------------------------- +// Title / narrator normalization +// --------------------------------------------------------------------------- + +/** Patterns in parentheses or brackets to strip (edition markers, format labels) */ +const EDITION_PAREN_RE = /[([][^)\]]*?(?:unabridged|abridged|edition|remaster(?:ed)?|anniversary|complete|original|version|narrat(?:ed|or)?|audio(?:book)?|full cast|dramatiz(?:ed|ation))[^)\]]*[)\]]/gi; + +/** Trailing subtitle after colon or long dash */ +const SUBTITLE_RE = /\s*[:]\s+.+$/; +const LONG_DASH_SUBTITLE_RE = /\s+[-\u2013\u2014]\s+.+$/; + +/** Trailing descriptors like "A Novel", "A Memoir" */ +const TRAILING_DESCRIPTOR_RE = /\s*[-:,]?\s+a\s+(novel|memoir|thriller|mystery|romance|story|tale|novella)\s*$/i; + +/** + * Normalize a title for dedup comparison. + * Strips subtitles, edition markers, and trailing descriptors. + */ +export function normalizeTitle(title: string): string { + let t = title.toLowerCase(); + // Remove parenthesized/bracketed edition markers + t = t.replace(EDITION_PAREN_RE, ''); + // Remove trailing descriptors before subtitle stripping + t = t.replace(TRAILING_DESCRIPTOR_RE, ''); + // Remove subtitle after colon + t = t.replace(SUBTITLE_RE, ''); + // Remove subtitle after long dash (but not short hyphenated words) + t = t.replace(LONG_DASH_SUBTITLE_RE, ''); + // Collapse whitespace and trim + return t.replace(/\s+/g, ' ').trim(); +} + +/** Normalize narrator for comparison. */ +function normalizeNarrator(narrator?: string): string { + return (narrator || '').toLowerCase().trim(); +} + +// --------------------------------------------------------------------------- +// Duration compatibility +// --------------------------------------------------------------------------- + +/** + * Check if two durations are compatible (represent the same recording). + * Tolerance: max(longerDuration * 0.01, 5) minutes. + * Missing duration on either side is treated as compatible. + */ +export function areDurationsCompatible(a?: number, b?: number): boolean { + if (a == null || b == null) return true; + const longer = Math.max(a, b); + const tolerance = Math.max(longer * 0.01, 5); + return Math.abs(a - b) <= tolerance; +} + +// --------------------------------------------------------------------------- +// Metadata scoring (for picking best representative) +// --------------------------------------------------------------------------- + +function metadataScore(book: AudibleAudiobook): number { + let score = 0; + if (book.coverArtUrl) score++; + if (book.rating != null) score++; + if (book.durationMinutes != null) score++; + if (book.description) score++; + if (book.narrator) score++; + if (book.releaseDate) score++; + if (book.genres && book.genres.length > 0) score++; + return score; +} + +// --------------------------------------------------------------------------- +// Dedup group types (for works-table persistence) +// --------------------------------------------------------------------------- + +/** Metadata about a group of ASINs that were collapsed during dedup. */ +export interface DedupGroup { + canonicalAsin: string; // ASIN of the "winner" (best metadata score) + allAsins: string[]; // All ASINs in this group (including canonical) + title: string; // Author from the canonical entry + author: string; // Author from the canonical entry + narrator?: string; // Narrator from the canonical entry + durationMinutes?: number; // Duration from the canonical entry +} + +/** Result of deduplication with group collection. */ +export interface DeduplicateResult { + books: AudibleAudiobook[]; // The deduped list (same as deduplicateAudiobooks returns) + groups: DedupGroup[]; // Groups where 2+ ASINs were collapsed +} + +// --------------------------------------------------------------------------- +// Main dedup functions +// --------------------------------------------------------------------------- + +/** + * Deduplicate audiobook listings by normalized title + narrator + duration. + * + * Same narrator + compatible duration + similar title = same recording -> collapse. + * Different narrator = different production -> keep both. + * Duration outside tolerance = different content (abridged vs unabridged) -> keep both. + * + * Preserves original ordering (position of first appearance). + */ +export function deduplicateAudiobooks(books: AudibleAudiobook[]): AudibleAudiobook[] { + return deduplicateAndCollectGroups(books).books; +} + +/** + * Deduplicate audiobooks AND return grouping metadata for works-table persistence. + * Returns both the deduped list and the groups where 2+ ASINs were collapsed. + */ +export function deduplicateAndCollectGroups(books: AudibleAudiobook[]): DeduplicateResult { + if (books.length <= 1) return { books: [...books], groups: [] }; + + // Group by normalized title + narrator + const titleNarratorGroups = new Map(); + const insertionOrder: string[] = []; + + for (const book of books) { + const key = `${normalizeTitle(book.title)}|||${normalizeNarrator(book.narrator)}`; + const group = titleNarratorGroups.get(key); + if (group) { + group.push(book); + } else { + titleNarratorGroups.set(key, [book]); + insertionOrder.push(key); + } + } + + const result: AudibleAudiobook[] = []; + const dedupGroups: DedupGroup[] = []; + + for (const key of insertionOrder) { + const group = titleNarratorGroups.get(key)!; + if (group.length === 1) { + result.push(group[0]); + continue; + } + + // Within a title+narrator group, further split by duration compatibility. + // Build sub-groups where all members are duration-compatible with the + // representative (first member). A book joins the first compatible sub-group. + const subGroups: AudibleAudiobook[][] = []; + + for (const book of group) { + let placed = false; + for (const sg of subGroups) { + // Check compatibility against the representative (first member) + if (areDurationsCompatible(sg[0].durationMinutes, book.durationMinutes)) { + sg.push(book); + placed = true; + break; + } + } + if (!placed) { + subGroups.push([book]); + } + } + + // From each sub-group, pick the best representative and collect group metadata + for (const sg of subGroups) { + let best = sg[0]; + let bestScore = metadataScore(best); + for (let i = 1; i < sg.length; i++) { + const score = metadataScore(sg[i]); + if (score > bestScore) { + best = sg[i]; + bestScore = score; + } + } + result.push(best); + + // Collect group metadata for works-table persistence (only multi-ASIN groups) + if (sg.length >= 2) { + dedupGroups.push({ + canonicalAsin: best.asin, + allAsins: sg.map(b => b.asin), + title: best.title, + author: best.author, + narrator: best.narrator, + durationMinutes: best.durationMinutes, + }); + } + } + } + + return { books: result, groups: dedupGroups }; +} diff --git a/src/lib/utils/parse-runtime.ts b/src/lib/utils/parse-runtime.ts new file mode 100644 index 0000000..148dcb7 --- /dev/null +++ b/src/lib/utils/parse-runtime.ts @@ -0,0 +1,44 @@ +/** + * Component: Runtime Parsing Utility + * Documentation: documentation/integrations/audible.md + * + * Shared runtime/duration text parser extracted from AudibleService. + * Handles all i18n patterns (English, German, Spanish, French) via + * language-specific regex patterns in LanguageConfig. + */ + +import type { LanguageConfig } from '../constants/language-config'; + +/** + * Parse runtime text (e.g. "12 hrs and 30 mins", "5 Std. 20 Min.") + * into total minutes using language-specific patterns. + * + * @param runtimeText - Raw runtime string from Audible HTML + * @param langConfig - Language configuration with hour/minute regex patterns + * @returns Total minutes, or undefined if no duration could be parsed + */ +export function parseRuntime(runtimeText: string, langConfig: LanguageConfig): number | undefined { + if (!runtimeText) return undefined; + + let totalMinutes = 0; + + // Try each hour pattern until one matches + for (const pattern of langConfig.scraping.runtimeHourPatterns) { + const match = runtimeText.match(pattern); + if (match) { + totalMinutes += parseInt(match[1]) * 60; + break; + } + } + + // Try each minute pattern until one matches + for (const pattern of langConfig.scraping.runtimeMinutePatterns) { + const match = runtimeText.match(pattern); + if (match) { + totalMinutes += parseInt(match[1]); + break; + } + } + + return totalMinutes > 0 ? totalMinutes : undefined; +} diff --git a/tests/helpers/prisma.ts b/tests/helpers/prisma.ts index fc551c1..dfcb5ac 100644 --- a/tests/helpers/prisma.ts +++ b/tests/helpers/prisma.ts @@ -47,6 +47,8 @@ export const createPrismaMock = () => ({ bookDateSwipe: createModelMock(), goodreadsShelf: createModelMock(), goodreadsBookMapping: createModelMock(), + work: createModelMock(), + workAsin: createModelMock(), $queryRaw: vi.fn(), $disconnect: vi.fn(), }); diff --git a/tests/services/works.service.test.ts b/tests/services/works.service.test.ts new file mode 100644 index 0000000..5efca96 --- /dev/null +++ b/tests/services/works.service.test.ts @@ -0,0 +1,306 @@ +/** + * Component: Works Service Tests + * Documentation: documentation/integrations/audible.md + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createPrismaMock } from '../helpers/prisma'; +import type { DedupGroup } from '@/lib/utils/deduplicate-audiobooks'; + +const prismaMock = createPrismaMock(); + +vi.mock('@/lib/db', () => ({ + prisma: prismaMock, +})); + +vi.mock('@/lib/utils/logger', () => ({ + RMABLogger: { + create: () => ({ + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }), + }, +})); + +describe('persistDedupGroups', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.resetModules(); + }); + + it('creates new work + work_asins for a fresh group', async () => { + prismaMock.workAsin.findMany.mockResolvedValue([]); + prismaMock.work.create.mockResolvedValue({ id: 'work-1' }); + prismaMock.workAsin.create.mockResolvedValue({}); + prismaMock.workAsin.updateMany.mockResolvedValue({ count: 0 }); + + const { persistDedupGroups } = await import('@/lib/services/works.service'); + + const groups: DedupGroup[] = [{ + canonicalAsin: 'ASIN_A', + allAsins: ['ASIN_A', 'ASIN_B'], + title: 'Test Book', + author: 'Test Author', + narrator: 'Test Narrator', + durationMinutes: 600, + }]; + + await persistDedupGroups(groups); + + expect(prismaMock.work.create).toHaveBeenCalledWith({ + data: { title: 'Test Book', author: 'Test Author' }, + }); + expect(prismaMock.workAsin.create).toHaveBeenCalledTimes(2); + + // Canonical ASIN should have narrator, duration, isCanonical=true + expect(prismaMock.workAsin.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + workId: 'work-1', + asin: 'ASIN_A', + narrator: 'Test Narrator', + durationMinutes: 600, + isCanonical: true, + source: 'dedup_auto', + }), + }); + + // Non-canonical ASIN should have isCanonical=false + expect(prismaMock.workAsin.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + workId: 'work-1', + asin: 'ASIN_B', + isCanonical: false, + source: 'dedup_auto', + }), + }); + }); + + it('adds new ASINs to existing work when canonical already exists', async () => { + prismaMock.workAsin.findMany.mockResolvedValue([ + { asin: 'ASIN_A', workId: 'existing-work' }, + ]); + prismaMock.workAsin.create.mockResolvedValue({}); + prismaMock.workAsin.updateMany.mockResolvedValue({ count: 1 }); + + const { persistDedupGroups } = await import('@/lib/services/works.service'); + + const groups: DedupGroup[] = [{ + canonicalAsin: 'ASIN_A', + allAsins: ['ASIN_A', 'ASIN_B', 'ASIN_C'], + title: 'Test Book', + author: 'Test Author', + narrator: 'Narrator', + durationMinutes: 500, + }]; + + await persistDedupGroups(groups); + + // Should NOT create a new work + expect(prismaMock.work.create).not.toHaveBeenCalled(); + + // Should create entries for ASIN_B and ASIN_C only (ASIN_A already exists) + expect(prismaMock.workAsin.create).toHaveBeenCalledTimes(2); + expect(prismaMock.workAsin.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + workId: 'existing-work', + asin: 'ASIN_B', + }), + }); + expect(prismaMock.workAsin.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + workId: 'existing-work', + asin: 'ASIN_C', + }), + }); + }); + + it('merges two separate works when dedup groups them together', async () => { + // ASIN_A is in work-1, ASIN_B is in work-2 + prismaMock.workAsin.findMany.mockResolvedValue([ + { asin: 'ASIN_A', workId: 'work-1' }, + { asin: 'ASIN_B', workId: 'work-2' }, + ]); + prismaMock.workAsin.updateMany.mockResolvedValue({ count: 1 }); + prismaMock.work.deleteMany.mockResolvedValue({ count: 1 }); + + const { persistDedupGroups } = await import('@/lib/services/works.service'); + + const groups: DedupGroup[] = [{ + canonicalAsin: 'ASIN_A', + allAsins: ['ASIN_A', 'ASIN_B'], + title: 'Merged Book', + author: 'Author', + }]; + + await persistDedupGroups(groups); + + // Should move work-2 ASINs to work-1 + expect(prismaMock.workAsin.updateMany).toHaveBeenCalledWith({ + where: { workId: { in: ['work-2'] } }, + data: { workId: 'work-1' }, + }); + + // Should delete work-2 + expect(prismaMock.work.deleteMany).toHaveBeenCalledWith({ + where: { id: { in: ['work-2'] } }, + }); + }); + + it('silently catches and logs errors without throwing', async () => { + prismaMock.workAsin.findMany.mockRejectedValue(new Error('DB connection failed')); + + const { persistDedupGroups } = await import('@/lib/services/works.service'); + + const groups: DedupGroup[] = [{ + canonicalAsin: 'ASIN_A', + allAsins: ['ASIN_A', 'ASIN_B'], + title: 'Test', + author: 'Auth', + }]; + + // Should not throw + await expect(persistDedupGroups(groups)).resolves.toBeUndefined(); + }); +}); + +describe('seedAsin', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.resetModules(); + }); + + it('creates single-ASIN work for new ASIN', async () => { + prismaMock.workAsin.findUnique.mockResolvedValue(null); + prismaMock.work.create.mockResolvedValue({ id: 'new-work' }); + prismaMock.workAsin.create.mockResolvedValue({}); + + const { seedAsin } = await import('@/lib/services/works.service'); + + await seedAsin('NEW_ASIN', 'New Book', 'Author', 'Narrator', 300); + + expect(prismaMock.work.create).toHaveBeenCalledWith({ + data: { title: 'New Book', author: 'Author' }, + }); + expect(prismaMock.workAsin.create).toHaveBeenCalledWith({ + data: { + workId: 'new-work', + asin: 'NEW_ASIN', + narrator: 'Narrator', + durationMinutes: 300, + isCanonical: true, + source: 'dedup_auto', + }, + }); + }); + + it('does nothing for already-tracked ASIN', async () => { + prismaMock.workAsin.findUnique.mockResolvedValue({ + id: 'existing', + asin: 'EXISTING_ASIN', + workId: 'work-1', + }); + + const { seedAsin } = await import('@/lib/services/works.service'); + + await seedAsin('EXISTING_ASIN', 'Book', 'Author'); + + expect(prismaMock.work.create).not.toHaveBeenCalled(); + expect(prismaMock.workAsin.create).not.toHaveBeenCalled(); + }); + + it('silently catches and logs errors without throwing', async () => { + prismaMock.workAsin.findUnique.mockRejectedValue(new Error('DB error')); + + const { seedAsin } = await import('@/lib/services/works.service'); + + await expect(seedAsin('ASIN', 'Book', 'Auth')).resolves.toBeUndefined(); + }); +}); + +describe('getSiblingAsins', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.resetModules(); + }); + + it('returns sibling ASINs correctly', async () => { + // First query: find input ASINs and their work IDs + prismaMock.workAsin.findMany + .mockResolvedValueOnce([ + { asin: 'ASIN_A', workId: 'work-1' }, + { asin: 'ASIN_C', workId: 'work-2' }, + ]) + // Second query: all ASINs in those works + .mockResolvedValueOnce([ + { asin: 'ASIN_A', workId: 'work-1' }, + { asin: 'ASIN_B', workId: 'work-1' }, + { asin: 'ASIN_C', workId: 'work-2' }, + { asin: 'ASIN_D', workId: 'work-2' }, + { asin: 'ASIN_E', workId: 'work-2' }, + ]); + + const { getSiblingAsins } = await import('@/lib/services/works.service'); + + const result = await getSiblingAsins(['ASIN_A', 'ASIN_C']); + + expect(result.get('ASIN_A')).toEqual(['ASIN_B']); + expect(result.get('ASIN_C')).toEqual(['ASIN_D', 'ASIN_E']); + }); + + it('returns empty map for unknown ASINs', async () => { + prismaMock.workAsin.findMany.mockResolvedValue([]); + + const { getSiblingAsins } = await import('@/lib/services/works.service'); + + const result = await getSiblingAsins(['UNKNOWN']); + + expect(result.size).toBe(0); + }); + + it('returns empty map for empty input', async () => { + const { getSiblingAsins } = await import('@/lib/services/works.service'); + + const result = await getSiblingAsins([]); + + expect(result.size).toBe(0); + // Should not query DB + expect(prismaMock.workAsin.findMany).not.toHaveBeenCalled(); + }); + + it('excludes the input ASIN itself from siblings', async () => { + prismaMock.workAsin.findMany + .mockResolvedValueOnce([ + { asin: 'ASIN_A', workId: 'work-1' }, + ]) + .mockResolvedValueOnce([ + { asin: 'ASIN_A', workId: 'work-1' }, + { asin: 'ASIN_B', workId: 'work-1' }, + ]); + + const { getSiblingAsins } = await import('@/lib/services/works.service'); + + const result = await getSiblingAsins(['ASIN_A']); + + expect(result.get('ASIN_A')).toEqual(['ASIN_B']); + expect(result.get('ASIN_A')).not.toContain('ASIN_A'); + }); + + it('omits ASINs with no siblings (single-ASIN works)', async () => { + prismaMock.workAsin.findMany + .mockResolvedValueOnce([ + { asin: 'ASIN_LONELY', workId: 'work-solo' }, + ]) + .mockResolvedValueOnce([ + { asin: 'ASIN_LONELY', workId: 'work-solo' }, + ]); + + const { getSiblingAsins } = await import('@/lib/services/works.service'); + + const result = await getSiblingAsins(['ASIN_LONELY']); + + // No siblings means it shouldn't be in the map at all + expect(result.has('ASIN_LONELY')).toBe(false); + }); +}); diff --git a/tests/utils/deduplicate-audiobooks.test.ts b/tests/utils/deduplicate-audiobooks.test.ts new file mode 100644 index 0000000..c60a4a5 --- /dev/null +++ b/tests/utils/deduplicate-audiobooks.test.ts @@ -0,0 +1,434 @@ +/** + * Component: Audiobook Deduplication Tests + * Documentation: documentation/integrations/audible.md + */ + +import { describe, expect, it } from 'vitest'; +import { + deduplicateAudiobooks, + deduplicateAndCollectGroups, + normalizeTitle, + areDurationsCompatible, +} from '@/lib/utils/deduplicate-audiobooks'; +import type { AudibleAudiobook } from '@/lib/integrations/audible.service'; + +// --------------------------------------------------------------------------- +// Helper: minimal AudibleAudiobook factory +// --------------------------------------------------------------------------- + +function makeBook(overrides: Partial & { asin: string; title: string; author: string }): AudibleAudiobook { + return { + narrator: undefined, + coverArtUrl: undefined, + durationMinutes: undefined, + rating: undefined, + description: undefined, + releaseDate: undefined, + genres: undefined, + series: undefined, + seriesPart: undefined, + seriesAsin: undefined, + authorAsin: undefined, + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// normalizeTitle +// --------------------------------------------------------------------------- + +describe('normalizeTitle', () => { + it('lowercases', () => { + expect(normalizeTitle('The Black Prism')).toBe('the black prism'); + }); + + it('strips (Unabridged)', () => { + expect(normalizeTitle('The Black Prism (Unabridged)')).toBe('the black prism'); + }); + + it('strips [Abridged Edition]', () => { + expect(normalizeTitle('The Black Prism [Abridged Edition]')).toBe('the black prism'); + }); + + it('strips (2024 Remastered Edition)', () => { + expect(normalizeTitle('The Hobbit (2024 Remastered Edition)')).toBe('the hobbit'); + }); + + it('strips subtitle after colon', () => { + expect(normalizeTitle('The Black Prism: Lightbringer, Book 1')).toBe('the black prism'); + }); + + it('strips subtitle after long dash', () => { + expect(normalizeTitle('The Black Prism \u2014 A Lightbringer Novel')).toBe('the black prism'); + }); + + it('strips trailing "A Novel"', () => { + expect(normalizeTitle('The Black Prism: A Novel')).toBe('the black prism'); + }); + + it('strips (Audiobook)', () => { + expect(normalizeTitle('The Hobbit (Audiobook)')).toBe('the hobbit'); + }); + + it('strips (Dramatized Adaptation)', () => { + expect(normalizeTitle('The Black Prism (Dramatized Adaptation)')).toBe('the black prism'); + }); + + it('strips (Full Cast Narration)', () => { + expect(normalizeTitle('The Black Prism (Full Cast Narration)')).toBe('the black prism'); + }); + + it('collapses whitespace', () => { + expect(normalizeTitle(' The Black Prism ')).toBe('the black prism'); + }); + + it('handles empty string', () => { + expect(normalizeTitle('')).toBe(''); + }); + + it('preserves hyphenated words (not subtitles)', () => { + // "well-known" has a short dash, not a subtitle separator + expect(normalizeTitle('A Well-Known Book')).toBe('a well-known book'); + }); +}); + +// --------------------------------------------------------------------------- +// areDurationsCompatible +// --------------------------------------------------------------------------- + +describe('areDurationsCompatible', () => { + it('returns true when both undefined', () => { + expect(areDurationsCompatible(undefined, undefined)).toBe(true); + }); + + it('returns true when one undefined', () => { + expect(areDurationsCompatible(600, undefined)).toBe(true); + expect(areDurationsCompatible(undefined, 600)).toBe(true); + }); + + it('returns true for identical durations', () => { + expect(areDurationsCompatible(600, 600)).toBe(true); + }); + + it('uses 1% of longer duration as tolerance for long books', () => { + // Two 40-hour books (2400 min): tolerance = max(2400*0.01, 5) = 24 min + expect(areDurationsCompatible(2400, 2424)).toBe(true); // exactly at tolerance + expect(areDurationsCompatible(2400, 2425)).toBe(false); // just over + }); + + it('uses 5-minute minimum tolerance for short books', () => { + // Two 2-hour books (120 min): tolerance = max(120*0.01, 5) = max(1.2, 5) = 5 min + expect(areDurationsCompatible(120, 125)).toBe(true); // exactly at 5-min minimum + expect(areDurationsCompatible(120, 126)).toBe(false); // just over + }); + + it('keeps abridged vs unabridged separate (large duration gap)', () => { + // Unabridged: 720 min (12 hrs), Abridged: 360 min (6 hrs) + expect(areDurationsCompatible(720, 360)).toBe(false); + }); + + it('symmetry: order does not matter', () => { + expect(areDurationsCompatible(2400, 2424)).toBe(true); + expect(areDurationsCompatible(2424, 2400)).toBe(true); + expect(areDurationsCompatible(120, 126)).toBe(false); + expect(areDurationsCompatible(126, 120)).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// deduplicateAudiobooks +// --------------------------------------------------------------------------- + +describe('deduplicateAudiobooks', () => { + it('returns empty array for empty input', () => { + expect(deduplicateAudiobooks([])).toEqual([]); + }); + + it('returns single book unchanged', () => { + const book = makeBook({ asin: 'A1', title: 'Book One', author: 'Author' }); + expect(deduplicateAudiobooks([book])).toEqual([book]); + }); + + it('passes through all-unique books unchanged', () => { + const books = [ + makeBook({ asin: 'A1', title: 'Book One', author: 'Auth', narrator: 'Nar A', durationMinutes: 600 }), + makeBook({ asin: 'A2', title: 'Book Two', author: 'Auth', narrator: 'Nar A', durationMinutes: 500 }), + makeBook({ asin: 'A3', title: 'Book Three', author: 'Auth', narrator: 'Nar B', durationMinutes: 700 }), + ]; + expect(deduplicateAudiobooks(books)).toHaveLength(3); + }); + + it('collapses simple duplicates (same title + narrator + similar duration)', () => { + const books = [ + makeBook({ asin: 'A1', title: 'The Black Prism', author: 'Brent Weeks', narrator: 'Simon Vance', durationMinutes: 1260 }), + makeBook({ asin: 'A2', title: 'The Black Prism', author: 'Brent Weeks', narrator: 'Simon Vance', durationMinutes: 1262 }), + ]; + const result = deduplicateAudiobooks(books); + expect(result).toHaveLength(1); + }); + + it('keeps books with different narrators (different production)', () => { + const books = [ + makeBook({ asin: 'A1', title: 'The Black Prism', author: 'Brent Weeks', narrator: 'Simon Vance', durationMinutes: 1260 }), + makeBook({ asin: 'A2', title: 'The Black Prism', author: 'Brent Weeks', narrator: 'Full Cast', durationMinutes: 480 }), + ]; + const result = deduplicateAudiobooks(books); + expect(result).toHaveLength(2); + }); + + it('keeps abridged vs unabridged (same narrator, very different duration)', () => { + const books = [ + makeBook({ asin: 'A1', title: 'The Hobbit', author: 'Tolkien', narrator: 'Andy Serkis', durationMinutes: 660 }), + makeBook({ asin: 'A2', title: 'The Hobbit', author: 'Tolkien', narrator: 'Andy Serkis', durationMinutes: 330 }), + ]; + const result = deduplicateAudiobooks(books); + expect(result).toHaveLength(2); + }); + + it('collapses when one book has missing duration', () => { + const books = [ + makeBook({ asin: 'A1', title: 'The Black Prism', author: 'Brent Weeks', narrator: 'Simon Vance', durationMinutes: 1260 }), + makeBook({ asin: 'A2', title: 'The Black Prism', author: 'Brent Weeks', narrator: 'Simon Vance', durationMinutes: undefined }), + ]; + const result = deduplicateAudiobooks(books); + expect(result).toHaveLength(1); + }); + + it('collapses when both books have missing duration', () => { + const books = [ + makeBook({ asin: 'A1', title: 'The Black Prism', author: 'Brent Weeks', narrator: 'Simon Vance' }), + makeBook({ asin: 'A2', title: 'The Black Prism', author: 'Brent Weeks', narrator: 'Simon Vance' }), + ]; + const result = deduplicateAudiobooks(books); + expect(result).toHaveLength(1); + }); + + it('collapses title variants with edition markers', () => { + const books = [ + makeBook({ asin: 'A1', title: 'The Black Prism (Unabridged)', author: 'Brent Weeks', narrator: 'Simon Vance', durationMinutes: 1260 }), + makeBook({ asin: 'A2', title: 'The Black Prism', author: 'Brent Weeks', narrator: 'Simon Vance', durationMinutes: 1258 }), + ]; + const result = deduplicateAudiobooks(books); + expect(result).toHaveLength(1); + }); + + it('collapses title variants with subtitles', () => { + const books = [ + makeBook({ asin: 'A1', title: 'The Black Prism: Lightbringer, Book 1', author: 'Brent Weeks', narrator: 'Simon Vance', durationMinutes: 1260 }), + makeBook({ asin: 'A2', title: 'The Black Prism', author: 'Brent Weeks', narrator: 'Simon Vance', durationMinutes: 1262 }), + ]; + const result = deduplicateAudiobooks(books); + expect(result).toHaveLength(1); + }); + + it('picks the representative with most metadata', () => { + const sparse = makeBook({ + asin: 'A1', title: 'The Black Prism', author: 'Brent Weeks', + narrator: 'Simon Vance', durationMinutes: 1260, + }); + const rich = makeBook({ + asin: 'A2', title: 'The Black Prism', author: 'Brent Weeks', + narrator: 'Simon Vance', durationMinutes: 1262, + coverArtUrl: 'https://img.jpg', rating: 4.5, description: 'Great book', + }); + const result = deduplicateAudiobooks([sparse, rich]); + expect(result).toHaveLength(1); + expect(result[0].asin).toBe('A2'); // rich entry wins + }); + + it('preserves original order (first-seen position)', () => { + const books = [ + makeBook({ asin: 'A1', title: 'Alpha', author: 'Auth', narrator: 'Nar', durationMinutes: 300 }), + makeBook({ asin: 'B1', title: 'Beta', author: 'Auth', narrator: 'Nar', durationMinutes: 400 }), + makeBook({ asin: 'A2', title: 'Alpha', author: 'Auth', narrator: 'Nar', durationMinutes: 302 }), + makeBook({ asin: 'C1', title: 'Charlie', author: 'Auth', narrator: 'Nar', durationMinutes: 500 }), + ]; + const result = deduplicateAudiobooks(books); + expect(result).toHaveLength(3); + expect(result.map(b => b.title)).toEqual(['Alpha', 'Beta', 'Charlie']); + }); + + it('handles Lightbringer-style scenario: unabridged + dramatized', () => { + // Simon Vance full narration (long) + const vance1 = makeBook({ + asin: 'SV1', title: 'The Black Prism', author: 'Brent Weeks', + narrator: 'Simon Vance', durationMinutes: 1260, + coverArtUrl: 'cover1.jpg', rating: 4.7, + }); + // Re-listed Simon Vance (same duration, different ASIN) + const vance2 = makeBook({ + asin: 'SV2', title: 'The Black Prism: Lightbringer Book 1', author: 'Brent Weeks', + narrator: 'Simon Vance', durationMinutes: 1262, + }); + // Dramatized with full cast (shorter, different narrator) + const drama = makeBook({ + asin: 'DR1', title: 'The Black Prism (Dramatized Adaptation)', author: 'Brent Weeks', + narrator: 'Full Cast', durationMinutes: 480, + coverArtUrl: 'cover-drama.jpg', + }); + + const result = deduplicateAudiobooks([vance1, vance2, drama]); + expect(result).toHaveLength(2); + // Simon Vance should collapse to 1, Full Cast stays + expect(result.find(b => b.narrator === 'Simon Vance')).toBeTruthy(); + expect(result.find(b => b.narrator === 'Full Cast')).toBeTruthy(); + // Should pick the richer entry for Simon Vance + const svResult = result.find(b => b.narrator === 'Simon Vance')!; + expect(svResult.asin).toBe('SV1'); // has cover + rating + }); + + it('uses percentage tolerance for very long audiobooks', () => { + // Two 40-hour books: tolerance = max(2400*0.01, 5) = 24 min + const books = [ + makeBook({ asin: 'A1', title: 'Long Book', author: 'Auth', narrator: 'Nar', durationMinutes: 2400 }), + makeBook({ asin: 'A2', title: 'Long Book', author: 'Auth', narrator: 'Nar', durationMinutes: 2420 }), + ]; + expect(deduplicateAudiobooks(books)).toHaveLength(1); + + // Beyond tolerance + const booksFar = [ + makeBook({ asin: 'A1', title: 'Long Book', author: 'Auth', narrator: 'Nar', durationMinutes: 2400 }), + makeBook({ asin: 'A2', title: 'Long Book', author: 'Auth', narrator: 'Nar', durationMinutes: 2430 }), + ]; + expect(deduplicateAudiobooks(booksFar)).toHaveLength(2); + }); + + it('treats missing narrator as its own group', () => { + // Two entries with same title but no narrator - should collapse + const books = [ + makeBook({ asin: 'A1', title: 'Test Book', author: 'Auth', narrator: undefined, durationMinutes: 300 }), + makeBook({ asin: 'A2', title: 'Test Book', author: 'Auth', narrator: undefined, durationMinutes: 302 }), + ]; + expect(deduplicateAudiobooks(books)).toHaveLength(1); + }); + + it('does not collapse empty-narrator with named narrator', () => { + const books = [ + makeBook({ asin: 'A1', title: 'Test Book', author: 'Auth', narrator: undefined, durationMinutes: 300 }), + makeBook({ asin: 'A2', title: 'Test Book', author: 'Auth', narrator: 'John Smith', durationMinutes: 302 }), + ]; + expect(deduplicateAudiobooks(books)).toHaveLength(2); + }); +}); + +// --------------------------------------------------------------------------- +// deduplicateAndCollectGroups +// --------------------------------------------------------------------------- + +describe('deduplicateAndCollectGroups', () => { + it('returns empty groups array when no duplicates', () => { + const books = [ + makeBook({ asin: 'A1', title: 'Book One', author: 'Auth', narrator: 'Nar A', durationMinutes: 600 }), + makeBook({ asin: 'A2', title: 'Book Two', author: 'Auth', narrator: 'Nar A', durationMinutes: 500 }), + ]; + const { books: result, groups } = deduplicateAndCollectGroups(books); + expect(result).toHaveLength(2); + expect(groups).toHaveLength(0); + }); + + it('returns empty groups for empty input', () => { + const { books: result, groups } = deduplicateAndCollectGroups([]); + expect(result).toHaveLength(0); + expect(groups).toHaveLength(0); + }); + + it('returns empty groups for single book', () => { + const book = makeBook({ asin: 'A1', title: 'Book One', author: 'Auth' }); + const { books: result, groups } = deduplicateAndCollectGroups([book]); + expect(result).toHaveLength(1); + expect(groups).toHaveLength(0); + }); + + it('returns group with 2 ASINs when 2 books match', () => { + const books = [ + makeBook({ asin: 'A1', title: 'The Black Prism', author: 'Brent Weeks', narrator: 'Simon Vance', durationMinutes: 1260 }), + makeBook({ asin: 'A2', title: 'The Black Prism', author: 'Brent Weeks', narrator: 'Simon Vance', durationMinutes: 1262 }), + ]; + const { books: result, groups } = deduplicateAndCollectGroups(books); + expect(result).toHaveLength(1); + expect(groups).toHaveLength(1); + expect(groups[0].allAsins).toHaveLength(2); + expect(groups[0].allAsins).toContain('A1'); + expect(groups[0].allAsins).toContain('A2'); + }); + + it('returns group with 3+ ASINs for multi-duplicate scenario', () => { + const books = [ + makeBook({ asin: 'A1', title: 'The Hobbit', author: 'Tolkien', narrator: 'Andy Serkis', durationMinutes: 660 }), + makeBook({ asin: 'A2', title: 'The Hobbit', author: 'Tolkien', narrator: 'Andy Serkis', durationMinutes: 662 }), + makeBook({ asin: 'A3', title: 'The Hobbit (Unabridged)', author: 'Tolkien', narrator: 'Andy Serkis', durationMinutes: 658 }), + ]; + const { books: result, groups } = deduplicateAndCollectGroups(books); + expect(result).toHaveLength(1); + expect(groups).toHaveLength(1); + expect(groups[0].allAsins).toHaveLength(3); + expect(groups[0].allAsins).toContain('A1'); + expect(groups[0].allAsins).toContain('A2'); + expect(groups[0].allAsins).toContain('A3'); + }); + + it('canonicalAsin is the one with highest metadata score', () => { + const sparse = makeBook({ + asin: 'SPARSE', title: 'The Black Prism', author: 'Brent Weeks', + narrator: 'Simon Vance', durationMinutes: 1260, + }); + const rich = makeBook({ + asin: 'RICH', title: 'The Black Prism', author: 'Brent Weeks', + narrator: 'Simon Vance', durationMinutes: 1262, + coverArtUrl: 'https://img.jpg', rating: 4.5, description: 'Great book', + }); + const { groups } = deduplicateAndCollectGroups([sparse, rich]); + expect(groups).toHaveLength(1); + expect(groups[0].canonicalAsin).toBe('RICH'); + }); + + it('groups only include entries with 2+ ASINs', () => { + const books = [ + makeBook({ asin: 'A1', title: 'Alpha', author: 'Auth', narrator: 'Nar', durationMinutes: 300 }), + makeBook({ asin: 'A2', title: 'Alpha', author: 'Auth', narrator: 'Nar', durationMinutes: 302 }), + makeBook({ asin: 'B1', title: 'Beta', author: 'Auth', narrator: 'Nar', durationMinutes: 500 }), + ]; + const { groups } = deduplicateAndCollectGroups(books); + // Only Alpha group should appear (Beta is a singleton) + expect(groups).toHaveLength(1); + expect(groups[0].allAsins).toContain('A1'); + expect(groups[0].allAsins).toContain('A2'); + }); + + it('duration-incompatible books produce separate entries (no group for singletons)', () => { + // Same title/narrator but very different durations (abridged vs unabridged) + const books = [ + makeBook({ asin: 'A1', title: 'The Hobbit', author: 'Tolkien', narrator: 'Andy Serkis', durationMinutes: 660 }), + makeBook({ asin: 'A2', title: 'The Hobbit', author: 'Tolkien', narrator: 'Andy Serkis', durationMinutes: 330 }), + ]; + const { books: result, groups } = deduplicateAndCollectGroups(books); + expect(result).toHaveLength(2); // Not collapsed + expect(groups).toHaveLength(0); // No multi-ASIN groups + }); + + it('books field matches what deduplicateAudiobooks returns', () => { + const books = [ + makeBook({ asin: 'A1', title: 'Alpha', author: 'Auth', narrator: 'Nar', durationMinutes: 300, coverArtUrl: 'img.jpg', rating: 4.5 }), + makeBook({ asin: 'A2', title: 'Alpha', author: 'Auth', narrator: 'Nar', durationMinutes: 302 }), + makeBook({ asin: 'B1', title: 'Beta', author: 'Auth', narrator: 'Nar', durationMinutes: 500 }), + makeBook({ asin: 'C1', title: 'Charlie', author: 'Auth', narrator: 'Nar', durationMinutes: 600 }), + makeBook({ asin: 'C2', title: 'Charlie', author: 'Auth', narrator: 'Nar', durationMinutes: 601 }), + ]; + const dedupOnly = deduplicateAudiobooks(books); + const { books: withGroups } = deduplicateAndCollectGroups(books); + expect(withGroups.map(b => b.asin)).toEqual(dedupOnly.map(b => b.asin)); + }); + + it('includes narrator and durationMinutes from canonical entry in group', () => { + const books = [ + makeBook({ asin: 'A1', title: 'Test Book', author: 'Auth', narrator: 'Jane Doe', durationMinutes: 480 }), + makeBook({ asin: 'A2', title: 'Test Book', author: 'Auth', narrator: 'Jane Doe', durationMinutes: 482, coverArtUrl: 'img.jpg', rating: 4.0 }), + ]; + const { groups } = deduplicateAndCollectGroups(books); + expect(groups).toHaveLength(1); + expect(groups[0].canonicalAsin).toBe('A2'); // richer metadata + expect(groups[0].narrator).toBe('Jane Doe'); + expect(groups[0].durationMinutes).toBe(482); + expect(groups[0].author).toBe('Auth'); + }); +}); From cbf02d3e24cd6757a110dab8cdd8519508262930 Mon Sep 17 00:00:00 2001 From: kikootwo Date: Tue, 3 Mar 2026 21:57:38 -0500 Subject: [PATCH 3/5] 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); + }); }); // --------------------------------------------------------------------------- From d0ce485bdc7e9d5fe508c5199b45dfaa9e18521d Mon Sep 17 00:00:00 2001 From: kikootwo Date: Wed, 4 Mar 2026 12:19:37 -0500 Subject: [PATCH 4/5] Enrich audiobook metadata from Audnexus Query Audnexus (Audible) to backfill missing metadata during manual imports and file organization. Adds getAudibleService imports and calls to fetch audiobook details by ASIN, then backfills series, seriesPart, seriesAsin, year (from releaseDate) and narrator when missing and updates the DB. Failures are non-fatal and logged; logs were added to surface enrichment steps. Also uses the resolved series/seriesPart when building organization metadata. --- src/app/api/admin/manual-import/route.ts | 43 +++++++ .../processors/organize-files.processor.ts | 112 +++++++++++++++++- 2 files changed, 152 insertions(+), 3 deletions(-) diff --git a/src/app/api/admin/manual-import/route.ts b/src/app/api/admin/manual-import/route.ts index d2aa482..dfbb6d0 100644 --- a/src/app/api/admin/manual-import/route.ts +++ b/src/app/api/admin/manual-import/route.ts @@ -12,6 +12,7 @@ import { prisma } from '@/lib/db'; import { getJobQueueService } from '@/lib/services/job-queue.service'; import { RMABLogger } from '@/lib/utils/logger'; import { AUDIO_EXTENSIONS } from '@/lib/constants/audio-formats'; +import { getAudibleService } from '@/lib/integrations/audible.service'; const logger = RMABLogger.create('API.Admin.ManualImport'); @@ -174,6 +175,48 @@ export async function POST(request: NextRequest) { ); } + // Enrich missing series/year data from Audnexus (mirrors request-creator.service.ts) + if (audiobook.audibleAsin && (!audiobook.series || !audiobook.year)) { + try { + const audibleService = getAudibleService(); + const audnexusData = await audibleService.getAudiobookDetails(audiobook.audibleAsin); + + if (audnexusData) { + const updates: Record = {}; + + if (!audiobook.series && audnexusData.series) { + updates.series = audnexusData.series; + } + if (!audiobook.seriesPart && audnexusData.seriesPart) { + updates.seriesPart = audnexusData.seriesPart; + } + if (!audiobook.seriesAsin && audnexusData.seriesAsin) { + updates.seriesAsin = audnexusData.seriesAsin; + } + if (!audiobook.year && audnexusData.releaseDate) { + const releaseYear = new Date(audnexusData.releaseDate).getFullYear(); + if (!isNaN(releaseYear)) { + updates.year = releaseYear; + } + } + if (!audiobook.narrator && audnexusData.narrator) { + updates.narrator = audnexusData.narrator; + } + + if (Object.keys(updates).length > 0) { + await prisma.audiobook.update({ + where: { id: audiobook.id }, + data: updates, + }); + logger.info(`Enriched audiobook metadata from Audnexus for ASIN ${audiobook.audibleAsin}`, updates); + } + } + } catch (error) { + // Non-fatal: series enrichment failure should never block the import + logger.warn(`Failed to enrich metadata from Audnexus for ASIN ${audiobook.audibleAsin}: ${error instanceof Error ? error.message : String(error)}`); + } + } + // Check for existing requests const existingRequest = await prisma.request.findFirst({ where: { diff --git a/src/lib/processors/organize-files.processor.ts b/src/lib/processors/organize-files.processor.ts index 3e6252b..99c3d56 100644 --- a/src/lib/processors/organize-files.processor.ts +++ b/src/lib/processors/organize-files.processor.ts @@ -15,6 +15,7 @@ import { PathMapper, PathMappingConfig } from '../utils/path-mapper'; import { generateFilesHash } from '../utils/files-hash'; import { fixEpubForKindle, cleanupFixedEpub } from '../utils/epub-fixer'; import { removeEmptyParentDirectories } from '../utils/cleanup-helpers'; +import { getAudibleService } from '../integrations/audible.service'; /** * Process organize files job @@ -118,7 +119,62 @@ export async function processOrganizeFiles(payload: OrganizeFilesPayload): Promi } } - logger.info(`Final metadata for path organization: year=${year || 'null'}, narrator=${narrator || 'null'}`) + // Enrich missing series data from Audnexus (safety net for records created without series) + let series = audiobook.series || undefined; + let seriesPart = audiobook.seriesPart || undefined; + + if (audiobook.audibleAsin && !series) { + try { + logger.info(`Missing series data, fetching from Audnexus for ASIN: ${audiobook.audibleAsin}`); + const audibleService = getAudibleService(); + const audnexusData = await audibleService.getAudiobookDetails(audiobook.audibleAsin); + + if (audnexusData) { + const updates: Record = {}; + + if (audnexusData.series) { + series = audnexusData.series; + updates.series = series; + logger.info(`Got series "${series}" from Audnexus`); + } + if (audnexusData.seriesPart) { + seriesPart = audnexusData.seriesPart; + updates.seriesPart = seriesPart; + logger.info(`Got seriesPart "${seriesPart}" from Audnexus`); + } + if (audnexusData.seriesAsin) { + updates.seriesAsin = audnexusData.seriesAsin; + } + // Also backfill year/narrator if still missing + if (!year && audnexusData.releaseDate) { + const releaseYear = new Date(audnexusData.releaseDate).getFullYear(); + if (!isNaN(releaseYear)) { + year = releaseYear; + updates.year = year; + logger.info(`Got year ${year} from Audnexus`); + } + } + if (!narrator && audnexusData.narrator) { + narrator = audnexusData.narrator; + updates.narrator = narrator; + logger.info(`Got narrator "${narrator}" from Audnexus`); + } + + if (Object.keys(updates).length > 0) { + await prisma.audiobook.update({ + where: { id: audiobookId }, + data: updates, + }); + logger.info(`Updated audiobook record with Audnexus metadata`); + } + } + } catch (error) { + // Non-fatal: missing series won't block organization, just degrades path quality + logger.warn(`Failed to fetch Audnexus data for ASIN ${audiobook.audibleAsin}: ${error instanceof Error ? error.message : String(error)}`); + } + } + + logger.info(`Final metadata for path organization: year=${year || 'null'}, narrator=${narrator || 'null'}, series=${series || 'null'}, seriesPart=${seriesPart || 'null'}`); // Get file organizer (reads media_dir from database config) const organizer = await getFileOrganizer(); @@ -151,8 +207,8 @@ export async function processOrganizeFiles(payload: OrganizeFilesPayload): Promi coverArtUrl: audiobook.coverArtUrl || undefined, asin: audiobook.audibleAsin || undefined, year, - series: audiobook.series || undefined, - seriesPart: audiobook.seriesPart || undefined, + series, + seriesPart, }, template, jobId ? { jobId, context: 'FileOrganizer' } : undefined, @@ -545,6 +601,56 @@ async function processEbookOrganization( } } + // Enrich missing series data from Audnexus (safety net for records created without series) + if (book.audibleAsin && !series) { + try { + logger.info(`Missing series data for ebook, fetching from Audnexus for ASIN: ${book.audibleAsin}`); + const audibleService = getAudibleService(); + const audnexusData = await audibleService.getAudiobookDetails(book.audibleAsin); + + if (audnexusData) { + const updates: Record = {}; + + if (audnexusData.series) { + series = audnexusData.series; + updates.series = series; + logger.info(`Got series "${series}" from Audnexus`); + } + if (audnexusData.seriesPart) { + seriesPart = audnexusData.seriesPart; + updates.seriesPart = seriesPart; + logger.info(`Got seriesPart "${seriesPart}" from Audnexus`); + } + if (audnexusData.seriesAsin) { + updates.seriesAsin = audnexusData.seriesAsin; + } + if (!year && audnexusData.releaseDate) { + const releaseYear = new Date(audnexusData.releaseDate).getFullYear(); + if (!isNaN(releaseYear)) { + year = releaseYear; + updates.year = year; + logger.info(`Got year ${year} from Audnexus`); + } + } + if (!narrator && audnexusData.narrator) { + narrator = audnexusData.narrator; + updates.narrator = narrator; + logger.info(`Got narrator "${narrator}" from Audnexus`); + } + + if (Object.keys(updates).length > 0) { + await prisma.audiobook.update({ + where: { id: audiobookId }, + data: updates, + }); + logger.info(`Updated book record with Audnexus metadata`); + } + } + } catch (error) { + logger.warn(`Failed to fetch Audnexus data for ASIN ${book.audibleAsin}: ${error instanceof Error ? error.message : String(error)}`); + } + } + logger.info(`Final metadata for path organization: year=${year || 'null'}, narrator=${narrator || 'null'}, series=${series || 'null'}, seriesPart=${seriesPart || 'null'}`); // Check if this is an indexer download (needs to keep source for seeding) From 441724c378ed08cf6050879fba7291bf9c7023c8 Mon Sep 17 00:00:00 2001 From: kikootwo Date: Wed, 4 Mar 2026 12:47:09 -0500 Subject: [PATCH 5/5] Normalize local usernames to lowercase Normalize local account usernames by trimming and lowercasing across the stack. Added a Prisma migration to lowercase existing plex_username and rewrite local plex_id values for non-deleted accounts. Updated LocalAuthProvider, admin login route, and setup completion to use normalized usernames when looking up, creating, and storing users (including plexId `local-{username}`). Added/updated tests to assert case-insensitive lookups, storage of lowercased usernames/plexIds, and duplicate username rejection. --- .../migration.sql | 3 + src/app/api/auth/admin/login/route.ts | 4 +- src/app/api/setup/complete/route.ts | 5 +- src/lib/services/auth/LocalAuthProvider.ts | 13 ++-- .../services/auth/local-auth-provider.test.ts | 70 +++++++++++++++++++ 5 files changed, 87 insertions(+), 8 deletions(-) create mode 100644 prisma/migrations/20260304000000_normalize_local_usernames/migration.sql diff --git a/prisma/migrations/20260304000000_normalize_local_usernames/migration.sql b/prisma/migrations/20260304000000_normalize_local_usernames/migration.sql new file mode 100644 index 0000000..5fbb0c2 --- /dev/null +++ b/prisma/migrations/20260304000000_normalize_local_usernames/migration.sql @@ -0,0 +1,3 @@ +-- Normalize existing local usernames to lowercase +UPDATE users SET plex_username = LOWER(plex_username) WHERE auth_provider = 'local' AND deleted_at IS NULL; +UPDATE users SET plex_id = 'local-' || LOWER(SUBSTRING(plex_id FROM 7)) WHERE plex_id LIKE 'local-%' AND plex_id NOT LIKE 'local-%-deleted-%'; diff --git a/src/app/api/auth/admin/login/route.ts b/src/app/api/auth/admin/login/route.ts index 291bb20..46310c5 100644 --- a/src/app/api/auth/admin/login/route.ts +++ b/src/app/api/auth/admin/login/route.ts @@ -38,9 +38,11 @@ export async function POST(request: NextRequest) { ); } + const normalizedUsername = username.trim().toLowerCase(); + // Find user by local admin identifier const user = await prisma.user.findUnique({ - where: { plexId: `local-${username}` }, + where: { plexId: `local-${normalizedUsername}` }, }); if (!user) { diff --git a/src/app/api/setup/complete/route.ts b/src/app/api/setup/complete/route.ts index 24dd593..637e0da 100644 --- a/src/app/api/setup/complete/route.ts +++ b/src/app/api/setup/complete/route.ts @@ -140,14 +140,15 @@ export async function POST(request: NextRequest) { ); } + const normalizedAdminUsername = admin.username.trim().toLowerCase(); const hashedPassword = await bcrypt.hash(admin.password, 10); const encryptionService = getEncryptionService(); const encryptedPassword = encryptionService.encrypt(hashedPassword); adminUser = await prisma.user.create({ data: { - plexId: `local-${admin.username}`, - plexUsername: admin.username, + plexId: `local-${normalizedAdminUsername}`, + plexUsername: normalizedAdminUsername, plexEmail: null, role: 'admin', isSetupAdmin: true, // Mark as setup admin - role cannot be changed diff --git a/src/lib/services/auth/LocalAuthProvider.ts b/src/lib/services/auth/LocalAuthProvider.ts index 1fbc929..c3dddb6 100644 --- a/src/lib/services/auth/LocalAuthProvider.ts +++ b/src/lib/services/auth/LocalAuthProvider.ts @@ -54,10 +54,12 @@ export class LocalAuthProvider implements IAuthProvider { return { success: false, error: 'Username and password required' }; } + const normalizedUsername = username.trim().toLowerCase(); + // Find user (exclude soft-deleted users) const user = await prisma.user.findFirst({ where: { - plexUsername: username, + plexUsername: normalizedUsername, authProvider: 'local', deletedAt: null, // Exclude soft-deleted users }, @@ -144,9 +146,10 @@ export class LocalAuthProvider implements IAuthProvider { async register(params: RegisterParams): Promise { try { const { username, password } = params; + const normalizedUsername = username?.trim().toLowerCase(); // Validate - if (!username || username.length < 3) { + if (!normalizedUsername || normalizedUsername.length < 3) { return { success: false, error: 'Username must be at least 3 characters' }; } @@ -167,7 +170,7 @@ export class LocalAuthProvider implements IAuthProvider { // Check username uniqueness (only among non-deleted users) const existing = await prisma.user.findFirst({ where: { - plexUsername: username, + plexUsername: normalizedUsername, authProvider: 'local', deletedAt: null, // Allow reuse of usernames from deleted accounts }, @@ -194,8 +197,8 @@ export class LocalAuthProvider implements IAuthProvider { // Create user const user = await prisma.user.create({ data: { - plexId: `local-${username}`, - plexUsername: username, + plexId: `local-${normalizedUsername}`, + plexUsername: normalizedUsername, authToken: encryptedHash, authProvider: 'local', role: isFirstUser ? 'admin' : 'user', diff --git a/tests/services/auth/local-auth-provider.test.ts b/tests/services/auth/local-auth-provider.test.ts index ff7a16e..f1e0a11 100644 --- a/tests/services/auth/local-auth-provider.test.ts +++ b/tests/services/auth/local-auth-provider.test.ts @@ -167,6 +167,31 @@ describe('LocalAuthProvider', () => { expect(result.error).toMatch(/invalid username or password/i); }); + it('normalizes username to lowercase on login', async () => { + prismaMock.user.findFirst.mockResolvedValue({ + id: 'user-ci', + plexId: 'local-admin', + plexUsername: 'admin', + role: 'admin', + authProvider: 'local', + authToken: 'enc:hash', + registrationStatus: 'approved', + deletedAt: null, + }); + prismaMock.user.update.mockResolvedValue({}); + bcryptCompare.mockResolvedValue(true); + + const { LocalAuthProvider } = await import('@/lib/services/auth/LocalAuthProvider'); + const provider = new LocalAuthProvider(); + await provider.handleCallback({ username: 'Admin', password: 'pass' }); + + expect(prismaMock.user.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ plexUsername: 'admin' }), + }) + ); + }); + it('blocks registration when disabled', async () => { configMock.get.mockResolvedValueOnce('false'); @@ -237,6 +262,51 @@ describe('LocalAuthProvider', () => { expect(result.error).toContain('Username already taken'); }); + it('stores lowercase username and plexId on registration', async () => { + configMock.get.mockResolvedValueOnce('true'); // registration enabled + configMock.get.mockResolvedValueOnce('false'); // no admin approval + prismaMock.user.findFirst.mockResolvedValue(null); + prismaMock.user.count.mockResolvedValue(1); + prismaMock.user.create.mockResolvedValue({ + id: 'user-ci2', + plexId: 'local-myuser', + plexUsername: 'myuser', + role: 'user', + }); + bcryptHash.mockResolvedValue('hash'); + + const { LocalAuthProvider } = await import('@/lib/services/auth/LocalAuthProvider'); + const provider = new LocalAuthProvider(); + await provider.register({ username: 'MyUser', password: 'password123' }); + + expect(prismaMock.user.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + plexId: 'local-myuser', + plexUsername: 'myuser', + }), + }) + ); + }); + + it('rejects duplicate username case-insensitively on registration', async () => { + configMock.get.mockResolvedValueOnce('true'); // registration enabled + prismaMock.user.findFirst.mockResolvedValue({ id: 'user-existing' }); + + const { LocalAuthProvider } = await import('@/lib/services/auth/LocalAuthProvider'); + const provider = new LocalAuthProvider(); + const result = await provider.register({ username: 'User', password: 'password123' }); + + expect(result.success).toBe(false); + expect(result.error).toContain('Username already taken'); + // The lookup should use the lowercased username + expect(prismaMock.user.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ plexUsername: 'user' }), + }) + ); + }); + it('creates admin user on first registration', async () => { configMock.get.mockResolvedValueOnce('true'); // registration enabled configMock.get.mockResolvedValueOnce('false'); // no admin approval