mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-02 20:30:10 +00:00
Merge branch 'main' into feature/hardover-shelves
This commit is contained in:
@@ -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;
|
||||
@@ -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;
|
||||
@@ -69,6 +69,8 @@ model User {
|
||||
hardcoverShelves HardcoverShelf[]
|
||||
reportedIssues ReportedIssue[] @relation("Reporter")
|
||||
resolvedIssues ReportedIssue[] @relation("Resolver")
|
||||
watchedSeries WatchedSeries[]
|
||||
watchedAuthors WatchedAuthor[]
|
||||
|
||||
@@index([plexId])
|
||||
@@index([role])
|
||||
@@ -574,3 +576,87 @@ model HardcoverBookMapping {
|
||||
@@index([audibleAsin])
|
||||
@@map("hardcover_book_mappings")
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// WORKS TABLE
|
||||
// Cross-ASIN audiobook identity mapping — links multiple Audible ASINs
|
||||
// to a single logical work for library matching across editions.
|
||||
// Documentation: documentation/integrations/audible.md
|
||||
// ============================================================================
|
||||
|
||||
model Work {
|
||||
id String @id @default(uuid())
|
||||
title String
|
||||
author String
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
// Relations
|
||||
asins WorkAsin[]
|
||||
|
||||
@@index([title])
|
||||
@@index([author])
|
||||
@@map("works")
|
||||
}
|
||||
|
||||
model WorkAsin {
|
||||
id String @id @default(uuid())
|
||||
workId String @map("work_id")
|
||||
asin String @unique
|
||||
narrator String?
|
||||
durationMinutes Int? @map("duration_minutes")
|
||||
isCanonical Boolean @default(false) @map("is_canonical")
|
||||
source String // 'dedup_auto' | 'admin_manual'
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
// Relations
|
||||
work Work @relation(fields: [workId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([workId])
|
||||
@@index([asin])
|
||||
@@map("work_asins")
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// WATCHED LISTS TABLES
|
||||
// Per-user series and author subscriptions for automatic new-release requests.
|
||||
// Documentation: documentation/features/watched-lists.md
|
||||
// ============================================================================
|
||||
|
||||
model WatchedSeries {
|
||||
id String @id @default(uuid())
|
||||
userId String @map("user_id")
|
||||
seriesAsin String @map("series_asin")
|
||||
seriesTitle String @map("series_title")
|
||||
coverArtUrl String? @map("cover_art_url") @db.Text
|
||||
lastCheckedAt DateTime? @map("last_checked_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
// Relations
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([userId, seriesAsin])
|
||||
@@index([userId])
|
||||
@@index([seriesAsin])
|
||||
@@map("watched_series")
|
||||
}
|
||||
|
||||
model WatchedAuthor {
|
||||
id String @id @default(uuid())
|
||||
userId String @map("user_id")
|
||||
authorAsin String @map("author_asin")
|
||||
authorName String @map("author_name")
|
||||
coverArtUrl String? @map("cover_art_url") @db.Text
|
||||
lastCheckedAt DateTime? @map("last_checked_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
// Relations
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([userId, authorAsin])
|
||||
@@index([userId])
|
||||
@@index([authorAsin])
|
||||
@@map("watched_authors")
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
]);
|
||||
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
]);
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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})`);
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
}
|
||||
+36
-31
@@ -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() {
|
||||
</div>
|
||||
) : (
|
||||
<AudiobookGrid
|
||||
audiobooks={filteredPopular}
|
||||
audiobooks={popular}
|
||||
isLoading={loadingPopular}
|
||||
emptyMessage="No popular audiobooks available"
|
||||
cardSize={cardSize}
|
||||
@@ -145,7 +141,7 @@ export default function HomePage() {
|
||||
</div>
|
||||
) : (
|
||||
<AudiobookGrid
|
||||
audiobooks={filteredNewReleases}
|
||||
audiobooks={newReleases}
|
||||
isLoading={loadingNewReleases}
|
||||
emptyMessage="No new releases available"
|
||||
cardSize={cardSize}
|
||||
@@ -181,22 +177,31 @@ export default function HomePage() {
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
{/* Sticky Pagination Controls */}
|
||||
<StickyPagination
|
||||
currentPage={popularPage}
|
||||
totalPages={popularTotalPages}
|
||||
onPageChange={handlePopularPageChange}
|
||||
sectionRef={popularSectionRef}
|
||||
{/* Unified Pagination — single context-aware pill for both sections */}
|
||||
<UnifiedPagination
|
||||
footerRef={footerRef}
|
||||
label="Popular Audiobooks"
|
||||
/>
|
||||
<StickyPagination
|
||||
currentPage={newReleasesPage}
|
||||
totalPages={newReleasesTotalPages}
|
||||
onPageChange={handleNewReleasesPageChange}
|
||||
sectionRef={newReleasesSectionRef}
|
||||
footerRef={footerRef}
|
||||
label="New Releases"
|
||||
sections={[
|
||||
{
|
||||
label: 'Popular Audiobooks',
|
||||
accentColor: 'bg-blue-500',
|
||||
currentPage: popularPage,
|
||||
totalPages: popularTotalPages,
|
||||
onPageChange: handlePopularPageChange,
|
||||
sectionRef: popularSectionRef,
|
||||
onScrollToSection: () =>
|
||||
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' }),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</ProtectedRoute>
|
||||
|
||||
@@ -12,6 +12,7 @@ import { useAuth } from '@/contexts/AuthContext';
|
||||
import { useRequests } from '@/lib/hooks/useRequests';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { ShelvesSection } from '@/components/profile/ShelvesSection';
|
||||
import { WatchedSeriesSection, WatchedAuthorsSection } from '@/components/profile/WatchedListsSection';
|
||||
|
||||
const statConfig = [
|
||||
{ key: 'total', label: 'Total', color: 'text-gray-900 dark:text-white' },
|
||||
@@ -142,6 +143,12 @@ export default function ProfilePage() {
|
||||
{/* Generic Shelves Section */}
|
||||
<ShelvesSection />
|
||||
|
||||
{/* Watched Series */}
|
||||
<WatchedSeriesSection />
|
||||
|
||||
{/* Watched Authors */}
|
||||
<WatchedAuthorsSection />
|
||||
|
||||
{/* Active Downloads */}
|
||||
{activeDownloads.length > 0 && (
|
||||
<section>
|
||||
|
||||
@@ -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) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Audible Link */}
|
||||
{author.audibleUrl && (
|
||||
<a
|
||||
href={author.audibleUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-3 inline-flex items-center gap-1.5 text-sm text-gray-500 dark:text-gray-400 hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors"
|
||||
>
|
||||
View on Audible
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
</a>
|
||||
)}
|
||||
{/* Actions row: Audible link + Watch button */}
|
||||
<div className="mt-3 flex flex-wrap items-center justify-center sm:justify-start gap-3">
|
||||
{author.audibleUrl && (
|
||||
<a
|
||||
href={author.audibleUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 text-sm text-gray-500 dark:text-gray-400 hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors"
|
||||
>
|
||||
View on Audible
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
</a>
|
||||
)}
|
||||
<WatchAuthorButton
|
||||
authorAsin={author.asin}
|
||||
authorName={author.name}
|
||||
coverArtUrl={author.image}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{author.description && (
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await deleteSeries(id);
|
||||
setConfirmDeleteId(null);
|
||||
} catch {
|
||||
// Error handled by hook
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<section>
|
||||
<SectionHeader title="Watched Series" icon="series" count={null} />
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{[1, 2].map((i) => <CardSkeleton key={i} squareCovers={squareCovers} />)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (series.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section>
|
||||
<SectionHeader title="Watched Series" icon="series" count={series.length} />
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{series.map((item) => (
|
||||
<WatchedSeriesCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
squareCovers={squareCovers}
|
||||
isDeleting={isDeleting && confirmDeleteId === item.id}
|
||||
confirmingDelete={confirmDeleteId === item.id}
|
||||
onNavigate={() => router.push(`/series/${item.seriesAsin}`)}
|
||||
onConfirmDelete={() => setConfirmDeleteId(item.id)}
|
||||
onCancelDelete={() => setConfirmDeleteId(null)}
|
||||
onDelete={() => handleDelete(item.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="rounded-xl bg-white dark:bg-gray-800 border border-gray-100 dark:border-gray-700/50 p-4 flex gap-4 hover:shadow-sm transition-shadow">
|
||||
{/* Cover */}
|
||||
<button onClick={onNavigate} className="flex-shrink-0">
|
||||
<div className={`relative w-14 ${squareCovers ? 'aspect-square' : 'aspect-[2/3]'} rounded-lg overflow-hidden bg-gradient-to-br from-emerald-100 to-teal-200 dark:from-emerald-900 dark:to-teal-900`}>
|
||||
{item.coverArtUrl ? (
|
||||
<Image src={item.coverArtUrl} alt={item.seriesTitle} fill className="object-cover" sizes="56px" />
|
||||
) : (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<button onClick={onNavigate} className="text-left">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white truncate hover:text-emerald-600 dark:hover:text-emerald-400 transition-colors">
|
||||
{item.seriesTitle}
|
||||
</h3>
|
||||
</button>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
|
||||
Last checked: {formatRelativeTime(item.lastCheckedAt)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Delete */}
|
||||
<div className="flex-shrink-0 flex items-center">
|
||||
{confirmingDelete ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={onDelete}
|
||||
disabled={isDeleting}
|
||||
className="px-2 py-1 text-xs font-medium text-red-600 bg-red-50 dark:bg-red-900/30 dark:text-red-400 rounded hover:bg-red-100 dark:hover:bg-red-900/50 transition-colors"
|
||||
>
|
||||
{isDeleting ? '...' : 'Remove'}
|
||||
</button>
|
||||
<button
|
||||
onClick={onCancelDelete}
|
||||
className="px-2 py-1 text-xs font-medium text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={onConfirmDelete}
|
||||
className="p-1.5 text-gray-400 hover:text-red-500 dark:text-gray-500 dark:hover:text-red-400 transition-colors rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700/50"
|
||||
title="Remove from watched"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Watched Authors Section
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function WatchedAuthorsSection() {
|
||||
const router = useRouter();
|
||||
const { authors, isLoading } = useWatchedAuthors();
|
||||
const { deleteAuthor, isLoading: isDeleting } = useDeleteWatchedAuthor();
|
||||
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await deleteAuthor(id);
|
||||
setConfirmDeleteId(null);
|
||||
} catch {
|
||||
// Error handled by hook
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<section>
|
||||
<SectionHeader title="Watched Authors" icon="author" count={null} />
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{[1, 2].map((i) => <CardSkeleton key={i} />)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (authors.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section>
|
||||
<SectionHeader title="Watched Authors" icon="author" count={authors.length} />
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{authors.map((item) => (
|
||||
<WatchedAuthorCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
isDeleting={isDeleting && confirmDeleteId === item.id}
|
||||
confirmingDelete={confirmDeleteId === item.id}
|
||||
onNavigate={() => router.push(`/authors/${item.authorAsin}`)}
|
||||
onConfirmDelete={() => setConfirmDeleteId(item.id)}
|
||||
onCancelDelete={() => setConfirmDeleteId(null)}
|
||||
onDelete={() => handleDelete(item.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function WatchedAuthorCard({
|
||||
item, isDeleting, confirmingDelete, onNavigate, onConfirmDelete, onCancelDelete, onDelete,
|
||||
}: {
|
||||
item: WatchedAuthorItem;
|
||||
isDeleting: boolean;
|
||||
confirmingDelete: boolean;
|
||||
onNavigate: () => void;
|
||||
onConfirmDelete: () => void;
|
||||
onCancelDelete: () => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-xl bg-white dark:bg-gray-800 border border-gray-100 dark:border-gray-700/50 p-4 flex gap-4 hover:shadow-sm transition-shadow">
|
||||
{/* Avatar */}
|
||||
<button onClick={onNavigate} className="flex-shrink-0">
|
||||
<div className="relative w-14 h-14 rounded-full overflow-hidden bg-gradient-to-br from-blue-100 to-indigo-200 dark:from-blue-900 dark:to-indigo-900">
|
||||
{item.coverArtUrl ? (
|
||||
<Image src={item.coverArtUrl} alt={item.authorName} fill className="object-cover" sizes="56px" />
|
||||
) : (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0 flex items-center">
|
||||
<div>
|
||||
<button onClick={onNavigate} className="text-left">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white truncate hover:text-blue-600 dark:hover:text-blue-400 transition-colors">
|
||||
{item.authorName}
|
||||
</h3>
|
||||
</button>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-0.5">
|
||||
Last checked: {formatRelativeTime(item.lastCheckedAt)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Delete */}
|
||||
<div className="flex-shrink-0 flex items-center">
|
||||
{confirmingDelete ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={onDelete}
|
||||
disabled={isDeleting}
|
||||
className="px-2 py-1 text-xs font-medium text-red-600 bg-red-50 dark:bg-red-900/30 dark:text-red-400 rounded hover:bg-red-100 dark:hover:bg-red-900/50 transition-colors"
|
||||
>
|
||||
{isDeleting ? '...' : 'Remove'}
|
||||
</button>
|
||||
<button
|
||||
onClick={onCancelDelete}
|
||||
className="px-2 py-1 text-xs font-medium text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={onConfirmDelete}
|
||||
className="p-1.5 text-gray-400 hover:text-red-500 dark:text-gray-500 dark:hover:text-red-400 transition-colors rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700/50"
|
||||
title="Remove from watched"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 (
|
||||
<div className="flex items-center gap-3 mb-5">
|
||||
<div className={`w-1 h-6 bg-gradient-to-b ${gradientColors} rounded-full`} />
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white">
|
||||
{title}
|
||||
</h2>
|
||||
{count !== null && (
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">({count})</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CardSkeleton({ squareCovers }: { squareCovers?: boolean }) {
|
||||
return (
|
||||
<div className="rounded-xl bg-white dark:bg-gray-800 border border-gray-100 dark:border-gray-700/50 p-4 flex gap-4 animate-pulse">
|
||||
<div className={`w-14 ${squareCovers ? 'aspect-square' : 'aspect-[2/3]'} rounded-lg bg-gray-200 dark:bg-gray-700`} />
|
||||
<div className="flex-1 space-y-2 py-2">
|
||||
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-3/4" />
|
||||
<div className="h-3 bg-gray-200 dark:bg-gray-700 rounded w-1/2" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Audible Link */}
|
||||
{series.audibleUrl && (
|
||||
<a
|
||||
href={series.audibleUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-3 inline-flex items-center gap-1.5 text-sm text-gray-500 dark:text-gray-400 hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors"
|
||||
>
|
||||
View on Audible
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
</a>
|
||||
)}
|
||||
{/* Actions row: Audible link + Watch button */}
|
||||
<div className="mt-3 flex flex-wrap items-center justify-center sm:justify-start gap-3">
|
||||
{series.audibleUrl && (
|
||||
<a
|
||||
href={series.audibleUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 text-sm text-gray-500 dark:text-gray-400 hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors"
|
||||
>
|
||||
View on Audible
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
</a>
|
||||
)}
|
||||
<WatchSeriesButton
|
||||
seriesAsin={series.asin}
|
||||
seriesTitle={series.title}
|
||||
coverArtUrl={series.books[0]?.coverArtUrl}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{series.description && (
|
||||
|
||||
@@ -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<HTMLElement | null>;
|
||||
label: string; // e.g., "Popular Audiobooks"
|
||||
footerRef?: React.RefObject<HTMLElement | null>; // 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 (
|
||||
<div
|
||||
className={`fixed bottom-6 left-1/2 -translate-x-1/2 z-40 transition-all duration-300 ${
|
||||
shouldShow ? 'translate-y-0 opacity-100' : 'translate-y-20 opacity-0'
|
||||
}`}
|
||||
>
|
||||
<div className="bg-white/95 dark:bg-gray-900/95 backdrop-blur-lg rounded-full shadow-lg border border-gray-200 dark:border-gray-700 px-4 py-2.5">
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Section Label - Hidden on small screens */}
|
||||
<div className="hidden md:block text-xs font-medium text-gray-600 dark:text-gray-400 pr-2 border-r border-gray-300 dark:border-gray-600">
|
||||
{label}
|
||||
</div>
|
||||
|
||||
{/* Previous Button */}
|
||||
<button
|
||||
onClick={handlePrevious}
|
||||
disabled={currentPage === 1}
|
||||
className="p-1.5 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800
|
||||
text-gray-700 dark:text-gray-300 disabled:opacity-30 disabled:cursor-not-allowed
|
||||
transition-colors"
|
||||
aria-label="Previous page"
|
||||
>
|
||||
<ChevronLeftIcon className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{/* Page Info & Jump */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400 whitespace-nowrap">
|
||||
Page
|
||||
</span>
|
||||
<form onSubmit={handleJumpSubmit} className="inline-flex">
|
||||
<input
|
||||
type="text"
|
||||
value={jumpPage}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</form>
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400 whitespace-nowrap">
|
||||
of {totalPages}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Next Button */}
|
||||
<button
|
||||
onClick={handleNext}
|
||||
disabled={currentPage === totalPages}
|
||||
className="p-1.5 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800
|
||||
text-gray-700 dark:text-gray-300 disabled:opacity-30 disabled:cursor-not-allowed
|
||||
transition-colors"
|
||||
aria-label="Next page"
|
||||
>
|
||||
<ChevronRightIcon className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<HTMLElement | null>;
|
||||
/** 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<HTMLElement | null>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400 select-none whitespace-nowrap">
|
||||
Page
|
||||
</span>
|
||||
<form onSubmit={commit} className="inline-flex">
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={value}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</form>
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400 select-none whitespace-nowrap">
|
||||
of {totalPages}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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<ReturnType<typeof setTimeout> | 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 (
|
||||
<div
|
||||
className={`
|
||||
fixed bottom-6 left-1/2 -translate-x-1/2 z-40
|
||||
transition-all duration-300 ease-out
|
||||
${shouldShow
|
||||
? 'translate-y-0 opacity-100 pointer-events-auto'
|
||||
: 'translate-y-4 opacity-0 pointer-events-none'
|
||||
}
|
||||
`}
|
||||
aria-hidden={!shouldShow}
|
||||
>
|
||||
{/* Pill surface */}
|
||||
<div
|
||||
className="
|
||||
flex items-center gap-0
|
||||
bg-white/90 dark:bg-gray-900/90
|
||||
backdrop-blur-xl
|
||||
rounded-full
|
||||
shadow-[0_8px_32px_rgba(0,0,0,0.12),0_2px_8px_rgba(0,0,0,0.08)]
|
||||
dark:shadow-[0_8px_32px_rgba(0,0,0,0.4),0_2px_8px_rgba(0,0,0,0.3)]
|
||||
border border-gray-200/60 dark:border-white/[0.08]
|
||||
px-1.5 py-1.5
|
||||
overflow-hidden
|
||||
"
|
||||
>
|
||||
{/* Section selector dots — left side */}
|
||||
<div className="flex flex-col gap-1 pl-2 pr-3">
|
||||
{sections.map((section, idx) => {
|
||||
const isActive = idx === activeIndex;
|
||||
return (
|
||||
<button
|
||||
key={section.label}
|
||||
onClick={() => {
|
||||
if (!isActive) section.onScrollToSection();
|
||||
}}
|
||||
disabled={isActive}
|
||||
title={section.label}
|
||||
aria-label={`Switch to ${section.label}`}
|
||||
className={`
|
||||
w-1.5 rounded-full transition-all duration-300 ease-out
|
||||
${isActive
|
||||
? `${section.accentColor} h-4 opacity-100`
|
||||
: 'bg-gray-300 dark:bg-gray-600 h-1.5 opacity-60 hover:opacity-90 hover:scale-110 cursor-pointer'
|
||||
}
|
||||
`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="w-px h-6 bg-gray-200 dark:bg-white/10 mr-3 flex-shrink-0" />
|
||||
|
||||
{/* Label + controls — cross-fades on section switch */}
|
||||
<div
|
||||
className={`
|
||||
flex items-center gap-3
|
||||
transition-opacity duration-200 ease-in-out
|
||||
${isTransitioning ? 'opacity-0' : 'opacity-100'}
|
||||
`}
|
||||
// key forces full remount on switch so input state resets cleanly
|
||||
key={activeIndex}
|
||||
>
|
||||
{/* Section label — hidden on small screens */}
|
||||
<span className="hidden sm:block text-xs font-medium text-gray-500 dark:text-gray-400 whitespace-nowrap pr-1 select-none">
|
||||
{active.label}
|
||||
</span>
|
||||
|
||||
{/* Previous */}
|
||||
<button
|
||||
onClick={handlePrev}
|
||||
disabled={active.currentPage === 1}
|
||||
aria-label="Previous page"
|
||||
className="
|
||||
p-1.5 rounded-full
|
||||
text-gray-600 dark:text-gray-300
|
||||
hover:bg-black/[0.06] dark:hover:bg-white/[0.08]
|
||||
active:bg-black/[0.1] dark:active:bg-white/[0.12]
|
||||
active:scale-95
|
||||
disabled:opacity-25 disabled:cursor-not-allowed
|
||||
transition-all duration-150
|
||||
"
|
||||
>
|
||||
<ChevronLeftIcon className="w-4 h-4" strokeWidth={2} />
|
||||
</button>
|
||||
|
||||
{/* Page jump */}
|
||||
<PageJump
|
||||
currentPage={active.currentPage}
|
||||
totalPages={active.totalPages}
|
||||
onPageChange={active.onPageChange}
|
||||
/>
|
||||
|
||||
{/* Next */}
|
||||
<button
|
||||
onClick={handleNext}
|
||||
disabled={active.currentPage === active.totalPages}
|
||||
aria-label="Next page"
|
||||
className="
|
||||
p-1.5 rounded-full
|
||||
text-gray-600 dark:text-gray-300
|
||||
hover:bg-black/[0.06] dark:hover:bg-white/[0.08]
|
||||
active:bg-black/[0.1] dark:active:bg-white/[0.12]
|
||||
active:scale-95
|
||||
disabled:opacity-25 disabled:cursor-not-allowed
|
||||
transition-all duration-150
|
||||
"
|
||||
>
|
||||
<ChevronRightIcon className="w-4 h-4" strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Right padding balance */}
|
||||
<div className="w-2" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<string | null>(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 (
|
||||
<div className="inline-flex flex-col items-start">
|
||||
<button
|
||||
onClick={handleClick}
|
||||
disabled={isLoading}
|
||||
className={`inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg transition-all duration-200 ${
|
||||
isWatching
|
||||
? 'bg-emerald-50 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300 hover:bg-emerald-100 dark:hover:bg-emerald-900/50 border border-emerald-200 dark:border-emerald-700/50'
|
||||
: 'bg-gray-100 dark:bg-gray-700/50 text-gray-700 dark:text-gray-300 hover:bg-emerald-50 dark:hover:bg-emerald-900/20 hover:text-emerald-700 dark:hover:text-emerald-300 border border-gray-200 dark:border-gray-600/50 hover:border-emerald-200 dark:hover:border-emerald-700/50'
|
||||
} ${isLoading ? 'opacity-60 cursor-not-allowed' : ''}`}
|
||||
>
|
||||
{isLoading ? (
|
||||
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
) : isWatching ? (
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
)}
|
||||
{isWatching ? 'Watching' : 'Watch Series'}
|
||||
</button>
|
||||
{error && (
|
||||
<span className="text-xs text-red-500 mt-1">{error}</span>
|
||||
)}
|
||||
<ConfirmModal
|
||||
isOpen={showConfirm}
|
||||
onClose={() => 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}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<string | null>(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 (
|
||||
<div className="inline-flex flex-col items-start">
|
||||
<button
|
||||
onClick={handleClick}
|
||||
disabled={isLoading}
|
||||
className={`inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg transition-all duration-200 ${
|
||||
isWatching
|
||||
? 'bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 hover:bg-blue-100 dark:hover:bg-blue-900/50 border border-blue-200 dark:border-blue-700/50'
|
||||
: 'bg-gray-100 dark:bg-gray-700/50 text-gray-700 dark:text-gray-300 hover:bg-blue-50 dark:hover:bg-blue-900/20 hover:text-blue-700 dark:hover:text-blue-300 border border-gray-200 dark:border-gray-600/50 hover:border-blue-200 dark:hover:border-blue-700/50'
|
||||
} ${isLoading ? 'opacity-60 cursor-not-allowed' : ''}`}
|
||||
>
|
||||
{isLoading ? (
|
||||
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
) : isWatching ? (
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
)}
|
||||
{isWatching ? 'Watching' : 'Watch Author'}
|
||||
</button>
|
||||
{error && (
|
||||
<span className="text-xs text-red-500 mt-1">{error}</span>
|
||||
)}
|
||||
<ConfirmModal
|
||||
isOpen={showConfirm}
|
||||
onClose={() => 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}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string | null>(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<string | null>(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 };
|
||||
}
|
||||
@@ -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<string | null>(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<string | null>(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 };
|
||||
}
|
||||
@@ -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<string>();
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<any> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -27,6 +27,7 @@ export type JobType =
|
||||
| 'cleanup_seeded_torrents'
|
||||
| 'monitor_rss_feeds'
|
||||
| 'sync_reading_shelves'
|
||||
| 'check_watched_lists'
|
||||
| 'send_notification'
|
||||
// Ebook-specific job types
|
||||
| 'search_ebook'
|
||||
@@ -114,6 +115,16 @@ export interface SyncShelvesPayload extends JobPayload {
|
||||
maxLookupsPerShelf?: number;
|
||||
}
|
||||
|
||||
export interface CheckWatchedListsPayload extends JobPayload {
|
||||
scheduledJobId?: string;
|
||||
/** If set, only process watched items for this user */
|
||||
userId?: string;
|
||||
/** If set, only process this specific series */
|
||||
seriesAsin?: string;
|
||||
/** If set, only process this specific author */
|
||||
authorAsin?: string;
|
||||
}
|
||||
|
||||
// Ebook-specific payload interfaces
|
||||
export interface SearchEbookPayload extends JobPayload {
|
||||
requestId: string;
|
||||
@@ -385,6 +396,12 @@ export class JobQueueService {
|
||||
return await processSyncShelves(payloadWithJobId);
|
||||
});
|
||||
|
||||
this.queue.process('check_watched_lists', 1, async (job: BullJob<CheckWatchedListsPayload>) => {
|
||||
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<SendNotificationPayload>) => {
|
||||
const { processSendNotification } = await import('../processors/send-notification.processor');
|
||||
@@ -768,6 +785,39 @@ export class JobQueueService {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add check watched lists job (watched series + watched authors)
|
||||
*/
|
||||
async addCheckWatchedListsJob(scheduledJobId?: string): Promise<string> {
|
||||
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<string> {
|
||||
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
|
||||
// =========================================================================
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -10,7 +10,7 @@ import { RMABLogger } from '../utils/logger';
|
||||
|
||||
const logger = RMABLogger.create('Scheduler');
|
||||
|
||||
export type ScheduledJobType = 'plex_library_scan' | 'plex_recently_added_check' | 'audible_refresh' | 'retry_missing_torrents' | 'retry_failed_imports' | 'cleanup_seeded_torrents' | 'monitor_rss_feeds' | 'sync_reading_shelves';
|
||||
export type ScheduledJobType = 'plex_library_scan' | 'plex_recently_added_check' | 'audible_refresh' | 'retry_missing_torrents' | 'retry_failed_imports' | 'cleanup_seeded_torrents' | 'monitor_rss_feeds' | 'sync_reading_shelves' | 'check_watched_lists';
|
||||
|
||||
export interface ScheduledJob {
|
||||
id: string;
|
||||
@@ -136,6 +136,13 @@ export class SchedulerService {
|
||||
enabled: true, // Enable by default
|
||||
payload: {},
|
||||
},
|
||||
{
|
||||
name: 'Check Watched Lists',
|
||||
type: 'check_watched_lists' as ScheduledJobType,
|
||||
schedule: '0 0 * * *', // Daily at midnight (every 24 hours)
|
||||
enabled: true, // Enable by default
|
||||
payload: {},
|
||||
},
|
||||
];
|
||||
|
||||
let created = 0;
|
||||
@@ -381,6 +388,9 @@ export class SchedulerService {
|
||||
case 'sync_reading_shelves':
|
||||
bullJobId = await this.triggerSyncShelves(job);
|
||||
break;
|
||||
case 'check_watched_lists':
|
||||
bullJobId = await this.triggerCheckWatchedLists(job);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown job type: ${job.type}`);
|
||||
}
|
||||
@@ -655,6 +665,13 @@ export class SchedulerService {
|
||||
private async triggerSyncShelves(job: any): Promise<string> {
|
||||
return await this.jobQueue.addSyncShelvesJob(job.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger watched lists check (watched series + watched authors)
|
||||
*/
|
||||
private async triggerCheckWatchedLists(job: any): Promise<string> {
|
||||
return await this.jobQueue.addCheckWatchedListsJob(job.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
|
||||
@@ -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<typeof RMABLogger.forJob>,
|
||||
options: WatchedListsSyncOptions = {}
|
||||
): Promise<WatchedListsSyncStats> {
|
||||
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<typeof RMABLogger.forJob> | ReturnType<typeof RMABLogger.create>,
|
||||
stats: WatchedListsSyncStats,
|
||||
options: WatchedListsSyncOptions
|
||||
): Promise<void> {
|
||||
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<string, typeof watchedSeries>();
|
||||
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<typeof RMABLogger.forJob> | ReturnType<typeof RMABLogger.create>,
|
||||
stats: WatchedListsSyncStats
|
||||
): Promise<void> {
|
||||
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<typeof RMABLogger.forJob> | ReturnType<typeof RMABLogger.create>,
|
||||
stats: WatchedListsSyncStats,
|
||||
options: WatchedListsSyncOptions
|
||||
): Promise<void> {
|
||||
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<string, typeof watchedAuthors>();
|
||||
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<typeof RMABLogger.forJob> | ReturnType<typeof RMABLogger.create>,
|
||||
stats: WatchedListsSyncStats
|
||||
): Promise<void> {
|
||||
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<typeof RMABLogger.forJob> | ReturnType<typeof RMABLogger.create>,
|
||||
stats: WatchedListsSyncStats
|
||||
): Promise<void> {
|
||||
// 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<Set<string>> {
|
||||
const owned = new Set<string>();
|
||||
|
||||
// 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<string>();
|
||||
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<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<Map<string, string[]>> {
|
||||
const result = new Map<string, string[]>();
|
||||
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<string, string[]>();
|
||||
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<string, string[]>();
|
||||
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;
|
||||
}
|
||||
@@ -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<string>();
|
||||
for (const siblings of siblingMap.values()) {
|
||||
for (const s of siblings) allSiblingAsins.add(s);
|
||||
}
|
||||
|
||||
if (allSiblingAsins.size > 0) {
|
||||
const siblingLibraryMatches = await prisma.plexLibrary.findMany({
|
||||
where: { asin: { in: [...allSiblingAsins] } },
|
||||
select: { asin: true, plexGuid: true },
|
||||
});
|
||||
const libraryAsinSet = new Set(
|
||||
siblingLibraryMatches.filter(m => m.asin).map(m => m.asin!.toLowerCase())
|
||||
);
|
||||
|
||||
// Update results where a sibling ASIN is found in the library
|
||||
for (const result of results) {
|
||||
if (result.isAvailable) continue;
|
||||
const siblings = siblingMap.get(result.asin);
|
||||
if (!siblings) continue;
|
||||
const matchedSiblingAsin = siblings.find(s => libraryAsinSet.has(s.toLowerCase()));
|
||||
if (matchedSiblingAsin) {
|
||||
const libMatch = siblingLibraryMatches.find(
|
||||
m => m.asin?.toLowerCase() === matchedSiblingAsin.toLowerCase()
|
||||
);
|
||||
(result as any).isAvailable = true;
|
||||
(result as any).plexGuid = libMatch?.plexGuid || null;
|
||||
}
|
||||
}
|
||||
|
||||
const siblingMatchCount = results.filter(r => {
|
||||
if (!r.isAvailable) return false;
|
||||
return siblingMap.has(r.asin);
|
||||
}).length;
|
||||
logger.debug('Sibling expansion', {
|
||||
unmatchedCount: unmatchedAsins.length,
|
||||
siblingGroupsFound: siblingMap.size,
|
||||
siblingMatches: siblingMatchCount,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Works table expansion is best-effort — direct matches still work
|
||||
logger.error('Sibling ASIN expansion failed', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
|
||||
// Always enrich with request status (check ANY user's requests)
|
||||
const asins = audiobooks.map(book => book.asin);
|
||||
|
||||
@@ -272,6 +328,57 @@ export async function enrichAudiobooksWithMatches(
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all ASINs that are considered "available" — present in library or have completed requests.
|
||||
* Used by paginated API routes to exclude available items at the DB level.
|
||||
*/
|
||||
export async function getAvailableAsins(): Promise<Set<string>> {
|
||||
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<string>();
|
||||
for (const item of libraryItems) {
|
||||
if (item.asin) asins.add(item.asin);
|
||||
}
|
||||
for (const item of completedRequests) {
|
||||
if (item.audibleAsin) asins.add(item.audibleAsin);
|
||||
}
|
||||
|
||||
// Expand with works-table sibling ASINs
|
||||
try {
|
||||
if (asins.size > 0) {
|
||||
const siblingMap = await getSiblingAsins([...asins]);
|
||||
for (const siblings of siblingMap.values()) {
|
||||
for (const s of siblings) asins.add(s);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Works table expansion is best-effort
|
||||
}
|
||||
|
||||
return asins;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize ISBN for comparison (remove dashes and spaces)
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* Component: Audiobook Deduplication Utility
|
||||
* Documentation: documentation/integrations/audible.md
|
||||
*
|
||||
* Deduplicates audiobook listings that represent the same recording
|
||||
* under different ASINs (publisher re-listings, rights transfers, etc.).
|
||||
*
|
||||
* Dedup key: normalized title + normalized narrator
|
||||
* Duration tolerance: max(longerDuration * 0.01, 5) minutes
|
||||
* Missing duration treated as compatible (graceful degradation).
|
||||
*/
|
||||
|
||||
import type { AudibleAudiobook } from '../integrations/audible.service';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Title / narrator normalization
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Patterns in parentheses or brackets to strip (edition markers, format labels) */
|
||||
const EDITION_PAREN_RE = /[([][^)\]]*?(?:unabridged|abridged|edition|remaster(?:ed)?|anniversary|complete|original|version|narrat(?:ed|or)?|audio(?:book)?|full cast|dramatiz(?:ed|ation))[^)\]]*[)\]]/gi;
|
||||
|
||||
/** Trailing subtitle after colon or long dash */
|
||||
const SUBTITLE_RE = /\s*[:]\s+.+$/;
|
||||
const LONG_DASH_SUBTITLE_RE = /\s+[-\u2013\u2014]\s+.+$/;
|
||||
|
||||
/** Trailing descriptors like "A Novel", "A Memoir" */
|
||||
const TRAILING_DESCRIPTOR_RE = /\s*[-:,]?\s+a\s+(novel|memoir|thriller|mystery|romance|story|tale|novella)\s*$/i;
|
||||
|
||||
/**
|
||||
* Normalize a title for dedup comparison.
|
||||
* Strips subtitles, edition markers, and trailing descriptors.
|
||||
*/
|
||||
export function normalizeTitle(title: string): string {
|
||||
let t = title.toLowerCase();
|
||||
// Remove parenthesized/bracketed edition markers
|
||||
t = t.replace(EDITION_PAREN_RE, '');
|
||||
// Remove trailing descriptors before subtitle stripping
|
||||
t = t.replace(TRAILING_DESCRIPTOR_RE, '');
|
||||
// Remove subtitle after colon
|
||||
t = t.replace(SUBTITLE_RE, '');
|
||||
// Remove subtitle after long dash (but not short hyphenated words)
|
||||
t = t.replace(LONG_DASH_SUBTITLE_RE, '');
|
||||
// Collapse whitespace and trim
|
||||
return t.replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
/** Normalize narrator for comparison. Sorts individual names so order doesn't matter. */
|
||||
function normalizeNarrator(narrator?: string): string {
|
||||
const raw = (narrator || '').toLowerCase().trim();
|
||||
if (!raw) return raw;
|
||||
return raw.split(',').map(n => n.trim()).filter(Boolean).sort().join(', ');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Duration compatibility
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Check if two durations are compatible (represent the same recording).
|
||||
* Tolerance: max(longerDuration * 0.01, 5) minutes.
|
||||
* Missing duration on either side is treated as compatible.
|
||||
*/
|
||||
export function areDurationsCompatible(a?: number, b?: number): boolean {
|
||||
if (a == null || b == null) return true;
|
||||
const longer = Math.max(a, b);
|
||||
const tolerance = Math.max(longer * 0.01, 5);
|
||||
return Math.abs(a - b) <= tolerance;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Metadata scoring (for picking best representative)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function metadataScore(book: AudibleAudiobook): number {
|
||||
let score = 0;
|
||||
if (book.coverArtUrl) score++;
|
||||
if (book.rating != null) score++;
|
||||
if (book.durationMinutes != null) score++;
|
||||
if (book.description) score++;
|
||||
if (book.narrator) score++;
|
||||
if (book.releaseDate) score++;
|
||||
if (book.genres && book.genres.length > 0) score++;
|
||||
return score;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dedup group types (for works-table persistence)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Metadata about a group of ASINs that were collapsed during dedup. */
|
||||
export interface DedupGroup {
|
||||
canonicalAsin: string; // ASIN of the "winner" (best metadata score)
|
||||
allAsins: string[]; // All ASINs in this group (including canonical)
|
||||
title: string; // Author from the canonical entry
|
||||
author: string; // Author from the canonical entry
|
||||
narrator?: string; // Narrator from the canonical entry
|
||||
durationMinutes?: number; // Duration from the canonical entry
|
||||
}
|
||||
|
||||
/** Result of deduplication with group collection. */
|
||||
export interface DeduplicateResult {
|
||||
books: AudibleAudiobook[]; // The deduped list (same as deduplicateAudiobooks returns)
|
||||
groups: DedupGroup[]; // Groups where 2+ ASINs were collapsed
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main dedup functions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Deduplicate audiobook listings by normalized title + narrator + duration.
|
||||
*
|
||||
* Same narrator + compatible duration + similar title = same recording -> collapse.
|
||||
* Different narrator = different production -> keep both.
|
||||
* Duration outside tolerance = different content (abridged vs unabridged) -> keep both.
|
||||
*
|
||||
* Preserves original ordering (position of first appearance).
|
||||
*/
|
||||
export function deduplicateAudiobooks(books: AudibleAudiobook[]): AudibleAudiobook[] {
|
||||
return deduplicateAndCollectGroups(books).books;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deduplicate audiobooks AND return grouping metadata for works-table persistence.
|
||||
* Returns both the deduped list and the groups where 2+ ASINs were collapsed.
|
||||
*/
|
||||
export function deduplicateAndCollectGroups(books: AudibleAudiobook[]): DeduplicateResult {
|
||||
if (books.length <= 1) return { books: [...books], groups: [] };
|
||||
|
||||
// Group by normalized title + narrator
|
||||
const titleNarratorGroups = new Map<string, AudibleAudiobook[]>();
|
||||
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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -47,17 +47,22 @@ vi.mock('@/components/ui/CardSizeControls', () => ({
|
||||
CardSizeControls: ({ size }: { size: number }) => <div data-testid="card-size" data-size={size} />,
|
||||
}));
|
||||
|
||||
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;
|
||||
}>;
|
||||
}) => (
|
||||
<button type="button" onClick={() => onPageChange(2)}>
|
||||
{label} next
|
||||
</button>
|
||||
<div>
|
||||
{sections.map((s) => (
|
||||
<button key={s.label} type="button" onClick={() => s.onPageChange(2)}>
|
||||
{s.label} next
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
<StickyPagination
|
||||
currentPage={1}
|
||||
totalPages={1}
|
||||
onPageChange={vi.fn()}
|
||||
sectionRef={sectionRef}
|
||||
label="Popular"
|
||||
/>
|
||||
);
|
||||
|
||||
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(
|
||||
<StickyPagination
|
||||
currentPage={2}
|
||||
totalPages={5}
|
||||
onPageChange={vi.fn()}
|
||||
sectionRef={sectionRef}
|
||||
footerRef={footerRef}
|
||||
label="Popular"
|
||||
/>
|
||||
);
|
||||
|
||||
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(
|
||||
<StickyPagination
|
||||
currentPage={2}
|
||||
totalPages={4}
|
||||
onPageChange={onPageChange}
|
||||
sectionRef={sectionRef}
|
||||
label="Popular"
|
||||
/>
|
||||
);
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -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, 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<typeof vi.fn>; disconnect: ReturnType<typeof vi.fn> }[] = [];
|
||||
|
||||
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(<UnifiedPagination sections={sections} />);
|
||||
// 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(<UnifiedPagination sections={sections} />);
|
||||
|
||||
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(
|
||||
<UnifiedPagination sections={sections} footerRef={footerRef} />
|
||||
);
|
||||
|
||||
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(<UnifiedPagination sections={sections} />);
|
||||
|
||||
// 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(<UnifiedPagination sections={sections} />);
|
||||
|
||||
// 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(<UnifiedPagination sections={sections} />);
|
||||
const root = container.querySelector('div.fixed') as HTMLElement;
|
||||
expect(root).toHaveClass('pointer-events-none');
|
||||
});
|
||||
});
|
||||
@@ -48,6 +48,10 @@ export const createPrismaMock = () => ({
|
||||
goodreadsShelf: createModelMock(),
|
||||
goodreadsBookMapping: createModelMock(),
|
||||
hardcoverShelf: createModelMock(),
|
||||
work: createModelMock(),
|
||||
workAsin: createModelMock(),
|
||||
watchedSeries: createModelMock(),
|
||||
watchedAuthor: createModelMock(),
|
||||
$queryRaw: vi.fn(),
|
||||
$disconnect: vi.fn(),
|
||||
});
|
||||
|
||||
@@ -22,6 +22,7 @@ const processorsMock = vi.hoisted(() => ({
|
||||
processRetryFailedImports: vi.fn().mockResolvedValue('ok'),
|
||||
processCleanupSeededTorrents: vi.fn().mockResolvedValue('ok'),
|
||||
processSyncShelves: vi.fn().mockResolvedValue('ok'),
|
||||
processCheckWatchedLists: vi.fn().mockResolvedValue('ok'),
|
||||
// Ebook processors
|
||||
processSearchEbook: vi.fn().mockResolvedValue('ok'),
|
||||
processStartDirectDownload: vi.fn().mockResolvedValue('ok'),
|
||||
@@ -120,6 +121,10 @@ vi.mock('@/lib/processors/sync-shelves.processor', () => ({
|
||||
processSyncShelves: processorsMock.processSyncShelves,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/processors/check-watched-lists.processor', () => ({
|
||||
processCheckWatchedLists: processorsMock.processCheckWatchedLists,
|
||||
}));
|
||||
|
||||
// Ebook processors
|
||||
vi.mock('@/lib/processors/search-ebook.processor', () => ({
|
||||
processSearchEbook: processorsMock.processSearchEbook,
|
||||
@@ -565,6 +570,7 @@ describe('JobQueueService', () => {
|
||||
expect(processorsMock.processRetryFailedImports).toHaveBeenCalled();
|
||||
expect(processorsMock.processCleanupSeededTorrents).toHaveBeenCalled();
|
||||
expect(processorsMock.processSyncShelves).toHaveBeenCalled();
|
||||
expect(processorsMock.processCheckWatchedLists).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns repeatable jobs from the queue', async () => {
|
||||
|
||||
@@ -81,7 +81,7 @@ describe('SchedulerService', () => {
|
||||
const service = new SchedulerService();
|
||||
await service.start();
|
||||
|
||||
expect(prismaMock.scheduledJob.create).toHaveBeenCalledTimes(8);
|
||||
expect(prismaMock.scheduledJob.create).toHaveBeenCalledTimes(9);
|
||||
expect(jobQueueMock.addRepeatableJob).toHaveBeenCalledWith(
|
||||
'audible_refresh',
|
||||
{ scheduledJobId: 'job-1' },
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,451 @@
|
||||
/**
|
||||
* Component: Audiobook Deduplication Tests
|
||||
* Documentation: documentation/integrations/audible.md
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
deduplicateAudiobooks,
|
||||
deduplicateAndCollectGroups,
|
||||
normalizeTitle,
|
||||
areDurationsCompatible,
|
||||
} from '@/lib/utils/deduplicate-audiobooks';
|
||||
import type { AudibleAudiobook } from '@/lib/integrations/audible.service';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: minimal AudibleAudiobook factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeBook(overrides: Partial<AudibleAudiobook> & { asin: string; title: string; author: string }): AudibleAudiobook {
|
||||
return {
|
||||
narrator: undefined,
|
||||
coverArtUrl: undefined,
|
||||
durationMinutes: undefined,
|
||||
rating: undefined,
|
||||
description: undefined,
|
||||
releaseDate: undefined,
|
||||
genres: undefined,
|
||||
series: undefined,
|
||||
seriesPart: undefined,
|
||||
seriesAsin: undefined,
|
||||
authorAsin: undefined,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// normalizeTitle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('normalizeTitle', () => {
|
||||
it('lowercases', () => {
|
||||
expect(normalizeTitle('The Black Prism')).toBe('the black prism');
|
||||
});
|
||||
|
||||
it('strips (Unabridged)', () => {
|
||||
expect(normalizeTitle('The Black Prism (Unabridged)')).toBe('the black prism');
|
||||
});
|
||||
|
||||
it('strips [Abridged Edition]', () => {
|
||||
expect(normalizeTitle('The Black Prism [Abridged Edition]')).toBe('the black prism');
|
||||
});
|
||||
|
||||
it('strips (2024 Remastered Edition)', () => {
|
||||
expect(normalizeTitle('The Hobbit (2024 Remastered Edition)')).toBe('the hobbit');
|
||||
});
|
||||
|
||||
it('strips subtitle after colon', () => {
|
||||
expect(normalizeTitle('The Black Prism: Lightbringer, Book 1')).toBe('the black prism');
|
||||
});
|
||||
|
||||
it('strips subtitle after long dash', () => {
|
||||
expect(normalizeTitle('The Black Prism \u2014 A Lightbringer Novel')).toBe('the black prism');
|
||||
});
|
||||
|
||||
it('strips trailing "A Novel"', () => {
|
||||
expect(normalizeTitle('The Black Prism: A Novel')).toBe('the black prism');
|
||||
});
|
||||
|
||||
it('strips (Audiobook)', () => {
|
||||
expect(normalizeTitle('The Hobbit (Audiobook)')).toBe('the hobbit');
|
||||
});
|
||||
|
||||
it('strips (Dramatized Adaptation)', () => {
|
||||
expect(normalizeTitle('The Black Prism (Dramatized Adaptation)')).toBe('the black prism');
|
||||
});
|
||||
|
||||
it('strips (Full Cast Narration)', () => {
|
||||
expect(normalizeTitle('The Black Prism (Full Cast Narration)')).toBe('the black prism');
|
||||
});
|
||||
|
||||
it('collapses whitespace', () => {
|
||||
expect(normalizeTitle(' The Black Prism ')).toBe('the black prism');
|
||||
});
|
||||
|
||||
it('handles empty string', () => {
|
||||
expect(normalizeTitle('')).toBe('');
|
||||
});
|
||||
|
||||
it('preserves hyphenated words (not subtitles)', () => {
|
||||
// "well-known" has a short dash, not a subtitle separator
|
||||
expect(normalizeTitle('A Well-Known Book')).toBe('a well-known book');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// areDurationsCompatible
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('areDurationsCompatible', () => {
|
||||
it('returns true when both undefined', () => {
|
||||
expect(areDurationsCompatible(undefined, undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true when one undefined', () => {
|
||||
expect(areDurationsCompatible(600, undefined)).toBe(true);
|
||||
expect(areDurationsCompatible(undefined, 600)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for identical durations', () => {
|
||||
expect(areDurationsCompatible(600, 600)).toBe(true);
|
||||
});
|
||||
|
||||
it('uses 1% of longer duration as tolerance for long books', () => {
|
||||
// Two 40-hour books (2400 min): tolerance = max(2400*0.01, 5) = 24 min
|
||||
expect(areDurationsCompatible(2400, 2424)).toBe(true); // exactly at tolerance
|
||||
expect(areDurationsCompatible(2400, 2425)).toBe(false); // just over
|
||||
});
|
||||
|
||||
it('uses 5-minute minimum tolerance for short books', () => {
|
||||
// Two 2-hour books (120 min): tolerance = max(120*0.01, 5) = max(1.2, 5) = 5 min
|
||||
expect(areDurationsCompatible(120, 125)).toBe(true); // exactly at 5-min minimum
|
||||
expect(areDurationsCompatible(120, 126)).toBe(false); // just over
|
||||
});
|
||||
|
||||
it('keeps abridged vs unabridged separate (large duration gap)', () => {
|
||||
// Unabridged: 720 min (12 hrs), Abridged: 360 min (6 hrs)
|
||||
expect(areDurationsCompatible(720, 360)).toBe(false);
|
||||
});
|
||||
|
||||
it('symmetry: order does not matter', () => {
|
||||
expect(areDurationsCompatible(2400, 2424)).toBe(true);
|
||||
expect(areDurationsCompatible(2424, 2400)).toBe(true);
|
||||
expect(areDurationsCompatible(120, 126)).toBe(false);
|
||||
expect(areDurationsCompatible(126, 120)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// deduplicateAudiobooks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('deduplicateAudiobooks', () => {
|
||||
it('returns empty array for empty input', () => {
|
||||
expect(deduplicateAudiobooks([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns single book unchanged', () => {
|
||||
const book = makeBook({ asin: 'A1', title: 'Book One', author: 'Author' });
|
||||
expect(deduplicateAudiobooks([book])).toEqual([book]);
|
||||
});
|
||||
|
||||
it('passes through all-unique books unchanged', () => {
|
||||
const books = [
|
||||
makeBook({ asin: 'A1', title: 'Book One', author: 'Auth', narrator: 'Nar A', durationMinutes: 600 }),
|
||||
makeBook({ asin: 'A2', title: 'Book Two', author: 'Auth', narrator: 'Nar A', durationMinutes: 500 }),
|
||||
makeBook({ asin: 'A3', title: 'Book Three', author: 'Auth', narrator: 'Nar B', durationMinutes: 700 }),
|
||||
];
|
||||
expect(deduplicateAudiobooks(books)).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('collapses simple duplicates (same title + narrator + similar duration)', () => {
|
||||
const books = [
|
||||
makeBook({ asin: 'A1', title: 'The Black Prism', author: 'Brent Weeks', narrator: 'Simon Vance', durationMinutes: 1260 }),
|
||||
makeBook({ asin: 'A2', title: 'The Black Prism', author: 'Brent Weeks', narrator: 'Simon Vance', durationMinutes: 1262 }),
|
||||
];
|
||||
const result = deduplicateAudiobooks(books);
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('keeps books with different narrators (different production)', () => {
|
||||
const books = [
|
||||
makeBook({ asin: 'A1', title: 'The Black Prism', author: 'Brent Weeks', narrator: 'Simon Vance', durationMinutes: 1260 }),
|
||||
makeBook({ asin: 'A2', title: 'The Black Prism', author: 'Brent Weeks', narrator: 'Full Cast', durationMinutes: 480 }),
|
||||
];
|
||||
const result = deduplicateAudiobooks(books);
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('keeps abridged vs unabridged (same narrator, very different duration)', () => {
|
||||
const books = [
|
||||
makeBook({ asin: 'A1', title: 'The Hobbit', author: 'Tolkien', narrator: 'Andy Serkis', durationMinutes: 660 }),
|
||||
makeBook({ asin: 'A2', title: 'The Hobbit', author: 'Tolkien', narrator: 'Andy Serkis', durationMinutes: 330 }),
|
||||
];
|
||||
const result = deduplicateAudiobooks(books);
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('collapses when one book has missing duration', () => {
|
||||
const books = [
|
||||
makeBook({ asin: 'A1', title: 'The Black Prism', author: 'Brent Weeks', narrator: 'Simon Vance', durationMinutes: 1260 }),
|
||||
makeBook({ asin: 'A2', title: 'The Black Prism', author: 'Brent Weeks', narrator: 'Simon Vance', durationMinutes: undefined }),
|
||||
];
|
||||
const result = deduplicateAudiobooks(books);
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('collapses when both books have missing duration', () => {
|
||||
const books = [
|
||||
makeBook({ asin: 'A1', title: 'The Black Prism', author: 'Brent Weeks', narrator: 'Simon Vance' }),
|
||||
makeBook({ asin: 'A2', title: 'The Black Prism', author: 'Brent Weeks', narrator: 'Simon Vance' }),
|
||||
];
|
||||
const result = deduplicateAudiobooks(books);
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('collapses title variants with edition markers', () => {
|
||||
const books = [
|
||||
makeBook({ asin: 'A1', title: 'The Black Prism (Unabridged)', author: 'Brent Weeks', narrator: 'Simon Vance', durationMinutes: 1260 }),
|
||||
makeBook({ asin: 'A2', title: 'The Black Prism', author: 'Brent Weeks', narrator: 'Simon Vance', durationMinutes: 1258 }),
|
||||
];
|
||||
const result = deduplicateAudiobooks(books);
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('collapses title variants with subtitles', () => {
|
||||
const books = [
|
||||
makeBook({ asin: 'A1', title: 'The Black Prism: Lightbringer, Book 1', author: 'Brent Weeks', narrator: 'Simon Vance', durationMinutes: 1260 }),
|
||||
makeBook({ asin: 'A2', title: 'The Black Prism', author: 'Brent Weeks', narrator: 'Simon Vance', durationMinutes: 1262 }),
|
||||
];
|
||||
const result = deduplicateAudiobooks(books);
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('picks the representative with most metadata', () => {
|
||||
const sparse = makeBook({
|
||||
asin: 'A1', title: 'The Black Prism', author: 'Brent Weeks',
|
||||
narrator: 'Simon Vance', durationMinutes: 1260,
|
||||
});
|
||||
const rich = makeBook({
|
||||
asin: 'A2', title: 'The Black Prism', author: 'Brent Weeks',
|
||||
narrator: 'Simon Vance', durationMinutes: 1262,
|
||||
coverArtUrl: 'https://img.jpg', rating: 4.5, description: 'Great book',
|
||||
});
|
||||
const result = deduplicateAudiobooks([sparse, rich]);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].asin).toBe('A2'); // rich entry wins
|
||||
});
|
||||
|
||||
it('preserves original order (first-seen position)', () => {
|
||||
const books = [
|
||||
makeBook({ asin: 'A1', title: 'Alpha', author: 'Auth', narrator: 'Nar', durationMinutes: 300 }),
|
||||
makeBook({ asin: 'B1', title: 'Beta', author: 'Auth', narrator: 'Nar', durationMinutes: 400 }),
|
||||
makeBook({ asin: 'A2', title: 'Alpha', author: 'Auth', narrator: 'Nar', durationMinutes: 302 }),
|
||||
makeBook({ asin: 'C1', title: 'Charlie', author: 'Auth', narrator: 'Nar', durationMinutes: 500 }),
|
||||
];
|
||||
const result = deduplicateAudiobooks(books);
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result.map(b => b.title)).toEqual(['Alpha', 'Beta', 'Charlie']);
|
||||
});
|
||||
|
||||
it('handles Lightbringer-style scenario: unabridged + dramatized', () => {
|
||||
// Simon Vance full narration (long)
|
||||
const vance1 = makeBook({
|
||||
asin: 'SV1', title: 'The Black Prism', author: 'Brent Weeks',
|
||||
narrator: 'Simon Vance', durationMinutes: 1260,
|
||||
coverArtUrl: 'cover1.jpg', rating: 4.7,
|
||||
});
|
||||
// Re-listed Simon Vance (same duration, different ASIN)
|
||||
const vance2 = makeBook({
|
||||
asin: 'SV2', title: 'The Black Prism: Lightbringer Book 1', author: 'Brent Weeks',
|
||||
narrator: 'Simon Vance', durationMinutes: 1262,
|
||||
});
|
||||
// Dramatized with full cast (shorter, different narrator)
|
||||
const drama = makeBook({
|
||||
asin: 'DR1', title: 'The Black Prism (Dramatized Adaptation)', author: 'Brent Weeks',
|
||||
narrator: 'Full Cast', durationMinutes: 480,
|
||||
coverArtUrl: 'cover-drama.jpg',
|
||||
});
|
||||
|
||||
const result = deduplicateAudiobooks([vance1, vance2, drama]);
|
||||
expect(result).toHaveLength(2);
|
||||
// Simon Vance should collapse to 1, Full Cast stays
|
||||
expect(result.find(b => b.narrator === 'Simon Vance')).toBeTruthy();
|
||||
expect(result.find(b => b.narrator === 'Full Cast')).toBeTruthy();
|
||||
// Should pick the richer entry for Simon Vance
|
||||
const svResult = result.find(b => b.narrator === 'Simon Vance')!;
|
||||
expect(svResult.asin).toBe('SV1'); // has cover + rating
|
||||
});
|
||||
|
||||
it('uses percentage tolerance for very long audiobooks', () => {
|
||||
// Two 40-hour books: tolerance = max(2400*0.01, 5) = 24 min
|
||||
const books = [
|
||||
makeBook({ asin: 'A1', title: 'Long Book', author: 'Auth', narrator: 'Nar', durationMinutes: 2400 }),
|
||||
makeBook({ asin: 'A2', title: 'Long Book', author: 'Auth', narrator: 'Nar', durationMinutes: 2420 }),
|
||||
];
|
||||
expect(deduplicateAudiobooks(books)).toHaveLength(1);
|
||||
|
||||
// Beyond tolerance
|
||||
const booksFar = [
|
||||
makeBook({ asin: 'A1', title: 'Long Book', author: 'Auth', narrator: 'Nar', durationMinutes: 2400 }),
|
||||
makeBook({ asin: 'A2', title: 'Long Book', author: 'Auth', narrator: 'Nar', durationMinutes: 2430 }),
|
||||
];
|
||||
expect(deduplicateAudiobooks(booksFar)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('treats missing narrator as its own group', () => {
|
||||
// Two entries with same title but no narrator - should collapse
|
||||
const books = [
|
||||
makeBook({ asin: 'A1', title: 'Test Book', author: 'Auth', narrator: undefined, durationMinutes: 300 }),
|
||||
makeBook({ asin: 'A2', title: 'Test Book', author: 'Auth', narrator: undefined, durationMinutes: 302 }),
|
||||
];
|
||||
expect(deduplicateAudiobooks(books)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('does not collapse empty-narrator with named narrator', () => {
|
||||
const books = [
|
||||
makeBook({ asin: 'A1', title: 'Test Book', author: 'Auth', narrator: undefined, durationMinutes: 300 }),
|
||||
makeBook({ asin: 'A2', title: 'Test Book', author: 'Auth', narrator: 'John Smith', durationMinutes: 302 }),
|
||||
];
|
||||
expect(deduplicateAudiobooks(books)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('collapses duplicates when narrators are listed in different order', () => {
|
||||
const books = [
|
||||
makeBook({
|
||||
asin: 'A1', title: 'The Passengers', author: 'John Marrs',
|
||||
narrator: 'Kristin Atherton, Roy McMillan, Clare Corbett, Tom Bateman, Patience Tomlinson, Shaheen Khan',
|
||||
durationMinutes: 600,
|
||||
}),
|
||||
makeBook({
|
||||
asin: 'A2', title: 'The Passengers', author: 'John Marrs',
|
||||
narrator: 'Clare Corbett, Roy McMillan, Tom Bateman, Shaheen Khan, Kristin Atherton, Patience Tomlinson',
|
||||
durationMinutes: 602,
|
||||
}),
|
||||
];
|
||||
const result = deduplicateAudiobooks(books);
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// deduplicateAndCollectGroups
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('deduplicateAndCollectGroups', () => {
|
||||
it('returns empty groups array when no duplicates', () => {
|
||||
const books = [
|
||||
makeBook({ asin: 'A1', title: 'Book One', author: 'Auth', narrator: 'Nar A', durationMinutes: 600 }),
|
||||
makeBook({ asin: 'A2', title: 'Book Two', author: 'Auth', narrator: 'Nar A', durationMinutes: 500 }),
|
||||
];
|
||||
const { books: result, groups } = deduplicateAndCollectGroups(books);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(groups).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('returns empty groups for empty input', () => {
|
||||
const { books: result, groups } = deduplicateAndCollectGroups([]);
|
||||
expect(result).toHaveLength(0);
|
||||
expect(groups).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('returns empty groups for single book', () => {
|
||||
const book = makeBook({ asin: 'A1', title: 'Book One', author: 'Auth' });
|
||||
const { books: result, groups } = deduplicateAndCollectGroups([book]);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(groups).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('returns group with 2 ASINs when 2 books match', () => {
|
||||
const books = [
|
||||
makeBook({ asin: 'A1', title: 'The Black Prism', author: 'Brent Weeks', narrator: 'Simon Vance', durationMinutes: 1260 }),
|
||||
makeBook({ asin: 'A2', title: 'The Black Prism', author: 'Brent Weeks', narrator: 'Simon Vance', durationMinutes: 1262 }),
|
||||
];
|
||||
const { books: result, groups } = deduplicateAndCollectGroups(books);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(groups).toHaveLength(1);
|
||||
expect(groups[0].allAsins).toHaveLength(2);
|
||||
expect(groups[0].allAsins).toContain('A1');
|
||||
expect(groups[0].allAsins).toContain('A2');
|
||||
});
|
||||
|
||||
it('returns group with 3+ ASINs for multi-duplicate scenario', () => {
|
||||
const books = [
|
||||
makeBook({ asin: 'A1', title: 'The Hobbit', author: 'Tolkien', narrator: 'Andy Serkis', durationMinutes: 660 }),
|
||||
makeBook({ asin: 'A2', title: 'The Hobbit', author: 'Tolkien', narrator: 'Andy Serkis', durationMinutes: 662 }),
|
||||
makeBook({ asin: 'A3', title: 'The Hobbit (Unabridged)', author: 'Tolkien', narrator: 'Andy Serkis', durationMinutes: 658 }),
|
||||
];
|
||||
const { books: result, groups } = deduplicateAndCollectGroups(books);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(groups).toHaveLength(1);
|
||||
expect(groups[0].allAsins).toHaveLength(3);
|
||||
expect(groups[0].allAsins).toContain('A1');
|
||||
expect(groups[0].allAsins).toContain('A2');
|
||||
expect(groups[0].allAsins).toContain('A3');
|
||||
});
|
||||
|
||||
it('canonicalAsin is the one with highest metadata score', () => {
|
||||
const sparse = makeBook({
|
||||
asin: 'SPARSE', title: 'The Black Prism', author: 'Brent Weeks',
|
||||
narrator: 'Simon Vance', durationMinutes: 1260,
|
||||
});
|
||||
const rich = makeBook({
|
||||
asin: 'RICH', title: 'The Black Prism', author: 'Brent Weeks',
|
||||
narrator: 'Simon Vance', durationMinutes: 1262,
|
||||
coverArtUrl: 'https://img.jpg', rating: 4.5, description: 'Great book',
|
||||
});
|
||||
const { groups } = deduplicateAndCollectGroups([sparse, rich]);
|
||||
expect(groups).toHaveLength(1);
|
||||
expect(groups[0].canonicalAsin).toBe('RICH');
|
||||
});
|
||||
|
||||
it('groups only include entries with 2+ ASINs', () => {
|
||||
const books = [
|
||||
makeBook({ asin: 'A1', title: 'Alpha', author: 'Auth', narrator: 'Nar', durationMinutes: 300 }),
|
||||
makeBook({ asin: 'A2', title: 'Alpha', author: 'Auth', narrator: 'Nar', durationMinutes: 302 }),
|
||||
makeBook({ asin: 'B1', title: 'Beta', author: 'Auth', narrator: 'Nar', durationMinutes: 500 }),
|
||||
];
|
||||
const { groups } = deduplicateAndCollectGroups(books);
|
||||
// Only Alpha group should appear (Beta is a singleton)
|
||||
expect(groups).toHaveLength(1);
|
||||
expect(groups[0].allAsins).toContain('A1');
|
||||
expect(groups[0].allAsins).toContain('A2');
|
||||
});
|
||||
|
||||
it('duration-incompatible books produce separate entries (no group for singletons)', () => {
|
||||
// Same title/narrator but very different durations (abridged vs unabridged)
|
||||
const books = [
|
||||
makeBook({ asin: 'A1', title: 'The Hobbit', author: 'Tolkien', narrator: 'Andy Serkis', durationMinutes: 660 }),
|
||||
makeBook({ asin: 'A2', title: 'The Hobbit', author: 'Tolkien', narrator: 'Andy Serkis', durationMinutes: 330 }),
|
||||
];
|
||||
const { books: result, groups } = deduplicateAndCollectGroups(books);
|
||||
expect(result).toHaveLength(2); // Not collapsed
|
||||
expect(groups).toHaveLength(0); // No multi-ASIN groups
|
||||
});
|
||||
|
||||
it('books field matches what deduplicateAudiobooks returns', () => {
|
||||
const books = [
|
||||
makeBook({ asin: 'A1', title: 'Alpha', author: 'Auth', narrator: 'Nar', durationMinutes: 300, coverArtUrl: 'img.jpg', rating: 4.5 }),
|
||||
makeBook({ asin: 'A2', title: 'Alpha', author: 'Auth', narrator: 'Nar', durationMinutes: 302 }),
|
||||
makeBook({ asin: 'B1', title: 'Beta', author: 'Auth', narrator: 'Nar', durationMinutes: 500 }),
|
||||
makeBook({ asin: 'C1', title: 'Charlie', author: 'Auth', narrator: 'Nar', durationMinutes: 600 }),
|
||||
makeBook({ asin: 'C2', title: 'Charlie', author: 'Auth', narrator: 'Nar', durationMinutes: 601 }),
|
||||
];
|
||||
const dedupOnly = deduplicateAudiobooks(books);
|
||||
const { books: withGroups } = deduplicateAndCollectGroups(books);
|
||||
expect(withGroups.map(b => b.asin)).toEqual(dedupOnly.map(b => b.asin));
|
||||
});
|
||||
|
||||
it('includes narrator and durationMinutes from canonical entry in group', () => {
|
||||
const books = [
|
||||
makeBook({ asin: 'A1', title: 'Test Book', author: 'Auth', narrator: 'Jane Doe', durationMinutes: 480 }),
|
||||
makeBook({ asin: 'A2', title: 'Test Book', author: 'Auth', narrator: 'Jane Doe', durationMinutes: 482, coverArtUrl: 'img.jpg', rating: 4.0 }),
|
||||
];
|
||||
const { groups } = deduplicateAndCollectGroups(books);
|
||||
expect(groups).toHaveLength(1);
|
||||
expect(groups[0].canonicalAsin).toBe('A2'); // richer metadata
|
||||
expect(groups[0].narrator).toBe('Jane Doe');
|
||||
expect(groups[0].durationMinutes).toBe(482);
|
||||
expect(groups[0].author).toBe('Auth');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user