Add watched series/authors feature

Introduce watched lists for series and authors end-to-end.

- Add DB migration to create watched_series and watched_authors tables with indexes and foreign keys.
- Implement API routes: GET/POST for listing/adding and DELETE by id for both /api/user/watched-series and /api/user/watched-authors. Validation, ownership checks, and immediate targeted job triggers are included.
- Add client hooks (useWatchedSeries, useWatchedAuthors) with add/delete helpers and SWR revalidation.
- Add UI components: WatchButton (toggle/confirm) and WatchedListsSection for profile display and removal UX.
- Add processor (check-watched-lists.processor) and service (watched-lists.service) to scrape Audible, deduplicate, check library ownership, and auto-create requests; supports targeted checks for newly watched items.
- Include tests for the watched-lists service.

These changes implement the watched-lists feature to let users watch series/authors and have the system automatically detect and request new releases.
This commit is contained in:
kikootwo
2026-03-03 21:57:38 -05:00
parent 610873af6b
commit cbf02d3e24
23 changed files with 2392 additions and 32 deletions
@@ -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 });
}
});
}
+125
View File
@@ -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 });
}
});
}
+125
View File
@@ -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 });
}
});
}
+7
View File
@@ -12,6 +12,7 @@ import { useAuth } from '@/contexts/AuthContext';
import { useRequests } from '@/lib/hooks/useRequests';
import { cn } from '@/lib/utils/cn';
import { GoodreadsShelvesSection } from '@/components/profile/GoodreadsShelvesSection';
import { WatchedSeriesSection, WatchedAuthorsSection } from '@/components/profile/WatchedListsSection';
const statConfig = [
{ key: 'total', label: 'Total', color: 'text-gray-900 dark:text-white' },
@@ -142,6 +143,12 @@ export default function ProfilePage() {
{/* Goodreads Shelves */}
<GoodreadsShelvesSection />
{/* Watched Series */}
<WatchedSeriesSection />
{/* Watched Authors */}
<WatchedAuthorsSection />
{/* Active Downloads */}
{activeDownloads.length > 0 && (
<section>
+22 -14
View File
@@ -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>
);
}
+22 -14
View File
@@ -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 && (
+186
View File
@@ -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>
);
}
+119
View File
@@ -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 };
}
+119
View File
@@ -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 };
}
@@ -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,
};
}
+50
View File
@@ -27,6 +27,7 @@ export type JobType =
| 'cleanup_seeded_torrents'
| 'monitor_rss_feeds'
| 'sync_goodreads_shelves'
| 'check_watched_lists'
| 'send_notification'
// Ebook-specific job types
| 'search_ebook'
@@ -113,6 +114,16 @@ export interface SyncGoodreadsShelvesPayload extends JobPayload {
maxLookupsPerShelf?: number;
}
export interface CheckWatchedListsPayload extends JobPayload {
scheduledJobId?: string;
/** If set, only process watched items for this user */
userId?: string;
/** If set, only process this specific series */
seriesAsin?: string;
/** If set, only process this specific author */
authorAsin?: string;
}
// Ebook-specific payload interfaces
export interface SearchEbookPayload extends JobPayload {
requestId: string;
@@ -384,6 +395,12 @@ export class JobQueueService {
return await processSyncGoodreadsShelves(payloadWithJobId);
});
this.queue.process('check_watched_lists', 1, async (job: BullJob<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');
@@ -766,6 +783,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
// =========================================================================
+18 -1
View File
@@ -10,7 +10,7 @@ import { RMABLogger } from '../utils/logger';
const logger = RMABLogger.create('Scheduler');
export type ScheduledJobType = 'plex_library_scan' | 'plex_recently_added_check' | 'audible_refresh' | 'retry_missing_torrents' | 'retry_failed_imports' | 'cleanup_seeded_torrents' | 'monitor_rss_feeds' | 'sync_goodreads_shelves';
export type ScheduledJobType = 'plex_library_scan' | 'plex_recently_added_check' | 'audible_refresh' | 'retry_missing_torrents' | 'retry_failed_imports' | 'cleanup_seeded_torrents' | 'monitor_rss_feeds' | 'sync_goodreads_shelves' | 'check_watched_lists';
export interface ScheduledJob {
id: string;
@@ -133,6 +133,13 @@ export class SchedulerService {
enabled: true, // Enable by default
payload: {},
},
{
name: 'Check Watched Lists',
type: 'check_watched_lists' as ScheduledJobType,
schedule: '0 0 * * *', // Daily at midnight (every 24 hours)
enabled: true, // Enable by default
payload: {},
},
];
let created = 0;
@@ -353,6 +360,9 @@ export class SchedulerService {
case 'sync_goodreads_shelves':
bullJobId = await this.triggerSyncGoodreadsShelves(job);
break;
case 'check_watched_lists':
bullJobId = await this.triggerCheckWatchedLists(job);
break;
default:
throw new Error(`Unknown job type: ${job.type}`);
}
@@ -627,6 +637,13 @@ export class SchedulerService {
private async triggerSyncGoodreadsShelves(job: any): Promise<string> {
return await this.jobQueue.addSyncGoodreadsShelvesJob(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
+414
View File
@@ -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));
}
+4 -2
View File
@@ -44,9 +44,11 @@ export function normalizeTitle(title: string): string {
return t.replace(/\s+/g, ' ').trim();
}
/** Normalize narrator for comparison. */
/** Normalize narrator for comparison. Sorts individual names so order doesn't matter. */
function normalizeNarrator(narrator?: string): string {
return (narrator || '').toLowerCase().trim();
const raw = (narrator || '').toLowerCase().trim();
if (!raw) return raw;
return raw.split(',').map(n => n.trim()).filter(Boolean).sort().join(', ');
}
// ---------------------------------------------------------------------------