mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +00:00
Add per-user ignored audiobooks feature
Introduce a per-user "ignored audiobooks" feature to suppress auto-requests. Changes include: - Database: add Prisma model IgnoredAudiobook and SQL migration to create ignored_audiobooks table with indexes and FK to users. - Backend: new API routes to list, add, delete, and check ignored audiobooks (/api/user/ignored-audiobooks, /check/:asin, /:id). Add annotateWithIgnoreStatus utility and integrate it into multiple audiobook list endpoints (popular, new-releases, category, search, authors, series). - Request creator: add ignore-list check (with sibling-ASIN expansion) and a bypassIgnore option for manual requests; return an 'ignored' reason when blocked. - Frontend: hooks (useIsIgnored, useToggleIgnore, useIgnoredList) and UI updates — AudiobookCard shows an "Ignored" indicator and AudiobookDetailsModal adds an ignore toggle and propagates local state changes. - Misc: adjust deduplication duration tolerance (to 5% / min 10 minutes), tweak SWR refresh intervals for shelves/syncing, and small logging/info updates. - Tests: add unit tests for request-creator ignore logic and update existing tests/mocks to account for ignore annotation; extend prisma test helper with ignoredAudiobook mock. This commit implements the ignore-list end-to-end (DB, server, client, and tests) so users can ignore specific ASINs and have auto-request flows respect that preference.
This commit is contained in:
@@ -0,0 +1,24 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "ignored_audiobooks" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"user_id" TEXT NOT NULL,
|
||||||
|
"asin" TEXT NOT NULL,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"author" TEXT NOT NULL,
|
||||||
|
"cover_art_url" TEXT,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "ignored_audiobooks_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "ignored_audiobooks_user_id_idx" ON "ignored_audiobooks"("user_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "ignored_audiobooks_asin_idx" ON "ignored_audiobooks"("asin");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "ignored_audiobooks_user_id_asin_key" ON "ignored_audiobooks"("user_id", "asin");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ignored_audiobooks" ADD CONSTRAINT "ignored_audiobooks_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -74,6 +74,7 @@ model User {
|
|||||||
watchedSeries WatchedSeries[]
|
watchedSeries WatchedSeries[]
|
||||||
watchedAuthors WatchedAuthor[]
|
watchedAuthors WatchedAuthor[]
|
||||||
homeSections UserHomeSection[]
|
homeSections UserHomeSection[]
|
||||||
|
ignoredAudiobooks IgnoredAudiobook[]
|
||||||
|
|
||||||
@@index([plexId])
|
@@index([plexId])
|
||||||
@@index([role])
|
@@index([role])
|
||||||
@@ -675,6 +676,32 @@ model WatchedAuthor {
|
|||||||
@@map("watched_authors")
|
@@map("watched_authors")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// IGNORED AUDIOBOOK TABLE
|
||||||
|
// Per-user ignore list for auto-request suppression.
|
||||||
|
// Stores the ASIN the user clicked ignore on; works-system expansion
|
||||||
|
// happens at check-time in request-creator.service.ts.
|
||||||
|
// Documentation: documentation/features/ignored-audiobooks.md
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
model IgnoredAudiobook {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
userId String @map("user_id")
|
||||||
|
asin String // Audible ASIN that was explicitly ignored
|
||||||
|
title String // Display only — snapshot at ignore time
|
||||||
|
author String // Display only — snapshot at ignore time
|
||||||
|
coverArtUrl String? @map("cover_art_url") @db.Text
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([userId, asin])
|
||||||
|
@@index([userId])
|
||||||
|
@@index([asin])
|
||||||
|
@@map("ignored_audiobooks")
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// USER HOME SECTION TABLE
|
// USER HOME SECTION TABLE
|
||||||
// Per-user configurable home page sections (popular, new_releases, category)
|
// Per-user configurable home page sections (popular, new_releases, category)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { prisma } from '@/lib/db';
|
|||||||
import { enrichAudiobooksWithMatches, getAvailableAsins } from '@/lib/utils/audiobook-matcher';
|
import { enrichAudiobooksWithMatches, getAvailableAsins } from '@/lib/utils/audiobook-matcher';
|
||||||
import { getCurrentUser } from '@/lib/middleware/auth';
|
import { getCurrentUser } from '@/lib/middleware/auth';
|
||||||
import { RMABLogger } from '@/lib/utils/logger';
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
|
import { annotateWithIgnoreStatus } from '@/lib/utils/ignored-audiobooks';
|
||||||
|
|
||||||
const logger = RMABLogger.create('API.Audiobooks.Category');
|
const logger = RMABLogger.create('API.Audiobooks.Category');
|
||||||
|
|
||||||
@@ -129,12 +130,15 @@ export async function GET(
|
|||||||
const userId = currentUser?.sub || undefined;
|
const userId = currentUser?.sub || undefined;
|
||||||
const enrichedAudiobooks = await enrichAudiobooksWithMatches(audibleBooks, userId);
|
const enrichedAudiobooks = await enrichAudiobooksWithMatches(audibleBooks, userId);
|
||||||
|
|
||||||
|
// Annotate with per-user ignore status
|
||||||
|
const annotatedAudiobooks = await annotateWithIgnoreStatus(enrichedAudiobooks, userId);
|
||||||
|
|
||||||
const totalPages = Math.ceil(totalCount / limit);
|
const totalPages = Math.ceil(totalCount / limit);
|
||||||
const hasMore = page < totalPages;
|
const hasMore = page < totalPages;
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
audiobooks: enrichedAudiobooks,
|
audiobooks: annotatedAudiobooks,
|
||||||
count: enrichedAudiobooks.length,
|
count: enrichedAudiobooks.length,
|
||||||
totalCount,
|
totalCount,
|
||||||
page,
|
page,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { prisma } from '@/lib/db';
|
|||||||
import { enrichAudiobooksWithMatches, getAvailableAsins } from '@/lib/utils/audiobook-matcher';
|
import { enrichAudiobooksWithMatches, getAvailableAsins } from '@/lib/utils/audiobook-matcher';
|
||||||
import { getCurrentUser } from '@/lib/middleware/auth';
|
import { getCurrentUser } from '@/lib/middleware/auth';
|
||||||
import { RMABLogger } from '@/lib/utils/logger';
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
|
import { annotateWithIgnoreStatus } from '@/lib/utils/ignored-audiobooks';
|
||||||
import { NEW_RELEASES_CATEGORY_ID } from '@/lib/processors/audible-refresh.processor';
|
import { NEW_RELEASES_CATEGORY_ID } from '@/lib/processors/audible-refresh.processor';
|
||||||
|
|
||||||
const logger = RMABLogger.create('API.Audiobooks.NewReleases');
|
const logger = RMABLogger.create('API.Audiobooks.NewReleases');
|
||||||
@@ -136,12 +137,15 @@ export async function GET(request: NextRequest) {
|
|||||||
// Enrich with real-time Plex library matching and request status
|
// Enrich with real-time Plex library matching and request status
|
||||||
const enrichedAudiobooks = await enrichAudiobooksWithMatches(audibleBooks, userId);
|
const enrichedAudiobooks = await enrichAudiobooksWithMatches(audibleBooks, userId);
|
||||||
|
|
||||||
|
// Annotate with per-user ignore status
|
||||||
|
const annotatedAudiobooks = await annotateWithIgnoreStatus(enrichedAudiobooks, userId);
|
||||||
|
|
||||||
const totalPages = Math.ceil(totalCount / limit);
|
const totalPages = Math.ceil(totalCount / limit);
|
||||||
const hasMore = page < totalPages;
|
const hasMore = page < totalPages;
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
audiobooks: enrichedAudiobooks,
|
audiobooks: annotatedAudiobooks,
|
||||||
count: enrichedAudiobooks.length,
|
count: enrichedAudiobooks.length,
|
||||||
totalCount,
|
totalCount,
|
||||||
page,
|
page,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { prisma } from '@/lib/db';
|
|||||||
import { enrichAudiobooksWithMatches, getAvailableAsins } from '@/lib/utils/audiobook-matcher';
|
import { enrichAudiobooksWithMatches, getAvailableAsins } from '@/lib/utils/audiobook-matcher';
|
||||||
import { getCurrentUser } from '@/lib/middleware/auth';
|
import { getCurrentUser } from '@/lib/middleware/auth';
|
||||||
import { RMABLogger } from '@/lib/utils/logger';
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
|
import { annotateWithIgnoreStatus } from '@/lib/utils/ignored-audiobooks';
|
||||||
import { POPULAR_CATEGORY_ID } from '@/lib/processors/audible-refresh.processor';
|
import { POPULAR_CATEGORY_ID } from '@/lib/processors/audible-refresh.processor';
|
||||||
|
|
||||||
const logger = RMABLogger.create('API.Audiobooks.Popular');
|
const logger = RMABLogger.create('API.Audiobooks.Popular');
|
||||||
@@ -136,12 +137,15 @@ export async function GET(request: NextRequest) {
|
|||||||
// Enrich with real-time Plex library matching and request status
|
// Enrich with real-time Plex library matching and request status
|
||||||
const enrichedAudiobooks = await enrichAudiobooksWithMatches(audibleBooks, userId);
|
const enrichedAudiobooks = await enrichAudiobooksWithMatches(audibleBooks, userId);
|
||||||
|
|
||||||
|
// Annotate with per-user ignore status
|
||||||
|
const annotatedAudiobooks = await annotateWithIgnoreStatus(enrichedAudiobooks, userId);
|
||||||
|
|
||||||
const totalPages = Math.ceil(totalCount / limit);
|
const totalPages = Math.ceil(totalCount / limit);
|
||||||
const hasMore = page < totalPages;
|
const hasMore = page < totalPages;
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
audiobooks: enrichedAudiobooks,
|
audiobooks: annotatedAudiobooks,
|
||||||
count: enrichedAudiobooks.length,
|
count: enrichedAudiobooks.length,
|
||||||
totalCount,
|
totalCount,
|
||||||
page,
|
page,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { deduplicateAndCollectGroups } from '@/lib/utils/deduplicate-audiobooks'
|
|||||||
import { persistDedupGroups } from '@/lib/services/works.service';
|
import { persistDedupGroups } from '@/lib/services/works.service';
|
||||||
import { getCurrentUser } from '@/lib/middleware/auth';
|
import { getCurrentUser } from '@/lib/middleware/auth';
|
||||||
import { RMABLogger } from '@/lib/utils/logger';
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
|
import { annotateWithIgnoreStatus } from '@/lib/utils/ignored-audiobooks';
|
||||||
|
|
||||||
const logger = RMABLogger.create('API.Audiobooks.Search');
|
const logger = RMABLogger.create('API.Audiobooks.Search');
|
||||||
|
|
||||||
@@ -51,10 +52,13 @@ export async function GET(request: NextRequest) {
|
|||||||
// Enrich search results with availability and request status information
|
// Enrich search results with availability and request status information
|
||||||
const enrichedResults = await enrichAudiobooksWithMatches(dedupedResults, userId);
|
const enrichedResults = await enrichAudiobooksWithMatches(dedupedResults, userId);
|
||||||
|
|
||||||
|
// Annotate with per-user ignore status
|
||||||
|
const annotatedResults = await annotateWithIgnoreStatus(enrichedResults, userId);
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
query: results.query,
|
query: results.query,
|
||||||
results: enrichedResults,
|
results: annotatedResults,
|
||||||
totalResults: enrichedResults.length,
|
totalResults: enrichedResults.length,
|
||||||
page: results.page,
|
page: results.page,
|
||||||
hasMore: results.hasMore,
|
hasMore: results.hasMore,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { deduplicateAndCollectGroups } from '@/lib/utils/deduplicate-audiobooks'
|
|||||||
import { persistDedupGroups } from '@/lib/services/works.service';
|
import { persistDedupGroups } from '@/lib/services/works.service';
|
||||||
import { getCurrentUser } from '@/lib/middleware/auth';
|
import { getCurrentUser } from '@/lib/middleware/auth';
|
||||||
import { RMABLogger } from '@/lib/utils/logger';
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
|
import { annotateWithIgnoreStatus } from '@/lib/utils/ignored-audiobooks';
|
||||||
|
|
||||||
const logger = RMABLogger.create('API.Authors.Books');
|
const logger = RMABLogger.create('API.Authors.Books');
|
||||||
|
|
||||||
@@ -67,11 +68,14 @@ export async function GET(
|
|||||||
const userId = currentUser.sub || undefined;
|
const userId = currentUser.sub || undefined;
|
||||||
const enrichedBooks = await enrichAudiobooksWithMatches(dedupedBooks, userId);
|
const enrichedBooks = await enrichAudiobooksWithMatches(dedupedBooks, userId);
|
||||||
|
|
||||||
logger.info(`Author books complete: "${authorName}" → ${enrichedBooks.length} books (page ${page})`);
|
// Annotate with per-user ignore status
|
||||||
|
const annotatedBooks = await annotateWithIgnoreStatus(enrichedBooks, userId);
|
||||||
|
|
||||||
|
logger.info(`Author books complete: "${authorName}" → ${annotatedBooks.length} books (page ${page})`);
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
books: enrichedBooks,
|
books: annotatedBooks,
|
||||||
authorName: authorName.trim(),
|
authorName: authorName.trim(),
|
||||||
authorAsin: asin,
|
authorAsin: asin,
|
||||||
totalBooks: enrichedBooks.length,
|
totalBooks: enrichedBooks.length,
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export async function POST(request: NextRequest) {
|
|||||||
narrator: audiobook.narrator,
|
narrator: audiobook.narrator,
|
||||||
description: audiobook.description,
|
description: audiobook.description,
|
||||||
coverArtUrl: audiobook.coverArtUrl,
|
coverArtUrl: audiobook.coverArtUrl,
|
||||||
}, { skipAutoSearch });
|
}, { skipAutoSearch, bypassIgnore: true });
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
const statusMap: Record<string, { error: string; status: number }> = {
|
const statusMap: Record<string, { error: string; status: number }> = {
|
||||||
@@ -61,6 +61,7 @@ export async function POST(request: NextRequest) {
|
|||||||
being_processed: { error: 'BeingProcessed', status: 409 },
|
being_processed: { error: 'BeingProcessed', status: 409 },
|
||||||
duplicate: { error: 'DuplicateRequest', status: 409 },
|
duplicate: { error: 'DuplicateRequest', status: 409 },
|
||||||
user_not_found: { error: 'UserNotFound', status: 404 },
|
user_not_found: { error: 'UserNotFound', status: 404 },
|
||||||
|
ignored: { error: 'Ignored', status: 409 },
|
||||||
};
|
};
|
||||||
const mapped = statusMap[result.reason] || { error: 'RequestError', status: 500 };
|
const mapped = statusMap[result.reason] || { error: 'RequestError', status: 500 };
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { scrapeSeriesPage } from '@/lib/integrations/audible-series';
|
|||||||
import { enrichAudiobooksWithMatches } from '@/lib/utils/audiobook-matcher';
|
import { enrichAudiobooksWithMatches } from '@/lib/utils/audiobook-matcher';
|
||||||
import { deduplicateAndCollectGroups } from '@/lib/utils/deduplicate-audiobooks';
|
import { deduplicateAndCollectGroups } from '@/lib/utils/deduplicate-audiobooks';
|
||||||
import { persistDedupGroups } from '@/lib/services/works.service';
|
import { persistDedupGroups } from '@/lib/services/works.service';
|
||||||
|
import { annotateWithIgnoreStatus } from '@/lib/utils/ignored-audiobooks';
|
||||||
|
|
||||||
const logger = RMABLogger.create('API.Series.Detail');
|
const logger = RMABLogger.create('API.Series.Detail');
|
||||||
|
|
||||||
@@ -63,13 +64,16 @@ export async function GET(
|
|||||||
const userId = currentUser.sub || undefined;
|
const userId = currentUser.sub || undefined;
|
||||||
const enrichedBooks = await enrichAudiobooksWithMatches(dedupedBooks, userId);
|
const enrichedBooks = await enrichAudiobooksWithMatches(dedupedBooks, userId);
|
||||||
|
|
||||||
logger.info(`Series detail complete: "${detail.title}" (${enrichedBooks.length} books, page ${page})`);
|
// Annotate with per-user ignore status
|
||||||
|
const annotatedBooks = await annotateWithIgnoreStatus(enrichedBooks, userId);
|
||||||
|
|
||||||
|
logger.info(`Series detail complete: "${detail.title}" (${annotatedBooks.length} books, page ${page})`);
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
series: {
|
series: {
|
||||||
...detail,
|
...detail,
|
||||||
books: enrichedBooks,
|
books: annotatedBooks,
|
||||||
},
|
},
|
||||||
hasMore: detail.hasMore,
|
hasMore: detail.hasMore,
|
||||||
page: detail.page,
|
page: detail.page,
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
/**
|
||||||
|
* Component: Ignored Audiobook Delete Route
|
||||||
|
* Documentation: documentation/features/ignored-audiobooks.md
|
||||||
|
*
|
||||||
|
* DELETE removes a single entry from the user's ignore list (un-ignore).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||||
|
import { prisma } from '@/lib/db';
|
||||||
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
|
|
||||||
|
const logger = RMABLogger.create('API.IgnoredAudiobooks');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/user/ignored-audiobooks/[id]
|
||||||
|
* Remove an audiobook from the user's ignore list
|
||||||
|
*/
|
||||||
|
export async function DELETE(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||||
|
try {
|
||||||
|
if (!req.user) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
// Verify ownership before deleting
|
||||||
|
const existing = await prisma.ignoredAudiobook.findUnique({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'NotFound', message: 'Ignored audiobook entry not found' },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing.userId !== req.user.id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Forbidden', message: 'Cannot modify another user\'s ignore list' },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.ignoredAudiobook.delete({ where: { id } });
|
||||||
|
|
||||||
|
logger.info(`User ${req.user.id} un-ignored ASIN ${existing.asin} ("${existing.title}")`);
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to remove ignored audiobook', {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'DeleteError', message: 'Failed to remove ignored audiobook' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
/**
|
||||||
|
* Component: Ignored Audiobook Check Route
|
||||||
|
* Documentation: documentation/features/ignored-audiobooks.md
|
||||||
|
*
|
||||||
|
* Quick check whether a specific ASIN is ignored by the current user.
|
||||||
|
* Includes works-system expansion to catch sibling ASINs.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||||
|
import { prisma } from '@/lib/db';
|
||||||
|
import { getSiblingAsins } from '@/lib/services/works.service';
|
||||||
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
|
|
||||||
|
const logger = RMABLogger.create('API.IgnoredAudiobooks.Check');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/user/ignored-audiobooks/check/[asin]
|
||||||
|
* Returns { ignored: boolean, ignoredId?: string } for the given ASIN.
|
||||||
|
* ignoredId is the ID of the matching IgnoredAudiobook record (for un-ignore).
|
||||||
|
*/
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ asin: string }> }
|
||||||
|
) {
|
||||||
|
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||||
|
try {
|
||||||
|
if (!req.user) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { asin } = await params;
|
||||||
|
|
||||||
|
// Direct check
|
||||||
|
const directIgnore = await prisma.ignoredAudiobook.findUnique({
|
||||||
|
where: { userId_asin: { userId: req.user.id, asin } },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (directIgnore) {
|
||||||
|
return NextResponse.json({
|
||||||
|
ignored: true,
|
||||||
|
ignoredId: directIgnore.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Works-system expansion: check sibling ASINs
|
||||||
|
try {
|
||||||
|
const siblingMap = await getSiblingAsins([asin]);
|
||||||
|
const siblings = siblingMap.get(asin);
|
||||||
|
if (siblings && siblings.length > 0) {
|
||||||
|
const siblingIgnore = await prisma.ignoredAudiobook.findFirst({
|
||||||
|
where: {
|
||||||
|
userId: req.user.id,
|
||||||
|
asin: { in: siblings },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (siblingIgnore) {
|
||||||
|
return NextResponse.json({
|
||||||
|
ignored: true,
|
||||||
|
ignoredId: siblingIgnore.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Works expansion is best-effort
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ ignored: false });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to check ignored status', {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'CheckError', message: 'Failed to check ignored status' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
/**
|
||||||
|
* Component: Ignored Audiobooks API Routes
|
||||||
|
* Documentation: documentation/features/ignored-audiobooks.md
|
||||||
|
*
|
||||||
|
* Per-user ignore list for auto-request suppression.
|
||||||
|
* GET returns the user's full ignore list; POST adds a new entry.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||||
|
import { prisma } from '@/lib/db';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
|
|
||||||
|
const logger = RMABLogger.create('API.IgnoredAudiobooks');
|
||||||
|
|
||||||
|
const AddIgnoredSchema = z.object({
|
||||||
|
asin: z.string().min(1).max(20),
|
||||||
|
title: z.string().min(1).max(500),
|
||||||
|
author: z.string().min(1).max(500),
|
||||||
|
coverArtUrl: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/user/ignored-audiobooks
|
||||||
|
* List the current user's ignored audiobooks
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||||
|
try {
|
||||||
|
if (!req.user) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const ignored = await prisma.ignoredAudiobook.findMany({
|
||||||
|
where: { userId: req.user.id },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
ignoredAudiobooks: ignored.map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
asin: item.asin,
|
||||||
|
title: item.title,
|
||||||
|
author: item.author,
|
||||||
|
coverArtUrl: item.coverArtUrl,
|
||||||
|
createdAt: item.createdAt.toISOString(),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to list ignored audiobooks', {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'FetchError', message: 'Failed to fetch ignored audiobooks' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/user/ignored-audiobooks
|
||||||
|
* Add an audiobook to the user's ignore list
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||||
|
try {
|
||||||
|
if (!req.user) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await req.json();
|
||||||
|
const data = AddIgnoredSchema.parse(body);
|
||||||
|
|
||||||
|
// Upsert to handle duplicate gracefully
|
||||||
|
const ignored = await prisma.ignoredAudiobook.upsert({
|
||||||
|
where: {
|
||||||
|
userId_asin: { userId: req.user.id, asin: data.asin },
|
||||||
|
},
|
||||||
|
update: {}, // Already exists — no-op
|
||||||
|
create: {
|
||||||
|
userId: req.user.id,
|
||||||
|
asin: data.asin,
|
||||||
|
title: data.title,
|
||||||
|
author: data.author,
|
||||||
|
coverArtUrl: data.coverArtUrl,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(`User ${req.user.id} ignored ASIN ${data.asin} ("${data.title}")`);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
ignoredAudiobook: {
|
||||||
|
id: ignored.id,
|
||||||
|
asin: ignored.asin,
|
||||||
|
title: ignored.title,
|
||||||
|
author: ignored.author,
|
||||||
|
coverArtUrl: ignored.coverArtUrl,
|
||||||
|
createdAt: ignored.createdAt.toISOString(),
|
||||||
|
},
|
||||||
|
}, { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to add ignored audiobook', {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'ValidationError', details: error.errors },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'CreateError', message: 'Failed to ignore audiobook' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -59,13 +59,15 @@ export function AudiobookCard({
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [showModal, setShowModal] = useState(false);
|
const [showModal, setShowModal] = useState(false);
|
||||||
const [localRequestStatus, setLocalRequestStatus] = useState<string | undefined>(undefined);
|
const [localRequestStatus, setLocalRequestStatus] = useState<string | undefined>(undefined);
|
||||||
|
const [localIsIgnored, setLocalIsIgnored] = useState<boolean | undefined>(undefined);
|
||||||
const [coverError, setCoverError] = useState(false);
|
const [coverError, setCoverError] = useState(false);
|
||||||
|
|
||||||
// Build a display-only audiobook with the local status override
|
// Build a display-only audiobook with local overrides
|
||||||
const displayAudiobook = localRequestStatus !== undefined
|
const displayAudiobook = localRequestStatus !== undefined
|
||||||
? { ...audiobook, requestStatus: localRequestStatus }
|
? { ...audiobook, requestStatus: localRequestStatus }
|
||||||
: audiobook;
|
: audiobook;
|
||||||
const status = getStatusConfig(displayAudiobook);
|
const status = getStatusConfig(displayAudiobook);
|
||||||
|
const isIgnored = localIsIgnored !== undefined ? localIsIgnored : audiobook.isIgnored;
|
||||||
|
|
||||||
const handleRequest = async (e: React.MouseEvent) => {
|
const handleRequest = async (e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -218,6 +220,19 @@ export function AudiobookCard({
|
|||||||
<span>{audiobook.rating.toFixed(1)}</span>
|
<span>{audiobook.rating.toFixed(1)}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Ignored Indicator - Bottom Left */}
|
||||||
|
{isIgnored && (
|
||||||
|
<div
|
||||||
|
className="absolute bottom-3 left-3 flex items-center gap-1 px-2 py-1 rounded-lg bg-black/50 backdrop-blur-md text-gray-300 text-xs font-medium transition-opacity duration-300 group-hover:opacity-0"
|
||||||
|
title="Ignored from auto-requests"
|
||||||
|
>
|
||||||
|
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M3.98 8.223A10.477 10.477 0 001.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.45 10.45 0 0112 4.5c4.756 0 8.773 3.162 10.065 7.498a10.523 10.523 0 01-4.293 5.774M6.228 6.228L3 3m3.228 3.228l3.65 3.65m7.894 7.894L21 21m-3.228-3.228l-3.65-3.65m0 0a3 3 0 10-4.243-4.243m4.242 4.242L9.88 9.88" />
|
||||||
|
</svg>
|
||||||
|
<span>Ignored</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -253,6 +268,7 @@ export function AudiobookCard({
|
|||||||
onClose={() => setShowModal(false)}
|
onClose={() => setShowModal(false)}
|
||||||
onRequestSuccess={onRequestSuccess}
|
onRequestSuccess={onRequestSuccess}
|
||||||
onStatusChange={(newStatus) => setLocalRequestStatus(newStatus)}
|
onStatusChange={(newStatus) => setLocalRequestStatus(newStatus)}
|
||||||
|
onIgnoreChange={(ignored) => setLocalIsIgnored(ignored)}
|
||||||
isRequested={audiobook.isRequested || localRequestStatus !== undefined}
|
isRequested={audiobook.isRequested || localRequestStatus !== undefined}
|
||||||
requestStatus={displayAudiobook.requestStatus}
|
requestStatus={displayAudiobook.requestStatus}
|
||||||
isAvailable={audiobook.isAvailable}
|
isAvailable={audiobook.isAvailable}
|
||||||
|
|||||||
@@ -19,8 +19,10 @@ import { usePreferences } from '@/contexts/PreferencesContext';
|
|||||||
import { InteractiveTorrentSearchModal } from '@/components/requests/InteractiveTorrentSearchModal';
|
import { InteractiveTorrentSearchModal } from '@/components/requests/InteractiveTorrentSearchModal';
|
||||||
import { ReportIssueModal } from '@/components/audiobooks/ReportIssueModal';
|
import { ReportIssueModal } from '@/components/audiobooks/ReportIssueModal';
|
||||||
import { ManualImportBrowser } from '@/components/audiobooks/ManualImportBrowser';
|
import { ManualImportBrowser } from '@/components/audiobooks/ManualImportBrowser';
|
||||||
import { FolderArrowDownIcon } from '@heroicons/react/24/outline';
|
import { FolderArrowDownIcon, EyeSlashIcon } from '@heroicons/react/24/outline';
|
||||||
|
import { EyeSlashIcon as EyeSlashSolidIcon } from '@heroicons/react/24/solid';
|
||||||
import { fetchWithAuth } from '@/lib/utils/api';
|
import { fetchWithAuth } from '@/lib/utils/api';
|
||||||
|
import { useIsIgnored, useToggleIgnore } from '@/lib/hooks/useIgnoredAudiobooks';
|
||||||
|
|
||||||
interface AudiobookDetailsModalProps {
|
interface AudiobookDetailsModalProps {
|
||||||
asin: string;
|
asin: string;
|
||||||
@@ -28,6 +30,7 @@ interface AudiobookDetailsModalProps {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onRequestSuccess?: () => void;
|
onRequestSuccess?: () => void;
|
||||||
onStatusChange?: (newStatus: string) => void;
|
onStatusChange?: (newStatus: string) => void;
|
||||||
|
onIgnoreChange?: (isIgnored: boolean) => void;
|
||||||
isRequested?: boolean;
|
isRequested?: boolean;
|
||||||
requestStatus?: string | null;
|
requestStatus?: string | null;
|
||||||
isAvailable?: boolean;
|
isAvailable?: boolean;
|
||||||
@@ -69,6 +72,7 @@ export function AudiobookDetailsModal({
|
|||||||
onClose,
|
onClose,
|
||||||
onRequestSuccess,
|
onRequestSuccess,
|
||||||
onStatusChange,
|
onStatusChange,
|
||||||
|
onIgnoreChange,
|
||||||
isRequested = false,
|
isRequested = false,
|
||||||
requestStatus = null,
|
requestStatus = null,
|
||||||
isAvailable = false,
|
isAvailable = false,
|
||||||
@@ -85,6 +89,9 @@ export function AudiobookDetailsModal({
|
|||||||
const { downloadAvailable, requestId } = useDownloadStatus(isOpen ? asin : null);
|
const { downloadAvailable, requestId } = useDownloadStatus(isOpen ? asin : null);
|
||||||
const { fetchEbook, isLoading: isFetchingEbook } = useFetchEbookByAsin();
|
const { fetchEbook, isLoading: isFetchingEbook } = useFetchEbookByAsin();
|
||||||
|
|
||||||
|
const { isIgnored, ignoredId, isLoading: isLoadingIgnore } = useIsIgnored(isOpen ? asin : null);
|
||||||
|
const { addIgnore, removeIgnore } = useToggleIgnore();
|
||||||
|
|
||||||
const [showToast, setShowToast] = useState(false);
|
const [showToast, setShowToast] = useState(false);
|
||||||
const [toastMessage, setToastMessage] = useState('');
|
const [toastMessage, setToastMessage] = useState('');
|
||||||
const [toastType, setToastType] = useState<'success' | 'error'>('success');
|
const [toastType, setToastType] = useState<'success' | 'error'>('success');
|
||||||
@@ -97,6 +104,7 @@ export function AudiobookDetailsModal({
|
|||||||
const [localRequestStatus, setLocalRequestStatus] = useState<string | null>(requestStatus ?? null);
|
const [localRequestStatus, setLocalRequestStatus] = useState<string | null>(requestStatus ?? null);
|
||||||
const [isDownloading, setIsDownloading] = useState(false);
|
const [isDownloading, setIsDownloading] = useState(false);
|
||||||
const [coverError, setCoverError] = useState(false);
|
const [coverError, setCoverError] = useState(false);
|
||||||
|
const [isTogglingIgnore, setIsTogglingIgnore] = useState(false);
|
||||||
|
|
||||||
// Sync local status when the prop changes (e.g. page data refreshes)
|
// Sync local status when the prop changes (e.g. page data refreshes)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -196,6 +204,31 @@ export function AudiobookDetailsModal({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleToggleIgnore = async () => {
|
||||||
|
if (!user || !audiobook) return;
|
||||||
|
setIsTogglingIgnore(true);
|
||||||
|
try {
|
||||||
|
if (isIgnored && ignoredId) {
|
||||||
|
await removeIgnore(ignoredId, asin);
|
||||||
|
onIgnoreChange?.(false);
|
||||||
|
showNotification('Removed from ignore list');
|
||||||
|
} else {
|
||||||
|
await addIgnore({
|
||||||
|
asin,
|
||||||
|
title: audiobook.title,
|
||||||
|
author: audiobook.author,
|
||||||
|
coverArtUrl: audiobook.coverArtUrl,
|
||||||
|
});
|
||||||
|
onIgnoreChange?.(true);
|
||||||
|
showNotification('Added to ignore list — auto-requests will skip this book');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showNotification(err instanceof Error ? err.message : 'Failed to update ignore status', 'error');
|
||||||
|
} finally {
|
||||||
|
setIsTogglingIgnore(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const formatDuration = (minutes?: number) => {
|
const formatDuration = (minutes?: number) => {
|
||||||
if (!minutes) return null;
|
if (!minutes) return null;
|
||||||
const hours = Math.floor(minutes / 60);
|
const hours = Math.floor(minutes / 60);
|
||||||
@@ -685,6 +718,26 @@ export function AudiobookDetailsModal({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Ignore Toggle - always visible when user is logged in */}
|
||||||
|
{user && !isLoadingIgnore && (
|
||||||
|
<button
|
||||||
|
onClick={handleToggleIgnore}
|
||||||
|
disabled={isTogglingIgnore}
|
||||||
|
className={`p-3 rounded-xl transition-colors disabled:opacity-50 ${
|
||||||
|
isIgnored
|
||||||
|
? 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
|
||||||
|
: 'bg-gray-100 dark:bg-gray-800/50 text-gray-400 dark:text-gray-500 hover:bg-gray-200 dark:hover:bg-gray-700'
|
||||||
|
}`}
|
||||||
|
title={isIgnored ? 'Stop Ignoring — auto-requests will resume for this book' : 'Ignore from Auto-Requests'}
|
||||||
|
>
|
||||||
|
{isIgnored ? (
|
||||||
|
<EyeSlashSolidIcon className="w-6 h-6" />
|
||||||
|
) : (
|
||||||
|
<EyeSlashIcon className="w-6 h-6" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -46,7 +46,13 @@ export function createShelfHooks<TShelf>(endpoint: string) {
|
|||||||
const key = accessToken ? endpoint : null;
|
const key = accessToken ? endpoint : null;
|
||||||
|
|
||||||
const { data, error, isLoading } = useSWR(key, fetcher, {
|
const { data, error, isLoading } = useSWR(key, fetcher, {
|
||||||
refreshInterval: 30000,
|
refreshInterval: (latestData: { shelves: TShelf[] } | undefined) => {
|
||||||
|
const shelves = latestData?.shelves || [];
|
||||||
|
const hasSyncing = shelves.some(
|
||||||
|
(s) => !(s as Record<string, unknown>)['lastSyncAt'],
|
||||||
|
);
|
||||||
|
return hasSyncing ? 3000 : 30000;
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export interface Audiobook {
|
|||||||
requestId?: string | null; // ID of request (if any)
|
requestId?: string | null; // ID of request (if any)
|
||||||
requestedByUsername?: string | null; // Username who requested (only if not current user)
|
requestedByUsername?: string | null; // Username who requested (only if not current user)
|
||||||
hasReportedIssue?: boolean; // True if an open issue exists for this audiobook
|
hasReportedIssue?: boolean; // True if an open issue exists for this audiobook
|
||||||
|
isIgnored?: boolean; // True if this user has ignored this audiobook from auto-requests
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useAudiobooks(type: 'popular' | 'new-releases', limit: number = 20, page: number = 1, hideAvailable: boolean = false) {
|
export function useAudiobooks(type: 'popular' | 'new-releases', limit: number = 20, page: number = 1, hideAvailable: boolean = false) {
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
/**
|
||||||
|
* Component: Ignored Audiobooks Hook
|
||||||
|
* Documentation: documentation/features/ignored-audiobooks.md
|
||||||
|
*
|
||||||
|
* Provides hooks for checking and toggling audiobook ignore status.
|
||||||
|
* - useIsIgnored(asin): check if a specific book is ignored
|
||||||
|
* - useToggleIgnore(): toggle ignore on/off for a book
|
||||||
|
* - useIgnoredList(): list all ignored books for the current user
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import useSWR, { mutate } from 'swr';
|
||||||
|
import { authenticatedFetcher, fetchWithAuth } from '@/lib/utils/api';
|
||||||
|
|
||||||
|
interface IgnoredAudiobook {
|
||||||
|
id: string;
|
||||||
|
asin: string;
|
||||||
|
title: string;
|
||||||
|
author: string;
|
||||||
|
coverArtUrl?: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IgnoreCheckResult {
|
||||||
|
ignored: boolean;
|
||||||
|
ignoredId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a specific ASIN is ignored by the current user.
|
||||||
|
* Includes works-system expansion on the server side.
|
||||||
|
*/
|
||||||
|
export function useIsIgnored(asin: string | null) {
|
||||||
|
const endpoint = asin ? `/api/user/ignored-audiobooks/check/${asin}` : null;
|
||||||
|
|
||||||
|
const { data, error, isLoading } = useSWR<IgnoreCheckResult>(
|
||||||
|
endpoint,
|
||||||
|
authenticatedFetcher,
|
||||||
|
{
|
||||||
|
revalidateOnFocus: false,
|
||||||
|
dedupingInterval: 30000,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isIgnored: data?.ignored ?? false,
|
||||||
|
ignoredId: data?.ignoredId ?? null,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle ignore status for an audiobook.
|
||||||
|
* Returns { addIgnore, removeIgnore } functions.
|
||||||
|
*/
|
||||||
|
export function useToggleIgnore() {
|
||||||
|
const addIgnore = async (book: {
|
||||||
|
asin: string;
|
||||||
|
title: string;
|
||||||
|
author: string;
|
||||||
|
coverArtUrl?: string;
|
||||||
|
}): Promise<IgnoredAudiobook> => {
|
||||||
|
const res = await fetchWithAuth('/api/user/ignored-audiobooks', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(book),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(err.message || 'Failed to ignore audiobook');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await res.json();
|
||||||
|
|
||||||
|
// Invalidate the check cache for this ASIN
|
||||||
|
mutate(`/api/user/ignored-audiobooks/check/${book.asin}`);
|
||||||
|
// Invalidate the full list
|
||||||
|
mutate('/api/user/ignored-audiobooks');
|
||||||
|
|
||||||
|
return result.ignoredAudiobook;
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeIgnore = async (id: string, asin: string): Promise<void> => {
|
||||||
|
const res = await fetchWithAuth(`/api/user/ignored-audiobooks/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(err.message || 'Failed to un-ignore audiobook');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invalidate the check cache for this ASIN
|
||||||
|
mutate(`/api/user/ignored-audiobooks/check/${asin}`);
|
||||||
|
// Invalidate the full list
|
||||||
|
mutate('/api/user/ignored-audiobooks');
|
||||||
|
};
|
||||||
|
|
||||||
|
return { addIgnore, removeIgnore };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all ignored audiobooks for the current user.
|
||||||
|
*/
|
||||||
|
export function useIgnoredList() {
|
||||||
|
const { data, error, isLoading } = useSWR<{ ignoredAudiobooks: IgnoredAudiobook[] }>(
|
||||||
|
'/api/user/ignored-audiobooks',
|
||||||
|
authenticatedFetcher,
|
||||||
|
{
|
||||||
|
revalidateOnFocus: false,
|
||||||
|
dedupingInterval: 60000,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
ignoredAudiobooks: data?.ignoredAudiobooks ?? [],
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -30,7 +30,11 @@ export function useShelves() {
|
|||||||
const endpoint = accessToken ? '/api/user/shelves' : null;
|
const endpoint = accessToken ? '/api/user/shelves' : null;
|
||||||
|
|
||||||
const { data, error, isLoading } = useSWR(endpoint, fetcher, {
|
const { data, error, isLoading } = useSWR(endpoint, fetcher, {
|
||||||
refreshInterval: 30000,
|
refreshInterval: (latestData: { shelves: GenericShelf[] } | undefined) => {
|
||||||
|
const shelves = latestData?.shelves || [];
|
||||||
|
const hasSyncing = shelves.some((s) => !s.lastSyncAt);
|
||||||
|
return hasSyncing ? 3000 : 30000;
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { getJobQueueService } from '@/lib/services/job-queue.service';
|
|||||||
import { findPlexMatch } from '@/lib/utils/audiobook-matcher';
|
import { findPlexMatch } from '@/lib/utils/audiobook-matcher';
|
||||||
import { getAudibleService } from '@/lib/integrations/audible.service';
|
import { getAudibleService } from '@/lib/integrations/audible.service';
|
||||||
import { RMABLogger } from '@/lib/utils/logger';
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
import { seedAsin } from '@/lib/services/works.service';
|
import { seedAsin, getSiblingAsins } from '@/lib/services/works.service';
|
||||||
|
|
||||||
const logger = RMABLogger.create('RequestCreator');
|
const logger = RMABLogger.create('RequestCreator');
|
||||||
|
|
||||||
@@ -27,11 +27,13 @@ export interface CreateRequestInput {
|
|||||||
|
|
||||||
export interface CreateRequestOptions {
|
export interface CreateRequestOptions {
|
||||||
skipAutoSearch?: boolean;
|
skipAutoSearch?: boolean;
|
||||||
|
/** When true, skip the per-user ignore list check (used for manual requests) */
|
||||||
|
bypassIgnore?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CreateRequestResult =
|
export type CreateRequestResult =
|
||||||
| { success: true; request: any }
|
| { success: true; request: any }
|
||||||
| { success: false; reason: 'already_available' | 'being_processed' | 'duplicate' | 'user_not_found'; message: string };
|
| { success: false; reason: 'already_available' | 'being_processed' | 'duplicate' | 'user_not_found' | 'ignored'; message: string };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a request for a user, with full duplicate detection, library checks,
|
* Create a request for a user, with full duplicate detection, library checks,
|
||||||
@@ -42,7 +44,7 @@ export async function createRequestForUser(
|
|||||||
audiobook: CreateRequestInput,
|
audiobook: CreateRequestInput,
|
||||||
options: CreateRequestOptions = {}
|
options: CreateRequestOptions = {}
|
||||||
): Promise<CreateRequestResult> {
|
): Promise<CreateRequestResult> {
|
||||||
const { skipAutoSearch = false } = options;
|
const { skipAutoSearch = false, bypassIgnore = false } = options;
|
||||||
|
|
||||||
// Check for existing active request (downloaded/available) for this ASIN
|
// Check for existing active request (downloaded/available) for this ASIN
|
||||||
const existingActiveRequest = await prisma.request.findFirst({
|
const existingActiveRequest = await prisma.request.findFirst({
|
||||||
@@ -81,6 +83,18 @@ export async function createRequestForUser(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check per-user ignore list (skipped for manual requests via bypassIgnore)
|
||||||
|
if (!bypassIgnore) {
|
||||||
|
const isIgnored = await checkIgnoreList(userId, audiobook.asin);
|
||||||
|
if (isIgnored) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
reason: 'ignored',
|
||||||
|
message: 'This audiobook is on your ignore list',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Fetch full details from Audnexus for year/series
|
// Fetch full details from Audnexus for year/series
|
||||||
let year: number | undefined;
|
let year: number | undefined;
|
||||||
let series: string | undefined;
|
let series: string | undefined;
|
||||||
@@ -279,3 +293,34 @@ export async function createRequestForUser(
|
|||||||
|
|
||||||
return { success: true, request: newRequest };
|
return { success: true, request: newRequest };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an ASIN (or any of its sibling ASINs via the works table)
|
||||||
|
* is on the user's ignore list. Returns true if the book should be blocked.
|
||||||
|
*/
|
||||||
|
async function checkIgnoreList(userId: string, asin: string): Promise<boolean> {
|
||||||
|
// Direct check: is this exact ASIN ignored?
|
||||||
|
const directIgnore = await prisma.ignoredAudiobook.findUnique({
|
||||||
|
where: { userId_asin: { userId, asin } },
|
||||||
|
});
|
||||||
|
if (directIgnore) return true;
|
||||||
|
|
||||||
|
// Works-system expansion: check sibling ASINs
|
||||||
|
try {
|
||||||
|
const siblingMap = await getSiblingAsins([asin]);
|
||||||
|
const siblings = siblingMap.get(asin);
|
||||||
|
if (siblings && siblings.length > 0) {
|
||||||
|
const siblingIgnore = await prisma.ignoredAudiobook.findFirst({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
asin: { in: siblings },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (siblingIgnore) return true;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Works expansion is best-effort — if it fails, only direct check applies
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
* under different ASINs (publisher re-listings, rights transfers, etc.).
|
* under different ASINs (publisher re-listings, rights transfers, etc.).
|
||||||
*
|
*
|
||||||
* Dedup key: normalized title + normalized narrator
|
* Dedup key: normalized title + normalized narrator
|
||||||
* Duration tolerance: max(longerDuration * 0.01, 5) minutes
|
* Duration tolerance: max(longerDuration * 0.05, 10) minutes
|
||||||
* Missing duration treated as compatible (graceful degradation).
|
* Missing duration treated as compatible (graceful degradation).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -95,13 +95,13 @@ function normalizeNarrator(narrator?: string): string {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if two durations are compatible (represent the same recording).
|
* Check if two durations are compatible (represent the same recording).
|
||||||
* Tolerance: max(longerDuration * 0.01, 5) minutes.
|
* Tolerance: max(longerDuration * 0.05, 10) minutes.
|
||||||
* Missing duration on either side is treated as compatible.
|
* Missing duration on either side is treated as compatible.
|
||||||
*/
|
*/
|
||||||
export function areDurationsCompatible(a?: number, b?: number): boolean {
|
export function areDurationsCompatible(a?: number, b?: number): boolean {
|
||||||
if (a == null || b == null) return true;
|
if (a == null || b == null) return true;
|
||||||
const longer = Math.max(a, b);
|
const longer = Math.max(a, b);
|
||||||
const tolerance = Math.max(longer * 0.01, 5);
|
const tolerance = Math.max(longer * 0.05, 10);
|
||||||
return Math.abs(a - b) <= tolerance;
|
return Math.abs(a - b) <= tolerance;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
/**
|
||||||
|
* Component: Ignored Audiobooks Utility
|
||||||
|
* Documentation: documentation/features/ignored-audiobooks.md
|
||||||
|
*
|
||||||
|
* Shared utility for annotating audiobook lists with per-user ignore status.
|
||||||
|
* Uses a single bulk query for the user's full ignore list, then annotates in-memory.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { prisma } from '@/lib/db';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Annotate an array of audiobook objects with `isIgnored: boolean`.
|
||||||
|
* Fetches the user's full ignore list in one query and matches by ASIN.
|
||||||
|
*
|
||||||
|
* If userId is undefined (unauthenticated), all books get `isIgnored: false`.
|
||||||
|
*/
|
||||||
|
export async function annotateWithIgnoreStatus<T extends { asin: string }>(
|
||||||
|
audiobooks: T[],
|
||||||
|
userId?: string
|
||||||
|
): Promise<(T & { isIgnored: boolean })[]> {
|
||||||
|
if (!userId || audiobooks.length === 0) {
|
||||||
|
return audiobooks.map((book) => ({ ...book, isIgnored: false }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single query: get all ASINs this user has ignored
|
||||||
|
const ignoredEntries = await prisma.ignoredAudiobook.findMany({
|
||||||
|
where: { userId },
|
||||||
|
select: { asin: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const ignoredAsinSet = new Set(ignoredEntries.map((e) => e.asin));
|
||||||
|
|
||||||
|
return audiobooks.map((book) => ({
|
||||||
|
...book,
|
||||||
|
isIgnored: ignoredAsinSet.has(book.asin),
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -27,6 +27,13 @@ vi.mock('@/lib/utils/audiobook-matcher', () => ({
|
|||||||
enrichAudiobooksWithMatches: enrichMock,
|
enrichAudiobooksWithMatches: enrichMock,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Mock ignore status annotation — pass-through that adds isIgnored: false
|
||||||
|
vi.mock('@/lib/utils/ignored-audiobooks', () => ({
|
||||||
|
annotateWithIgnoreStatus: vi.fn(async (books: any[]) =>
|
||||||
|
books.map((b: any) => ({ ...b, isIgnored: false }))
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('@/lib/middleware/auth', () => ({
|
vi.mock('@/lib/middleware/auth', () => ({
|
||||||
getCurrentUser: currentUserMock,
|
getCurrentUser: currentUserMock,
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ export const createPrismaMock = () => ({
|
|||||||
watchedAuthor: createModelMock(),
|
watchedAuthor: createModelMock(),
|
||||||
userHomeSection: createModelMock(),
|
userHomeSection: createModelMock(),
|
||||||
audibleCacheCategory: createModelMock(),
|
audibleCacheCategory: createModelMock(),
|
||||||
|
ignoredAudiobook: createModelMock(),
|
||||||
$queryRaw: vi.fn(),
|
$queryRaw: vi.fn(),
|
||||||
$transaction: vi.fn(),
|
$transaction: vi.fn(),
|
||||||
$disconnect: vi.fn(),
|
$disconnect: vi.fn(),
|
||||||
|
|||||||
@@ -0,0 +1,200 @@
|
|||||||
|
/**
|
||||||
|
* Component: Request Creator Ignore Tests
|
||||||
|
* Documentation: documentation/features/ignored-audiobooks.md
|
||||||
|
*
|
||||||
|
* Tests the per-user ignore list check in createRequestForUser,
|
||||||
|
* including direct ASIN match, works-system sibling expansion,
|
||||||
|
* and the bypassIgnore option.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { createPrismaMock } from '../helpers/prisma';
|
||||||
|
|
||||||
|
const prismaMock = createPrismaMock();
|
||||||
|
|
||||||
|
vi.mock('@/lib/db', () => ({
|
||||||
|
prisma: prismaMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/utils/logger', () => ({
|
||||||
|
RMABLogger: {
|
||||||
|
create: () => ({
|
||||||
|
debug: vi.fn(),
|
||||||
|
info: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock findPlexMatch to return null (not in library)
|
||||||
|
vi.mock('@/lib/utils/audiobook-matcher', () => ({
|
||||||
|
findPlexMatch: vi.fn().mockResolvedValue(null),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock AudibleService
|
||||||
|
vi.mock('@/lib/integrations/audible.service', () => ({
|
||||||
|
getAudibleService: () => ({
|
||||||
|
getAudiobookDetails: vi.fn().mockResolvedValue(null),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock job queue
|
||||||
|
vi.mock('@/lib/services/job-queue.service', () => ({
|
||||||
|
getJobQueueService: () => ({
|
||||||
|
addSearchJob: vi.fn().mockResolvedValue(undefined),
|
||||||
|
addNotificationJob: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock getSiblingAsins from works.service
|
||||||
|
const mockGetSiblingAsins = vi.fn().mockResolvedValue(new Map());
|
||||||
|
const mockSeedAsin = vi.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
vi.mock('@/lib/services/works.service', () => ({
|
||||||
|
getSiblingAsins: (...args: any[]) => mockGetSiblingAsins(...args),
|
||||||
|
seedAsin: (...args: any[]) => mockSeedAsin(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const TEST_AUDIOBOOK = {
|
||||||
|
asin: 'B00TEST001',
|
||||||
|
title: 'Test Book',
|
||||||
|
author: 'Test Author',
|
||||||
|
};
|
||||||
|
|
||||||
|
const TEST_USER_ID = 'user-123';
|
||||||
|
|
||||||
|
describe('createRequestForUser — ignore list', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
|
||||||
|
// Default: no existing requests, no library matches
|
||||||
|
prismaMock.request.findFirst.mockResolvedValue(null);
|
||||||
|
prismaMock.audiobook.findFirst.mockResolvedValue(null);
|
||||||
|
prismaMock.audiobook.create.mockResolvedValue({
|
||||||
|
id: 'audiobook-1',
|
||||||
|
audibleAsin: TEST_AUDIOBOOK.asin,
|
||||||
|
title: TEST_AUDIOBOOK.title,
|
||||||
|
author: TEST_AUDIOBOOK.author,
|
||||||
|
narrator: null,
|
||||||
|
});
|
||||||
|
prismaMock.request.create.mockResolvedValue({
|
||||||
|
id: 'request-1',
|
||||||
|
userId: TEST_USER_ID,
|
||||||
|
audiobookId: 'audiobook-1',
|
||||||
|
status: 'pending',
|
||||||
|
audiobook: { id: 'audiobook-1', title: 'Test Book' },
|
||||||
|
user: { id: TEST_USER_ID, plexUsername: 'testuser' },
|
||||||
|
});
|
||||||
|
prismaMock.user.findUnique.mockResolvedValue({
|
||||||
|
role: 'user',
|
||||||
|
autoApproveRequests: true,
|
||||||
|
plexUsername: 'testuser',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Default: not ignored
|
||||||
|
prismaMock.ignoredAudiobook.findUnique.mockResolvedValue(null);
|
||||||
|
prismaMock.ignoredAudiobook.findFirst.mockResolvedValue(null);
|
||||||
|
mockGetSiblingAsins.mockResolvedValue(new Map());
|
||||||
|
mockSeedAsin.mockResolvedValue(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks auto-request when ASIN is directly ignored', async () => {
|
||||||
|
prismaMock.ignoredAudiobook.findUnique.mockResolvedValue({
|
||||||
|
id: 'ignored-1',
|
||||||
|
userId: TEST_USER_ID,
|
||||||
|
asin: TEST_AUDIOBOOK.asin,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { createRequestForUser } = await import('@/lib/services/request-creator.service');
|
||||||
|
const result = await createRequestForUser(TEST_USER_ID, TEST_AUDIOBOOK);
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
if (!result.success) {
|
||||||
|
expect(result.reason).toBe('ignored');
|
||||||
|
expect(result.message).toContain('ignore list');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should NOT create a request
|
||||||
|
expect(prismaMock.request.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks auto-request when sibling ASIN is ignored', async () => {
|
||||||
|
// Direct ASIN not ignored
|
||||||
|
prismaMock.ignoredAudiobook.findUnique.mockResolvedValue(null);
|
||||||
|
|
||||||
|
// But a sibling is ignored
|
||||||
|
mockGetSiblingAsins.mockResolvedValue(new Map([
|
||||||
|
[TEST_AUDIOBOOK.asin, ['B00SIBLING']],
|
||||||
|
]));
|
||||||
|
prismaMock.ignoredAudiobook.findFirst.mockResolvedValue({
|
||||||
|
id: 'ignored-sibling',
|
||||||
|
userId: TEST_USER_ID,
|
||||||
|
asin: 'B00SIBLING',
|
||||||
|
});
|
||||||
|
|
||||||
|
const { createRequestForUser } = await import('@/lib/services/request-creator.service');
|
||||||
|
const result = await createRequestForUser(TEST_USER_ID, TEST_AUDIOBOOK);
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
if (!result.success) {
|
||||||
|
expect(result.reason).toBe('ignored');
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(prismaMock.request.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows manual request with bypassIgnore even when ignored', async () => {
|
||||||
|
prismaMock.ignoredAudiobook.findUnique.mockResolvedValue({
|
||||||
|
id: 'ignored-1',
|
||||||
|
userId: TEST_USER_ID,
|
||||||
|
asin: TEST_AUDIOBOOK.asin,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { createRequestForUser } = await import('@/lib/services/request-creator.service');
|
||||||
|
const result = await createRequestForUser(TEST_USER_ID, TEST_AUDIOBOOK, {
|
||||||
|
bypassIgnore: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(prismaMock.request.create).toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Should NOT have even checked the ignore list
|
||||||
|
expect(prismaMock.ignoredAudiobook.findUnique).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows request when ASIN is not ignored', async () => {
|
||||||
|
prismaMock.ignoredAudiobook.findUnique.mockResolvedValue(null);
|
||||||
|
prismaMock.ignoredAudiobook.findFirst.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const { createRequestForUser } = await import('@/lib/services/request-creator.service');
|
||||||
|
const result = await createRequestForUser(TEST_USER_ID, TEST_AUDIOBOOK);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(prismaMock.request.create).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls through gracefully when works expansion fails', async () => {
|
||||||
|
prismaMock.ignoredAudiobook.findUnique.mockResolvedValue(null);
|
||||||
|
mockGetSiblingAsins.mockRejectedValue(new Error('DB error'));
|
||||||
|
|
||||||
|
const { createRequestForUser } = await import('@/lib/services/request-creator.service');
|
||||||
|
const result = await createRequestForUser(TEST_USER_ID, TEST_AUDIOBOOK);
|
||||||
|
|
||||||
|
// Should still succeed since direct check passed and expansion is best-effort
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(prismaMock.request.create).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not check siblings when no sibling ASINs exist', async () => {
|
||||||
|
prismaMock.ignoredAudiobook.findUnique.mockResolvedValue(null);
|
||||||
|
mockGetSiblingAsins.mockResolvedValue(new Map());
|
||||||
|
|
||||||
|
const { createRequestForUser } = await import('@/lib/services/request-creator.service');
|
||||||
|
const result = await createRequestForUser(TEST_USER_ID, TEST_AUDIOBOOK);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
// Should not have queried findFirst for sibling check since map was empty
|
||||||
|
expect(prismaMock.ignoredAudiobook.findFirst).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -137,16 +137,18 @@ describe('areDurationsCompatible', () => {
|
|||||||
expect(areDurationsCompatible(600, 600)).toBe(true);
|
expect(areDurationsCompatible(600, 600)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses 1% of longer duration as tolerance for long books', () => {
|
it('uses 5% of longer duration as tolerance for long books', () => {
|
||||||
// Two 40-hour books (2400 min): tolerance = max(2400*0.01, 5) = 24 min
|
// tolerance = max(longer*0.05, 10). When b > a, longer = b, so threshold shifts.
|
||||||
expect(areDurationsCompatible(2400, 2424)).toBe(true); // exactly at tolerance
|
// 2400 vs 2526: longer=2526, tol=126.3, diff=126 → true
|
||||||
expect(areDurationsCompatible(2400, 2425)).toBe(false); // just over
|
expect(areDurationsCompatible(2400, 2526)).toBe(true);
|
||||||
|
// 2400 vs 2527: longer=2527, tol=126.35, diff=127 → false
|
||||||
|
expect(areDurationsCompatible(2400, 2527)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses 5-minute minimum tolerance for short books', () => {
|
it('uses 10-minute minimum tolerance for short books', () => {
|
||||||
// Two 2-hour books (120 min): tolerance = max(120*0.01, 5) = max(1.2, 5) = 5 min
|
// Two 2-hour books (120 min): tolerance = max(120*0.05, 10) = max(6, 10) = 10 min
|
||||||
expect(areDurationsCompatible(120, 125)).toBe(true); // exactly at 5-min minimum
|
expect(areDurationsCompatible(120, 130)).toBe(true); // exactly at 10-min minimum
|
||||||
expect(areDurationsCompatible(120, 126)).toBe(false); // just over
|
expect(areDurationsCompatible(120, 131)).toBe(false); // just over
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps abridged vs unabridged separate (large duration gap)', () => {
|
it('keeps abridged vs unabridged separate (large duration gap)', () => {
|
||||||
@@ -155,10 +157,10 @@ describe('areDurationsCompatible', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('symmetry: order does not matter', () => {
|
it('symmetry: order does not matter', () => {
|
||||||
expect(areDurationsCompatible(2400, 2424)).toBe(true);
|
expect(areDurationsCompatible(2400, 2526)).toBe(true);
|
||||||
expect(areDurationsCompatible(2424, 2400)).toBe(true);
|
expect(areDurationsCompatible(2526, 2400)).toBe(true);
|
||||||
expect(areDurationsCompatible(120, 126)).toBe(false);
|
expect(areDurationsCompatible(120, 131)).toBe(false);
|
||||||
expect(areDurationsCompatible(126, 120)).toBe(false);
|
expect(areDurationsCompatible(131, 120)).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -305,17 +307,17 @@ describe('deduplicateAudiobooks', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('uses percentage tolerance for very long audiobooks', () => {
|
it('uses percentage tolerance for very long audiobooks', () => {
|
||||||
// Two 40-hour books: tolerance = max(2400*0.01, 5) = 24 min
|
// tolerance = max(longer*0.05, 10). 2400 vs 2526: longer=2526, tol=126.3, diff=126 → same
|
||||||
const books = [
|
const books = [
|
||||||
makeBook({ asin: 'A1', title: 'Long Book', author: 'Auth', narrator: 'Nar', durationMinutes: 2400 }),
|
makeBook({ asin: 'A1', title: 'Long Book', author: 'Auth', narrator: 'Nar', durationMinutes: 2400 }),
|
||||||
makeBook({ asin: 'A2', title: 'Long Book', author: 'Auth', narrator: 'Nar', durationMinutes: 2420 }),
|
makeBook({ asin: 'A2', title: 'Long Book', author: 'Auth', narrator: 'Nar', durationMinutes: 2526 }),
|
||||||
];
|
];
|
||||||
expect(deduplicateAudiobooks(books)).toHaveLength(1);
|
expect(deduplicateAudiobooks(books)).toHaveLength(1);
|
||||||
|
|
||||||
// Beyond tolerance
|
// Beyond tolerance: 2400 vs 2600: longer=2600, tol=130, diff=200 → different
|
||||||
const booksFar = [
|
const booksFar = [
|
||||||
makeBook({ asin: 'A1', title: 'Long Book', author: 'Auth', narrator: 'Nar', durationMinutes: 2400 }),
|
makeBook({ asin: 'A1', title: 'Long Book', author: 'Auth', narrator: 'Nar', durationMinutes: 2400 }),
|
||||||
makeBook({ asin: 'A2', title: 'Long Book', author: 'Auth', narrator: 'Nar', durationMinutes: 2430 }),
|
makeBook({ asin: 'A2', title: 'Long Book', author: 'Auth', narrator: 'Nar', durationMinutes: 2600 }),
|
||||||
];
|
];
|
||||||
expect(deduplicateAudiobooks(booksFar)).toHaveLength(2);
|
expect(deduplicateAudiobooks(booksFar)).toHaveLength(2);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user