mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 21:00:09 +00:00
Add release blocklist feature
Introduce a per-request release blocklist to auto-block permanently failing releases and provide admin management. Changes include: - Database: add BlockedRelease model (blocked_releases) to Prisma schema with unique (requestId, releaseKey) and indexes; documented in backend database docs. - Service & utils: new blocklist.service, release-key and filter helpers for normalization and matching; processors updated to emit auto-blocks (monitor-download, organize-files, search processors, RSS). - HTTP API: add admin endpoints GET/DELETE /api/admin/blocklist, DELETE /api/admin/blocklist/[id], and GET /api/admin/blocklist/by-request/[requestId]. - Admin UI: new /admin/blocklist page and numerous React components (toolbar, filters, table, rows, pagination, skeleton, chips, date picker) with URL-driven state hook and per-row unblock UX. - Tests: add unit/integration tests for service, routes, utils, and updated processor tests. The blocklist is idempotent (upsert), filters search results before ranking (interactive search shows badges only), and admin-only APIs require auth. This commit wires docs, API, DB, frontend and tests for the new feature.
This commit is contained in:
@@ -26,6 +26,20 @@ import {
|
||||
} from '@/lib/hooks/useRequests';
|
||||
import { useReplaceWithTorrent } from '@/lib/hooks/useReportedIssues';
|
||||
import { Audiobook } from '@/lib/hooks/useAudiobooks';
|
||||
import { fetchWithAuth } from '@/lib/utils/api';
|
||||
import { normalizeReleaseKey } from '@/lib/utils/release-key';
|
||||
|
||||
interface BlockedReleaseLookup {
|
||||
/** normalized release key → reason text */
|
||||
byKey: Map<string, string>;
|
||||
/** release hash (torrentHash / nzbId / infoHash) → reason text */
|
||||
byHash: Map<string, string>;
|
||||
}
|
||||
|
||||
const EMPTY_BLOCKED_LOOKUP: BlockedReleaseLookup = {
|
||||
byKey: new Map(),
|
||||
byHash: new Map(),
|
||||
};
|
||||
|
||||
interface InteractiveTorrentSearchModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -118,6 +132,14 @@ export function InteractiveTorrentSearchModal({
|
||||
|
||||
const [results, setResults] = useState<(RankedTorrent & { qualityScore?: number; source?: string; ebookFormat?: string })[]>([]);
|
||||
const [confirmTorrent, setConfirmTorrent] = useState<TorrentResult | null>(null);
|
||||
const [blockedLookup, setBlockedLookup] = useState<BlockedReleaseLookup>(EMPTY_BLOCKED_LOOKUP);
|
||||
|
||||
// Per locked decision #3, interactive search is NOT filtered — it shows
|
||||
// everything; we just mark blocked rows visually so admins know. The admin
|
||||
// endpoint enforces auth/role; non-admin users silently get a 403 and no
|
||||
// badges are rendered. We only attempt the fetch when we have a requestId
|
||||
// (the ASIN-based ebook flow has no per-request blocklist context).
|
||||
const canFetchBlocklist = !!requestId && isOpen;
|
||||
const [searchTitle, setSearchTitle] = useState(customSearchTerms || audiobook.title);
|
||||
const [isCustomConfirming, setIsCustomConfirming] = useState(false);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
@@ -163,6 +185,42 @@ export function InteractiveTorrentSearchModal({
|
||||
setExpandedGuids(new Set());
|
||||
}, [isOpen, audiobook.title, customSearchTerms]);
|
||||
|
||||
// Reset blocklist lookup when modal closes; fetch when admin opens it.
|
||||
useEffect(() => {
|
||||
if (!canFetchBlocklist) {
|
||||
setBlockedLookup(EMPTY_BLOCKED_LOOKUP);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const response = await fetchWithAuth(
|
||||
`/api/admin/blocklist/by-request/${requestId}`
|
||||
);
|
||||
if (!response.ok) {
|
||||
// 403 (non-admin via API token, etc.) silently leaves badge off.
|
||||
return;
|
||||
}
|
||||
const data: {
|
||||
entries: Array<{ releaseName: string; releaseHash: string | null; reason: string }>;
|
||||
} = await response.json();
|
||||
if (cancelled) return;
|
||||
const byKey = new Map<string, string>();
|
||||
const byHash = new Map<string, string>();
|
||||
for (const entry of data.entries) {
|
||||
byKey.set(normalizeReleaseKey(entry.releaseName), entry.reason);
|
||||
if (entry.releaseHash) byHash.set(entry.releaseHash.toLowerCase(), entry.reason);
|
||||
}
|
||||
setBlockedLookup({ byKey, byHash });
|
||||
} catch {
|
||||
// Network errors — leave badge off rather than disrupt search UI.
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [canFetchBlocklist, requestId]);
|
||||
|
||||
// Perform search when modal opens
|
||||
useEffect(() => {
|
||||
if (isOpen && results.length === 0) {
|
||||
@@ -392,6 +450,7 @@ export function InteractiveTorrentSearchModal({
|
||||
isEbookMode={isEbookMode}
|
||||
isExpanded={expandedGuids.has(result.guid)}
|
||||
isDownloading={isDownloading}
|
||||
blockedReason={resolveBlockedReason(result, blockedLookup)}
|
||||
onToggleExpand={() => {
|
||||
setExpandedGuids((prev) => {
|
||||
const next = new Set(prev);
|
||||
@@ -520,11 +579,27 @@ export function InteractiveTorrentSearchModal({
|
||||
return createPortal(modalContent, document.body);
|
||||
}
|
||||
|
||||
function resolveBlockedReason(
|
||||
result: RankedTorrent & { source?: string },
|
||||
lookup: BlockedReleaseLookup
|
||||
): string | null {
|
||||
if (lookup.byKey.size === 0 && lookup.byHash.size === 0) return null;
|
||||
const byName = lookup.byKey.get(normalizeReleaseKey(result.title));
|
||||
if (byName) return byName;
|
||||
if (result.infoHash) {
|
||||
const byHash = lookup.byHash.get(result.infoHash.toLowerCase());
|
||||
if (byHash) return byHash;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
interface ResultRowProps {
|
||||
result: RankedTorrent & { qualityScore?: number; source?: string; ebookFormat?: string };
|
||||
isEbookMode: boolean;
|
||||
isExpanded: boolean;
|
||||
isDownloading: boolean;
|
||||
/** Non-null when this result matches a blocklist entry for the current request. */
|
||||
blockedReason: string | null;
|
||||
onToggleExpand: () => void;
|
||||
onDownload: () => void;
|
||||
}
|
||||
@@ -534,6 +609,7 @@ function ResultRow({
|
||||
isEbookMode,
|
||||
isExpanded,
|
||||
isDownloading,
|
||||
blockedReason,
|
||||
onToggleExpand,
|
||||
onDownload,
|
||||
}: ResultRowProps) {
|
||||
@@ -566,6 +642,21 @@ function ResultRow({
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Blocked badge — informational, NOT a warning. Per zach.md "displayed
|
||||
source data stays true to source" — the badge adds context, the
|
||||
title above is rendered verbatim either way. */}
|
||||
{blockedReason && (
|
||||
<div
|
||||
className="inline-flex items-center gap-1 mb-1 px-2 py-0.5 rounded-full text-[11px] font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-200"
|
||||
title={`Already blocked for this request: ${blockedReason}`}
|
||||
>
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
|
||||
</svg>
|
||||
<span>Already blocked — {blockedReason}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title Row */}
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<a
|
||||
|
||||
Reference in New Issue
Block a user