mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-02 20:30:10 +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:
@@ -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)
|
||||
|
||||
@@ -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 `<button>`, optimistic update, toast on success/failure.
|
||||
- **Filters:** Source dropdown, Date range (shared with logs preset list), free-text search.
|
||||
- **Sort:** clickable column headers on Release name / Reason / Blocked at; URL-driven; persists in shareable link.
|
||||
- **Bulk Clear (`Clear filtered (N)` or `Clear all (N)`):** opens a typed-token confirmation modal. Button label adapts to active filter state.
|
||||
- **Empty states:** "fresh" / "filters-too-tight" / "search-no-match" — pure function of `{ total, hasFilters, hasSearch }`.
|
||||
|
||||
**Nav entry:** Quick Actions tile on the admin dashboard (`src/app/admin/page.tsx`).
|
||||
|
||||
## Request Detail Chip
|
||||
**Component:** `BlockedReleasesChip` ([src/app/admin/components/BlockedReleasesChip.tsx](../../src/app/admin/components/BlockedReleasesChip.tsx))
|
||||
|
||||
Rendered in the title cell of each request row in `RecentRequestsTable` when `blockedCount > 0`. Real `<button>` with explicit chevron — no surprise expansion. Click opens a portal-anchored popover that lazy-loads `GET /api/admin/blocklist/by-request/[requestId]` and lists each blocked release with a per-row Unblock button.
|
||||
|
||||
The `_count.blockedReleases` aggregate is included in the existing `/api/admin/requests` response as an additive field.
|
||||
|
||||
## Interactive Search Badge
|
||||
When the admin opens `InteractiveTorrentSearchModal` for a request, the modal fetches the per-request blocklist (admin-only — non-admin gets 403, no badge). Each result row is checked against the lookup (normalized name OR `infoHash`). Matches render an amber **"Already blocked — <reason>"** chip inline. Interactive search results are **not filtered** — admin sees the full picture.
|
||||
|
||||
## Test Coverage
|
||||
- `tests/utils/release-key.test.ts` — normalization rules.
|
||||
- `tests/services/blocklist.service.test.ts` — upsert idempotency, lookup match, JobEvent emission.
|
||||
- `tests/processors/*` — auto-block triggers + filter coverage on each search path.
|
||||
- `tests/api/admin-blocklist.routes.test.ts` — auth gate, where composition, single + bulk DELETE, by-request GET, sort/pagination/limit clamp.
|
||||
|
||||
## UX Rules Honored
|
||||
- **Intentional affordances** — every tappable element is a real `<button>`/`<a>` with hover/focus treatment; expand-rows show an explicit chevron.
|
||||
- **Source data stays true** — release names render verbatim. Chips/badges add context (source, reason, "blocked"), they never replace the original string.
|
||||
|
||||
## Out of Scope (v2)
|
||||
- Global (cross-request) blocklist + per-block toggle UI.
|
||||
- Manual proactive admin block.
|
||||
- Requester-facing UI surface.
|
||||
- Auto-expiration / TTL.
|
||||
- Zero-seeder torrents as a block trigger.
|
||||
- Indexer-side push (Prowlarr blocklist API).
|
||||
|
||||
## Related
|
||||
- [Database schema](../backend/database.md)
|
||||
- [Search processors](../phase3/prowlarr.md)
|
||||
- [Admin dashboard](../admin-dashboard.md)
|
||||
- [Request deletion](request-deletion.md) — interaction with hard/soft delete cascade.
|
||||
@@ -111,12 +111,32 @@ PostgreSQL database storing users, audiobooks, requests, downloads, configuratio
|
||||
- Indexes: `job_id`, `created_at`
|
||||
- **Purpose:** Store detailed event logs for job operations (shown in admin logs UI)
|
||||
|
||||
### Blocked_Releases
|
||||
- `id` (UUID PK), `request_id` (FK → Requests, CASCADE on hard delete)
|
||||
- `release_name` (text) - original release title as the indexer returned it
|
||||
- `release_key` (text) - normalized lookup key: `trim().toLowerCase()` of release_name
|
||||
- `release_hash` (nullable) - `torrentHash` (qBit) OR `nzbId` (SAB/NZBGet); mutually exclusive in source
|
||||
- `indexer_name` (nullable), `indexer_id` (int, nullable)
|
||||
- `source` ('organize_fail'|'download_fail'|'manual'; 'manual' reserved for v2)
|
||||
- `reason` (text) - short, e.g. "No audiobook files found", "Download failed (par2)"
|
||||
- `reason_detail` (text, nullable) - raw client error string (SAB failMessage, NZBGet Par/Unpack code)
|
||||
- `download_history_id` (nullable) - traceability to the DownloadHistory row that drove the block
|
||||
- `job_id` (nullable) - origin job; also drives JobEvent emission via RMABLogger.forJob
|
||||
- `created_at` (timestamp)
|
||||
- Unique: `(request_id, release_key)` - idempotency for concurrent auto-block writes
|
||||
- Indexes: `request_id`, `release_key`, `release_hash`, `created_at DESC`
|
||||
- **Purpose:** Per-request blocklist. Search processors filter their candidate set against this table so future searches skip releases that have already failed for the same request.
|
||||
- **Soft/hard delete:** Soft-delete (sets `requests.deleted_at`) does NOT cascade - blocklist entries survive. Hard-delete cascades and wipes entries.
|
||||
- **Match rules:** Case-insensitive exact match on `release_key` OR exact match on `release_hash`.
|
||||
- **Service:** Single writer is `src/lib/services/blocklist.service.ts` (`addAutoBlock` is idempotent via upsert; never throws).
|
||||
|
||||
## Relationships
|
||||
|
||||
- User → Requests (1:many)
|
||||
- Audiobook → Requests (1:many)
|
||||
- Request → Download History (1:many)
|
||||
- Request → Jobs (1:many, nullable)
|
||||
- Request → Blocked Releases (1:many, CASCADE on hard delete)
|
||||
- Job → Job Events (1:many, CASCADE delete)
|
||||
|
||||
## Setup Strategy
|
||||
|
||||
@@ -259,6 +259,7 @@ model Request {
|
||||
jobs Job[]
|
||||
parentRequest Request? @relation("EbookParent", fields: [parentRequestId], references: [id], onDelete: SetNull)
|
||||
childRequests Request[] @relation("EbookParent")
|
||||
blockedReleases BlockedRelease[]
|
||||
|
||||
@@index([userId])
|
||||
@@index([audiobookId])
|
||||
@@ -270,6 +271,42 @@ model Request {
|
||||
@@map("requests")
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// BLOCKED RELEASES TABLE
|
||||
// Per-request blocklist of failed releases (organize-fail or download-fail).
|
||||
// Search processors filter their candidate set against this table so future
|
||||
// searches skip releases that have already failed for the same request.
|
||||
// Documentation: documentation/backend/database.md
|
||||
// ============================================================================
|
||||
|
||||
model BlockedRelease {
|
||||
id String @id @default(uuid())
|
||||
requestId String @map("request_id")
|
||||
releaseName String @map("release_name") @db.Text
|
||||
releaseKey String @map("release_key") @db.Text // normalized: trim + lowercase
|
||||
releaseHash String? @map("release_hash") // torrentHash OR nzbId (mutually exclusive in source)
|
||||
indexerName String? @map("indexer_name")
|
||||
indexerId Int? @map("indexer_id")
|
||||
source String // 'organize_fail' | 'download_fail' | 'manual' (manual reserved for v2)
|
||||
reason String @db.Text // short reason, e.g. "No audiobook files found"
|
||||
reasonDetail String? @map("reason_detail") @db.Text // raw client error (SAB failMessage, NZBGet Par/Unpack)
|
||||
downloadHistoryId String? @map("download_history_id") // traceability to the DownloadHistory row that failed
|
||||
jobId String? @map("job_id") // origin job (also drives JobEvent emission via logger)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
// Relations
|
||||
// Cascade: hard-delete of Request wipes its blocklist rows.
|
||||
// Soft-delete (Request.deletedAt) does NOT cascade — entries survive.
|
||||
request Request @relation(fields: [requestId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([requestId, releaseKey]) // idempotency: one row per (request, normalized name)
|
||||
@@index([requestId])
|
||||
@@index([releaseKey])
|
||||
@@index([releaseHash])
|
||||
@@index([createdAt(sort: Desc)])
|
||||
@@map("blocked_releases")
|
||||
}
|
||||
|
||||
model DownloadHistory {
|
||||
id String @id @default(uuid())
|
||||
requestId String @map("request_id")
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Component: Blocklist — Active Filter Chips
|
||||
* Documentation: documentation/admin-features/release-blocklist.md
|
||||
*
|
||||
* Dismissable chip strip showing every active filter PLUS the search term.
|
||||
* Each chip is a real <button> with aria-label="Remove filter: <name>" and a
|
||||
* visible × glyph (per zach.md UX rule on intentional affordances).
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import {
|
||||
DATE_PRESETS,
|
||||
getActivePresetId,
|
||||
} from '@/lib/constants/log-filters';
|
||||
import { useBlocklistUrlState } from '../hooks/useBlocklistUrlState';
|
||||
import { SOURCE_LABELS } from '../types';
|
||||
|
||||
export default function BlocklistActiveFilterChips() {
|
||||
const { filters, setFilters, removeFilter } = useBlocklistUrlState();
|
||||
|
||||
const chips: ChipDescriptor[] = [];
|
||||
|
||||
if (filters.search !== '') {
|
||||
chips.push({
|
||||
key: 'search',
|
||||
name: 'search',
|
||||
label: `Search: "${filters.search}"`,
|
||||
onRemove: () => removeFilter('search'),
|
||||
});
|
||||
}
|
||||
if (filters.source !== 'all') {
|
||||
chips.push({
|
||||
key: 'source',
|
||||
name: 'source',
|
||||
label: `Source: ${SOURCE_LABELS[filters.source] ?? filters.source}`,
|
||||
onRemove: () => removeFilter('source'),
|
||||
});
|
||||
}
|
||||
if (filters.requestId !== null) {
|
||||
chips.push({
|
||||
key: 'requestId',
|
||||
name: 'request',
|
||||
label: `Request: ${filters.requestId}`,
|
||||
onRemove: () => removeFilter('requestId'),
|
||||
});
|
||||
}
|
||||
if (filters.dateFrom !== null || filters.dateTo !== null) {
|
||||
chips.push({
|
||||
key: 'date',
|
||||
name: 'date range',
|
||||
label: `Date: ${formatDateChipLabel(filters.dateFrom, filters.dateTo)}`,
|
||||
onRemove: () => setFilters({ dateFrom: null, dateTo: null }),
|
||||
});
|
||||
}
|
||||
|
||||
if (chips.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="mb-4 flex flex-wrap gap-2" role="group" aria-label="Active filters">
|
||||
{chips.map((chip) => (
|
||||
<Chip key={chip.key} chip={chip} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ChipDescriptor {
|
||||
key: string;
|
||||
name: string;
|
||||
label: string;
|
||||
onRemove: () => void;
|
||||
}
|
||||
|
||||
function Chip({ chip }: { chip: ChipDescriptor }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={chip.onRemove}
|
||||
aria-label={`Remove filter: ${chip.name}`}
|
||||
className="inline-flex items-center gap-1.5 pl-3 pr-2 py-1.5 bg-blue-50 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-800 text-blue-800 dark:text-blue-200 rounded-full text-sm font-medium hover:bg-blue-100 dark:hover:bg-blue-900/50 transition-colors min-h-[36px]"
|
||||
>
|
||||
<span className="truncate max-w-[20rem]">{chip.label}</span>
|
||||
<svg className="w-3.5 h-3.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
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',
|
||||
});
|
||||
}
|
||||
@@ -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 (
|
||||
<div>
|
||||
<label className={LABEL_CLASS} htmlFor="blocklist-date-preset">Date Range</label>
|
||||
<select
|
||||
id="blocklist-date-preset"
|
||||
value={activePreset}
|
||||
onChange={(e) => handlePresetChange(e.target.value as DatePresetId)}
|
||||
className={INPUT_CLASS}
|
||||
>
|
||||
{DATE_PRESETS.map((p) => (
|
||||
<option key={p.id} value={p.id}>{p.label}</option>
|
||||
))}
|
||||
</select>
|
||||
{showCustom && (
|
||||
<CustomDateInputs dateFrom={dateFrom} dateTo={dateTo} onChange={handleCustomChange} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="mt-2 space-y-2">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<input
|
||||
type="datetime-local"
|
||||
aria-label="Date from"
|
||||
value={fromLocal}
|
||||
onChange={(e) =>
|
||||
onChange({ dateFrom: localInputToIso(e.target.value), dateTo })
|
||||
}
|
||||
className={INPUT_CLASS}
|
||||
/>
|
||||
<input
|
||||
type="datetime-local"
|
||||
aria-label="Date to"
|
||||
value={toLocal}
|
||||
onChange={(e) =>
|
||||
onChange({ dateFrom, dateTo: localInputToIso(e.target.value) })
|
||||
}
|
||||
className={INPUT_CLASS}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Times are in your local timezone (sent as UTC).
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="mb-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 sm:gap-4">
|
||||
<SourceDropdown
|
||||
value={filters.source}
|
||||
onChange={(value) => setFilters({ source: value })}
|
||||
/>
|
||||
<BlocklistDateRangePicker
|
||||
dateFrom={filters.dateFrom}
|
||||
dateTo={filters.dateTo}
|
||||
onChange={(next) => setFilters(next)}
|
||||
/>
|
||||
</div>
|
||||
{showClearAll && (
|
||||
<div className="mt-3 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearAll}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-2 text-sm font-medium text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700/60 transition-colors min-h-[44px]"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
Clear all filters
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SourceDropdown({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: BlockSourceFilter;
|
||||
onChange: (value: BlockSourceFilter) => void;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<label className={LABEL_CLASS} htmlFor="blocklist-source-filter">Source</label>
|
||||
<select
|
||||
id="blocklist-source-filter"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value as BlockSourceFilter)}
|
||||
className={INPUT_CLASS}
|
||||
>
|
||||
{VALID_SOURCES.map((opt) => (
|
||||
<option key={opt} value={opt}>
|
||||
{SOURCE_LABELS[opt]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="mt-6 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex flex-wrap items-center gap-3 sm:gap-4 text-sm text-gray-600 dark:text-gray-400">
|
||||
<span data-testid="blocklist-pagination-summary">
|
||||
Page <span className="font-medium text-gray-900 dark:text-gray-100">{page}</span> of{' '}
|
||||
<span className="font-medium text-gray-900 dark:text-gray-100">{Math.max(1, totalPages)}</span>
|
||||
{' · '}
|
||||
<span className="font-medium text-gray-900 dark:text-gray-100">
|
||||
{total.toLocaleString()}
|
||||
</span>{' '}
|
||||
{total === 1 ? 'entry' : 'entries'}
|
||||
</span>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<span className="text-gray-600 dark:text-gray-400">Per page</span>
|
||||
<select
|
||||
value={limit}
|
||||
onChange={(e) => onLimitChange(Number(e.target.value) as ValidLimit)}
|
||||
className="min-h-[44px] px-3 py-2 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-blue-500 text-sm"
|
||||
aria-label="Page size"
|
||||
>
|
||||
{VALID_LIMITS.map((opt) => (
|
||||
<option key={opt} value={opt}>
|
||||
{opt}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onPageChange(page - 1)}
|
||||
disabled={page <= 1}
|
||||
className="inline-flex items-center gap-1.5 min-h-[44px] px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
aria-label="Previous page"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
<span className="hidden sm:inline">Previous</span>
|
||||
</button>
|
||||
|
||||
<label className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400 sr-only sm:not-sr-only">
|
||||
Go to
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={Math.max(1, totalPages)}
|
||||
value={jumpValue}
|
||||
onChange={(e) => setJumpValue(e.target.value)}
|
||||
onBlur={submitJump}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
submitJump();
|
||||
}
|
||||
}}
|
||||
className="min-h-[44px] w-20 px-3 py-2 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-blue-500 text-sm text-center"
|
||||
aria-label="Jump to page"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onPageChange(page + 1)}
|
||||
disabled={page >= totalPages}
|
||||
className="inline-flex items-center gap-1.5 min-h-[44px] px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
aria-label="Next page"
|
||||
>
|
||||
<span className="hidden sm:inline">Next</span>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
/**
|
||||
* Component: Blocklist Row (desktop + mobile)
|
||||
* Documentation: documentation/admin-features/release-blocklist.md
|
||||
*
|
||||
* Per-row Unblock is a real <button> with intentional treatment (per zach.md).
|
||||
* Expand chevron explicitly discloses the long reason detail when present.
|
||||
* No accidental tap targets, no surprise expansions.
|
||||
*
|
||||
* Release name is rendered VERBATIM from the source — chips/badges add context,
|
||||
* they don't replace (per zach.md "displayed source data stays true to source").
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useToast } from '@/components/ui/Toast';
|
||||
import { fetchWithAuth } from '@/lib/utils/api';
|
||||
import { BlockedReleaseRow, SOURCE_BADGE_LABEL } from '../types';
|
||||
|
||||
interface BlocklistRowProps {
|
||||
entry: BlockedReleaseRow;
|
||||
/** Optimistic removal — called immediately on click so the row disappears. */
|
||||
onUnblocked: (id: string) => void;
|
||||
/** Called when the API call fails so the row can be reinserted. */
|
||||
onUnblockFailed: (entry: BlockedReleaseRow, error: string) => void;
|
||||
}
|
||||
|
||||
function formatTimestamp(iso: string): { absolute: string; relative: string } {
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) {
|
||||
return { absolute: '—', relative: '—' };
|
||||
}
|
||||
const absolute = d.toLocaleString([], {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
const diffMs = Date.now() - d.getTime();
|
||||
const diffMin = Math.floor(diffMs / 60000);
|
||||
let relative: string;
|
||||
if (diffMin < 1) relative = 'just now';
|
||||
else if (diffMin < 60) relative = `${diffMin}m ago`;
|
||||
else if (diffMin < 60 * 24) relative = `${Math.floor(diffMin / 60)}h ago`;
|
||||
else relative = `${Math.floor(diffMin / (60 * 24))}d ago`;
|
||||
return { absolute, relative };
|
||||
}
|
||||
|
||||
function SourceBadge({ source }: { source: string }) {
|
||||
const label = SOURCE_BADGE_LABEL[source] ?? source;
|
||||
const styles: Record<string, string> = {
|
||||
organize_fail: 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300',
|
||||
download_fail: 'bg-rose-100 text-rose-800 dark:bg-rose-900/30 dark:text-rose-300',
|
||||
manual: 'bg-slate-100 text-slate-800 dark:bg-slate-700 dark:text-slate-200',
|
||||
};
|
||||
const cls = styles[source] ?? 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200';
|
||||
return (
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${cls}`}>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function useUnblock(
|
||||
entry: BlockedReleaseRow,
|
||||
onUnblocked: (id: string) => void,
|
||||
onUnblockFailed: (entry: BlockedReleaseRow, error: string) => void
|
||||
) {
|
||||
const toast = useToast();
|
||||
const [isUnblocking, setIsUnblocking] = useState(false);
|
||||
|
||||
const unblock = async () => {
|
||||
if (isUnblocking) return;
|
||||
setIsUnblocking(true);
|
||||
onUnblocked(entry.id);
|
||||
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}`);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to unblock';
|
||||
onUnblockFailed(entry, message);
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setIsUnblocking(false);
|
||||
}
|
||||
};
|
||||
|
||||
return { isUnblocking, unblock };
|
||||
}
|
||||
|
||||
function RequestRelation({ entry }: { entry: BlockedReleaseRow }) {
|
||||
const r = entry.request;
|
||||
if (!r || !r.audiobook) {
|
||||
return <span className="text-gray-400 dark:text-gray-500">—</span>;
|
||||
}
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate" title={r.audiobook.title}>
|
||||
{r.audiobook.title}
|
||||
</span>
|
||||
{r.deletedAt && (
|
||||
<span
|
||||
className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-semibold uppercase tracking-wide bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-300 flex-shrink-0"
|
||||
title={`Request deleted at ${new Date(r.deletedAt).toLocaleString()}`}
|
||||
>
|
||||
Deleted
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 truncate" title={r.audiobook.author}>
|
||||
{r.audiobook.author}
|
||||
{r.user && <span> · {r.user.plexUsername}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReasonCell({
|
||||
entry,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
}: {
|
||||
entry: BlockedReleaseRow;
|
||||
isExpanded: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
const hasDetail = !!entry.reasonDetail && entry.reasonDetail.trim().length > 0;
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-start gap-1.5">
|
||||
<p className={`text-sm text-gray-700 dark:text-gray-300 ${isExpanded ? 'whitespace-pre-wrap break-words' : 'truncate'}`}>
|
||||
{entry.reason}
|
||||
</p>
|
||||
{hasDetail && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
aria-expanded={isExpanded}
|
||||
aria-label={isExpanded ? 'Hide reason detail' : 'Show reason detail'}
|
||||
className="flex-shrink-0 p-1.5 -my-1 rounded-md text-gray-400 hover:text-gray-700 hover:bg-gray-100 dark:text-gray-500 dark:hover:text-gray-300 dark:hover:bg-gray-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/40 transition-colors"
|
||||
>
|
||||
<svg
|
||||
className={`w-3 h-3 transition-transform duration-200 ease-out ${isExpanded ? 'rotate-90' : ''}`}
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path fillRule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{isExpanded && hasDetail && (
|
||||
<pre className="mt-1.5 text-xs text-gray-600 dark:text-gray-400 whitespace-pre-wrap break-words font-mono bg-gray-50 dark:bg-gray-900/40 rounded px-2 py-1.5 border border-gray-100 dark:border-gray-700/50">
|
||||
{entry.reasonDetail}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UnblockButton({ isUnblocking, onClick }: { isUnblocking: boolean; onClick: () => void }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={isUnblocking}
|
||||
aria-label="Unblock release"
|
||||
className="inline-flex items-center gap-1.5 min-h-[36px] px-3 py-1.5 text-sm font-medium text-blue-700 dark:text-blue-300 bg-blue-50 dark:bg-blue-900/30 hover:bg-blue-100 dark:hover:bg-blue-900/50 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isUnblocking ? (
|
||||
<svg className="w-3.5 h-3.5 animate-spin" fill="none" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
)}
|
||||
<span>Unblock</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Desktop row — <tr>
|
||||
// ---------------------------------------------------------------------------
|
||||
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 (
|
||||
<tr className="hover:bg-gray-50 dark:hover:bg-gray-900/40 transition-colors">
|
||||
<td className="px-6 py-4 align-top">
|
||||
<p
|
||||
className="text-sm font-medium text-gray-900 dark:text-gray-100 break-words"
|
||||
title={entry.releaseName}
|
||||
>
|
||||
{entry.releaseName}
|
||||
</p>
|
||||
</td>
|
||||
<td className="px-6 py-4 align-top">
|
||||
<ReasonCell entry={entry} isExpanded={reasonExpanded} onToggle={() => setReasonExpanded((v) => !v)} />
|
||||
</td>
|
||||
<td className="px-6 py-4 align-top">
|
||||
<SourceBadge source={entry.source} />
|
||||
</td>
|
||||
<td className="px-6 py-4 align-top">
|
||||
<RequestRelation entry={entry} />
|
||||
</td>
|
||||
<td className="px-6 py-4 align-top text-sm text-gray-700 dark:text-gray-300">
|
||||
{entry.indexerName ?? <span className="text-gray-400 dark:text-gray-500">—</span>}
|
||||
</td>
|
||||
<td className="px-6 py-4 align-top text-sm text-gray-500 dark:text-gray-400" title={absolute}>
|
||||
{relative}
|
||||
</td>
|
||||
<td className="px-6 py-4 align-top text-right">
|
||||
<UnblockButton isUnblocking={isUnblocking} onClick={unblock} />
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 (
|
||||
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4 space-y-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<SourceBadge source={entry.source} />
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400" title={absolute}>
|
||||
{relative}
|
||||
</span>
|
||||
</div>
|
||||
<UnblockButton isUnblocking={isUnblocking} onClick={unblock} />
|
||||
</div>
|
||||
|
||||
<p
|
||||
className="text-sm font-medium text-gray-900 dark:text-gray-100 break-words"
|
||||
title={entry.releaseName}
|
||||
>
|
||||
{entry.releaseName}
|
||||
</p>
|
||||
|
||||
<ReasonCell entry={entry} isExpanded={reasonExpanded} onToggle={() => setReasonExpanded((v) => !v)} />
|
||||
|
||||
{entry.request?.audiobook && (
|
||||
<div className="pt-2 border-t border-gray-100 dark:border-gray-700/60">
|
||||
<p className="text-[10px] uppercase tracking-wide font-semibold text-gray-400 dark:text-gray-500 mb-0.5">
|
||||
Associated request
|
||||
</p>
|
||||
<RequestRelation entry={entry} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{entry.indexerName && (
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Indexer: <span className="font-medium text-gray-700 dark:text-gray-300">{entry.indexerName}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const BlocklistRow = {
|
||||
Desktop: DesktopRow,
|
||||
Mobile: MobileRow,
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Component: Blocklist Skeleton
|
||||
* Documentation: documentation/admin-features/release-blocklist.md
|
||||
*/
|
||||
|
||||
export function BlocklistSkeleton() {
|
||||
return (
|
||||
<div className="space-y-2" data-testid="blocklist-skeleton">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4 animate-pulse"
|
||||
>
|
||||
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-3/4 mb-2" />
|
||||
<div className="h-3 bg-gray-100 dark:bg-gray-700/60 rounded w-1/2" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<th
|
||||
className={`px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider ${className}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
className="inline-flex items-center gap-1.5 hover:text-gray-900 dark:hover:text-gray-100 transition-colors uppercase tracking-wider font-medium"
|
||||
aria-label={`Sort by ${label}`}
|
||||
>
|
||||
{label}
|
||||
<SortGlyph active={isActive} order={order} />
|
||||
</button>
|
||||
</th>
|
||||
);
|
||||
}
|
||||
|
||||
function SortGlyph({ active, order }: { active: boolean; order: 'asc' | 'desc' }) {
|
||||
if (!active) {
|
||||
return (
|
||||
<svg className="w-3.5 h-3.5 text-gray-300 dark:text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 9l4-4 4 4m0 6l-4 4-4-4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
return order === 'asc' ? (
|
||||
<svg className="w-3.5 h-3.5 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 15l7-7 7 7" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-3.5 h-3.5 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function BlocklistTable({ entries, onUnblocked, onUnblockFailed }: BlocklistTableProps) {
|
||||
return (
|
||||
<>
|
||||
{/* Mobile cards */}
|
||||
<div className="space-y-3 sm:hidden">
|
||||
{entries.map((entry) => (
|
||||
<BlocklistRow.Mobile
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
onUnblocked={onUnblocked}
|
||||
onUnblockFailed={onUnblockFailed}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Desktop table */}
|
||||
<div className="hidden sm:block bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead className="bg-gray-50 dark:bg-gray-900">
|
||||
<tr>
|
||||
<SortableHeader field="releaseName" label="Release name" />
|
||||
<SortableHeader field="reason" label="Reason" />
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Source
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Associated request
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Indexer
|
||||
</th>
|
||||
<SortableHeader field="createdAt" label="Blocked at" />
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{entries.map((entry) => (
|
||||
<BlocklistRow.Desktop
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
onUnblocked={onUnblocked}
|
||||
onUnblockFailed={onUnblockFailed}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="sticky top-0 z-10 mb-6 sm:mb-8 bg-gray-50 dark:bg-gray-900 py-4 -mx-4 px-4 sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8 border-b border-gray-200 dark:border-gray-800">
|
||||
{/* Row 1: title + back link */}
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-gray-100">
|
||||
Release Blocklist
|
||||
</h1>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
Releases auto-blocked from download or organize failures. Unblock to allow re-grabbing.
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/admin"
|
||||
className="inline-flex items-center gap-2 min-h-[44px] px-4 py-2.5 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-900 dark:text-gray-100 rounded-lg transition-colors text-sm font-medium self-start sm:self-auto flex-shrink-0"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
<span>Back to Dashboard</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Row 2: "Clear filtered (N)" button — only when something would be cleared */}
|
||||
{canClear && (
|
||||
<div className="flex flex-wrap items-center gap-2 mt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmOpen(true)}
|
||||
className="inline-flex items-center gap-1.5 min-h-[44px] px-3.5 py-2 rounded-lg text-sm font-medium bg-red-50 text-red-700 hover:bg-red-100 dark:bg-red-900/20 dark:text-red-300 dark:hover:bg-red-900/40 transition-colors"
|
||||
aria-label={
|
||||
filtersActive
|
||||
? `Clear ${total} filtered blocklist entries`
|
||||
: `Clear all ${total} blocklist entries`
|
||||
}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
{filtersActive ? `Clear filtered (${total.toLocaleString()})` : `Clear all (${total.toLocaleString()})`}
|
||||
</button>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{filtersActive
|
||||
? 'Unblocks every entry matching the current filters.'
|
||||
: 'Unblocks every entry. Apply a filter first to scope.'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Row 3: search input */}
|
||||
<div className="mt-3 relative">
|
||||
<span className="pointer-events-none absolute inset-y-0 left-3 flex items-center">
|
||||
<svg className="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
|
||||
</svg>
|
||||
</span>
|
||||
<input
|
||||
type="search"
|
||||
value={searchInput}
|
||||
onChange={(e) => 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 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSearchInput('');
|
||||
removeFilter('search');
|
||||
}}
|
||||
aria-label="Clear search"
|
||||
className="absolute inset-y-0 right-2 my-auto inline-flex items-center justify-center w-8 h-8 rounded-full text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ClearFilteredConfirmModal
|
||||
isOpen={confirmOpen}
|
||||
onClose={() => setConfirmOpen(false)}
|
||||
onCleared={onCleared}
|
||||
total={total}
|
||||
filtersActive={filtersActive}
|
||||
queryString={buildBulkClearQueryString(filters as BlocklistFilterState)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<Modal isOpen={isOpen} onClose={isClearing ? () => {} : onClose} title={title} size="sm" showCloseButton={false}>
|
||||
<div className="space-y-5">
|
||||
<p className="text-sm text-gray-700 dark:text-gray-300 leading-relaxed">
|
||||
{description}
|
||||
</p>
|
||||
|
||||
<div className="rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800/60 px-4 py-3">
|
||||
<p className="text-sm font-medium text-red-800 dark:text-red-200">
|
||||
This cannot be undone.
|
||||
</p>
|
||||
<p className="text-xs text-red-700 dark:text-red-300 mt-1">
|
||||
Type <span className="font-mono font-bold">CLEAR</span> below to confirm.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="blocklist-clear-token"
|
||||
className="block text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-1.5"
|
||||
>
|
||||
Confirmation
|
||||
</label>
|
||||
<input
|
||||
id="blocklist-clear-token"
|
||||
type="text"
|
||||
value={token}
|
||||
onChange={(e) => 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]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 justify-end">
|
||||
<Button onClick={onClose} variant="outline" disabled={isClearing}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConfirm}
|
||||
variant="danger"
|
||||
loading={isClearing}
|
||||
disabled={!canConfirm}
|
||||
>
|
||||
{filtersActive ? `Clear ${total.toLocaleString()}` : `Clear all ${total.toLocaleString()}`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -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<BlocklistFilterState>) => 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<ReturnType<typeof setTimeout> | 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<BlocklistFilterState>) => {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="text-center py-16">
|
||||
<p className="text-gray-700 dark:text-gray-300 text-base font-medium">
|
||||
No blocked releases.
|
||||
</p>
|
||||
<p className="text-gray-500 dark:text-gray-400 text-sm mt-1">
|
||||
RMAB will add releases here automatically when downloads or imports fail.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (kind === 'search-no-match') {
|
||||
return (
|
||||
<div className="text-center py-16">
|
||||
<p className="text-gray-700 dark:text-gray-300 text-base font-medium">
|
||||
No matches for “{searchValue}”.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClearSearch}
|
||||
className="mt-3 inline-flex items-center gap-1.5 min-h-[44px] px-4 py-2 text-sm font-medium text-blue-600 dark:text-blue-400 hover:underline"
|
||||
>
|
||||
Clear search
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="text-center py-16">
|
||||
<p className="text-gray-700 dark:text-gray-300 text-base font-medium">
|
||||
No entries match your current filters.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClearFilters}
|
||||
className="mt-3 inline-flex items-center gap-1.5 min-h-[44px] px-4 py-2 text-sm font-medium text-blue-600 dark:text-blue-400 hover:underline"
|
||||
>
|
||||
Clear filters
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AdminBlocklistContent() {
|
||||
const { filters, setFilters, clearAll } = useBlocklistUrlState();
|
||||
const key = buildBlocklistApiKey(filters);
|
||||
|
||||
const { data, error, mutate } = useSWR<BlocklistData>(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<Set<string>>(() => 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<string>();
|
||||
for (const id of prev) {
|
||||
if (serverIds.has(id)) next.add(id);
|
||||
}
|
||||
return next.size === prev.size ? prev : next;
|
||||
});
|
||||
}, [data]);
|
||||
|
||||
const visibleEntries = useMemo<BlockedReleaseRow[]>(() => {
|
||||
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 (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 sm:py-8">
|
||||
<BlocklistToolbar total={total} onCleared={handleBulkCleared} />
|
||||
<BlocklistFilters />
|
||||
<BlocklistActiveFilterChips />
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
<h3 className="text-sm font-medium text-red-800 dark:text-red-200">
|
||||
Error Loading Blocklist
|
||||
</h3>
|
||||
<p className="text-sm text-red-700 dark:text-red-300 mt-1">
|
||||
{error?.message || 'Failed to load blocklist'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showSkeleton ? (
|
||||
<BlocklistSkeleton />
|
||||
) : emptyKind ? (
|
||||
<EmptyState
|
||||
kind={emptyKind}
|
||||
onClearFilters={clearAll}
|
||||
onClearSearch={() => setFilters({ search: '' })}
|
||||
searchValue={filters.search}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<BlocklistTable
|
||||
entries={visibleEntries}
|
||||
onUnblocked={handleUnblocked}
|
||||
onUnblockFailed={handleUnblockFailed}
|
||||
/>
|
||||
<BlocklistPagination
|
||||
pagination={pagination}
|
||||
onPageChange={(page) => setFilters({ page })}
|
||||
onLimitChange={(limit: ValidLimit) => setFilters({ limit })}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AdminBlocklistPage() {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<ToastProvider>
|
||||
<AdminBlocklistContent />
|
||||
</ToastProvider>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -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<BlockSourceFilter, string> = {
|
||||
all: 'All sources',
|
||||
organize_fail: 'Organize failure',
|
||||
download_fail: 'Download failure',
|
||||
manual: 'Manual',
|
||||
};
|
||||
|
||||
export const SOURCE_BADGE_LABEL: Record<string, string> = {
|
||||
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';
|
||||
}
|
||||
@@ -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 <button> with explicit chevron, no
|
||||
* surprise expansion (per zach.md UX rule on intentional affordances).
|
||||
*
|
||||
* Fetches the per-request blocklist on first expand only (lazy) — closing
|
||||
* collapses the panel without re-fetch. Each "Unblock" inside the panel hits
|
||||
* the same DELETE endpoint as the admin blocklist page.
|
||||
*
|
||||
* Displayed release names are rendered verbatim — chips/badges add context,
|
||||
* they don't replace (per zach.md "displayed source data stays true to source").
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useToast } from '@/components/ui/Toast';
|
||||
import { fetchWithAuth, authenticatedFetcher } from '@/lib/utils/api';
|
||||
import useSWR from 'swr';
|
||||
import { SOURCE_BADGE_LABEL } from '@/app/admin/blocklist/types';
|
||||
import type { BlockedReleaseRow } from '@/app/admin/blocklist/types';
|
||||
|
||||
interface BlockedReleasesChipProps {
|
||||
requestId: string;
|
||||
blockedCount: number;
|
||||
/** Called after a successful unblock so the parent table can refresh. */
|
||||
onChange: () => void;
|
||||
}
|
||||
|
||||
interface ByRequestResponse {
|
||||
entries: BlockedReleaseRow[];
|
||||
count: number;
|
||||
}
|
||||
|
||||
export function BlockedReleasesChip({ requestId, blockedCount, onChange }: BlockedReleasesChipProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const buttonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const popoverRef = useRef<HTMLDivElement | null>(null);
|
||||
const [position, setPosition] = useState<{ top: number; left: number } | null>(null);
|
||||
|
||||
const swrKey = isOpen ? `/api/admin/blocklist/by-request/${requestId}` : null;
|
||||
const { data, error, mutate, isLoading } = useSWR<ByRequestResponse>(swrKey, authenticatedFetcher);
|
||||
|
||||
// Recompute popover anchor when opening or on window resize/scroll.
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const recompute = () => {
|
||||
const el = buttonRef.current;
|
||||
if (!el) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
setPosition({
|
||||
top: rect.bottom + 6,
|
||||
left: rect.left,
|
||||
});
|
||||
};
|
||||
recompute();
|
||||
window.addEventListener('resize', recompute);
|
||||
window.addEventListener('scroll', recompute, true);
|
||||
return () => {
|
||||
window.removeEventListener('resize', recompute);
|
||||
window.removeEventListener('scroll', recompute, true);
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
// Close on outside click or Escape.
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
const target = e.target as Node;
|
||||
if (
|
||||
popoverRef.current?.contains(target) ||
|
||||
buttonRef.current?.contains(target)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setIsOpen(false);
|
||||
};
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setIsOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', handleClick);
|
||||
document.addEventListener('keydown', handleKey);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClick);
|
||||
document.removeEventListener('keydown', handleKey);
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
if (blockedCount <= 0) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
onClick={() => setIsOpen((v) => !v)}
|
||||
aria-expanded={isOpen}
|
||||
aria-label={`${blockedCount} ${blockedCount === 1 ? 'release' : 'releases'} blocked — show details`}
|
||||
title={`${blockedCount} ${blockedCount === 1 ? 'release' : 'releases'} blocked for this request`}
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-200 hover:bg-amber-200 dark:hover:bg-amber-900/60 transition-colors min-h-[24px]"
|
||||
>
|
||||
<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>{blockedCount} {blockedCount === 1 ? 'release' : 'releases'} blocked</span>
|
||||
<svg
|
||||
className={`w-3 h-3 transition-transform duration-200 ${isOpen ? 'rotate-180' : ''}`}
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path fillRule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{isOpen && position && typeof window !== 'undefined' && createPortal(
|
||||
<div
|
||||
ref={popoverRef}
|
||||
role="dialog"
|
||||
aria-label="Blocked releases"
|
||||
style={{ top: position.top, left: position.left }}
|
||||
className="fixed z-50 w-80 max-w-[calc(100vw-2rem)] max-h-[60vh] overflow-y-auto bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-xl"
|
||||
>
|
||||
<div className="px-3 py-2 border-b border-gray-200 dark:border-gray-700 sticky top-0 bg-white dark:bg-gray-800 flex items-center justify-between">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
Blocked for this request
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(false)}
|
||||
aria-label="Close"
|
||||
className="p-1 -mr-1 rounded text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-3">
|
||||
{isLoading && (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Loading…</p>
|
||||
)}
|
||||
{error && (
|
||||
<p className="text-sm text-red-600 dark:text-red-400">Failed to load blocked releases.</p>
|
||||
)}
|
||||
{data && data.entries.length === 0 && (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">No blocked releases.</p>
|
||||
)}
|
||||
{data && data.entries.length > 0 && (
|
||||
<ul className="space-y-3">
|
||||
{data.entries.map((entry) => (
|
||||
<BlockedEntryItem
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
onRemoved={() => {
|
||||
mutate();
|
||||
onChange();
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
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 (
|
||||
<li className="border border-gray-100 dark:border-gray-700/60 rounded-md p-2.5">
|
||||
<p
|
||||
className="text-sm text-gray-900 dark:text-gray-100 break-words"
|
||||
title={entry.releaseName}
|
||||
>
|
||||
{entry.releaseName}
|
||||
</p>
|
||||
<div className="flex items-center gap-1.5 mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-semibold uppercase tracking-wide bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-200">
|
||||
{sourceLabel}
|
||||
</span>
|
||||
<span className="truncate" title={entry.reason}>{entry.reason}</span>
|
||||
</div>
|
||||
<div className="mt-2 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleUnblock}
|
||||
disabled={isUnblocking}
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1 text-xs font-medium text-blue-700 dark:text-blue-300 bg-blue-50 dark:bg-blue-900/30 hover:bg-blue-100 dark:hover:bg-blue-900/50 rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isUnblocking ? 'Unblocking…' : 'Unblock'}
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
</span>
|
||||
)}
|
||||
{(request.blockedCount ?? 0) > 0 && (
|
||||
<BlockedReleasesChip
|
||||
requestId={request.requestId}
|
||||
blockedCount={request.blockedCount ?? 0}
|
||||
onChange={() => mutate(apiUrl)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{request.author}
|
||||
|
||||
+25
-1
@@ -649,7 +649,7 @@ function AdminDashboardContent() {
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4 mb-8">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-8">
|
||||
<Link
|
||||
href="/admin/settings"
|
||||
className="block p-6 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg hover:shadow-md transition-all"
|
||||
@@ -735,6 +735,30 @@ function AdminDashboardContent() {
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/admin/blocklist"
|
||||
className="block p-6 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg hover:shadow-md transition-all"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<svg
|
||||
className="w-6 h-6 text-gray-600 dark:text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<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 className="font-medium text-gray-900 dark:text-gray-100">
|
||||
Blocklist
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<button
|
||||
onClick={() => setIsBulkImportOpen(true)}
|
||||
className="block p-6 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg hover:shadow-md transition-all text-left"
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Component: Admin Blocklist — Single Unblock
|
||||
* Documentation: documentation/admin-features/release-blocklist.md
|
||||
*
|
||||
* DELETE /api/admin/blocklist/[id] → removes a single blocklist entry.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { Prisma } from '@/generated/prisma';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import { removeBlock } from '@/lib/services/blocklist.service';
|
||||
|
||||
const logger = RMABLogger.create('API.Admin.Blocklist.Unblock');
|
||||
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
return requireAdmin(req, async () => {
|
||||
const { id } = await params;
|
||||
if (!id || typeof id !== 'string' || id.trim().length === 0) {
|
||||
return NextResponse.json({ error: 'Invalid id' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
await removeBlock(id);
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
error.code === 'P2025'
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: 'NotFound', message: 'Blocklist entry not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
logger.error('Failed to remove blocklist entry', {
|
||||
id,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to remove blocklist entry' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Component: Admin Blocklist — Per-Request Lookup
|
||||
* Documentation: documentation/admin-features/release-blocklist.md
|
||||
*
|
||||
* GET /api/admin/blocklist/by-request/[requestId]
|
||||
* → { entries: BlockedRelease[], count: number }
|
||||
*
|
||||
* Lightweight, unpaginated lookup used by:
|
||||
* - The "N releases blocked" chip on the admin recent-requests table.
|
||||
* - The InteractiveTorrentSearchModal "already blocked" badge.
|
||||
*
|
||||
* Per-request blocklists are bounded by indexer candidate count (~tens),
|
||||
* so no pagination is needed.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import { getBlocklistForRequest } from '@/lib/services/blocklist.service';
|
||||
|
||||
const logger = RMABLogger.create('API.Admin.Blocklist.ByRequest');
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ requestId: string }> }
|
||||
) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
return requireAdmin(req, async () => {
|
||||
const { requestId } = await params;
|
||||
if (!requestId || typeof requestId !== 'string' || requestId.trim().length === 0) {
|
||||
return NextResponse.json({ error: 'Invalid requestId' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const entries = await getBlocklistForRequest(requestId);
|
||||
return NextResponse.json({ entries, count: entries.length });
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch blocklist for request', {
|
||||
requestId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch blocklist for request' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* Component: Admin Blocklist API (list + filter-scoped bulk clear)
|
||||
* Documentation: documentation/admin-features/release-blocklist.md
|
||||
*
|
||||
* GET /api/admin/blocklist → paginated, filtered, sorted list
|
||||
* DELETE /api/admin/blocklist?…filters → filter-scoped bulk clear ("Clear filtered (N)")
|
||||
*
|
||||
* `buildBlocklistWhere` is exported as a pure function for the route tests AND
|
||||
* for the DELETE handler to share with GET — the bulk clear MUST scope to the
|
||||
* exact same rows the user is currently viewing.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { Prisma } from '@/generated/prisma';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import { clearBlocklist } from '@/lib/services/blocklist.service';
|
||||
|
||||
const logger = RMABLogger.create('API.Admin.Blocklist');
|
||||
|
||||
const VALID_LIMITS = [25, 50, 100] as const;
|
||||
const DEFAULT_LIMIT = 50;
|
||||
const VALID_SOURCES = ['organize_fail', 'download_fail', 'manual'] as const;
|
||||
const VALID_SORT_FIELDS = ['createdAt', 'releaseName', 'reason'] as const;
|
||||
const VALID_SORT_ORDERS = ['asc', 'desc'] as const;
|
||||
|
||||
export interface BlocklistWhereParams {
|
||||
requestId?: string | null;
|
||||
source?: string | null;
|
||||
search?: string | null;
|
||||
dateFrom?: string | null;
|
||||
dateTo?: string | null;
|
||||
}
|
||||
|
||||
function parseLimit(raw: string | null): number {
|
||||
const n = Number(raw);
|
||||
return (VALID_LIMITS as readonly number[]).includes(n) ? n : DEFAULT_LIMIT;
|
||||
}
|
||||
|
||||
function parsePage(raw: string | null): number {
|
||||
const n = parseInt(raw ?? '1', 10);
|
||||
return Number.isFinite(n) && n >= 1 ? n : 1;
|
||||
}
|
||||
|
||||
function parseDate(raw: string | null | undefined): Date | null {
|
||||
if (!raw) return null;
|
||||
const d = new Date(raw);
|
||||
return Number.isNaN(d.getTime()) ? null : d;
|
||||
}
|
||||
|
||||
function trim(raw: string | null | undefined): string | null {
|
||||
if (!raw) return null;
|
||||
const t = raw.trim();
|
||||
return t.length > 0 ? t : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the Prisma where clause for blocklist queries.
|
||||
* Pure function — same input always yields same output. Exported for tests AND
|
||||
* for the DELETE handler so bulk-clear filter scope matches GET exactly.
|
||||
*/
|
||||
export function buildBlocklistWhere(
|
||||
params: BlocklistWhereParams
|
||||
): Prisma.BlockedReleaseWhereInput {
|
||||
const where: Prisma.BlockedReleaseWhereInput = {};
|
||||
|
||||
const requestId = trim(params.requestId);
|
||||
if (requestId) {
|
||||
where.requestId = requestId;
|
||||
}
|
||||
|
||||
const source = trim(params.source);
|
||||
if (source && source !== 'all' && (VALID_SOURCES as readonly string[]).includes(source)) {
|
||||
where.source = source;
|
||||
}
|
||||
|
||||
const from = parseDate(params.dateFrom);
|
||||
const to = parseDate(params.dateTo);
|
||||
if (from || to) {
|
||||
where.createdAt = {
|
||||
...(from ? { gte: from } : {}),
|
||||
...(to ? { lte: to } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
const search = trim(params.search);
|
||||
if (search) {
|
||||
where.OR = [
|
||||
{ releaseName: { contains: search, mode: 'insensitive' } },
|
||||
{ reason: { contains: search, mode: 'insensitive' } },
|
||||
];
|
||||
}
|
||||
|
||||
return where;
|
||||
}
|
||||
|
||||
function whereFromSearchParams(searchParams: URLSearchParams): Prisma.BlockedReleaseWhereInput {
|
||||
return buildBlocklistWhere({
|
||||
requestId: searchParams.get('requestId'),
|
||||
source: searchParams.get('source'),
|
||||
search: searchParams.get('search'),
|
||||
dateFrom: searchParams.get('dateFrom'),
|
||||
dateTo: searchParams.get('dateTo'),
|
||||
});
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
return requireAdmin(req, async () => {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const page = parsePage(searchParams.get('page'));
|
||||
const limit = parseLimit(searchParams.get('limit'));
|
||||
|
||||
const sortByRaw = searchParams.get('sortBy') ?? 'createdAt';
|
||||
const sortBy = (VALID_SORT_FIELDS as readonly string[]).includes(sortByRaw)
|
||||
? (sortByRaw as (typeof VALID_SORT_FIELDS)[number])
|
||||
: 'createdAt';
|
||||
const sortOrderRaw = searchParams.get('sortOrder') ?? 'desc';
|
||||
const sortOrder = (VALID_SORT_ORDERS as readonly string[]).includes(sortOrderRaw)
|
||||
? (sortOrderRaw as (typeof VALID_SORT_ORDERS)[number])
|
||||
: 'desc';
|
||||
|
||||
const where = whereFromSearchParams(searchParams);
|
||||
|
||||
const orderBy: Prisma.BlockedReleaseOrderByWithRelationInput = { [sortBy]: sortOrder };
|
||||
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const [entries, totalCount] = await Promise.all([
|
||||
prisma.blockedRelease.findMany({
|
||||
where,
|
||||
select: {
|
||||
id: true,
|
||||
requestId: true,
|
||||
releaseName: true,
|
||||
releaseHash: true,
|
||||
indexerName: true,
|
||||
indexerId: true,
|
||||
source: true,
|
||||
reason: true,
|
||||
reasonDetail: true,
|
||||
downloadHistoryId: true,
|
||||
jobId: true,
|
||||
createdAt: true,
|
||||
request: {
|
||||
select: {
|
||||
id: true,
|
||||
deletedAt: true,
|
||||
audiobook: { select: { title: true, author: true } },
|
||||
user: { select: { plexUsername: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy,
|
||||
skip,
|
||||
take: limit,
|
||||
}),
|
||||
prisma.blockedRelease.count({ where }),
|
||||
]);
|
||||
|
||||
return NextResponse.json({
|
||||
entries,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total: totalCount,
|
||||
totalPages: Math.max(1, Math.ceil(totalCount / limit)),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch blocklist', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch blocklist' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/admin/blocklist?<same filter params as GET>
|
||||
*
|
||||
* Filter-scoped bulk clear. The "Clear filtered (N)" admin UI hits this with
|
||||
* the exact same query string used for the current GET. Returns the count of
|
||||
* rows actually deleted. Empty filters intentionally allowed — the UI gates
|
||||
* with a typed-token confirmation modal; the server's job is enforcing the
|
||||
* auth + admin boundary.
|
||||
*/
|
||||
export async function DELETE(request: NextRequest) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
return requireAdmin(req, async () => {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const where = whereFromSearchParams(searchParams);
|
||||
const result = await clearBlocklist(where);
|
||||
return NextResponse.json({ count: result.count });
|
||||
} catch (error) {
|
||||
logger.error('Failed to clear blocklist', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to clear blocklist' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -119,6 +119,11 @@ export async function GET(request: NextRequest) {
|
||||
},
|
||||
take: 1,
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
blockedReleases: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy,
|
||||
skip: (page - 1) * pageSize,
|
||||
@@ -141,6 +146,7 @@ export async function GET(request: NextRequest) {
|
||||
torrentUrl: request.downloadHistory[0]?.torrentUrl || null,
|
||||
downloadAttempts: request.downloadAttempts,
|
||||
customSearchTerms: request.customSearchTerms || null,
|
||||
blockedCount: request._count?.blockedReleases ?? 0,
|
||||
}));
|
||||
|
||||
return NextResponse.json({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -11,6 +11,25 @@ import { getConfigService } from '../services/config.service';
|
||||
import { getDownloadClientManager } from '../services/download-client-manager.service';
|
||||
import { CLIENT_PROTOCOL_MAP, DownloadClientType } from '../interfaces/download-client.interface';
|
||||
import { isTransientConnectionError } from '../utils/connection-errors';
|
||||
import { addAutoBlock } from '../services/blocklist.service';
|
||||
|
||||
/**
|
||||
* Map a download client's error signal to a coarse, human-readable reason
|
||||
* for the blocklist row. Substring matching on the unified `errorMessage` is
|
||||
* intentional and acceptable per locked engineering decisions #5 and #6 —
|
||||
* the raw client string is preserved in `reasonDetail`.
|
||||
*/
|
||||
function classifyDownloadFailure(errorMessage: string | null | undefined): string {
|
||||
const text = (errorMessage ?? '').toLowerCase();
|
||||
if (!text) return 'Download failed';
|
||||
if (text.includes('par') || text.includes('repair')) return 'Download failed (par2)';
|
||||
if (text.includes('missing articles') || text.includes('failed articles')) return 'Download failed (missing articles)';
|
||||
if (text.includes('unpack')) return 'Download failed (unpack)';
|
||||
if (text.includes('password') || text.includes('encrypted')) return 'Download failed (password)';
|
||||
if (text.includes('missingfiles') || text.includes('missing files')) return 'Download failed (missing files)';
|
||||
if (text.includes('torrent') && text.includes('error')) return 'Download failed (torrent error)';
|
||||
return 'Download failed';
|
||||
}
|
||||
|
||||
/**
|
||||
* Process monitor download job
|
||||
@@ -197,6 +216,7 @@ export async function processMonitorDownload(payload: MonitorDownloadPayload): P
|
||||
logger.error(`Download failed for request ${requestId}`);
|
||||
|
||||
const errorMessage = `Download failed in ${client.clientType}`;
|
||||
const clientErrorDetail = info.errorMessage ?? null;
|
||||
|
||||
// Update request to failed
|
||||
await prisma.request.update({
|
||||
@@ -217,6 +237,27 @@ export async function processMonitorDownload(payload: MonitorDownloadPayload): P
|
||||
},
|
||||
});
|
||||
|
||||
// Auto-block this release. The client itself reported `failed`, so this
|
||||
// is a real release problem — distinct from the connection-exhausted
|
||||
// path below at line ~317 which we deliberately do NOT block on.
|
||||
const failedDownload = await prisma.downloadHistory.findUnique({
|
||||
where: { id: downloadHistoryId },
|
||||
});
|
||||
if (failedDownload?.torrentName) {
|
||||
await addAutoBlock({
|
||||
requestId,
|
||||
releaseName: failedDownload.torrentName,
|
||||
releaseHash: failedDownload.torrentHash ?? failedDownload.nzbId ?? null,
|
||||
indexerName: failedDownload.indexerName ?? null,
|
||||
indexerId: failedDownload.indexerId ?? null,
|
||||
source: 'download_fail',
|
||||
reason: classifyDownloadFailure(clientErrorDetail),
|
||||
reasonDetail: clientErrorDetail,
|
||||
downloadHistoryId: failedDownload.id,
|
||||
jobId,
|
||||
});
|
||||
}
|
||||
|
||||
// Send notification for request failure
|
||||
const request = await prisma.request.findUnique({
|
||||
where: { id: requestId },
|
||||
|
||||
@@ -9,6 +9,8 @@ import { prisma } from '../db';
|
||||
import { RMABLogger } from '../utils/logger';
|
||||
import { getJobQueueService } from '../services/job-queue.service';
|
||||
import { shouldSkipAutoSearch } from '../utils/release-date';
|
||||
import { getBlocklistForRequest } from '../services/blocklist.service';
|
||||
import { normalizeReleaseKey } from '../utils/release-key';
|
||||
|
||||
export interface MonitorRssFeedsPayload {
|
||||
jobId?: string;
|
||||
@@ -89,6 +91,13 @@ export async function processMonitorRssFeeds(payload: MonitorRssFeedsPayload): P
|
||||
const authorWords = audiobook.author.toLowerCase().split(' ');
|
||||
const titleWords = audiobook.title.toLowerCase().split(' ').slice(0, 3);
|
||||
|
||||
// Hoist blocklist lookup outside the per-torrent loop: one query per request.
|
||||
const blocklist = await getBlocklistForRequest(request.id);
|
||||
const blockedKeys = new Set(blocklist.map(b => b.releaseKey));
|
||||
const blockedHashes = new Set(
|
||||
blocklist.filter(b => b.releaseHash).map(b => b.releaseHash as string)
|
||||
);
|
||||
|
||||
for (const torrent of rssResults) {
|
||||
const torrentTitle = torrent.title.toLowerCase();
|
||||
|
||||
@@ -97,6 +106,18 @@ export async function processMonitorRssFeeds(payload: MonitorRssFeedsPayload): P
|
||||
const titleMatchCount = titleWords.filter(word => word.length > 2 && torrentTitle.includes(word)).length;
|
||||
|
||||
if (hasAuthor && titleMatchCount >= 2) {
|
||||
// Blocklist guard: skip RSS-driven auto-grab of a release that was already
|
||||
// blocked for this request. Otherwise a previously bad release re-enters
|
||||
// the pipeline via RSS and defeats the blocklist's purpose.
|
||||
const torrentInfoHash = (torrent as { infoHash?: string }).infoHash;
|
||||
if (
|
||||
blockedKeys.has(normalizeReleaseKey(torrent.title)) ||
|
||||
(torrentInfoHash && blockedHashes.has(torrentInfoHash))
|
||||
) {
|
||||
logger.debug(`Skipped blocklisted RSS match for request ${request.id}: ${torrent.title}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
logger.info(`Match found! "${audiobook.title}" by ${audiobook.author} matches torrent: ${torrent.title}`);
|
||||
|
||||
// Release-date gate: skip RSS-driven auto-search for unreleased books.
|
||||
|
||||
@@ -16,6 +16,7 @@ import { generateFilesHash } from '../utils/files-hash';
|
||||
import { fixEpubForKindle, cleanupFixedEpub } from '../utils/epub-fixer';
|
||||
import { removeEmptyParentDirectories } from '../utils/cleanup-helpers';
|
||||
import { getAudibleService } from '../integrations/audible.service';
|
||||
import { addAutoBlock } from '../services/blocklist.service';
|
||||
|
||||
/**
|
||||
* Process organize files job
|
||||
@@ -462,6 +463,34 @@ export async function processOrganizeFiles(payload: OrganizeFilesPayload): Promi
|
||||
},
|
||||
});
|
||||
|
||||
// Auto-block the selected release on this request's blocklist so the
|
||||
// next search won't pick it again. Terminal warn only — never on retry.
|
||||
// Substring matching on errorMessage is intentional: the throw strings
|
||||
// live in file-organizer.ts and are stable today.
|
||||
const selectedDownload = await prisma.downloadHistory.findFirst({
|
||||
where: { requestId, selected: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
if (selectedDownload?.torrentName) {
|
||||
const blockReason = errorMessage.includes('No audiobook files found')
|
||||
? 'No audiobook files found'
|
||||
: errorMessage.includes('No ebook files found')
|
||||
? 'No ebook files found'
|
||||
: 'Organize failed';
|
||||
await addAutoBlock({
|
||||
requestId,
|
||||
releaseName: selectedDownload.torrentName,
|
||||
releaseHash: selectedDownload.torrentHash ?? selectedDownload.nzbId ?? null,
|
||||
indexerName: selectedDownload.indexerName ?? null,
|
||||
indexerId: selectedDownload.indexerId ?? null,
|
||||
source: 'organize_fail',
|
||||
reason: blockReason,
|
||||
reasonDetail: errorMessage,
|
||||
downloadHistoryId: selectedDownload.id,
|
||||
jobId,
|
||||
});
|
||||
}
|
||||
|
||||
// Send notification for request failure
|
||||
const request = await prisma.request.findUnique({
|
||||
where: { id: requestId },
|
||||
|
||||
@@ -15,6 +15,7 @@ import { getProwlarrService } from '../integrations/prowlarr.service';
|
||||
import { rankEbookTorrents, RankedEbookTorrent } from '../utils/ranking-algorithm';
|
||||
import { groupIndexersByCategories, getGroupDescription } from '../utils/indexer-grouping';
|
||||
import { getLanguageForRegion } from '../constants/language-config';
|
||||
import { filterBlockedResults } from '../utils/filter-blocked-results';
|
||||
import type { AudibleRegion } from '../types/audible';
|
||||
|
||||
// Import ebook scraper functions for Anna's Archive
|
||||
@@ -310,9 +311,20 @@ async function searchIndexers(
|
||||
return null;
|
||||
}
|
||||
|
||||
// Strip blocklisted releases before ranking.
|
||||
const { kept: nonBlockedResults, blockedCount } = await filterBlockedResults(requestId, allResults);
|
||||
if (blockedCount > 0) {
|
||||
logger.debug(`Filtered out ${blockedCount} blocklisted release(s) before ranking`);
|
||||
}
|
||||
|
||||
if (nonBlockedResults.length === 0) {
|
||||
logger.warn(`All ${allResults.length} ebook candidates were blocklisted`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Log filter info (ebooks > 20MB will be filtered)
|
||||
const preFilterCount = allResults.length;
|
||||
const aboveThreshold = allResults.filter(r => (r.size / (1024 * 1024)) > 20);
|
||||
const preFilterCount = nonBlockedResults.length;
|
||||
const aboveThreshold = nonBlockedResults.filter(r => (r.size / (1024 * 1024)) > 20);
|
||||
if (aboveThreshold.length > 0) {
|
||||
logger.info(`Will filter ${aboveThreshold.length} results > 20 MB (too large for ebooks)`);
|
||||
}
|
||||
@@ -323,7 +335,7 @@ async function searchIndexers(
|
||||
|
||||
// Rank results with ebook-specific scoring
|
||||
// This filters out > 20MB and uses inverted size scoring
|
||||
const rankedResults = rankEbookTorrents(allResults, {
|
||||
const rankedResults = rankEbookTorrents(nonBlockedResults, {
|
||||
title: audiobook.title,
|
||||
author: audiobook.author,
|
||||
preferredFormat,
|
||||
|
||||
@@ -10,6 +10,7 @@ import { getRankingAlgorithm } from '../utils/ranking-algorithm';
|
||||
import { groupIndexersByCategories, getGroupDescription } from '../utils/indexer-grouping';
|
||||
import { RMABLogger } from '../utils/logger';
|
||||
import { getLanguageForRegion } from '../constants/language-config';
|
||||
import { filterBlockedResults } from '../utils/filter-blocked-results';
|
||||
import type { AudibleRegion } from '../types/audible';
|
||||
|
||||
/**
|
||||
@@ -113,18 +114,29 @@ export async function processSearchIndexers(payload: SearchIndexersPayload): Pro
|
||||
}
|
||||
}
|
||||
|
||||
const searchResults = allResults;
|
||||
logger.info(`Found ${searchResults.length} total results from ${groups.length} group${groups.length > 1 ? 's' : ''}`);
|
||||
const preBlocklistCount = allResults.length;
|
||||
const { kept: searchResults, blockedCount } = await filterBlockedResults(requestId, allResults);
|
||||
if (blockedCount > 0) {
|
||||
logger.debug(`Filtered out ${blockedCount} blocklisted release(s) before ranking`);
|
||||
}
|
||||
logger.info(`Found ${searchResults.length} total results from ${groups.length} group${groups.length > 1 ? 's' : ''}${blockedCount > 0 ? ` (${blockedCount} blocked)` : ''}`);
|
||||
|
||||
if (searchResults.length === 0) {
|
||||
// No results found - queue for re-search instead of failing
|
||||
logger.warn(`No torrents/nzbs found for request ${requestId}, marking as awaiting_search`);
|
||||
// No usable results — either Prowlarr returned nothing, or the blocklist
|
||||
// removed everything it returned. Surface a blocklist-specific message in
|
||||
// the latter case so admins know to unblock (or accept it as terminal).
|
||||
const allBlocked = blockedCount > 0 && preBlocklistCount > 0;
|
||||
const errorMessage = allBlocked
|
||||
? `No usable releases — ${preBlocklistCount} candidates tried, all blocked`
|
||||
: 'No torrents/nzbs found. Will retry automatically.';
|
||||
|
||||
logger.warn(`${errorMessage} for request ${requestId}, marking as awaiting_search`);
|
||||
|
||||
await prisma.request.update({
|
||||
where: { id: requestId },
|
||||
data: {
|
||||
status: 'awaiting_search',
|
||||
errorMessage: 'No torrents/nzbs found. Will retry automatically.',
|
||||
errorMessage,
|
||||
lastSearchAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
@@ -132,7 +144,7 @@ export async function processSearchIndexers(payload: SearchIndexersPayload): Pro
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: 'No torrents/nzbs found, queued for re-search',
|
||||
message: allBlocked ? errorMessage : 'No torrents/nzbs found, queued for re-search',
|
||||
requestId,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* Component: Release Blocklist Service
|
||||
* Documentation: documentation/backend/database.md
|
||||
*
|
||||
* Single writer for the BlockedRelease table. Search processors call into this
|
||||
* service when a download or organize-files step fails permanently so the next
|
||||
* search for that request skips the same release.
|
||||
*
|
||||
* Invariant: addAutoBlock NEVER throws. A failed blocklist write must not break
|
||||
* the originating processor or prevent the request from transitioning to `warn`.
|
||||
*/
|
||||
|
||||
import { prisma } from '@/lib/db';
|
||||
import { Prisma, type BlockedRelease } from '@/generated/prisma';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import { normalizeReleaseKey } from '@/lib/utils/release-key';
|
||||
|
||||
const logger = RMABLogger.create('Blocklist');
|
||||
|
||||
export type BlockSource = 'organize_fail' | 'download_fail';
|
||||
|
||||
export interface AddAutoBlockInput {
|
||||
requestId: string;
|
||||
releaseName: string;
|
||||
source: BlockSource;
|
||||
/** Short, human-readable. E.g. "No audiobook files found", "Download failed (par2)" */
|
||||
reason: string;
|
||||
/** torrentHash (qBit) OR nzbId (SAB / NZBGet). Mutually exclusive in source. */
|
||||
releaseHash?: string | null;
|
||||
indexerName?: string | null;
|
||||
indexerId?: number | null;
|
||||
/** Raw client error string (SAB failMessage, NZBGet Par/Unpack code, etc.) */
|
||||
reasonDetail?: string | null;
|
||||
/** Links to the specific DownloadHistory row that drove this block. */
|
||||
downloadHistoryId?: string | null;
|
||||
/** When provided, a JobEvent log entry is emitted via RMABLogger.forJob. */
|
||||
jobId?: string | null;
|
||||
}
|
||||
|
||||
export interface AddAutoBlockResult {
|
||||
blocked: BlockedRelease | null;
|
||||
wasNew: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Idempotently record a blocked release for a request.
|
||||
*
|
||||
* Behavior:
|
||||
* - Upserts on the unique `(requestId, releaseKey)` index so concurrent writes
|
||||
* converge on a single row (first writer wins on metadata; subsequent calls
|
||||
* are a no-op update).
|
||||
* - Emits a JobEvent log line (context `Blocklist.AutoBlock`) when `jobId` is
|
||||
* provided. The logger persists it to `job_events` automatically.
|
||||
* - NEVER throws. On DB failure, logs the error and returns
|
||||
* `{ blocked: null, wasNew: false }` so the caller's lifecycle continues.
|
||||
*/
|
||||
export async function addAutoBlock(
|
||||
input: AddAutoBlockInput
|
||||
): Promise<AddAutoBlockResult> {
|
||||
const releaseKey = normalizeReleaseKey(input.releaseName);
|
||||
const before = new Date();
|
||||
|
||||
try {
|
||||
const blocked = await prisma.blockedRelease.upsert({
|
||||
where: { requestId_releaseKey: { requestId: input.requestId, releaseKey } },
|
||||
create: {
|
||||
requestId: input.requestId,
|
||||
releaseName: input.releaseName,
|
||||
releaseKey,
|
||||
releaseHash: input.releaseHash ?? null,
|
||||
indexerName: input.indexerName ?? null,
|
||||
indexerId: input.indexerId ?? null,
|
||||
source: input.source,
|
||||
reason: input.reason,
|
||||
reasonDetail: input.reasonDetail ?? null,
|
||||
downloadHistoryId: input.downloadHistoryId ?? null,
|
||||
jobId: input.jobId ?? null,
|
||||
},
|
||||
update: {},
|
||||
});
|
||||
|
||||
const wasNew = blocked.createdAt >= before;
|
||||
|
||||
if (input.jobId) {
|
||||
RMABLogger.forJob(input.jobId, 'Blocklist.AutoBlock').info(
|
||||
wasNew
|
||||
? `Blocked release: ${input.releaseName}`
|
||||
: `Release already blocked: ${input.releaseName}`,
|
||||
{
|
||||
requestId: input.requestId,
|
||||
source: input.source,
|
||||
reason: input.reason,
|
||||
releaseHash: input.releaseHash ?? undefined,
|
||||
indexerName: input.indexerName ?? undefined,
|
||||
downloadHistoryId: input.downloadHistoryId ?? undefined,
|
||||
wasNew,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return { blocked, wasNew };
|
||||
} catch (error) {
|
||||
logger.error('Failed to record blocked release', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
requestId: input.requestId,
|
||||
releaseName: input.releaseName,
|
||||
source: input.source,
|
||||
});
|
||||
return { blocked: null, wasNew: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a release should be filtered out for a given request.
|
||||
* Matches on normalized name OR on hash (when both sides have one).
|
||||
*/
|
||||
export async function isReleaseBlocked(
|
||||
requestId: string,
|
||||
releaseName: string,
|
||||
releaseHash?: string | null
|
||||
): Promise<boolean> {
|
||||
const releaseKey = normalizeReleaseKey(releaseName);
|
||||
|
||||
const orClauses: Prisma.BlockedReleaseWhereInput[] = [{ releaseKey }];
|
||||
if (releaseHash) {
|
||||
orClauses.push({ releaseHash });
|
||||
}
|
||||
|
||||
const hit = await prisma.blockedRelease.findFirst({
|
||||
where: { requestId, OR: orClauses },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
return hit !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return every blocklist entry for a request, newest first.
|
||||
* Used by the request-detail admin chip (Phase 5).
|
||||
*/
|
||||
export async function getBlocklistForRequest(
|
||||
requestId: string
|
||||
): Promise<BlockedRelease[]> {
|
||||
return prisma.blockedRelease.findMany({
|
||||
where: { requestId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a single blocklist entry by id. Used by the admin "Unblock" action.
|
||||
*/
|
||||
export async function removeBlock(id: string): Promise<void> {
|
||||
await prisma.blockedRelease.delete({ where: { id } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk delete blocklist entries matching the provided where clause. The admin
|
||||
* "Clear filtered (N)" action passes the same where clause used by the listing
|
||||
* query so the operation is filter-scoped, never a global wipe.
|
||||
*/
|
||||
export async function clearBlocklist(
|
||||
where: Prisma.BlockedReleaseWhereInput
|
||||
): Promise<{ count: number }> {
|
||||
const result = await prisma.blockedRelease.deleteMany({ where });
|
||||
return { count: result.count };
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Component: Blocked Results Filter
|
||||
* Documentation: documentation/backend/database.md
|
||||
*
|
||||
* Pre-rank filter applied by every automatic search path (audiobook, ebook, RSS)
|
||||
* to remove releases already on a request's blocklist. Matches case-insensitive
|
||||
* on release name and exact on hash (when both sides have one).
|
||||
*
|
||||
* Interactive admin search does NOT call this — admins see all results and the
|
||||
* UI surfaces a blocked badge instead.
|
||||
*/
|
||||
|
||||
import { getBlocklistForRequest } from '@/lib/services/blocklist.service';
|
||||
import { normalizeReleaseKey } from '@/lib/utils/release-key';
|
||||
|
||||
export interface FilterableResult {
|
||||
title: string;
|
||||
infoHash?: string;
|
||||
}
|
||||
|
||||
export interface FilterBlockedResultsOutput<T> {
|
||||
kept: T[];
|
||||
blockedCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter out search results that match a row on the request's blocklist.
|
||||
*
|
||||
* Match rules:
|
||||
* - Name: case-insensitive exact via [[normalize-release-key]].
|
||||
* - Hash: exact, only when both the result and a blocklist row have one.
|
||||
*
|
||||
* Returns the original array unchanged when there are no results or no
|
||||
* blocklist rows — both are common hot-path cases, so we short-circuit.
|
||||
*/
|
||||
export async function filterBlockedResults<T extends FilterableResult>(
|
||||
requestId: string,
|
||||
results: T[]
|
||||
): Promise<FilterBlockedResultsOutput<T>> {
|
||||
if (results.length === 0) {
|
||||
return { kept: results, blockedCount: 0 };
|
||||
}
|
||||
|
||||
const blocklist = await getBlocklistForRequest(requestId);
|
||||
if (blocklist.length === 0) {
|
||||
return { kept: results, blockedCount: 0 };
|
||||
}
|
||||
|
||||
const keys = new Set(blocklist.map(b => b.releaseKey));
|
||||
const hashes = new Set(
|
||||
blocklist.filter(b => b.releaseHash).map(b => b.releaseHash as string)
|
||||
);
|
||||
|
||||
const kept = results.filter(r => {
|
||||
if (keys.has(normalizeReleaseKey(r.title))) return false;
|
||||
if (r.infoHash && hashes.has(r.infoHash)) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
return { kept, blockedCount: results.length - kept.length };
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Component: Release Key Normalizer
|
||||
* Documentation: documentation/backend/database.md
|
||||
*
|
||||
* Pure helper used by the blocklist service and search filters to compare
|
||||
* release names case-insensitively without per-call .toLowerCase() drift.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Normalize a release name into a stable lookup key.
|
||||
* Rule: trim outer whitespace, then lowercase.
|
||||
*
|
||||
* Used as the persisted `release_key` column AND the runtime comparison value
|
||||
* for filtering search results — both sides MUST go through this function.
|
||||
*/
|
||||
export function normalizeReleaseKey(name: string): string {
|
||||
return name.trim().toLowerCase();
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
/**
|
||||
* Component: Admin Blocklist API Route Tests
|
||||
* Documentation: documentation/testing.md
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createPrismaMock } from '../helpers/prisma';
|
||||
|
||||
let authRequest: any;
|
||||
|
||||
const prismaMock = createPrismaMock();
|
||||
const requireAuthMock = vi.hoisted(() => vi.fn());
|
||||
const requireAdminMock = vi.hoisted(() => vi.fn());
|
||||
const clearBlocklistMock = vi.hoisted(() => vi.fn());
|
||||
const removeBlockMock = vi.hoisted(() => vi.fn());
|
||||
const getBlocklistForRequestMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('@/lib/db', () => ({
|
||||
prisma: prismaMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/middleware/auth', () => ({
|
||||
requireAuth: requireAuthMock,
|
||||
requireAdmin: requireAdminMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/blocklist.service', () => ({
|
||||
clearBlocklist: clearBlocklistMock,
|
||||
removeBlock: removeBlockMock,
|
||||
getBlocklistForRequest: getBlocklistForRequestMock,
|
||||
}));
|
||||
|
||||
async function callList(query: string = '') {
|
||||
prismaMock.blockedRelease.findMany.mockResolvedValueOnce([]);
|
||||
prismaMock.blockedRelease.count.mockResolvedValueOnce(0);
|
||||
const { GET } = await import('@/app/api/admin/blocklist/route');
|
||||
const url = `http://localhost/api/admin/blocklist${query ? `?${query}` : ''}`;
|
||||
const response = await GET({ url } as any);
|
||||
const payload = await response.json();
|
||||
const findManyArgs = prismaMock.blockedRelease.findMany.mock.calls[0][0];
|
||||
const countArgs = prismaMock.blockedRelease.count.mock.calls[0][0];
|
||||
return { response, payload, findManyArgs, countArgs };
|
||||
}
|
||||
|
||||
async function callBulkDelete(query: string = '') {
|
||||
clearBlocklistMock.mockResolvedValueOnce({ count: 0 });
|
||||
const { DELETE } = await import('@/app/api/admin/blocklist/route');
|
||||
const url = `http://localhost/api/admin/blocklist${query ? `?${query}` : ''}`;
|
||||
const response = await DELETE({ url } as any);
|
||||
const payload = await response.json();
|
||||
const where = clearBlocklistMock.mock.calls[0]?.[0];
|
||||
return { response, payload, where };
|
||||
}
|
||||
|
||||
describe('Admin blocklist list route', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
authRequest = { user: { id: 'admin-1', role: 'admin' } };
|
||||
requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest));
|
||||
requireAdminMock.mockImplementation((_req: any, handler: any) => handler());
|
||||
});
|
||||
|
||||
it('returns paginated entries', async () => {
|
||||
prismaMock.blockedRelease.findMany.mockResolvedValueOnce([{ id: 'b1' }]);
|
||||
prismaMock.blockedRelease.count.mockResolvedValueOnce(1);
|
||||
|
||||
const { GET } = await import('@/app/api/admin/blocklist/route');
|
||||
const response = await GET({ url: 'http://localhost/api/admin/blocklist?page=1&limit=25' } as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.entries).toHaveLength(1);
|
||||
expect(payload.pagination.total).toBe(1);
|
||||
});
|
||||
|
||||
describe('where composition', () => {
|
||||
it('builds empty where when no filters provided', async () => {
|
||||
const { findManyArgs } = await callList();
|
||||
expect(findManyArgs.where).toEqual({});
|
||||
});
|
||||
|
||||
it('applies requestId filter', async () => {
|
||||
const { findManyArgs } = await callList('requestId=req-123');
|
||||
expect(findManyArgs.where).toEqual({ requestId: 'req-123' });
|
||||
});
|
||||
|
||||
it('applies source filter when valid', async () => {
|
||||
const { findManyArgs } = await callList('source=organize_fail');
|
||||
expect(findManyArgs.where).toEqual({ source: 'organize_fail' });
|
||||
});
|
||||
|
||||
it('drops source filter when invalid', async () => {
|
||||
const { findManyArgs } = await callList('source=bogus');
|
||||
expect(findManyArgs.where).toEqual({});
|
||||
});
|
||||
|
||||
it('drops source filter when "all"', async () => {
|
||||
const { findManyArgs } = await callList('source=all');
|
||||
expect(findManyArgs.where).toEqual({});
|
||||
});
|
||||
|
||||
it('applies dateFrom and dateTo as createdAt range', async () => {
|
||||
const { findManyArgs } = await callList(
|
||||
'dateFrom=2026-01-01T00:00:00.000Z&dateTo=2026-02-01T00:00:00.000Z'
|
||||
);
|
||||
expect(findManyArgs.where.createdAt).toEqual({
|
||||
gte: new Date('2026-01-01T00:00:00.000Z'),
|
||||
lte: new Date('2026-02-01T00:00:00.000Z'),
|
||||
});
|
||||
});
|
||||
|
||||
it('silently drops invalid date strings', async () => {
|
||||
const { findManyArgs } = await callList('dateFrom=not-a-date&dateTo=also-not-a-date');
|
||||
expect(findManyArgs.where.createdAt).toBeUndefined();
|
||||
});
|
||||
|
||||
it('applies search as case-insensitive OR over releaseName + reason', async () => {
|
||||
const { findManyArgs } = await callList('search=epub');
|
||||
expect(findManyArgs.where.OR).toEqual([
|
||||
{ releaseName: { contains: 'epub', mode: 'insensitive' } },
|
||||
{ reason: { contains: 'epub', mode: 'insensitive' } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('treats whitespace-only search as no search', async () => {
|
||||
const { findManyArgs } = await callList('search=%20%20%20');
|
||||
expect(findManyArgs.where.OR).toBeUndefined();
|
||||
});
|
||||
|
||||
it('treats whitespace-only requestId as no filter', async () => {
|
||||
const { findManyArgs } = await callList('requestId=%20');
|
||||
expect(findManyArgs.where.requestId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('combines all filters together', async () => {
|
||||
const { findManyArgs } = await callList(
|
||||
'requestId=r-1&source=download_fail&dateFrom=2026-01-01T00:00:00.000Z&dateTo=2026-02-01T00:00:00.000Z&search=par2'
|
||||
);
|
||||
const where = findManyArgs.where;
|
||||
expect(where.requestId).toBe('r-1');
|
||||
expect(where.source).toBe('download_fail');
|
||||
expect(where.createdAt.gte).toEqual(new Date('2026-01-01T00:00:00.000Z'));
|
||||
expect(where.createdAt.lte).toEqual(new Date('2026-02-01T00:00:00.000Z'));
|
||||
expect(where.OR).toEqual([
|
||||
{ releaseName: { contains: 'par2', mode: 'insensitive' } },
|
||||
{ reason: { contains: 'par2', mode: 'insensitive' } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('uses identical where for findMany and count', async () => {
|
||||
const { findManyArgs, countArgs } = await callList('source=download_fail&search=par2');
|
||||
expect(countArgs.where).toEqual(findManyArgs.where);
|
||||
});
|
||||
});
|
||||
|
||||
describe('limit clamp', () => {
|
||||
const cases: Array<[string | null, number]> = [
|
||||
['25', 25],
|
||||
['50', 50],
|
||||
['100', 100],
|
||||
['24', 50],
|
||||
['75', 50],
|
||||
['101', 50],
|
||||
['abc', 50],
|
||||
[null, 50],
|
||||
];
|
||||
|
||||
for (const [raw, expected] of cases) {
|
||||
it(`limit=${raw} → take ${expected}`, async () => {
|
||||
const query = raw === null ? '' : `limit=${raw}`;
|
||||
const { findManyArgs, payload } = await callList(query);
|
||||
expect(findManyArgs.take).toBe(expected);
|
||||
expect(payload.pagination.limit).toBe(expected);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('sort', () => {
|
||||
it('defaults to createdAt desc', async () => {
|
||||
const { findManyArgs } = await callList();
|
||||
expect(findManyArgs.orderBy).toEqual({ createdAt: 'desc' });
|
||||
});
|
||||
|
||||
it('applies sortBy=releaseName sortOrder=asc', async () => {
|
||||
const { findManyArgs } = await callList('sortBy=releaseName&sortOrder=asc');
|
||||
expect(findManyArgs.orderBy).toEqual({ releaseName: 'asc' });
|
||||
});
|
||||
|
||||
it('falls back to createdAt for unknown sortBy', async () => {
|
||||
const { findManyArgs } = await callList('sortBy=bogus');
|
||||
expect(findManyArgs.orderBy).toEqual({ createdAt: 'desc' });
|
||||
});
|
||||
|
||||
it('falls back to desc for unknown sortOrder', async () => {
|
||||
const { findManyArgs } = await callList('sortBy=reason&sortOrder=sideways');
|
||||
expect(findManyArgs.orderBy).toEqual({ reason: 'desc' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('pagination math', () => {
|
||||
it('page=2 with limit=50 and total=75 returns totalPages=2 and skip=50', async () => {
|
||||
prismaMock.blockedRelease.findMany.mockResolvedValueOnce([]);
|
||||
prismaMock.blockedRelease.count.mockResolvedValueOnce(75);
|
||||
const { GET } = await import('@/app/api/admin/blocklist/route');
|
||||
const response = await GET({
|
||||
url: 'http://localhost/api/admin/blocklist?page=2&limit=50',
|
||||
} as any);
|
||||
const payload = await response.json();
|
||||
const findManyArgs = prismaMock.blockedRelease.findMany.mock.calls[0][0];
|
||||
|
||||
expect(findManyArgs.skip).toBe(50);
|
||||
expect(findManyArgs.take).toBe(50);
|
||||
expect(payload.pagination.page).toBe(2);
|
||||
expect(payload.pagination.limit).toBe(50);
|
||||
expect(payload.pagination.total).toBe(75);
|
||||
expect(payload.pagination.totalPages).toBe(2);
|
||||
});
|
||||
|
||||
it('coerces invalid page to 1', async () => {
|
||||
const { findManyArgs, payload } = await callList('page=-3');
|
||||
expect(findManyArgs.skip).toBe(0);
|
||||
expect(payload.pagination.page).toBe(1);
|
||||
});
|
||||
|
||||
it('totalPages is at least 1 when total is 0', async () => {
|
||||
const { payload } = await callList();
|
||||
expect(payload.pagination.totalPages).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Admin blocklist bulk-clear DELETE', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
authRequest = { user: { id: 'admin-1', role: 'admin' } };
|
||||
requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest));
|
||||
requireAdminMock.mockImplementation((_req: any, handler: any) => handler());
|
||||
});
|
||||
|
||||
it('returns count from clearBlocklist', async () => {
|
||||
clearBlocklistMock.mockResolvedValueOnce({ count: 7 });
|
||||
const { DELETE } = await import('@/app/api/admin/blocklist/route');
|
||||
const response = await DELETE({ url: 'http://localhost/api/admin/blocklist' } as any);
|
||||
const payload = await response.json();
|
||||
expect(payload).toEqual({ count: 7 });
|
||||
});
|
||||
|
||||
it('passes filter-scoped where to clearBlocklist', async () => {
|
||||
const { where } = await callBulkDelete('source=organize_fail&requestId=r-1');
|
||||
expect(where).toEqual({ requestId: 'r-1', source: 'organize_fail' });
|
||||
});
|
||||
|
||||
it('passes empty where when no filters given (admin UI gates with typed token)', async () => {
|
||||
const { where } = await callBulkDelete();
|
||||
expect(where).toEqual({});
|
||||
});
|
||||
|
||||
it('returns 500 when clearBlocklist throws', async () => {
|
||||
clearBlocklistMock.mockRejectedValueOnce(new Error('db down'));
|
||||
const { DELETE } = await import('@/app/api/admin/blocklist/route');
|
||||
const response = await DELETE({ url: 'http://localhost/api/admin/blocklist' } as any);
|
||||
expect(response.status).toBe(500);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Admin blocklist single-unblock DELETE', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
authRequest = { user: { id: 'admin-1', role: 'admin' } };
|
||||
requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest));
|
||||
requireAdminMock.mockImplementation((_req: any, handler: any) => handler());
|
||||
});
|
||||
|
||||
it('calls removeBlock with the route param id', async () => {
|
||||
removeBlockMock.mockResolvedValueOnce(undefined);
|
||||
const { DELETE } = await import('@/app/api/admin/blocklist/[id]/route');
|
||||
const response = await DELETE(
|
||||
{ url: 'http://localhost/api/admin/blocklist/abc-123' } as any,
|
||||
{ params: Promise.resolve({ id: 'abc-123' }) }
|
||||
);
|
||||
expect(removeBlockMock).toHaveBeenCalledWith('abc-123');
|
||||
const payload = await response.json();
|
||||
expect(payload).toEqual({ success: true });
|
||||
});
|
||||
|
||||
it('rejects whitespace-only id with 400', async () => {
|
||||
const { DELETE } = await import('@/app/api/admin/blocklist/[id]/route');
|
||||
const response = await DELETE(
|
||||
{ url: 'http://localhost/api/admin/blocklist/' } as any,
|
||||
{ params: Promise.resolve({ id: ' ' }) }
|
||||
);
|
||||
expect(response.status).toBe(400);
|
||||
expect(removeBlockMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('maps Prisma P2025 to 404 NotFound', async () => {
|
||||
const { Prisma } = await import('@/generated/prisma');
|
||||
const notFound = new Prisma.PrismaClientKnownRequestError('not found', {
|
||||
code: 'P2025',
|
||||
clientVersion: 'test',
|
||||
});
|
||||
removeBlockMock.mockRejectedValueOnce(notFound);
|
||||
const { DELETE } = await import('@/app/api/admin/blocklist/[id]/route');
|
||||
const response = await DELETE(
|
||||
{ url: 'http://localhost/api/admin/blocklist/missing' } as any,
|
||||
{ params: Promise.resolve({ id: 'missing' }) }
|
||||
);
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
||||
it('maps unknown errors to 500', async () => {
|
||||
removeBlockMock.mockRejectedValueOnce(new Error('boom'));
|
||||
const { DELETE } = await import('@/app/api/admin/blocklist/[id]/route');
|
||||
const response = await DELETE(
|
||||
{ url: 'http://localhost/api/admin/blocklist/some-id' } as any,
|
||||
{ params: Promise.resolve({ id: 'some-id' }) }
|
||||
);
|
||||
expect(response.status).toBe(500);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Admin blocklist by-request GET', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
authRequest = { user: { id: 'admin-1', role: 'admin' } };
|
||||
requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest));
|
||||
requireAdminMock.mockImplementation((_req: any, handler: any) => handler());
|
||||
});
|
||||
|
||||
it('returns entries + count for the request', async () => {
|
||||
const rows = [
|
||||
{ id: 'b1', releaseName: 'Foo' },
|
||||
{ id: 'b2', releaseName: 'Bar' },
|
||||
];
|
||||
getBlocklistForRequestMock.mockResolvedValueOnce(rows);
|
||||
const { GET } = await import('@/app/api/admin/blocklist/by-request/[requestId]/route');
|
||||
const response = await GET(
|
||||
{ url: 'http://localhost/api/admin/blocklist/by-request/r-1' } as any,
|
||||
{ params: Promise.resolve({ requestId: 'r-1' }) }
|
||||
);
|
||||
const payload = await response.json();
|
||||
expect(getBlocklistForRequestMock).toHaveBeenCalledWith('r-1');
|
||||
expect(payload).toEqual({ entries: rows, count: 2 });
|
||||
});
|
||||
|
||||
it('rejects whitespace-only requestId with 400', async () => {
|
||||
const { GET } = await import('@/app/api/admin/blocklist/by-request/[requestId]/route');
|
||||
const response = await GET(
|
||||
{ url: 'http://localhost/api/admin/blocklist/by-request/' } as any,
|
||||
{ params: Promise.resolve({ requestId: ' ' }) }
|
||||
);
|
||||
expect(response.status).toBe(400);
|
||||
expect(getBlocklistForRequestMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -58,6 +58,7 @@ export const createPrismaMock = () => ({
|
||||
userHomeSection: createModelMock(),
|
||||
audibleCacheCategory: createModelMock(),
|
||||
ignoredAudiobook: createModelMock(),
|
||||
blockedRelease: createModelMock(),
|
||||
$queryRaw: vi.fn(),
|
||||
$transaction: vi.fn(),
|
||||
$disconnect: vi.fn(),
|
||||
|
||||
@@ -156,7 +156,7 @@ describe('processMonitorDownload', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('marks request failed when download fails', async () => {
|
||||
it('marks request failed when download fails and auto-blocks the release', async () => {
|
||||
const qbtClientMock = {
|
||||
clientType: 'qbittorrent',
|
||||
protocol: 'torrent',
|
||||
@@ -180,6 +180,20 @@ describe('processMonitorDownload', () => {
|
||||
audiobook: { title: 'Book', author: 'Author' },
|
||||
user: { plexUsername: 'user' },
|
||||
});
|
||||
prismaMock.downloadHistory.findUnique.mockResolvedValue({
|
||||
id: 'dh-3',
|
||||
torrentName: 'Book - Author [M4B]',
|
||||
torrentHash: 'hash-3',
|
||||
nzbId: null,
|
||||
indexerName: 'TestIndexer',
|
||||
indexerId: 4,
|
||||
});
|
||||
prismaMock.blockedRelease.upsert.mockResolvedValue({
|
||||
id: 'block-3',
|
||||
releaseName: 'Book - Author [M4B]',
|
||||
releaseKey: 'book - author [m4b]',
|
||||
createdAt: new Date(),
|
||||
});
|
||||
|
||||
const { processMonitorDownload } = await import('@/lib/processors/monitor-download.processor');
|
||||
const result = await processMonitorDownload({
|
||||
@@ -196,6 +210,60 @@ describe('processMonitorDownload', () => {
|
||||
data: expect.objectContaining({ status: 'failed' }),
|
||||
})
|
||||
);
|
||||
expect(prismaMock.blockedRelease.upsert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { requestId_releaseKey: { requestId: 'req-3', releaseKey: 'book - author [m4b]' } },
|
||||
create: expect.objectContaining({
|
||||
requestId: 'req-3',
|
||||
releaseName: 'Book - Author [M4B]',
|
||||
releaseHash: 'hash-3',
|
||||
source: 'download_fail',
|
||||
downloadHistoryId: 'dh-3',
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('does not auto-block when permanent failure is from connection-exhaustion path', async () => {
|
||||
// Simulate the connection-failure-exhausted fallthrough: getDownload rejects
|
||||
// with a transient connection error AND prevConnectionFailureCount is already
|
||||
// at the cap, so the processor enters PATH 3 (permanent error) without the
|
||||
// client ever reporting `failed`. That path must NOT auto-block.
|
||||
const econn = Object.assign(new Error('connect ECONNREFUSED 127.0.0.1:8080'), {
|
||||
code: 'ECONNREFUSED',
|
||||
});
|
||||
const qbtClientMock = {
|
||||
clientType: 'qbittorrent',
|
||||
protocol: 'torrent',
|
||||
getDownload: vi.fn().mockRejectedValue(econn),
|
||||
};
|
||||
downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(qbtClientMock);
|
||||
prismaMock.request.update.mockResolvedValue({});
|
||||
prismaMock.request.findUnique.mockResolvedValue({
|
||||
id: 'req-conn',
|
||||
audiobook: { title: 'Book', author: 'Author' },
|
||||
user: { plexUsername: 'user' },
|
||||
});
|
||||
|
||||
const { processMonitorDownload } = await import('@/lib/processors/monitor-download.processor');
|
||||
await expect(processMonitorDownload({
|
||||
requestId: 'req-conn',
|
||||
downloadHistoryId: 'dh-conn',
|
||||
downloadClientId: 'hash-conn',
|
||||
downloadClient: 'qbittorrent',
|
||||
jobId: 'job-conn',
|
||||
// Already at the cap — next call enters PATH 3 (permanent), not the
|
||||
// self-rescheduling retry branch.
|
||||
connectionFailureCount: 30,
|
||||
})).rejects.toThrow();
|
||||
|
||||
expect(prismaMock.request.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ status: 'failed' }),
|
||||
})
|
||||
);
|
||||
// CRITICAL: connection exhaustion is transient infra, not a release problem.
|
||||
expect(prismaMock.blockedRelease.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles SABnzbd completion and queues organize job', async () => {
|
||||
|
||||
@@ -35,6 +35,8 @@ function futureDate(days = 30): Date {
|
||||
describe('processMonitorRssFeeds', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Default to empty blocklist so the filter is a no-op unless a test overrides.
|
||||
prismaMock.blockedRelease.findMany.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it('matches RSS items and queues search jobs', async () => {
|
||||
@@ -108,6 +110,42 @@ describe('processMonitorRssFeeds', () => {
|
||||
expect(prismaMock.request.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not queue a search when the matching RSS release is on the request blocklist', async () => {
|
||||
configMock.get.mockImplementation(async (key: string) => {
|
||||
if (key === 'prowlarr_indexers') {
|
||||
return JSON.stringify([{ id: 1, name: 'Indexer', rssEnabled: true }]);
|
||||
}
|
||||
if (key === 'indexer.skip_unreleased') return null;
|
||||
return null;
|
||||
});
|
||||
|
||||
prowlarrMock.getAllRssFeeds.mockResolvedValue([
|
||||
{ title: 'Great Book - Author Name' },
|
||||
]);
|
||||
|
||||
prismaMock.request.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 'req-blocked',
|
||||
type: 'audiobook',
|
||||
status: 'awaiting_search',
|
||||
releaseDate: null,
|
||||
audiobook: { id: 'a1', title: 'Great Book', author: 'Author Name', audibleAsin: 'ASIN1' },
|
||||
},
|
||||
]);
|
||||
|
||||
// The RSS torrent's normalized name is on the request's blocklist.
|
||||
prismaMock.blockedRelease.findMany.mockResolvedValue([
|
||||
{ id: 'b1', releaseKey: 'great book - author name', releaseHash: null },
|
||||
]);
|
||||
|
||||
const { processMonitorRssFeeds } = await import('@/lib/processors/monitor-rss-feeds.processor');
|
||||
const result = await processMonitorRssFeeds({ jobId: 'job-rss-blocked' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(jobQueueMock.addSearchJob).not.toHaveBeenCalled();
|
||||
expect(jobQueueMock.addSearchEbookJob).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('runs RSS search when matched book is unreleased but setting is OFF', async () => {
|
||||
configMock.get.mockImplementation(async (key: string) => {
|
||||
if (key === 'prowlarr_indexers') {
|
||||
|
||||
@@ -256,9 +256,11 @@ describe('processOrganizeFiles', () => {
|
||||
data: expect.objectContaining({ status: 'awaiting_import' }),
|
||||
})
|
||||
);
|
||||
// Auto-block must NOT fire on a retry — only on the terminal warn transition.
|
||||
expect(prismaMock.blockedRelease.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('marks request as warn when max retries exceeded and notifies user', async () => {
|
||||
it('marks request as warn when max retries exceeded, auto-blocks the release, and notifies user', async () => {
|
||||
prismaMock.request.update.mockResolvedValue({});
|
||||
prismaMock.audiobook.findUnique.mockResolvedValue({
|
||||
id: 'a6',
|
||||
@@ -285,6 +287,20 @@ describe('processOrganizeFiles', () => {
|
||||
audiobook: { title: 'Book', author: 'Author' },
|
||||
user: { plexUsername: 'user' },
|
||||
});
|
||||
prismaMock.downloadHistory.findFirst.mockResolvedValue({
|
||||
id: 'dh-6',
|
||||
torrentName: 'Book by Author [M4B]',
|
||||
torrentHash: 'hash-6',
|
||||
nzbId: null,
|
||||
indexerName: 'TestIndexer',
|
||||
indexerId: 7,
|
||||
});
|
||||
prismaMock.blockedRelease.upsert.mockResolvedValue({
|
||||
id: 'block-6',
|
||||
releaseName: 'Book by Author [M4B]',
|
||||
releaseKey: 'book by author [m4b]',
|
||||
createdAt: new Date(),
|
||||
});
|
||||
configMock.get.mockResolvedValue(null);
|
||||
|
||||
const { processOrganizeFiles } = await import('@/lib/processors/organize-files.processor');
|
||||
@@ -309,6 +325,23 @@ describe('processOrganizeFiles', () => {
|
||||
'user',
|
||||
expect.stringContaining('Max retries')
|
||||
);
|
||||
// Terminal warn writes a single blocklist row keyed on the selected download.
|
||||
expect(prismaMock.blockedRelease.upsert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { requestId_releaseKey: { requestId: 'req-6', releaseKey: 'book by author [m4b]' } },
|
||||
create: expect.objectContaining({
|
||||
requestId: 'req-6',
|
||||
releaseName: 'Book by Author [M4B]',
|
||||
releaseKey: 'book by author [m4b]',
|
||||
releaseHash: 'hash-6',
|
||||
indexerName: 'TestIndexer',
|
||||
indexerId: 7,
|
||||
source: 'organize_fail',
|
||||
reason: 'No audiobook files found',
|
||||
downloadHistoryId: 'dh-6',
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('marks request failed for non-retryable errors and notifies user', async () => {
|
||||
@@ -357,6 +390,8 @@ describe('processOrganizeFiles', () => {
|
||||
'user',
|
||||
expect.stringContaining('File organization failed')
|
||||
);
|
||||
// Non-retryable failures do not auto-block — only terminal warn does.
|
||||
expect(prismaMock.blockedRelease.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('queues retry when organizer returns EPERM copy failure', async () => {
|
||||
|
||||
@@ -36,6 +36,8 @@ describe('processSearchIndexers', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
configMock.getAudibleRegion.mockResolvedValue('us');
|
||||
// Default to empty blocklist so the filter is a no-op unless a test overrides.
|
||||
prismaMock.blockedRelease.findMany.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it('marks request awaiting_search when no results found', async () => {
|
||||
@@ -124,6 +126,178 @@ describe('processSearchIndexers', () => {
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('filters out blocklisted releases by name (case-insensitive) before ranking', async () => {
|
||||
configMock.get.mockImplementation(async (key: string) => {
|
||||
if (key === 'prowlarr_indexers') {
|
||||
return JSON.stringify([{ id: 1, name: 'Indexer', protocol: 'torrent', priority: 10, categories: [3030] }]);
|
||||
}
|
||||
if (key === 'indexer_flag_config') return JSON.stringify([]);
|
||||
return null;
|
||||
});
|
||||
|
||||
prowlarrMock.searchWithVariations.mockResolvedValue([
|
||||
{
|
||||
indexer: 'Indexer',
|
||||
indexerId: 1,
|
||||
title: 'BAD Release - Author',
|
||||
size: 50 * 1024 * 1024,
|
||||
seeders: 10,
|
||||
publishDate: new Date(),
|
||||
downloadUrl: 'magnet:?xt=urn:btih:bad',
|
||||
guid: 'guid-bad',
|
||||
format: 'M4B',
|
||||
},
|
||||
{
|
||||
indexer: 'Indexer',
|
||||
indexerId: 1,
|
||||
title: 'Good Release - Author',
|
||||
size: 50 * 1024 * 1024,
|
||||
seeders: 20,
|
||||
publishDate: new Date(),
|
||||
downloadUrl: 'magnet:?xt=urn:btih:good',
|
||||
guid: 'guid-good',
|
||||
format: 'M4B',
|
||||
},
|
||||
]);
|
||||
|
||||
// Blocklist contains the bad release with lowercased key — must match case-insensitively.
|
||||
prismaMock.blockedRelease.findMany.mockResolvedValue([
|
||||
{ id: 'b1', releaseKey: 'bad release - author', releaseHash: null },
|
||||
]);
|
||||
prismaMock.request.update.mockResolvedValue({});
|
||||
|
||||
const { processSearchIndexers } = await import('@/lib/processors/search-indexers.processor');
|
||||
const result = await processSearchIndexers({
|
||||
requestId: 'req-filter-name',
|
||||
audiobook: { id: 'a-filter', title: 'Good Release', author: 'Author' },
|
||||
jobId: 'job-filter-name',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(jobQueueMock.addDownloadJob).toHaveBeenCalledTimes(1);
|
||||
expect(jobQueueMock.addDownloadJob).toHaveBeenCalledWith(
|
||||
'req-filter-name',
|
||||
expect.objectContaining({ id: 'a-filter' }),
|
||||
expect.objectContaining({ title: 'Good Release - Author' })
|
||||
);
|
||||
});
|
||||
|
||||
it('filters out blocklisted releases by infoHash even when title differs', async () => {
|
||||
configMock.get.mockImplementation(async (key: string) => {
|
||||
if (key === 'prowlarr_indexers') {
|
||||
return JSON.stringify([{ id: 1, name: 'Indexer', protocol: 'torrent', priority: 10, categories: [3030] }]);
|
||||
}
|
||||
if (key === 'indexer_flag_config') return JSON.stringify([]);
|
||||
return null;
|
||||
});
|
||||
|
||||
prowlarrMock.searchWithVariations.mockResolvedValue([
|
||||
{
|
||||
indexer: 'Indexer',
|
||||
indexerId: 1,
|
||||
title: 'Some Other Title - Author',
|
||||
size: 50 * 1024 * 1024,
|
||||
seeders: 10,
|
||||
publishDate: new Date(),
|
||||
downloadUrl: 'magnet:?xt=urn:btih:abc',
|
||||
guid: 'guid-hash-bad',
|
||||
infoHash: 'abc123',
|
||||
format: 'M4B',
|
||||
},
|
||||
{
|
||||
indexer: 'Indexer',
|
||||
indexerId: 1,
|
||||
title: 'Good Release - Author',
|
||||
size: 50 * 1024 * 1024,
|
||||
seeders: 20,
|
||||
publishDate: new Date(),
|
||||
downloadUrl: 'magnet:?xt=urn:btih:def',
|
||||
guid: 'guid-hash-good',
|
||||
infoHash: 'def456',
|
||||
format: 'M4B',
|
||||
},
|
||||
]);
|
||||
|
||||
prismaMock.blockedRelease.findMany.mockResolvedValue([
|
||||
{ id: 'b2', releaseKey: 'unrelated key', releaseHash: 'abc123' },
|
||||
]);
|
||||
prismaMock.request.update.mockResolvedValue({});
|
||||
|
||||
const { processSearchIndexers } = await import('@/lib/processors/search-indexers.processor');
|
||||
const result = await processSearchIndexers({
|
||||
requestId: 'req-filter-hash',
|
||||
audiobook: { id: 'a-filter-hash', title: 'Good Release', author: 'Author' },
|
||||
jobId: 'job-filter-hash',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(jobQueueMock.addDownloadJob).toHaveBeenCalledWith(
|
||||
'req-filter-hash',
|
||||
expect.anything(),
|
||||
expect.objectContaining({ title: 'Good Release - Author' })
|
||||
);
|
||||
});
|
||||
|
||||
it('uses blocklist-exhaustion message when every candidate is blocked', async () => {
|
||||
configMock.get.mockImplementation(async (key: string) => {
|
||||
if (key === 'prowlarr_indexers') {
|
||||
return JSON.stringify([{ id: 1, name: 'Indexer', protocol: 'torrent', priority: 10, categories: [3030] }]);
|
||||
}
|
||||
if (key === 'indexer_flag_config') return JSON.stringify([]);
|
||||
return null;
|
||||
});
|
||||
|
||||
prowlarrMock.searchWithVariations.mockResolvedValue([
|
||||
{
|
||||
indexer: 'Indexer',
|
||||
indexerId: 1,
|
||||
title: 'Bad Release One',
|
||||
size: 50 * 1024 * 1024,
|
||||
seeders: 10,
|
||||
publishDate: new Date(),
|
||||
downloadUrl: 'magnet:?xt=urn:btih:1',
|
||||
guid: 'g1',
|
||||
format: 'M4B',
|
||||
},
|
||||
{
|
||||
indexer: 'Indexer',
|
||||
indexerId: 1,
|
||||
title: 'Bad Release Two',
|
||||
size: 50 * 1024 * 1024,
|
||||
seeders: 5,
|
||||
publishDate: new Date(),
|
||||
downloadUrl: 'magnet:?xt=urn:btih:2',
|
||||
guid: 'g2',
|
||||
format: 'M4B',
|
||||
},
|
||||
]);
|
||||
|
||||
prismaMock.blockedRelease.findMany.mockResolvedValue([
|
||||
{ id: 'b1', releaseKey: 'bad release one', releaseHash: null },
|
||||
{ id: 'b2', releaseKey: 'bad release two', releaseHash: null },
|
||||
]);
|
||||
prismaMock.request.update.mockResolvedValue({});
|
||||
|
||||
const { processSearchIndexers } = await import('@/lib/processors/search-indexers.processor');
|
||||
const result = await processSearchIndexers({
|
||||
requestId: 'req-exhausted',
|
||||
audiobook: { id: 'a-exhausted', title: 'Bad Release', author: 'Author' },
|
||||
jobId: 'job-exhausted',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toMatch(/No usable releases — 2 candidates tried, all blocked/);
|
||||
expect(prismaMock.request.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
status: 'awaiting_search',
|
||||
errorMessage: 'No usable releases — 2 candidates tried, all blocked',
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(jobQueueMock.addDownloadJob).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
/**
|
||||
* Component: Blocklist Service Tests
|
||||
* Documentation: documentation/backend/database.md
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createPrismaMock } from '../helpers/prisma';
|
||||
|
||||
const prismaMock = createPrismaMock();
|
||||
|
||||
vi.mock('@/lib/db', () => ({
|
||||
prisma: prismaMock,
|
||||
}));
|
||||
|
||||
const jobLoggerInfo = vi.fn();
|
||||
const jobLoggerWarn = vi.fn();
|
||||
const jobLoggerError = vi.fn();
|
||||
const createdLoggerInfo = vi.fn();
|
||||
const createdLoggerError = vi.fn();
|
||||
const forJobSpy = vi.fn(() => ({
|
||||
debug: vi.fn(),
|
||||
info: jobLoggerInfo,
|
||||
warn: jobLoggerWarn,
|
||||
error: jobLoggerError,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/utils/logger', () => ({
|
||||
RMABLogger: {
|
||||
create: () => ({
|
||||
debug: vi.fn(),
|
||||
info: createdLoggerInfo,
|
||||
warn: vi.fn(),
|
||||
error: createdLoggerError,
|
||||
}),
|
||||
forJob: forJobSpy,
|
||||
},
|
||||
}));
|
||||
|
||||
function baseInput() {
|
||||
return {
|
||||
requestId: 'req-1',
|
||||
releaseName: 'Some.Release.Name',
|
||||
source: 'organize_fail' as const,
|
||||
reason: 'No audiobook files found',
|
||||
};
|
||||
}
|
||||
|
||||
function fakeRow(overrides: Partial<{ id: string; releaseKey: string; createdAt: Date }> = {}) {
|
||||
return {
|
||||
id: overrides.id ?? 'block-1',
|
||||
requestId: 'req-1',
|
||||
releaseName: 'Some.Release.Name',
|
||||
releaseKey: overrides.releaseKey ?? 'some.release.name',
|
||||
releaseHash: null,
|
||||
indexerName: null,
|
||||
indexerId: null,
|
||||
source: 'organize_fail',
|
||||
reason: 'No audiobook files found',
|
||||
reasonDetail: null,
|
||||
downloadHistoryId: null,
|
||||
jobId: null,
|
||||
createdAt: overrides.createdAt ?? new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
describe('addAutoBlock', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('upserts on (requestId, releaseKey) with the normalized key', async () => {
|
||||
prismaMock.blockedRelease.upsert.mockResolvedValue(fakeRow());
|
||||
|
||||
const { addAutoBlock } = await import('@/lib/services/blocklist.service');
|
||||
await addAutoBlock({ ...baseInput(), releaseName: ' Some.Release.NAME ' });
|
||||
|
||||
expect(prismaMock.blockedRelease.upsert).toHaveBeenCalledTimes(1);
|
||||
const callArg = prismaMock.blockedRelease.upsert.mock.calls[0][0];
|
||||
expect(callArg.where).toEqual({
|
||||
requestId_releaseKey: { requestId: 'req-1', releaseKey: 'some.release.name' },
|
||||
});
|
||||
expect(callArg.create.releaseKey).toBe('some.release.name');
|
||||
expect(callArg.create.releaseName).toBe(' Some.Release.NAME ');
|
||||
expect(callArg.update).toEqual({});
|
||||
});
|
||||
|
||||
it('passes all metadata fields through to create', async () => {
|
||||
prismaMock.blockedRelease.upsert.mockResolvedValue(fakeRow());
|
||||
|
||||
const { addAutoBlock } = await import('@/lib/services/blocklist.service');
|
||||
await addAutoBlock({
|
||||
requestId: 'req-1',
|
||||
releaseName: 'Foo',
|
||||
source: 'download_fail',
|
||||
reason: 'Download failed (par2)',
|
||||
releaseHash: 'abc123',
|
||||
indexerName: 'NZBgeek',
|
||||
indexerId: 7,
|
||||
reasonDetail: 'Status: FAILURE/PAR; Par: FAILURE',
|
||||
downloadHistoryId: 'dh-9',
|
||||
jobId: 'job-42',
|
||||
});
|
||||
|
||||
const create = prismaMock.blockedRelease.upsert.mock.calls[0][0].create;
|
||||
expect(create).toMatchObject({
|
||||
requestId: 'req-1',
|
||||
releaseName: 'Foo',
|
||||
releaseKey: 'foo',
|
||||
releaseHash: 'abc123',
|
||||
indexerName: 'NZBgeek',
|
||||
indexerId: 7,
|
||||
source: 'download_fail',
|
||||
reason: 'Download failed (par2)',
|
||||
reasonDetail: 'Status: FAILURE/PAR; Par: FAILURE',
|
||||
downloadHistoryId: 'dh-9',
|
||||
jobId: 'job-42',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns wasNew=true when the row was just created', async () => {
|
||||
// createdAt in the future relative to before-call timestamp
|
||||
const future = new Date(Date.now() + 1000);
|
||||
prismaMock.blockedRelease.upsert.mockResolvedValue(fakeRow({ createdAt: future }));
|
||||
|
||||
const { addAutoBlock } = await import('@/lib/services/blocklist.service');
|
||||
const result = await addAutoBlock(baseInput());
|
||||
|
||||
expect(result.wasNew).toBe(true);
|
||||
expect(result.blocked).not.toBeNull();
|
||||
});
|
||||
|
||||
it('returns wasNew=false when the row already existed (idempotent second call)', async () => {
|
||||
// createdAt before the call started
|
||||
const past = new Date(Date.now() - 10_000);
|
||||
prismaMock.blockedRelease.upsert.mockResolvedValue(fakeRow({ createdAt: past }));
|
||||
|
||||
const { addAutoBlock } = await import('@/lib/services/blocklist.service');
|
||||
const result = await addAutoBlock(baseInput());
|
||||
|
||||
expect(result.wasNew).toBe(false);
|
||||
expect(result.blocked).not.toBeNull();
|
||||
});
|
||||
|
||||
it('emits a JobEvent via RMABLogger.forJob when jobId is provided', async () => {
|
||||
prismaMock.blockedRelease.upsert.mockResolvedValue(
|
||||
fakeRow({ createdAt: new Date(Date.now() + 1000) })
|
||||
);
|
||||
|
||||
const { addAutoBlock } = await import('@/lib/services/blocklist.service');
|
||||
await addAutoBlock({ ...baseInput(), jobId: 'job-42' });
|
||||
|
||||
expect(forJobSpy).toHaveBeenCalledWith('job-42', 'Blocklist.AutoBlock');
|
||||
expect(jobLoggerInfo).toHaveBeenCalledTimes(1);
|
||||
const [message, metadata] = jobLoggerInfo.mock.calls[0];
|
||||
expect(message).toContain('Some.Release.Name');
|
||||
expect(metadata).toMatchObject({
|
||||
requestId: 'req-1',
|
||||
source: 'organize_fail',
|
||||
reason: 'No audiobook files found',
|
||||
wasNew: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('does NOT emit a JobEvent when jobId is null', async () => {
|
||||
prismaMock.blockedRelease.upsert.mockResolvedValue(fakeRow());
|
||||
|
||||
const { addAutoBlock } = await import('@/lib/services/blocklist.service');
|
||||
await addAutoBlock(baseInput());
|
||||
|
||||
expect(forJobSpy).not.toHaveBeenCalled();
|
||||
expect(jobLoggerInfo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('swallows DB errors and returns { blocked: null, wasNew: false }', async () => {
|
||||
prismaMock.blockedRelease.upsert.mockRejectedValue(new Error('DB exploded'));
|
||||
|
||||
const { addAutoBlock } = await import('@/lib/services/blocklist.service');
|
||||
const result = await addAutoBlock({ ...baseInput(), jobId: 'job-42' });
|
||||
|
||||
expect(result).toEqual({ blocked: null, wasNew: false });
|
||||
expect(createdLoggerError).toHaveBeenCalledTimes(1);
|
||||
// Failure path must NOT attempt the job-log either (no row to describe).
|
||||
expect(forJobSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isReleaseBlocked', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('returns true on normalized name match', async () => {
|
||||
prismaMock.blockedRelease.findFirst.mockResolvedValue({ id: 'block-1' });
|
||||
|
||||
const { isReleaseBlocked } = await import('@/lib/services/blocklist.service');
|
||||
const result = await isReleaseBlocked('req-1', ' The.Templar.LEGACY ');
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(prismaMock.blockedRelease.findFirst).toHaveBeenCalledWith({
|
||||
where: { requestId: 'req-1', OR: [{ releaseKey: 'the.templar.legacy' }] },
|
||||
select: { id: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('returns true on hash match even when name differs', async () => {
|
||||
prismaMock.blockedRelease.findFirst.mockResolvedValue({ id: 'block-2' });
|
||||
|
||||
const { isReleaseBlocked } = await import('@/lib/services/blocklist.service');
|
||||
const result = await isReleaseBlocked('req-1', 'A different name', 'abc-hash');
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(prismaMock.blockedRelease.findFirst).toHaveBeenCalledWith({
|
||||
where: {
|
||||
requestId: 'req-1',
|
||||
OR: [{ releaseKey: 'a different name' }, { releaseHash: 'abc-hash' }],
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('returns false when nothing matches', async () => {
|
||||
prismaMock.blockedRelease.findFirst.mockResolvedValue(null);
|
||||
|
||||
const { isReleaseBlocked } = await import('@/lib/services/blocklist.service');
|
||||
const result = await isReleaseBlocked('req-1', 'name', 'hash');
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('does not include a hash clause when hash is null or undefined', async () => {
|
||||
prismaMock.blockedRelease.findFirst.mockResolvedValue(null);
|
||||
|
||||
const { isReleaseBlocked } = await import('@/lib/services/blocklist.service');
|
||||
await isReleaseBlocked('req-1', 'name');
|
||||
await isReleaseBlocked('req-1', 'name', null);
|
||||
|
||||
for (const call of prismaMock.blockedRelease.findFirst.mock.calls) {
|
||||
expect(call[0].where.OR).toEqual([{ releaseKey: 'name' }]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBlocklistForRequest', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('queries by requestId ordered by createdAt desc', async () => {
|
||||
const rows = [fakeRow({ id: 'a' }), fakeRow({ id: 'b' })];
|
||||
prismaMock.blockedRelease.findMany.mockResolvedValue(rows);
|
||||
|
||||
const { getBlocklistForRequest } = await import('@/lib/services/blocklist.service');
|
||||
const result = await getBlocklistForRequest('req-1');
|
||||
|
||||
expect(result).toBe(rows);
|
||||
expect(prismaMock.blockedRelease.findMany).toHaveBeenCalledWith({
|
||||
where: { requestId: 'req-1' },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeBlock', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('deletes a single row by id', async () => {
|
||||
const { removeBlock } = await import('@/lib/services/blocklist.service');
|
||||
await removeBlock('block-1');
|
||||
|
||||
expect(prismaMock.blockedRelease.delete).toHaveBeenCalledWith({
|
||||
where: { id: 'block-1' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearBlocklist', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('deleteMany with the provided where clause and returns the count', async () => {
|
||||
prismaMock.blockedRelease.deleteMany.mockResolvedValue({ count: 7 });
|
||||
|
||||
const { clearBlocklist } = await import('@/lib/services/blocklist.service');
|
||||
const result = await clearBlocklist({ requestId: 'req-1' });
|
||||
|
||||
expect(prismaMock.blockedRelease.deleteMany).toHaveBeenCalledWith({
|
||||
where: { requestId: 'req-1' },
|
||||
});
|
||||
expect(result).toEqual({ count: 7 });
|
||||
});
|
||||
|
||||
it('passes an arbitrary filter through unchanged', async () => {
|
||||
prismaMock.blockedRelease.deleteMany.mockResolvedValue({ count: 0 });
|
||||
|
||||
const { clearBlocklist } = await import('@/lib/services/blocklist.service');
|
||||
await clearBlocklist({ source: 'organize_fail', requestId: 'req-1' });
|
||||
|
||||
expect(prismaMock.blockedRelease.deleteMany).toHaveBeenCalledWith({
|
||||
where: { source: 'organize_fail', requestId: 'req-1' },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Component: Blocked Results Filter Tests
|
||||
* Documentation: documentation/backend/database.md
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const getBlocklistForRequestMock = vi.fn();
|
||||
|
||||
vi.mock('@/lib/services/blocklist.service', () => ({
|
||||
getBlocklistForRequest: getBlocklistForRequestMock,
|
||||
}));
|
||||
|
||||
describe('filterBlockedResults', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('returns input unchanged when results array is empty', async () => {
|
||||
const { filterBlockedResults } = await import('@/lib/utils/filter-blocked-results');
|
||||
const { kept, blockedCount } = await filterBlockedResults('req-1', []);
|
||||
expect(kept).toEqual([]);
|
||||
expect(blockedCount).toBe(0);
|
||||
// Empty results should short-circuit before hitting the DB.
|
||||
expect(getBlocklistForRequestMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns input unchanged when blocklist is empty', async () => {
|
||||
getBlocklistForRequestMock.mockResolvedValueOnce([]);
|
||||
const { filterBlockedResults } = await import('@/lib/utils/filter-blocked-results');
|
||||
const results = [{ title: 'Some Release' }];
|
||||
const { kept, blockedCount } = await filterBlockedResults('req-1', results);
|
||||
expect(kept).toBe(results);
|
||||
expect(blockedCount).toBe(0);
|
||||
});
|
||||
|
||||
it('removes results that match a blocked release name case-insensitively', async () => {
|
||||
getBlocklistForRequestMock.mockResolvedValueOnce([
|
||||
{ releaseKey: 'foo bar [m4b]', releaseHash: null },
|
||||
]);
|
||||
const { filterBlockedResults } = await import('@/lib/utils/filter-blocked-results');
|
||||
const { kept, blockedCount } = await filterBlockedResults('req-1', [
|
||||
{ title: ' FOO BAR [M4B] ' },
|
||||
{ title: 'Other Release' },
|
||||
]);
|
||||
expect(kept).toEqual([{ title: 'Other Release' }]);
|
||||
expect(blockedCount).toBe(1);
|
||||
});
|
||||
|
||||
it('removes results that match by infoHash even when title differs', async () => {
|
||||
getBlocklistForRequestMock.mockResolvedValueOnce([
|
||||
{ releaseKey: 'something else', releaseHash: 'abc123' },
|
||||
]);
|
||||
const { filterBlockedResults } = await import('@/lib/utils/filter-blocked-results');
|
||||
const { kept, blockedCount } = await filterBlockedResults('req-1', [
|
||||
{ title: 'Different Title', infoHash: 'abc123' },
|
||||
{ title: 'Keep Me', infoHash: 'zzz999' },
|
||||
]);
|
||||
expect(kept).toEqual([{ title: 'Keep Me', infoHash: 'zzz999' }]);
|
||||
expect(blockedCount).toBe(1);
|
||||
});
|
||||
|
||||
it('does not filter by hash when the result has no infoHash', async () => {
|
||||
getBlocklistForRequestMock.mockResolvedValueOnce([
|
||||
{ releaseKey: 'unrelated', releaseHash: 'abc123' },
|
||||
]);
|
||||
const { filterBlockedResults } = await import('@/lib/utils/filter-blocked-results');
|
||||
const results = [{ title: 'No Hash Result' }];
|
||||
const { kept, blockedCount } = await filterBlockedResults('req-1', results);
|
||||
expect(kept).toEqual(results);
|
||||
expect(blockedCount).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Component: Release Key Normalizer Tests
|
||||
* Documentation: documentation/backend/database.md
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { normalizeReleaseKey } from '@/lib/utils/release-key';
|
||||
|
||||
describe('normalizeReleaseKey', () => {
|
||||
it('lowercases ASCII characters', () => {
|
||||
expect(normalizeReleaseKey('SomeReleaseName')).toBe('somereleasename');
|
||||
});
|
||||
|
||||
it('trims leading and trailing whitespace', () => {
|
||||
expect(normalizeReleaseKey(' hello ')).toBe('hello');
|
||||
});
|
||||
|
||||
it('combines trim and lowercase', () => {
|
||||
expect(normalizeReleaseKey(' MIXED.Case Release ')).toBe('mixed.case release');
|
||||
});
|
||||
|
||||
it('preserves internal whitespace', () => {
|
||||
expect(normalizeReleaseKey('the templar legacy')).toBe('the templar legacy');
|
||||
});
|
||||
|
||||
it('returns empty string for empty input', () => {
|
||||
expect(normalizeReleaseKey('')).toBe('');
|
||||
});
|
||||
|
||||
it('returns empty string for whitespace-only input', () => {
|
||||
expect(normalizeReleaseKey(' ')).toBe('');
|
||||
});
|
||||
|
||||
it('passes through unicode characters (with native lowercasing)', () => {
|
||||
expect(normalizeReleaseKey('Éclair')).toBe('éclair');
|
||||
});
|
||||
|
||||
it('is idempotent — normalizing a normalized value is a no-op', () => {
|
||||
const once = normalizeReleaseKey(' Some Release ');
|
||||
expect(normalizeReleaseKey(once)).toBe(once);
|
||||
});
|
||||
|
||||
it('treats different-case variants of the same release as the same key', () => {
|
||||
expect(normalizeReleaseKey('THE.TEMPLAR.LEGACY')).toBe(
|
||||
normalizeReleaseKey('the.templar.legacy')
|
||||
);
|
||||
expect(normalizeReleaseKey(' The.Templar.Legacy ')).toBe(
|
||||
normalizeReleaseKey('the.templar.legacy')
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user