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:
kikootwo
2026-03-11 11:56:35 -04:00
parent da7ad7cac1
commit 09cff5b68d
25 changed files with 873 additions and 35 deletions
+7 -1
View File
@@ -46,7 +46,13 @@ export function createShelfHooks<TShelf>(endpoint: string) {
const key = accessToken ? endpoint : null;
const { data, error, isLoading } = useSWR(key, fetcher, {
refreshInterval: 30000,
refreshInterval: (latestData: { shelves: TShelf[] } | undefined) => {
const shelves = latestData?.shelves || [];
const hasSyncing = shelves.some(
(s) => !(s as Record<string, unknown>)['lastSyncAt'],
);
return hasSyncing ? 3000 : 30000;
},
});
return {
+1
View File
@@ -33,6 +33,7 @@ export interface Audiobook {
requestId?: string | null; // ID of request (if any)
requestedByUsername?: string | null; // Username who requested (only if not current user)
hasReportedIssue?: boolean; // True if an open issue exists for this audiobook
isIgnored?: boolean; // True if this user has ignored this audiobook from auto-requests
}
export function useAudiobooks(type: 'popular' | 'new-releases', limit: number = 20, page: number = 1, hideAvailable: boolean = false) {
+123
View File
@@ -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,
};
}
+5 -1
View File
@@ -30,7 +30,11 @@ export function useShelves() {
const endpoint = accessToken ? '/api/user/shelves' : null;
const { data, error, isLoading } = useSWR(endpoint, fetcher, {
refreshInterval: 30000,
refreshInterval: (latestData: { shelves: GenericShelf[] } | undefined) => {
const shelves = latestData?.shelves || [];
const hasSyncing = shelves.some((s) => !s.lastSyncAt);
return hasSyncing ? 3000 : 30000;
},
});
return {
+48 -3
View File
@@ -12,7 +12,7 @@ import { getJobQueueService } from '@/lib/services/job-queue.service';
import { findPlexMatch } from '@/lib/utils/audiobook-matcher';
import { getAudibleService } from '@/lib/integrations/audible.service';
import { RMABLogger } from '@/lib/utils/logger';
import { seedAsin } from '@/lib/services/works.service';
import { seedAsin, getSiblingAsins } from '@/lib/services/works.service';
const logger = RMABLogger.create('RequestCreator');
@@ -27,11 +27,13 @@ export interface CreateRequestInput {
export interface CreateRequestOptions {
skipAutoSearch?: boolean;
/** When true, skip the per-user ignore list check (used for manual requests) */
bypassIgnore?: boolean;
}
export type CreateRequestResult =
| { success: true; request: any }
| { success: false; reason: 'already_available' | 'being_processed' | 'duplicate' | 'user_not_found'; message: string };
| { success: false; reason: 'already_available' | 'being_processed' | 'duplicate' | 'user_not_found' | 'ignored'; message: string };
/**
* Create a request for a user, with full duplicate detection, library checks,
@@ -42,7 +44,7 @@ export async function createRequestForUser(
audiobook: CreateRequestInput,
options: CreateRequestOptions = {}
): Promise<CreateRequestResult> {
const { skipAutoSearch = false } = options;
const { skipAutoSearch = false, bypassIgnore = false } = options;
// Check for existing active request (downloaded/available) for this ASIN
const existingActiveRequest = await prisma.request.findFirst({
@@ -81,6 +83,18 @@ export async function createRequestForUser(
};
}
// Check per-user ignore list (skipped for manual requests via bypassIgnore)
if (!bypassIgnore) {
const isIgnored = await checkIgnoreList(userId, audiobook.asin);
if (isIgnored) {
return {
success: false,
reason: 'ignored',
message: 'This audiobook is on your ignore list',
};
}
}
// Fetch full details from Audnexus for year/series
let year: number | undefined;
let series: string | undefined;
@@ -279,3 +293,34 @@ export async function createRequestForUser(
return { success: true, request: newRequest };
}
/**
* Check if an ASIN (or any of its sibling ASINs via the works table)
* is on the user's ignore list. Returns true if the book should be blocked.
*/
async function checkIgnoreList(userId: string, asin: string): Promise<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;
}
+3 -3
View File
@@ -6,7 +6,7 @@
* under different ASINs (publisher re-listings, rights transfers, etc.).
*
* Dedup key: normalized title + normalized narrator
* Duration tolerance: max(longerDuration * 0.01, 5) minutes
* Duration tolerance: max(longerDuration * 0.05, 10) minutes
* Missing duration treated as compatible (graceful degradation).
*/
@@ -95,13 +95,13 @@ function normalizeNarrator(narrator?: string): string {
/**
* Check if two durations are compatible (represent the same recording).
* Tolerance: max(longerDuration * 0.01, 5) minutes.
* Tolerance: max(longerDuration * 0.05, 10) minutes.
* Missing duration on either side is treated as compatible.
*/
export function areDurationsCompatible(a?: number, b?: number): boolean {
if (a == null || b == null) return true;
const longer = Math.max(a, b);
const tolerance = Math.max(longer * 0.01, 5);
const tolerance = Math.max(longer * 0.05, 10);
return Math.abs(a - b) <= tolerance;
}
+37
View File
@@ -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),
}));
}