mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +00:00
09cff5b68d
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.
135 lines
4.2 KiB
TypeScript
135 lines
4.2 KiB
TypeScript
/**
|
|
* Component: Audiobooks Fetching Hook
|
|
* Documentation: documentation/frontend/components.md
|
|
*/
|
|
|
|
'use client';
|
|
|
|
import { useRef, useEffect, useCallback } from 'react';
|
|
import useSWR from 'swr';
|
|
import useSWRInfinite from 'swr/infinite';
|
|
import { authenticatedFetcher } from '@/lib/utils/api';
|
|
|
|
export interface Audiobook {
|
|
asin: string;
|
|
title: string;
|
|
author: string;
|
|
authorAsin?: string;
|
|
narrator?: string;
|
|
description?: string;
|
|
coverArtUrl?: string;
|
|
durationMinutes?: number;
|
|
releaseDate?: string;
|
|
rating?: number;
|
|
genres?: string[];
|
|
series?: string; // Series name (e.g., "A Song of Ice and Fire")
|
|
seriesPart?: string; // Position in series (e.g., "1", "1.5")
|
|
seriesAsin?: string; // Audible ASIN for the series (links to /series/{asin})
|
|
isAvailable?: boolean; // Set by real-time matching against plex_library
|
|
plexGuid?: string | null;
|
|
dbId?: string | null;
|
|
isRequested?: boolean; // Set if ANY user has requested this audiobook
|
|
requestStatus?: string | null; // Status of request (if any)
|
|
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) {
|
|
const hideParam = hideAvailable ? '&hideAvailable=true' : '';
|
|
const endpoint =
|
|
type === 'popular'
|
|
? `/api/audiobooks/popular?page=${page}&limit=${limit}${hideParam}`
|
|
: `/api/audiobooks/new-releases?page=${page}&limit=${limit}${hideParam}`;
|
|
|
|
const { data, error, isLoading } = useSWR(endpoint, authenticatedFetcher, {
|
|
revalidateOnFocus: false,
|
|
revalidateOnReconnect: false,
|
|
dedupingInterval: 60000, // Cache for 1 minute
|
|
});
|
|
|
|
return {
|
|
audiobooks: data?.audiobooks || [],
|
|
totalCount: data?.totalCount || 0,
|
|
totalPages: data?.totalPages || 0,
|
|
currentPage: data?.page || page,
|
|
hasMore: data?.hasMore || false,
|
|
message: data?.message || null,
|
|
isLoading,
|
|
error,
|
|
};
|
|
}
|
|
|
|
function dedupeByAsin<T extends { asin: string }>(items: T[]): T[] {
|
|
const seen = new Set<string>();
|
|
return items.filter(item => {
|
|
if (seen.has(item.asin)) return false;
|
|
seen.add(item.asin);
|
|
return true;
|
|
});
|
|
}
|
|
|
|
export function useSearch(query: string) {
|
|
const prevQueryRef = useRef(query);
|
|
|
|
const { data, error, size, setSize, isLoading, isValidating } = useSWRInfinite(
|
|
(pageIndex, prevPageData) => {
|
|
if (!query || query.length === 0) return null;
|
|
if (pageIndex === 0) return `/api/audiobooks/search?q=${encodeURIComponent(query)}&page=1`;
|
|
if (!prevPageData?.hasMore) return null;
|
|
return `/api/audiobooks/search?q=${encodeURIComponent(query)}&page=${pageIndex + 1}`;
|
|
},
|
|
authenticatedFetcher,
|
|
{
|
|
revalidateOnFocus: false,
|
|
dedupingInterval: 30000,
|
|
revalidateFirstPage: false,
|
|
}
|
|
);
|
|
|
|
// Reset to page 1 when query changes
|
|
useEffect(() => {
|
|
if (query !== prevQueryRef.current) {
|
|
prevQueryRef.current = query;
|
|
setSize(1);
|
|
}
|
|
}, [query, setSize]);
|
|
|
|
const results = data ? dedupeByAsin(data.flatMap(page => page?.results || [])) : [];
|
|
const totalResults = data?.[0]?.totalResults || 0;
|
|
const hasMore = !!(data && data.length > 0 && data[data.length - 1]?.hasMore);
|
|
const isLoadingInitial = !data && !error && !!query;
|
|
const isLoadingMore = !!(data && typeof data[size - 1] === 'undefined' && isValidating);
|
|
|
|
const loadMore = useCallback(() => {
|
|
setSize(prev => prev + 1);
|
|
}, [setSize]);
|
|
|
|
return {
|
|
results,
|
|
totalResults,
|
|
hasMore,
|
|
isLoading: isLoadingInitial,
|
|
isLoadingMore,
|
|
loadMore,
|
|
error,
|
|
};
|
|
}
|
|
|
|
export function useAudiobookDetails(asin: string | null) {
|
|
const endpoint = asin ? `/api/audiobooks/${asin}` : null;
|
|
|
|
const { data, error, isLoading } = useSWR(endpoint, authenticatedFetcher, {
|
|
revalidateOnFocus: false,
|
|
dedupingInterval: 300000, // Cache for 5 minutes
|
|
});
|
|
|
|
return {
|
|
audiobook: data?.audiobook || null,
|
|
audibleBaseUrl: data?.audibleBaseUrl || 'https://www.audible.com',
|
|
isLoading,
|
|
error,
|
|
};
|
|
}
|