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:
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 { 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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
}));
|
||||
}
|
||||
Reference in New Issue
Block a user