diff --git a/documentation/TABLEOFCONTENTS.md b/documentation/TABLEOFCONTENTS.md index 5546c01..083e34a 100644 --- a/documentation/TABLEOFCONTENTS.md +++ b/documentation/TABLEOFCONTENTS.md @@ -107,6 +107,7 @@ - **Jobs management UI** → [backend/services/scheduler.md](backend/services/scheduler.md) - **Request deletion (soft delete, seeding awareness)** → [admin-features/request-deletion.md](admin-features/request-deletion.md) - **Request approval system, auto-approve settings** → [admin-features/request-approval.md](admin-features/request-approval.md) +- **Release blocklist (auto-block failed releases, /admin/blocklist)** → [admin-features/release-blocklist.md](admin-features/release-blocklist.md) ## Fixes & Improvements - **File hash-based library matching (ABS)** → [fixes/file-hash-matching.md](fixes/file-hash-matching.md) @@ -150,6 +151,9 @@ **"How do I delete requests?"** → [admin-features/request-deletion.md](admin-features/request-deletion.md) **"How do I approve/deny user requests?"** → [admin-features/request-approval.md](admin-features/request-approval.md) **"How do I enable auto-approve for requests?"** → [admin-features/request-approval.md](admin-features/request-approval.md) +**"How does the release blocklist work?"** → [admin-features/release-blocklist.md](admin-features/release-blocklist.md) +**"Why does the same bad release keep getting re-downloaded?"** → [admin-features/release-blocklist.md](admin-features/release-blocklist.md) (it shouldn't anymore — auto-blocked on permanent failure) +**"How do I unblock a release?"** → [admin-features/release-blocklist.md](admin-features/release-blocklist.md) (admin → /admin/blocklist → Unblock, or chip on the request row) **"How does the admin book info modal work?"** → [admin-features/request-approval.md](admin-features/request-approval.md#ui-features), [frontend/components.md](frontend/components.md#component-apis) **"How do I customize audiobook folder organization?"** → [settings-pages.md](settings-pages.md#audiobook-organization-template), [phase3/file-organization.md](phase3/file-organization.md#target-structure) **"How do I deploy?"** → [deployment/docker.md](deployment/docker.md) (multi-container), [deployment/unified.md](deployment/unified.md) (all-in-one) diff --git a/documentation/admin-features/release-blocklist.md b/documentation/admin-features/release-blocklist.md new file mode 100644 index 0000000..251837d --- /dev/null +++ b/documentation/admin-features/release-blocklist.md @@ -0,0 +1,119 @@ +# Release Blocklist + +**Status:** ✅ Implemented | Per-request, reactive, auto-block + admin manage. + +## Overview +Releases that fail to download permanently OR fail to organize after retries are added to a per-request blocklist. Future searches for that request skip them. Admins manage via `/admin/blocklist`. + +## Auto-Block Triggers +- **Organize failure** — final `warn` transition in `organize-files.processor.ts` (after `max_import_retries`). Source: `organize_fail`. +- **Download failure** — `progressState === 'failed'` in `monitor-download.processor.ts` (client-reported permanent failure). Source: `download_fail`. **NOT** block-worthy: connection-failure exhaustion, download client unreachable, auth failure. +- Transient retry paths do NOT block — only terminal failures do. + +## Search Filter Scope (filters BEFORE ranking) +All three automatic search paths apply the per-request filter: +- `search-indexers.processor.ts` (audiobook search) +- `search-ebook.processor.ts` (ebook search) +- `monitor-rss-feeds.processor.ts` (RSS auto-grab) +- **Interactive search is NOT filtered.** Admin sees all results; blocked entries get an "Already blocked" badge in the modal. + +Match: case-insensitive on normalized release name OR exact on `releaseHash` (`torrentHash` for torrents, `nzbId` for NZBs). + +## Data Model +**Table:** `blocked_releases` ([backend/database.md](../backend/database.md)) + +Key fields: +- `requestId` — FK to `Request`, `onDelete: Cascade`. +- `releaseName` — verbatim, displayed as-is in admin UI. +- `releaseKey` — normalized (`trim().toLowerCase()`), used for matching. +- `releaseHash` — unifies `torrentHash` / `nzbId`. +- `source` — `'organize_fail' | 'download_fail' | 'manual'` (manual reserved for v2). +- `reason` — short human-readable (e.g. "No audiobook files found"). +- `reasonDetail` — longer client error (SAB `failMessage`, NZBGet par/unpack codes). +- `downloadHistoryId` — traceability link. +- `jobId` — for `JobEvent` filtering. + +Unique constraint: `(requestId, releaseKey)` — idempotent upsert under concurrent writes. + +Delete behavior: +- **Soft-delete of request** → blocklist rows survive (no cascade). +- **Hard-delete of request** → blocklist rows wiped via `onDelete: Cascade`. + +## Service API +**File:** `src/lib/services/blocklist.service.ts` +- `addAutoBlock(input)` — idempotent upsert; never throws; emits `JobEvent` (context `Blocklist.AutoBlock`). +- `isReleaseBlocked(requestId, name, hash?)` — match-check used by search filters. +- `getBlocklistForRequest(requestId)` — list, newest first; powers chip + interactive-search badge. +- `removeBlock(id)` — single unblock. +- `clearBlocklist(where)` — filter-scoped bulk delete, returns `{ count }`. + +## HTTP API +**Auth:** all endpoints require `requireAuth` + `requireAdmin`. + +| Method | Path | Purpose | +|---|---|---| +| GET | `/api/admin/blocklist` | Paginated list with filters + sort | +| DELETE | `/api/admin/blocklist?…` | Filter-scoped bulk clear (same filter params as GET) | +| DELETE | `/api/admin/blocklist/[id]` | Single unblock | +| GET | `/api/admin/blocklist/by-request/[requestId]` | Lightweight per-request lookup (chip + badge) | + +### `GET /api/admin/blocklist` +Query params: `requestId`, `source`, `search` (contains-OR over `releaseName`+`reason`, case-insensitive), `dateFrom`, `dateTo`, `page`, `limit` (25/50/100), `sortBy` (`createdAt`|`releaseName`|`reason`), `sortOrder` (`asc`|`desc`). + +Response: `{ entries: BlockedReleaseRow[], pagination: { page, limit, total, totalPages } }`. Each `entries` row includes the joined `request.audiobook` + `request.user` for display and `request.deletedAt` for the "(deleted)" badge. + +### `DELETE /api/admin/blocklist` +Filter-scoped — passes the same query params used for the GET. Returns `{ count }`. UI gates with a typed-token modal ("CLEAR"); auth/role is the server-side security boundary. + +### `GET /api/admin/blocklist/by-request/[requestId]` +Returns `{ entries: BlockedRelease[], count }`. No pagination (per-request blocklists are small). + +`buildBlocklistWhere(params)` is exported pure for tests + reuse by DELETE. + +## Admin UI +**Page:** `/admin/blocklist` ([src/app/admin/blocklist/page.tsx](../../src/app/admin/blocklist/page.tsx)) + +Mirrors `/admin/logs` patterns: URL ↔ state via `useBlocklistUrlState`, SWR with `keepPreviousData`, sticky toolbar + filter row + chip strip + table + pagination. + +- **Columns:** Release name (verbatim), Reason (+ expand chevron for detail), Source badge, Associated request (title + author + user, with "(deleted)" badge if soft-deleted), Indexer, Blocked at (relative; title attribute = absolute), Actions. +- **Per-row Unblock:** real ` + ); +} + +function formatDateChipLabel(dateFrom: string | null, dateTo: string | null): string { + const presetId = getActivePresetId(dateFrom, dateTo); + if (presetId === 'custom') { + return `${formatLocal(dateFrom)} – ${formatLocal(dateTo)}`; + } + const preset = DATE_PRESETS.find((p) => p.id === presetId); + return preset?.label ?? 'Custom'; +} + +function formatLocal(iso: string | null): string { + if (!iso) return '…'; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return '…'; + return d.toLocaleString([], { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); +} diff --git a/src/app/admin/blocklist/components/BlocklistDateRangePicker.tsx b/src/app/admin/blocklist/components/BlocklistDateRangePicker.tsx new file mode 100644 index 0000000..fc82849 --- /dev/null +++ b/src/app/admin/blocklist/components/BlocklistDateRangePicker.tsx @@ -0,0 +1,131 @@ +/** + * Component: Blocklist — Date Range Picker + * Documentation: documentation/admin-features/release-blocklist.md + * + * Sibling of admin/logs/components/DateRangePicker — no pause-on-interact + * registration since the blocklist page has no auto-refresh. Same preset list + * (defined in @/lib/constants/log-filters which is shared, not logs-only). + */ + +'use client'; + +import { useMemo, useState } from 'react'; +import { + DATE_PRESETS, + getActivePresetId, + presetToRange, + type DatePresetId, +} from '@/lib/constants/log-filters'; +import { INPUT_CLASS, LABEL_CLASS } from '@/app/admin/logs/components/filter-styles'; + +interface BlocklistDateRangePickerProps { + dateFrom: string | null; + dateTo: string | null; + onChange: (next: { dateFrom: string | null; dateTo: string | null }) => void; +} + +export default function BlocklistDateRangePicker({ + dateFrom, + dateTo, + onChange, +}: BlocklistDateRangePickerProps) { + const [forceCustom, setForceCustom] = useState(false); + const derivedPreset = useMemo( + () => getActivePresetId(dateFrom, dateTo), + [dateFrom, dateTo] + ); + const activePreset: DatePresetId = forceCustom ? 'custom' : derivedPreset; + const showCustom = activePreset === 'custom'; + + const handlePresetChange = (id: DatePresetId) => { + if (id === 'custom') { + setForceCustom(true); + return; + } + setForceCustom(false); + onChange(presetToRange(id)); + }; + + const handleCustomChange = (next: { dateFrom: string | null; dateTo: string | null }) => { + setForceCustom(true); + onChange(next); + }; + + return ( +
+ + + {showCustom && ( + + )} +
+ ); +} + +function CustomDateInputs({ + dateFrom, + dateTo, + onChange, +}: { + dateFrom: string | null; + dateTo: string | null; + onChange: (next: { dateFrom: string | null; dateTo: string | null }) => void; +}) { + const fromLocal = useMemo(() => isoToLocalInputValue(dateFrom), [dateFrom]); + const toLocal = useMemo(() => isoToLocalInputValue(dateTo), [dateTo]); + + return ( +
+
+ + onChange({ dateFrom: localInputToIso(e.target.value), dateTo }) + } + className={INPUT_CLASS} + /> + + onChange({ dateFrom, dateTo: localInputToIso(e.target.value) }) + } + className={INPUT_CLASS} + /> +
+

+ Times are in your local timezone (sent as UTC). +

+
+ ); +} + +function isoToLocalInputValue(iso: string | null): string { + if (!iso) return ''; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return ''; + const pad = (n: number) => String(n).padStart(2, '0'); + return ( + `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` + + `T${pad(d.getHours())}:${pad(d.getMinutes())}` + ); +} + +function localInputToIso(value: string): string | null { + if (!value) return null; + const d = new Date(value); + if (Number.isNaN(d.getTime())) return null; + return d.toISOString(); +} diff --git a/src/app/admin/blocklist/components/BlocklistFilters.tsx b/src/app/admin/blocklist/components/BlocklistFilters.tsx new file mode 100644 index 0000000..6da2821 --- /dev/null +++ b/src/app/admin/blocklist/components/BlocklistFilters.tsx @@ -0,0 +1,84 @@ +/** + * Component: Admin Blocklist — Filter Picker Row + * Documentation: documentation/admin-features/release-blocklist.md + * + * Two visible filter controls in v1: Source dropdown + Date Range. + * Plus a "Clear all filters" affordance when any filter or search is active. + * + * Mirrors the logs/components/LogsFilters layout. Consumes + * useBlocklistUrlState() directly — no prop drilling. + */ + +'use client'; + +import { useBlocklistUrlState } from '../hooks/useBlocklistUrlState'; +import { + BlockSourceFilter, + hasActiveFilters, + hasActiveSearch, + SOURCE_LABELS, + VALID_SOURCES, +} from '../types'; +import BlocklistDateRangePicker from './BlocklistDateRangePicker'; +import { INPUT_CLASS, LABEL_CLASS } from '@/app/admin/logs/components/filter-styles'; + +export default function BlocklistFilters() { + const { filters, setFilters, clearAll } = useBlocklistUrlState(); + const showClearAll = hasActiveFilters(filters) || hasActiveSearch(filters); + + return ( +
+
+ setFilters({ source: value })} + /> + setFilters(next)} + /> +
+ {showClearAll && ( +
+ +
+ )} +
+ ); +} + +function SourceDropdown({ + value, + onChange, +}: { + value: BlockSourceFilter; + onChange: (value: BlockSourceFilter) => void; +}) { + return ( +
+ + +
+ ); +} diff --git a/src/app/admin/blocklist/components/BlocklistPagination.tsx b/src/app/admin/blocklist/components/BlocklistPagination.tsx new file mode 100644 index 0000000..7c2b716 --- /dev/null +++ b/src/app/admin/blocklist/components/BlocklistPagination.tsx @@ -0,0 +1,129 @@ +/** + * Component: BlocklistPagination + * Documentation: documentation/admin-features/release-blocklist.md + * + * Prev/next + jump-to-page + page-size selector + "Page X of Y · N total". + * Keyboard accessible. Each interactive element ≥ 44×44 touch target. + * + * Not reusing LogsPagination because that file is wired into the logs page's + * auto-refresh pause registry (useAutoRefreshControl). The blocklist page has + * no auto-refresh, so importing the logs version would force adding a + * provider for plumbing the blocklist page doesn't need. + */ + +'use client'; + +import { useEffect, useState } from 'react'; +import { VALID_LIMITS, ValidLimit, BlocklistPagination as PaginationData } from '../types'; + +interface BlocklistPaginationProps { + pagination: PaginationData; + onPageChange: (next: number) => void; + onLimitChange: (next: ValidLimit) => void; +} + +export function BlocklistPagination({ + pagination, + onPageChange, + onLimitChange, +}: BlocklistPaginationProps) { + const { page, limit, total, totalPages } = pagination; + const [jumpValue, setJumpValue] = useState(String(page)); + + useEffect(() => { + setJumpValue(String(page)); + }, [page]); + + const submitJump = () => { + const parsed = Number.parseInt(jumpValue, 10); + if (!Number.isFinite(parsed)) { + setJumpValue(String(page)); + return; + } + const clamped = Math.min(Math.max(1, parsed), Math.max(1, totalPages)); + if (clamped !== page) onPageChange(clamped); + setJumpValue(String(clamped)); + }; + + return ( +
+
+ + Page {page} of{' '} + {Math.max(1, totalPages)} + {' · '} + + {total.toLocaleString()} + {' '} + {total === 1 ? 'entry' : 'entries'} + + + +
+ +
+ + + + + +
+
+ ); +} diff --git a/src/app/admin/blocklist/components/BlocklistRow.tsx b/src/app/admin/blocklist/components/BlocklistRow.tsx new file mode 100644 index 0000000..4fb1085 --- /dev/null +++ b/src/app/admin/blocklist/components/BlocklistRow.tsx @@ -0,0 +1,284 @@ +/** + * Component: Blocklist Row (desktop + mobile) + * Documentation: documentation/admin-features/release-blocklist.md + * + * Per-row Unblock is a real + )} + + {isExpanded && hasDetail && ( +
+          {entry.reasonDetail}
+        
+ )} + + ); +} + +function UnblockButton({ isUnblocking, onClick }: { isUnblocking: boolean; onClick: () => void }) { + return ( + + ); +} + +// --------------------------------------------------------------------------- +// Desktop row — +// --------------------------------------------------------------------------- +function DesktopRow({ entry, onUnblocked, onUnblockFailed }: BlocklistRowProps) { + const { isUnblocking, unblock } = useUnblock(entry, onUnblocked, onUnblockFailed); + const [reasonExpanded, setReasonExpanded] = useState(false); + const { absolute, relative } = formatTimestamp(entry.createdAt); + + return ( + + +

+ {entry.releaseName} +

+ + + setReasonExpanded((v) => !v)} /> + + + + + + + + + {entry.indexerName ?? } + + + {relative} + + + + + + ); +} + +// --------------------------------------------------------------------------- +// Mobile card +// --------------------------------------------------------------------------- +function MobileRow({ entry, onUnblocked, onUnblockFailed }: BlocklistRowProps) { + const { isUnblocking, unblock } = useUnblock(entry, onUnblocked, onUnblockFailed); + const [reasonExpanded, setReasonExpanded] = useState(false); + const { absolute, relative } = formatTimestamp(entry.createdAt); + + return ( +
+
+
+ + + {relative} + +
+ +
+ +

+ {entry.releaseName} +

+ + setReasonExpanded((v) => !v)} /> + + {entry.request?.audiobook && ( +
+

+ Associated request +

+ +
+ )} + + {entry.indexerName && ( +

+ Indexer: {entry.indexerName} +

+ )} +
+ ); +} + +export const BlocklistRow = { + Desktop: DesktopRow, + Mobile: MobileRow, +}; diff --git a/src/app/admin/blocklist/components/BlocklistSkeleton.tsx b/src/app/admin/blocklist/components/BlocklistSkeleton.tsx new file mode 100644 index 0000000..a871919 --- /dev/null +++ b/src/app/admin/blocklist/components/BlocklistSkeleton.tsx @@ -0,0 +1,20 @@ +/** + * Component: Blocklist Skeleton + * Documentation: documentation/admin-features/release-blocklist.md + */ + +export function BlocklistSkeleton() { + return ( +
+ {Array.from({ length: 6 }).map((_, i) => ( +
+
+
+
+ ))} +
+ ); +} diff --git a/src/app/admin/blocklist/components/BlocklistTable.tsx b/src/app/admin/blocklist/components/BlocklistTable.tsx new file mode 100644 index 0000000..3f69ccb --- /dev/null +++ b/src/app/admin/blocklist/components/BlocklistTable.tsx @@ -0,0 +1,130 @@ +/** + * Component: BlocklistTable + * Documentation: documentation/admin-features/release-blocklist.md + * + * Desktop = sortable table, mobile = stacked cards. Sortable columns clickable + * with explicit affordance (cursor + sort icon) — per zach.md UX rule on + * intentional affordances. + */ + +'use client'; + +import { useBlocklistUrlState } from '../hooks/useBlocklistUrlState'; +import { BlockedReleaseRow, SortField } from '../types'; +import { BlocklistRow } from './BlocklistRow'; + +interface BlocklistTableProps { + entries: BlockedReleaseRow[]; + onUnblocked: (id: string) => void; + onUnblockFailed: (entry: BlockedReleaseRow, error: string) => void; +} + +interface SortableHeaderProps { + field: SortField; + label: string; + className?: string; +} + +function SortableHeader({ field, label, className = '' }: SortableHeaderProps) { + const { filters, setFilters } = useBlocklistUrlState(); + const isActive = filters.sortBy === field; + const order = filters.sortOrder; + + const handleClick = () => { + if (isActive) { + setFilters({ sortOrder: order === 'asc' ? 'desc' : 'asc' }); + } else { + setFilters({ sortBy: field, sortOrder: 'desc' }); + } + }; + + return ( + + + + ); +} + +function SortGlyph({ active, order }: { active: boolean; order: 'asc' | 'desc' }) { + if (!active) { + return ( + + ); + } + return order === 'asc' ? ( + + ) : ( + + ); +} + +export function BlocklistTable({ entries, onUnblocked, onUnblockFailed }: BlocklistTableProps) { + return ( + <> + {/* Mobile cards */} +
+ {entries.map((entry) => ( + + ))} +
+ + {/* Desktop table */} +
+
+ + + + + + + + + + + + + + {entries.map((entry) => ( + + ))} + +
+ Source + + Associated request + + Indexer + + Actions +
+
+
+ + ); +} diff --git a/src/app/admin/blocklist/components/BlocklistToolbar.tsx b/src/app/admin/blocklist/components/BlocklistToolbar.tsx new file mode 100644 index 0000000..ed7b349 --- /dev/null +++ b/src/app/admin/blocklist/components/BlocklistToolbar.tsx @@ -0,0 +1,131 @@ +/** + * Component: BlocklistToolbar + * Documentation: documentation/admin-features/release-blocklist.md + * + * Sticky header with title, back-to-dashboard link, search input, and a + * "Clear filtered (N)" affordance that opens the typed-token confirm modal. + * + * The "Clear filtered" button is intentionally visible AND distinct (red-tinted) + * per zach.md UX rule: "UI affordances must be visibly intentional. First-time + * user should grok what's tappable from the design." + */ + +'use client'; + +import Link from 'next/link'; +import { useState } from 'react'; +import { useBlocklistUrlState } from '../hooks/useBlocklistUrlState'; +import { + BlocklistFilterState, + buildBulkClearQueryString, + hasActiveFilters, + hasActiveSearch, +} from '../types'; +import { ClearFilteredConfirmModal } from './ClearFilteredConfirmModal'; + +interface BlocklistToolbarProps { + /** Total rows matching current filters (drives "Clear filtered (N)" label). */ + total: number; + /** Called after successful bulk clear so the page can refresh data. */ + onCleared: () => void; +} + +export function BlocklistToolbar({ total, onCleared }: BlocklistToolbarProps) { + const { filters, searchInput, setSearchInput, removeFilter } = useBlocklistUrlState(); + const [confirmOpen, setConfirmOpen] = useState(false); + + const filtersActive = hasActiveFilters(filters) || hasActiveSearch(filters); + const canClear = total > 0; + + return ( +
+ {/* Row 1: title + back link */} +
+
+

+ Release Blocklist +

+

+ Releases auto-blocked from download or organize failures. Unblock to allow re-grabbing. +

+
+ + + Back to Dashboard + +
+ + {/* Row 2: "Clear filtered (N)" button — only when something would be cleared */} + {canClear && ( +
+ + + {filtersActive + ? 'Unblocks every entry matching the current filters.' + : 'Unblocks every entry. Apply a filter first to scope.'} + +
+ )} + + {/* Row 3: search input */} +
+ + + + setSearchInput(e.target.value)} + placeholder="Search release name or reason…" + aria-label="Search blocklist" + className="w-full min-h-[44px] pl-9 pr-10 py-2.5 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 text-sm" + /> + {searchInput && ( + + )} +
+ + setConfirmOpen(false)} + onCleared={onCleared} + total={total} + filtersActive={filtersActive} + queryString={buildBulkClearQueryString(filters as BlocklistFilterState)} + /> +
+ ); +} diff --git a/src/app/admin/blocklist/components/ClearFilteredConfirmModal.tsx b/src/app/admin/blocklist/components/ClearFilteredConfirmModal.tsx new file mode 100644 index 0000000..cdaecb0 --- /dev/null +++ b/src/app/admin/blocklist/components/ClearFilteredConfirmModal.tsx @@ -0,0 +1,135 @@ +/** + * Component: Clear Filtered Confirm Modal + * Documentation: documentation/admin-features/release-blocklist.md + * + * Bulk-clear guardrail: admin must type "CLEAR" before the destructive button + * activates. UI-only friction (not a server security boundary — auth+admin is). + * Per product brief: "red confirmation modal, requires typing 'CLEAR' or similar." + */ + +'use client'; + +import { useEffect, useState } from 'react'; +import { Modal } from '@/components/ui/Modal'; +import { Button } from '@/components/ui/Button'; +import { useToast } from '@/components/ui/Toast'; +import { fetchWithAuth } from '@/lib/utils/api'; + +const REQUIRED_TOKEN = 'CLEAR'; + +interface ClearFilteredConfirmModalProps { + isOpen: boolean; + onClose: () => void; + onCleared: () => void; + total: number; + filtersActive: boolean; + /** Pre-built filter query string (no page/limit/sort) — DELETE body. */ + queryString: string; +} + +export function ClearFilteredConfirmModal({ + isOpen, + onClose, + onCleared, + total, + filtersActive, + queryString, +}: ClearFilteredConfirmModalProps) { + const toast = useToast(); + const [token, setToken] = useState(''); + const [isClearing, setIsClearing] = useState(false); + + // Reset typed token whenever the modal opens. + useEffect(() => { + if (isOpen) setToken(''); + }, [isOpen]); + + const canConfirm = token.trim().toUpperCase() === REQUIRED_TOKEN && !isClearing; + + const handleConfirm = async () => { + if (!canConfirm) return; + setIsClearing(true); + try { + const url = queryString + ? `/api/admin/blocklist?${queryString}` + : '/api/admin/blocklist'; + const response = await fetchWithAuth(url, { method: 'DELETE' }); + if (!response.ok) { + const body = await response.json().catch(() => ({})); + throw new Error(body.error || 'Failed to clear blocklist'); + } + const { count } = await response.json(); + toast.success( + count === 1 + ? 'Unblocked 1 release' + : `Unblocked ${count.toLocaleString()} releases` + ); + onCleared(); + onClose(); + } catch (error) { + toast.error( + error instanceof Error ? error.message : 'Failed to clear blocklist' + ); + } finally { + setIsClearing(false); + } + }; + + const title = filtersActive ? 'Clear filtered entries' : 'Clear all entries'; + const description = filtersActive + ? `This will unblock ${total.toLocaleString()} ${total === 1 ? 'release' : 'releases'} matching the current filters. Future searches will be free to grab them again.` + : `This will unblock all ${total.toLocaleString()} ${total === 1 ? 'release' : 'releases'} in the blocklist. Future searches will be free to grab them again.`; + + return ( + {} : onClose} title={title} size="sm" showCloseButton={false}> +
+

+ {description} +

+ +
+

+ This cannot be undone. +

+

+ Type CLEAR below to confirm. +

+
+ +
+ + setToken(e.target.value)} + disabled={isClearing} + autoComplete="off" + placeholder="Type CLEAR" + aria-label="Type CLEAR to confirm" + className="w-full px-3 py-2.5 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-red-500 focus:outline-none text-sm font-mono uppercase min-h-[44px]" + /> +
+ +
+ + +
+
+
+ ); +} diff --git a/src/app/admin/blocklist/hooks/useBlocklistUrlState.ts b/src/app/admin/blocklist/hooks/useBlocklistUrlState.ts new file mode 100644 index 0000000..07aaea3 --- /dev/null +++ b/src/app/admin/blocklist/hooks/useBlocklistUrlState.ts @@ -0,0 +1,217 @@ +/** + * Component: useBlocklistUrlState Hook + * Documentation: documentation/admin-features/release-blocklist.md + * + * URL ↔ typed filter state for /admin/blocklist. URL is the source of truth. + * Sibling of useLogsUrlState — no shared date hydrate default here because + * the blocklist defaults to "All time" (admin needs to see everything by + * default; data set is small). + * + * - Reads URL params on every render (invalid values silently dropped). + * - Writes URL via router.replace (no history pollution). + * - Debounces search input writes (300ms) so typing feels instant. + * - Any non-page filter change resets page to 1. + */ + +'use client'; + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useRouter, useSearchParams, usePathname } from 'next/navigation'; +import { + BLOCKLIST_PARAMS, + BlocklistFilterState, + BlockSourceFilter, + DEFAULT_FILTER_STATE, + DEFAULT_LIMIT, + DEFAULT_PAGE, + DEFAULT_SORT_BY, + DEFAULT_SORT_ORDER, + SortField, + SortOrder, + VALID_LIMITS, + VALID_SORT_FIELDS, + VALID_SORT_ORDERS, + VALID_SOURCES, + ValidLimit, +} from '../types'; + +const SEARCH_DEBOUNCE_MS = 300; + +function isValidIsoDate(value: string | null): value is string { + if (!value) return false; + const d = new Date(value); + return !Number.isNaN(d.getTime()); +} + +function parseFromUrl(params: URLSearchParams): BlocklistFilterState { + const search = params.get(BLOCKLIST_PARAMS.search); + const sourceRaw = params.get(BLOCKLIST_PARAMS.source); + const requestId = params.get(BLOCKLIST_PARAMS.requestId); + const dateFrom = params.get(BLOCKLIST_PARAMS.dateFrom); + const dateTo = params.get(BLOCKLIST_PARAMS.dateTo); + const sortByRaw = params.get(BLOCKLIST_PARAMS.sortBy); + const sortOrderRaw = params.get(BLOCKLIST_PARAMS.sortOrder); + const pageRaw = params.get(BLOCKLIST_PARAMS.page); + const limitRaw = params.get(BLOCKLIST_PARAMS.limit); + + let page = DEFAULT_PAGE; + if (pageRaw) { + const parsed = Number.parseInt(pageRaw, 10); + if (Number.isFinite(parsed) && parsed >= 1) page = parsed; + } + + let limit: ValidLimit = DEFAULT_LIMIT; + if (limitRaw) { + const parsed = Number.parseInt(limitRaw, 10); + if ((VALID_LIMITS as readonly number[]).includes(parsed)) { + limit = parsed as ValidLimit; + } + } + + const source: BlockSourceFilter = + sourceRaw && (VALID_SOURCES as readonly string[]).includes(sourceRaw) + ? (sourceRaw as BlockSourceFilter) + : 'all'; + + const sortBy: SortField = + sortByRaw && (VALID_SORT_FIELDS as readonly string[]).includes(sortByRaw) + ? (sortByRaw as SortField) + : DEFAULT_SORT_BY; + + const sortOrder: SortOrder = + sortOrderRaw && (VALID_SORT_ORDERS as readonly string[]).includes(sortOrderRaw) + ? (sortOrderRaw as SortOrder) + : DEFAULT_SORT_ORDER; + + return { + search: search ?? '', + source, + requestId: requestId && requestId.length > 0 ? requestId : null, + dateFrom: isValidIsoDate(dateFrom) ? dateFrom : null, + dateTo: isValidIsoDate(dateTo) ? dateTo : null, + sortBy, + sortOrder, + page, + limit, + }; +} + +function serializeToUrl(state: BlocklistFilterState): URLSearchParams { + const params = new URLSearchParams(); + if (state.page !== DEFAULT_PAGE) params.set(BLOCKLIST_PARAMS.page, String(state.page)); + if (state.limit !== DEFAULT_LIMIT) params.set(BLOCKLIST_PARAMS.limit, String(state.limit)); + if (state.source && state.source !== 'all') { + params.set(BLOCKLIST_PARAMS.source, state.source); + } + if (state.requestId) params.set(BLOCKLIST_PARAMS.requestId, state.requestId); + if (state.search) params.set(BLOCKLIST_PARAMS.search, state.search); + if (state.dateFrom) params.set(BLOCKLIST_PARAMS.dateFrom, state.dateFrom); + if (state.dateTo) params.set(BLOCKLIST_PARAMS.dateTo, state.dateTo); + if (state.sortBy !== DEFAULT_SORT_BY) params.set(BLOCKLIST_PARAMS.sortBy, state.sortBy); + if (state.sortOrder !== DEFAULT_SORT_ORDER) { + params.set(BLOCKLIST_PARAMS.sortOrder, state.sortOrder); + } + return params; +} + +export interface UseBlocklistUrlStateResult { + filters: BlocklistFilterState; + setFilters: (partial: Partial) => void; + setSearchInput: (value: string) => void; + searchInput: string; + clearAll: () => void; + removeFilter: (key: keyof BlocklistFilterState) => void; +} + +export function useBlocklistUrlState(): UseBlocklistUrlStateResult { + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + + const filters = useMemo( + () => parseFromUrl(new URLSearchParams(searchParams?.toString() ?? '')), + [searchParams] + ); + + const [searchInput, setSearchInputState] = useState(filters.search); + const searchDebounceRef = useRef | null>(null); + + useEffect(() => { + setSearchInputState(filters.search); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [filters.search]); + + const writeUrl = useCallback( + (nextState: BlocklistFilterState) => { + const qs = serializeToUrl(nextState).toString(); + const url = qs ? `${pathname}?${qs}` : pathname; + router.replace(url, { scroll: false }); + }, + [pathname, router] + ); + + const setFilters = useCallback( + (partial: Partial) => { + const isOnlyPageChange = + Object.keys(partial).length === 1 && + Object.prototype.hasOwnProperty.call(partial, 'page'); + const next: BlocklistFilterState = { + ...filters, + ...partial, + page: isOnlyPageChange ? (partial.page ?? filters.page) : DEFAULT_PAGE, + }; + writeUrl(next); + }, + [filters, writeUrl] + ); + + const setSearchInput = useCallback( + (value: string) => { + setSearchInputState(value); + if (searchDebounceRef.current) clearTimeout(searchDebounceRef.current); + searchDebounceRef.current = setTimeout(() => { + const next: BlocklistFilterState = { + ...filters, + search: value, + page: DEFAULT_PAGE, + }; + writeUrl(next); + }, SEARCH_DEBOUNCE_MS); + }, + [filters, writeUrl] + ); + + useEffect(() => { + return () => { + if (searchDebounceRef.current) clearTimeout(searchDebounceRef.current); + }; + }, []); + + const clearAll = useCallback(() => { + writeUrl(DEFAULT_FILTER_STATE); + setSearchInputState(''); + }, [writeUrl]); + + const removeFilter = useCallback( + (key: keyof BlocklistFilterState) => { + const defaultValue = DEFAULT_FILTER_STATE[key]; + const next: BlocklistFilterState = { + ...filters, + [key]: defaultValue, + page: DEFAULT_PAGE, + } as BlocklistFilterState; + writeUrl(next); + if (key === 'search') setSearchInputState(''); + }, + [filters, writeUrl] + ); + + return { + filters, + setFilters, + setSearchInput, + searchInput, + clearAll, + removeFilter, + }; +} diff --git a/src/app/admin/blocklist/page.tsx b/src/app/admin/blocklist/page.tsx new file mode 100644 index 0000000..24d1f61 --- /dev/null +++ b/src/app/admin/blocklist/page.tsx @@ -0,0 +1,214 @@ +/** + * Component: Admin Blocklist Page + * Documentation: documentation/admin-features/release-blocklist.md + * + * Thin orchestrator: reads URL via useBlocklistUrlState, owns SWR + optimistic + * row state, composes sub-components. Mirrors /admin/logs/page.tsx patterns. + */ + +'use client'; + +import { Suspense, useState, useEffect, useMemo } from 'react'; +import useSWR from 'swr'; +import { ToastProvider } from '@/components/ui/Toast'; +import { authenticatedFetcher } from '@/lib/utils/api'; +import { useBlocklistUrlState } from './hooks/useBlocklistUrlState'; +import { + BlockedReleaseRow, + BlocklistData, + buildBlocklistApiKey, + computeEmptyState, + hasActiveFilters, + hasActiveSearch, + ValidLimit, +} from './types'; +import { BlocklistToolbar } from './components/BlocklistToolbar'; +import BlocklistFilters from './components/BlocklistFilters'; +import BlocklistActiveFilterChips from './components/BlocklistActiveFilterChips'; +import { BlocklistTable } from './components/BlocklistTable'; +import { BlocklistPagination } from './components/BlocklistPagination'; +import { BlocklistSkeleton } from './components/BlocklistSkeleton'; + +function EmptyState({ + kind, + onClearFilters, + onClearSearch, + searchValue, +}: { + kind: 'fresh' | 'filters-too-tight' | 'search-no-match'; + onClearFilters: () => void; + onClearSearch: () => void; + searchValue: string; +}) { + if (kind === 'fresh') { + return ( +
+

+ No blocked releases. +

+

+ RMAB will add releases here automatically when downloads or imports fail. +

+
+ ); + } + if (kind === 'search-no-match') { + return ( +
+

+ No matches for “{searchValue}”. +

+ +
+ ); + } + return ( +
+

+ No entries match your current filters. +

+ +
+ ); +} + +function AdminBlocklistContent() { + const { filters, setFilters, clearAll } = useBlocklistUrlState(); + const key = buildBlocklistApiKey(filters); + + const { data, error, mutate } = useSWR(key, authenticatedFetcher, { + keepPreviousData: true, + }); + + // Optimistic-removal overlay: ids removed by the current session's Unblock + // clicks. Once SWR returns fresh data, the next-render derivation drops any + // ids that are no longer present anyway. + const [optimisticRemoved, setOptimisticRemoved] = useState>(() => new Set()); + + // Reconcile optimistic state with server data: any id we removed that is + // also absent from the new data can be forgotten. + useEffect(() => { + if (!data) return; + setOptimisticRemoved((prev) => { + if (prev.size === 0) return prev; + const serverIds = new Set(data.entries.map((e) => e.id)); + const next = new Set(); + for (const id of prev) { + if (serverIds.has(id)) next.add(id); + } + return next.size === prev.size ? prev : next; + }); + }, [data]); + + const visibleEntries = useMemo(() => { + if (!data) return []; + if (optimisticRemoved.size === 0) return data.entries; + return data.entries.filter((e) => !optimisticRemoved.has(e.id)); + }, [data, optimisticRemoved]); + + const handleUnblocked = (id: string) => { + setOptimisticRemoved((prev) => { + const next = new Set(prev); + next.add(id); + return next; + }); + }; + + const handleUnblockFailed = (entry: BlockedReleaseRow) => { + // Roll back the optimistic removal. The next SWR cycle will re-fetch. + setOptimisticRemoved((prev) => { + if (!prev.has(entry.id)) return prev; + const next = new Set(prev); + next.delete(entry.id); + return next; + }); + }; + + const handleBulkCleared = () => { + // Drop optimistic state and refresh — bulk delete invalidates row mapping. + setOptimisticRemoved(new Set()); + mutate(); + }; + + const showSkeleton = !data; + const total = data?.pagination.total ?? 0; + const pagination = data?.pagination ?? { + page: filters.page, + limit: filters.limit, + total: 0, + totalPages: 1, + }; + + const emptyKind = computeEmptyState({ + total: visibleEntries.length, + hasFilters: hasActiveFilters(filters), + hasSearch: hasActiveSearch(filters), + }); + + return ( +
+
+ + + + + {error && ( +
+

+ Error Loading Blocklist +

+

+ {error?.message || 'Failed to load blocklist'} +

+
+ )} + + {showSkeleton ? ( + + ) : emptyKind ? ( + setFilters({ search: '' })} + searchValue={filters.search} + /> + ) : ( + <> + + setFilters({ page })} + onLimitChange={(limit: ValidLimit) => setFilters({ limit })} + /> + + )} +
+
+ ); +} + +export default function AdminBlocklistPage() { + return ( + + + + + + ); +} diff --git a/src/app/admin/blocklist/types.ts b/src/app/admin/blocklist/types.ts new file mode 100644 index 0000000..55f850c --- /dev/null +++ b/src/app/admin/blocklist/types.ts @@ -0,0 +1,185 @@ +/** + * Component: Admin Blocklist — Shared Types & Filter Contract + * Documentation: documentation/admin-features/release-blocklist.md + * + * URL ↔ API param contract for the /admin/blocklist page. URL param names === + * API query param names — no translation layer. + */ + +export const BLOCKLIST_PARAMS = { + search: 'search', + source: 'source', + requestId: 'requestId', + dateFrom: 'dateFrom', + dateTo: 'dateTo', + sortBy: 'sortBy', + sortOrder: 'sortOrder', + page: 'page', + limit: 'limit', +} as const; + +export const VALID_LIMITS = [25, 50, 100] as const; +export type ValidLimit = (typeof VALID_LIMITS)[number]; + +export const VALID_SOURCES = ['all', 'organize_fail', 'download_fail', 'manual'] as const; +export type BlockSourceFilter = (typeof VALID_SOURCES)[number]; + +export const VALID_SORT_FIELDS = ['createdAt', 'releaseName', 'reason'] as const; +export type SortField = (typeof VALID_SORT_FIELDS)[number]; + +export const VALID_SORT_ORDERS = ['asc', 'desc'] as const; +export type SortOrder = (typeof VALID_SORT_ORDERS)[number]; + +export const DEFAULT_LIMIT: ValidLimit = 50; +export const DEFAULT_PAGE = 1; +export const DEFAULT_SORT_BY: SortField = 'createdAt'; +export const DEFAULT_SORT_ORDER: SortOrder = 'desc'; + +export interface BlocklistFilterState { + search: string; + source: BlockSourceFilter; + requestId: string | null; + dateFrom: string | null; + dateTo: string | null; + sortBy: SortField; + sortOrder: SortOrder; + page: number; + limit: ValidLimit; +} + +export const DEFAULT_FILTER_STATE: BlocklistFilterState = { + search: '', + source: 'all', + requestId: null, + dateFrom: null, + dateTo: null, + sortBy: DEFAULT_SORT_BY, + sortOrder: DEFAULT_SORT_ORDER, + page: DEFAULT_PAGE, + limit: DEFAULT_LIMIT, +}; + +export const SOURCE_LABELS: Record = { + all: 'All sources', + organize_fail: 'Organize failure', + download_fail: 'Download failure', + manual: 'Manual', +}; + +export const SOURCE_BADGE_LABEL: Record = { + organize_fail: 'Organize', + download_fail: 'Download', + manual: 'Manual', +}; + +// --------------------------------------------------------------------------- +// API response shape — mirrors the route's `select` projection. +// --------------------------------------------------------------------------- +export interface BlockedReleaseRequestRelation { + id: string; + deletedAt: string | null; + audiobook: { title: string; author: string } | null; + user: { plexUsername: string } | null; +} + +export interface BlockedReleaseRow { + id: string; + requestId: string; + releaseName: string; + releaseHash: string | null; + indexerName: string | null; + indexerId: number | null; + source: string; + reason: string; + reasonDetail: string | null; + downloadHistoryId: string | null; + jobId: string | null; + createdAt: string; + request: BlockedReleaseRequestRelation | null; +} + +export interface BlocklistPagination { + page: number; + limit: number; + total: number; + totalPages: number; +} + +export interface BlocklistData { + entries: BlockedReleaseRow[]; + pagination: BlocklistPagination; +} + +// --------------------------------------------------------------------------- +// SWR / URL builders — single source of truth for the API query string. +// `buildBlocklistQueryString` is reused by the bulk-clear DELETE call so the +// clear-scope matches what the user sees. +// --------------------------------------------------------------------------- +export function buildBlocklistQueryString(state: BlocklistFilterState): string { + const params = new URLSearchParams(); + params.set(BLOCKLIST_PARAMS.page, String(state.page)); + params.set(BLOCKLIST_PARAMS.limit, String(state.limit)); + + if (state.source && state.source !== 'all') { + params.set(BLOCKLIST_PARAMS.source, state.source); + } + if (state.requestId) params.set(BLOCKLIST_PARAMS.requestId, state.requestId); + if (state.search) params.set(BLOCKLIST_PARAMS.search, state.search); + if (state.dateFrom) params.set(BLOCKLIST_PARAMS.dateFrom, state.dateFrom); + if (state.dateTo) params.set(BLOCKLIST_PARAMS.dateTo, state.dateTo); + if (state.sortBy !== DEFAULT_SORT_BY) params.set(BLOCKLIST_PARAMS.sortBy, state.sortBy); + if (state.sortOrder !== DEFAULT_SORT_ORDER) { + params.set(BLOCKLIST_PARAMS.sortOrder, state.sortOrder); + } + + return params.toString(); +} + +export function buildBlocklistApiKey(state: BlocklistFilterState): string { + return `/api/admin/blocklist?${buildBlocklistQueryString(state)}`; +} + +/** + * Build the query string the bulk-clear DELETE call should use. Strips + * page/limit/sort (irrelevant for delete scope) — only filter axes survive. + */ +export function buildBulkClearQueryString(state: BlocklistFilterState): string { + const params = new URLSearchParams(); + if (state.source && state.source !== 'all') { + params.set(BLOCKLIST_PARAMS.source, state.source); + } + if (state.requestId) params.set(BLOCKLIST_PARAMS.requestId, state.requestId); + if (state.search) params.set(BLOCKLIST_PARAMS.search, state.search); + if (state.dateFrom) params.set(BLOCKLIST_PARAMS.dateFrom, state.dateFrom); + if (state.dateTo) params.set(BLOCKLIST_PARAMS.dateTo, state.dateTo); + return params.toString(); +} + +// --------------------------------------------------------------------------- +// Filter-state predicates — drive empty-state copy + chip strip + Clear button +// --------------------------------------------------------------------------- +export function hasActiveFilters(state: BlocklistFilterState): boolean { + return ( + state.source !== 'all' || + state.requestId !== null || + state.dateFrom !== null || + state.dateTo !== null + ); +} + +export function hasActiveSearch(state: BlocklistFilterState): boolean { + return state.search !== ''; +} + +export type EmptyStateKind = 'fresh' | 'filters-too-tight' | 'search-no-match'; + +export function computeEmptyState(args: { + total: number; + hasFilters: boolean; + hasSearch: boolean; +}): EmptyStateKind | null { + if (args.total > 0) return null; + if (args.hasSearch) return 'search-no-match'; + if (args.hasFilters) return 'filters-too-tight'; + return 'fresh'; +} diff --git a/src/app/admin/components/BlockedReleasesChip.tsx b/src/app/admin/components/BlockedReleasesChip.tsx new file mode 100644 index 0000000..4ea5971 --- /dev/null +++ b/src/app/admin/components/BlockedReleasesChip.tsx @@ -0,0 +1,233 @@ +/** + * Component: Blocked Releases Chip (request-detail surface) + * Documentation: documentation/admin-features/release-blocklist.md + * + * Visible chip on a request row showing "N releases blocked" — click to expand + * a popover listing names + reasons. Real + + {isOpen && position && typeof window !== 'undefined' && createPortal( +
+
+

+ Blocked for this request +

+ +
+ +
+ {isLoading && ( +

Loading…

+ )} + {error && ( +

Failed to load blocked releases.

+ )} + {data && data.entries.length === 0 && ( +

No blocked releases.

+ )} + {data && data.entries.length > 0 && ( +
    + {data.entries.map((entry) => ( + { + mutate(); + onChange(); + }} + /> + ))} +
+ )} +
+
, + document.body + )} + + ); +} + +function BlockedEntryItem({ + entry, + onRemoved, +}: { + entry: BlockedReleaseRow; + onRemoved: () => void; +}) { + const toast = useToast(); + const [isUnblocking, setIsUnblocking] = useState(false); + + const handleUnblock = async () => { + setIsUnblocking(true); + try { + const response = await fetchWithAuth(`/api/admin/blocklist/${entry.id}`, { + method: 'DELETE', + }); + if (!response.ok) { + const body = await response.json().catch(() => ({})); + throw new Error(body.error || body.message || 'Failed to unblock'); + } + toast.success(`Unblocked: ${entry.releaseName}`); + onRemoved(); + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Failed to unblock'); + } finally { + setIsUnblocking(false); + } + }; + + const sourceLabel = SOURCE_BADGE_LABEL[entry.source] ?? entry.source; + + return ( +
  • +

    + {entry.releaseName} +

    +
    + + {sourceLabel} + + {entry.reason} +
    +
    + +
    +
  • + ); +} diff --git a/src/app/admin/components/RecentRequestsTable.tsx b/src/app/admin/components/RecentRequestsTable.tsx index 611d10c..75b6916 100644 --- a/src/app/admin/components/RecentRequestsTable.tsx +++ b/src/app/admin/components/RecentRequestsTable.tsx @@ -14,6 +14,7 @@ import { mutate } from 'swr'; import { authenticatedFetcher, fetchWithAuth } from '@/lib/utils/api'; import { useToast } from '@/components/ui/Toast'; import { AudiobookDetailsModal } from '@/components/audiobooks/AudiobookDetailsModal'; +import { BlockedReleasesChip } from './BlockedReleasesChip'; interface RecentRequest { requestId: string; @@ -30,6 +31,7 @@ interface RecentRequest { torrentUrl?: string | null; downloadAttempts?: number; customSearchTerms?: string | null; + blockedCount?: number; } interface User { @@ -677,6 +679,13 @@ export function RecentRequestsTable({ ebookSidecarEnabled = false, annasArchiveB Custom Search )} + {(request.blockedCount ?? 0) > 0 && ( + mutate(apiUrl)} + /> + )}
    {request.author} diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index d54fd9e..beb27c2 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -649,7 +649,7 @@ function AdminDashboardContent() {
    {/* Quick Actions */} -
    +
    + +
    + + + + + Blocklist + +
    + +