Compare commits

..

21 Commits

Author SHA1 Message Date
kikootwo 96aa01a3ec Bump package version to 1.2.1
Update package.json version from 1.2.0 to 1.2.1 to mark a patch release. No other changes included in this commit.
2026-05-18 15:10:04 -04:00
kikootwo 411b5f88a4 Add per-indexer ratio-based seeding policy
Introduce a per-indexer ratioLimit alongside seedingTimeMinutes to control torrent cleanup. Updates include: documentation (scheduler and settings pages), types and API (saved indexer config now includes ratioLimit), setup and management UI (new TorrentSeedingFields component, modal wiring, validation and handlers), and processor logic (cleanup-seeded-torrents now requires AND-semantics between time and ratio; 0 disables a criterion, both 0 = never cleaned, undefined client ratio with ratioLimit>0 = not met). Tests were added/updated to cover ratio-only, time+ratio, missing-ratio, and UI interactions. Default behavior: ratioLimit defaults to 0 (no ratio requirement).
2026-05-18 15:07:50 -04:00
kikootwo 01e61f3368 Improve metadata-tagger tests and add integration
Switch unit tests to mock child_process.execFile and assert argv structure instead of a single shell command string. Add helpers (lastCallArgv, expectMetadataArg) and expand coverage to catch the #171 quote-regression, validate sanitization of invisible/whitespace/null chars, ensure no shell-quoting is introduced, and cover all format branches (m4b/mp3/flac). Add a new integration test suite that runs real ffmpeg/ffprobe (skips if binaries missing) to verify metadata round-trips byte-for-byte. Update metadata-tagger implementation (binary change) to use the argv-style spawn/execFile path expected by the tests.
2026-05-18 14:13:07 -04:00
kikootwo 5d9a764151 Controlled pagination pill with lock & fit-scroll
Make the floating pagination pill a controlled component and add lock/fit-aware scroll behavior. UnifiedPagination now accepts activeIndex and onDominantSectionChange, reports observer-determined dominant section (parent may ignore when locked) and only shows/hides based on footer visibility. HomePage implements controlled state (activeIndex, lockedTo) with Prev/Next/jump locking, release on wheel/touch/key or 30s safety timeout, and dot clicks that always navigate and release locks. Extracted scroll math to src/lib/utils/paginationScroll.ts (decideScrollForPageChange) so paging avoids scrolling when a section fits below the sticky header and clamps targets; added unit tests and updated component tests and docs to reflect the new behavior. Removed now-unused onPageChange prop from HomeSection.
2026-05-18 13:21:06 -04:00
kikootwo b1492fc32e 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.
2026-05-18 12:15:51 -04:00
kikootwo fb0445d95f Centralize and standardize User-Agent string
Introduce a centralized RMAB_USER_AGENT constant (ReadMeABook/<version>) and update audible service calls to use it instead of hardcoded values. This avoids the default axios UA (which some indexers reject) and replaces the previous `rmab/` identifier. Adds unit tests to verify the User-Agent format and ensure it doesn't resemble generic bot signatures.
2026-05-18 09:45:57 -04:00
kikootwo a2d25e8a39 Merge branch 'main' of https://github.com/kikootwo/ReadMeABook 2026-05-18 09:40:44 -04:00
kikootwo cea98f67ef Merge pull request #206 from Bukowskaii/bugfix/http403_nzbfinder
Fixes a 403 Issue where NZBFinder rejected requests with "axios/x.y.z" user-agent string
2026-05-18 09:40:38 -04:00
kikootwo dd5a5962b4 Add job descriptions and stale-name renames
Show human-friendly per-job descriptions on the Admin Jobs page (JOB_DESCRIPTIONS) and remove the old "About Scheduled Jobs" info box. Add STALE_NAME_REWRITES and renameStaleJobNames() in SchedulerService to automatically rewrite legacy exact-literal job names (e.g. "Plex Library Scan") to neutral defaults on startup; updates are type-gated and use updateMany with exact matches so admin-customized names are not touched. Log successful renames and swallow rename errors so startup remains idempotent. Tests and documentation were updated to reflect the new UI text and to cover rename behavior.
2026-05-18 09:31:41 -04:00
kikootwo eef6ae3462 Add admin system logs UI and API support
Introduce a complete admin System Logs feature: adds frontend components (filters, date picker, active filter chips, rows, detail panel, skeletons, pagination, toolbar, user typeahead, and styles) under src/app/admin/logs/components, plus hooks (useAutoRefreshControl, useLogsUrlState, useUserSearch) and types. Add constants for job labels and log filters, wire URL-driven filters/search/date-range/hasError/user/audiobookQuery with pause-on-interact behavior and page-size options. Update API route (/api/admin/logs) to support the expanded query params and exported where-builder. Update documentation (TABLEOFCONTENTS and admin-dashboard) and add/adjust tests for the new admin logs UI and API behavior.
2026-05-18 08:29:32 -04:00
kikootwo 06195e6570 Add find_missing_ebooks scheduled job
Introduce a safety-net scheduled job that scans completed audiobooks and auto-triggers ebook fetches for missing companions. Changes include:

- New Prisma migration + schema field: requests.ebook_auto_retry_count (nullable) to track lifetime auto-retries.
- New processor: src/lib/processors/find-missing-ebooks.processor.ts implementing the scan (limit 50), gating by ebook_auto_grab_enabled and source flags, creating ebook child requests or retrying failed ones up to a cap of 5, using transactions for race-safety and rolling back the counter if enqueue fails.
- Job queue integration: add job type, payload, processor registration, and addFindMissingEbooksJob helper.
- Scheduler integration: register the scheduled job (daily midnight) and trigger path.
- Documentation updates: backend scheduler and ebook-sidecar docs describing behavior and limits.
- Tests: add comprehensive unit tests for the processor and update scheduler tests and job-queue test helper.

This implements automated recovery for missing ebook companions while keeping the retry counter processor-private and ensuring safe concurrency handling.
2026-05-17 18:22:55 -04:00
kikootwo 6ec53ff7e3 Add API token allowlist, docs, UI and tests
Introduce API token allowlist support and documentation. Adds a new backend docs page for API tokens and updates TABLEOFCONTENTS. Implements API token constants and a compiled matcher (isEndpointAllowed) with support for single-segment :placeholders and an isWrite flag. Split getCurrentUser into a JWT-only helper and added getCurrentUserAsync to recognize rmab_ API tokens; updated the audiobooks search route to use getCurrentUserAsync. Update API docs UI (EndpointCard and api-docs page) to surface Write badges and disable "Try it" for mutating endpoints, and add a profile warning in ApiTokensSection. Add tests for the allowlist matcher and middleware, and adjust existing route tests/mocks accordingly.
2026-05-16 14:17:49 -04:00
kikootwo e39e44ee44 Add modal props & update RequestCard/tests
Extend AudiobookDetailsModal props with onStatusChange, onIgnoreChange, hideRequestActions, hasReportedIssue, and aiReason. Stop forcing hideRequestActions when opening the modal from RequestCard so the modal can control whether request actions are shown. Add tests: verify admin sticky footer/status pill in AudiobookDetailsModal for pending requests, and add a RequestCard test that mocks AudiobookDetailsModal to assert the modal receives isOpen, asin and that hideRequestActions is not forced. Reset the new mock between tests.
2026-05-16 11:30:44 -04:00
kikootwo 8bcfadc877 Enforce build+tests; add indexer option in test
Update CLAUDE.md to require both `docker compose build readmeabook` and a full `npm run test` (0 failures) before reporting work as ready to be tested. Also modify tests/components/admin-settings-indexers.test.tsx to include `indexerOptions.skipUnreleased: true` in the test fixture so the test reflects the skipUnreleased option.
2026-05-16 10:46:06 -04:00
kikootwo 1065577a04 Extract title tags & per-row chevron expand
Add parsing and UX for bracketed title metadata and per-row title expansion. Introduces extractTitleTags (src/lib/utils/title-tags.ts) to pull bracketed tags from result titles (de-duplicated, slash-split) and useIsTruncated (src/lib/hooks/useIsTruncated.ts) to detect horizontal overflow. Refactors InteractiveTorrentSearchModal to a ResultRow component that renders title chips (slate chips) for parsed tags (filtered vs displayFormat), shows a chevron disclosure only when the title is truncated (or while expanded), toggles expansion per GUID, and resets expansion state when the modal closes. Tests added/updated for the component, hook, and parser; documentation updated to reflect behavior.
2026-05-16 10:41:44 -04:00
kikootwo 31d30bdfa0 Render Modal with createPortal
Import createPortal from react-dom and render the Modal into document.body using a React portal. Add a server-side guard (typeof document === 'undefined') to avoid SSR/runtime errors and preserve the existing early return when the modal is closed. This ensures the modal overlays correctly (z-index/positioning) by mounting at the document root.
2026-05-16 06:31:23 -04:00
Tom Bernens e74787ffc0 missed a few 2026-05-15 20:45:47 -07:00
Tom Bernens 0561459782 bulljobs don't respect common headers
added the common truth (user-agent.ts) to all bulljob services
2026-05-15 20:03:40 -07:00
Tom Bernens e65e737bee user-agent.ts
defines global user-agent string for all http requests

instrumentation.ts
    sets axios default user-agent to our global user-agent string on library import
2026-05-15 18:06:41 -07:00
kikootwo f23afc1ba2 Add Plex format coercion (.mp4 → .m4b)
Implement Plex-compatible file-extension coercion to avoid Plex silently ignoring .mp4 (and single-file .m4a) audiobooks (issue #166). Adds a DB migration and configuration key (plex_format_coercion_enabled, default true), exposes a toggle in the setup wizard and Admin Paths settings, and persists/reads the setting in the admin/setup APIs.

Introduces src/lib/utils/format-coercion.ts (coerceToPlexCompatible) and related constants in src/lib/constants/audio-formats.ts (PLEX_COMPATIBLE_EXTENSIONS, COERCION_RENAME_MAP, DRM_EXTENSIONS, TRANSCODE_REQUIRED_EXTENSIONS). The organize-files processor now runs coercion after organizing/tagging and before generating the filesHash and triggering scans; coercion is idempotent, never overwrites existing targets, logs warnings on DRM/transcode/permission errors, and is non-fatal.

Adds unit tests for the coercion util and updates processor & setup UI tests. Updates documentation (TABLEOFCONTENTS, file-organization, fixes/file-hash-matching, settings-pages) describing behavior, config, and constraints.
2026-05-15 19:33:59 -04:00
kikootwo 6f8ac86a43 Add skip-unreleased auto-search feature
Introduce an indexer-wide option to skip automatic searches for books with future release dates (config key: `indexer.skip_unreleased`, default ON). Adds a GET/PUT admin API for indexer options, a UI toggle on the Indexers settings tab (persisted on save), and persistence of a request-level releaseDate in the Prisma schema.

Adds a new request status `awaiting_release` and wires it through constants, UI components (StatusBadge, RequestCard, RecentRequestsTable, Audiobook card/modal, RequestActions), API request flows (bookdate swipe, request creation, manual search, request PATCHs, request listing groups), and services. Implements a pure release-date utility (isUnreleased / shouldSkipAutoSearch) and updates background processors: monitor-rss-feeds (skip matches but do not mutate status), retry-missing-torrents (drives bidirectional transitions between awaiting_search and awaiting_release and queues searches when appropriate), and request-creator/bookdate swipe (gate initial auto-search). Adds tests for the swipe gate and other related test updates. Logs transitions and gate decisions for observability.
2026-05-15 15:35:01 -04:00
174 changed files with 13980 additions and 1059 deletions
+3
View File
@@ -57,3 +57,6 @@ next-env.d.ts
/test-data
/bookdrop
dockerfile.patch
# zach-flow scratch artifacts (locked briefs, orchestrator state)
.zach-flow/
+5 -1
View File
@@ -2,7 +2,11 @@
**Critical:** This document defines AI-optimized documentation standards and development workflow. **NEVER PERFORM COMMITS ON THE REPOSITORY.**
**ALWAYS DO:** When you feel work is complete, use the docker compose build readmebook to confirm you have no errors. If the build succeeds, then you can tell me it is ready to be tested.
**ALWAYS DO:** When you feel work is complete, you MUST verify BOTH of the following pass before reporting the work as ready to test:
1. `docker compose build readmeabook` — must succeed with no errors.
2. `npm run test` — the FULL test suite must pass (0 failures). Running a subset is not sufficient; the entire suite must be green.
Only after BOTH succeed may you tell the user the work is ready to be tested.
**NEVER implement without approval.** When asked to assess, investigate, or fix a problem:
1. **Research & analyze** — Read code, trace the issue, identify root cause.
+5
View File
@@ -102,10 +102,12 @@
## Admin Features
- **Dashboard (metrics, downloads, requests)** → [admin-dashboard.md](admin-dashboard.md)
- **System logs (filters, search, pagination, /api/admin/logs)** → [admin-dashboard.md](admin-dashboard.md)
- **Bulk import (scan folders, match Audible, batch import)** → [features/bulk-import.md](features/bulk-import.md)
- **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)
@@ -149,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)
+26 -11
View File
@@ -57,9 +57,18 @@ Comprehensive overview of system metrics, active requests, download monitoring,
- Update global auto-approve setting (boolean)
**GET /api/admin/logs**
- Query params: page, limit, status, type
- Returns: Job logs with request/audiobook/user details, pagination info
- Filters: status (all/pending/active/completed/failed/delayed/stuck), type (all job types)
- Query params: page, limit, status, type, search, dateFrom, dateTo, hasError, userId, audiobookQuery
- limit: one of 25/50/100 (default 50; invalid values clamp to 50)
- status: 'all' or one of pending/active/completed/failed/delayed/stuck
- type: 'all' or any job type key
- dateFrom / dateTo: ISO UTC strings; invalid dates silently dropped
- hasError: 'true' or '1' → `status in (failed, stuck) OR errorMessage IS NOT NULL`
- userId: uuid → filters via `request.userId`
- audiobookQuery: free text → OR-contains (case-insensitive) on `request.audiobook.{title,author}`
- search: free text → 6-column OR: bullJobId (startsWith, case-sensitive), errorMessage (contains-i), events.some.message (contains-i), request.audiobook.title/author (contains-i), request.user.plexUsername (contains-i)
- hasError + search combine under top-level `AND`; other filters compose via AND on `where`
- Where-builder: exported `buildLogsWhere(params)` in route file (pure, testable)
- Returns: `{ logs, pagination: { page, limit, total, totalPages } }`
## Request Management Features
@@ -112,15 +121,21 @@ Comprehensive overview of system metrics, active requests, download monitoring,
## System Logs Features
- Real-time job monitoring (10s refresh)
- Filter by status (pending/active/completed/failed/delayed/stuck)
- Filter by job type (search_indexers/monitor_download/organize_files/scan_plex/match_plex)
- Real-time job monitoring (10s SWR refresh; pauses on interact)
- **Filter row (5 pickers):** Status · Job Type · Date Range · User typeahead · Audiobook free-text
- Status: dropdown over VALID_STATUSES (from `src/app/admin/logs/types.ts`); labels via `STATUS_OPTIONS` in `src/lib/constants/log-filters.ts`
- Job Type: dropdown over `JOB_TYPE_LABELS` insertion order (`src/lib/constants/job-labels.ts`)
- Date Range: presets (Last hour / 24h / 7d / 30d / Custom / All time) — default = Last 7 days (Zach #1); Custom uses `<input type="datetime-local">` rendered as local time, wired as UTC ISO
- User: typeahead via `useUserSearch` (fetch-once from `/api/admin/users`, SWR-cached, in-memory filter, max 10 suggestions); selection sets `userId = User.id`
- Audiobook: free-text → server-side OR-contains on title/author (Zach #4 — no picker)
- **Active filter chips:** dismissable `<button aria-label="Remove filter: X">` strip; NOT sticky (Zach #6 — scrolls with content). Errors-only renders as a chip when active.
- **Clear all filters:** visible only when ≥1 filter or the search input is non-default
- **Pause-on-interact reasons (registered to `useAutoRefreshControl`):**
- `logs-status-dropdown`, `logs-type-dropdown`, `logs-date-picker`, `logs-user-typeahead`, `logs-book-input`
- **URL = source of truth** via `useLogsUrlState` (`src/app/admin/logs/hooks/`); param names exported as `LOG_PARAMS`; same names used by `/api/admin/logs`
- Shows related audiobook/user for request jobs
- Expandable error messages
- Duration calculation
- Attempt tracking (current/max)
- Pagination (50 logs per page)
- Shows Bull job ID
- Expandable error messages, duration calc, attempt tracking, Bull job ID
- Pagination: page-size selector (25 / 50 / 100), default 50
## Tech Stack
@@ -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 — &lt;reason&gt;"** 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.
+23 -1
View File
@@ -60,12 +60,14 @@ PostgreSQL database storing users, audiobooks, requests, downloads, configuratio
### Requests
- `id` (UUID PK), `user_id` (FK), `audiobook_id` (FK)
- `status` ('pending'|'searching'|'downloading'|'processing'|'downloaded'|'available'|'failed'|'cancelled'|'awaiting_search'|'awaiting_import'|'warn'|'awaiting_approval'|'denied')
- `status` ('pending'|'searching'|'downloading'|'processing'|'downloaded'|'available'|'failed'|'cancelled'|'awaiting_search'|'awaiting_import'|'awaiting_release'|'warn'|'awaiting_approval'|'denied')
- **Approval flow:** awaiting_approval → (approve) → pending → searching → downloading → processing → downloaded → available
- **Denial flow:** awaiting_approval → (deny) → denied
- **awaiting_approval** - Request pending admin approval (only if auto-approve disabled)
- **denied** - Request rejected by admin (terminal state)
- **pending** - Request approved and queued for processing
- **awaiting_release** - Book has a future release date; auto-search skipped until release (admin toggle controls behavior)
- `release_date` (Date, nullable) - Book release date snapshot from Audnexus at request creation; used by skip-unreleased-auto-search gate
- `progress` (0-100), `priority`, `error_message`
- `search_attempts`, `download_attempts`, `import_attempts`, `max_import_retries` (default 5)
- `last_search_at`, `last_import_at`, `created_at`, `updated_at`, `completed_at`
@@ -109,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
@@ -0,0 +1,84 @@
# API Tokens
**Status:** ✅ Implemented | Personal long-lived tokens, allowlisted endpoints, write capability per issue #169
## Overview
Static `rmab_`-prefixed tokens act with the owner's full user-level permissions on a fixed allowlist of endpoints. JWT sessions are NOT restricted by the allowlist.
## Key Details
- **Prefix:** `rmab_` (12-char stored display prefix: `rmab_` + 7 hex chars)
- **Storage:** SHA-256 hash in `apiToken.tokenHash`; full token shown ONCE on create
- **Role binding:** Token `role` matches token owner's role at creation time; admin tokens require admin-created
- **Per-user cap:** 25 active (non-expired) tokens (`MAX_TOKENS_PER_USER`)
- **Expiry:** Optional (`never`, `30d`, `90d`, `1y`)
- **Soft-deleted users:** Tokens reject if `tokenUser.deletedAt` is set
- **Identity attribution:** `req.user.id` resolves to `apiToken.userId` (target user), NOT `apiToken.createdById`
- **Header:** `Authorization: Bearer rmab_<token>`
## Allowed Endpoints
| Method | Path | Title | Write | Admin |
|---|---|---|---|---|
| GET | `/api/auth/me` | Current user | | |
| GET | `/api/audiobooks/search` | Search audiobooks | | |
| GET | `/api/requests` | List requests | | |
| POST | `/api/requests` | Create request | ✓ | |
| GET | `/api/requests/:id` | Get request by ID | | |
| GET | `/api/admin/metrics` | System metrics | | ✓ |
| GET | `/api/admin/downloads/active` | Active downloads | | ✓ |
| GET | `/api/admin/requests/recent` | Recent requests | | ✓ |
Source of truth: `src/lib/constants/api-tokens.ts` (`API_TOKEN_ALLOWED_ENDPOINTS`, `API_TOKEN_ENDPOINT_DOCS`).
## Matcher (`isEndpointAllowed`)
- Compiled once at module load.
- `path` entries containing `:name` are converted to anchored regexes where each placeholder matches `[^/]+` (a single segment).
- Sibling sub-routes (e.g. `/api/requests/:id/select-torrent`) are NOT matched by the `/api/requests/:id` entry — they require their own allowlist entry.
- Method comparison is case-insensitive.
## POST `/api/requests` (Write)
- Body: `{ "audiobook": { "asin", "title", "author", "narrator?", "description?", "coverArtUrl?" } }`
- Internally calls `createRequestForUser(req.user.id, audiobook, { bypassIgnore: true })` — token requests bypass the ignore list, matching UI behavior.
- Optional query param: `?skipAutoSearch=true` defers search-job creation.
- Side effects (identical to UI): duplicate detection, library check, Audnexus enrichment, audiobook upsert, ignore-list check (bypassed), per-user dedup, auto-approve gating, release-date gate, notification queue, search-job queue.
- Auto-approve: follows the token owner's per-user `autoApproveRequests` setting, then global. No bypass.
- Response: `201 { success: true, request }` or named error: `{ error: "AlreadyAvailable" | "BeingProcessed" | "DuplicateRequest" | "Ignored" | "UserNotFound" | "ValidationError", message }`
## GET `/api/requests/:id`
- Returns full request including `audiobook`, `downloadHistory` (selected), and recent `jobs`.
- Ownership enforced: `requestRecord.userId === req.user.id || role === 'admin'` → otherwise 403.
- Soft-deleted requests (`deletedAt != null`) return 404.
## GET `/api/audiobooks/search`
- Auth is optional, NOT gated by allowlist (route never calls `requireAuth`).
- Uses `getCurrentUserAsync` to recognize both JWT sessions AND API tokens for per-user enrichment (request status, ignore status).
- Without auth: returns generic results with no user-context annotations.
- With JWT or `rmab_` token: returns results enriched with `isRequested`, `requestStatus`, `requestId`, `isIgnored`, etc.
## Auth flow
1. Request hits route; `requireAuth` extracts `Authorization: Bearer ...` token.
2. If token starts with `rmab_``authenticateApiToken` (SHA-256 lookup, expiry + soft-delete check, fire-and-forget `lastUsedAt` update).
3. If on the allowlist → handler runs with `req.user = { sub, id, plexId, username, role }`.
4. If not on the allowlist → 403 "This endpoint is not available via API token authentication".
5. JWT tokens skip the allowlist entirely.
## UI surfaces
- `/api-docs` page (`src/app/api-docs/page.tsx`) — auto-renders `API_TOKEN_ENDPOINT_DOCS`. Endpoints with `isWrite: true` show an amber **Write** badge; the "Try it" button is disabled with a "use curl" hint to avoid sending mutating requests from a UI that cannot construct request bodies.
- Profile → API Tokens (`src/components/profile/ApiTokensSection.tsx`) — create/revoke UI. Includes a one-line warning that tokens act with the owner's full permissions.
- Admin → Users → API Tokens — admin can create tokens on behalf of any user.
## Files
- Constants + matcher: `src/lib/constants/api-tokens.ts`
- Middleware: `src/lib/middleware/auth.ts` (`requireAuth`, `getCurrentUser`, `getCurrentUserAsync`)
- Routes:
- `src/app/api/user/api-tokens/route.ts` (user create/list/revoke)
- `src/app/api/admin/api-tokens/route.ts` (admin)
- UI: `src/app/api-docs/page.tsx`, `src/components/api-docs/EndpointCard.tsx`, `src/components/api-docs/TokenInput.tsx`, `src/components/profile/ApiTokensSection.tsx`
## Tests
- `tests/constants/api-tokens.test.ts` — matcher: positive matches, negative matches, sub-route exclusion, method case-insensitivity, allowlist/docs parity.
- `tests/middleware/auth.middleware.test.ts` — middleware token auth path, allowlist enforcement (incl. dynamic ID match), sibling-route blocking, `getCurrentUserAsync`.
- `tests/api/requests-id.route.test.ts` — owner GET 200, cross-user GET 403.
## Related
- [backend/services/auth.md](auth.md) — JWT sessions, role-based access control
- [backend/services/notifications.md](notifications.md) — request notification triggers
+1
View File
@@ -48,6 +48,7 @@ Manages background job queue using Bull (Redis-backed) for async tasks: searchin
**search_indexers:**
- No torrents found → 'awaiting_search' status (not failed)
- Allows automatic retry via scheduled job
- Upstream release-date gate: 4 enqueue sites (`request-creator.service`, `retry-missing-torrents.processor`, `monitor-rss-feeds.processor`, `bookdate/swipe/route`) check `shouldSkipAutoSearch` against `indexer.skip_unreleased`; gated requests are created/kept in `awaiting_release` and `addSearchJob` is not called. Manual search bypasses the gate.
**organize_files:**
- No audiobook files found → 'awaiting_import' status
+6 -3
View File
@@ -12,16 +12,18 @@ Manages recurring/scheduled jobs providing automated tasks (Plex scans, Audible
- Schedule editing UI with toast notifications
- Human-friendly schedule descriptions and editor (preset/custom/advanced modes)
- Real-time cron expression preview
- Admin Jobs page shows per-job descriptions inline; startup auto-renames legacy "Plex *" job names to neutral defaults (type-gated, exact-literal match only)
## Scheduled Jobs
1. **plex_library_scan** - Default: every 6 hours, full library scan, disabled by default (enable after setup)
2. **plex_recently_added_check** - Default: every 5 minutes, lightweight polling of top 10 recently added items, enabled by default
3. **audible_refresh** - Default: daily midnight, fetches 200 popular + 200 new releases, stores with rankings, disabled by default
4. **retry_missing_torrents** - Default: daily midnight, re-searches 'awaiting_search' status (limit 50), handles both audiobook and ebook requests, enabled by default
4. **retry_missing_torrents** - Default: daily midnight, processes union of `awaiting_search` `awaiting_release` (limit 50), handles both audiobook and ebook requests. Bidirectional transitions: `awaiting_search``awaiting_release` when release date is future + `indexer.skip_unreleased` ON; `awaiting_release``awaiting_search` + run search when release date has passed or setting OFF. Sole owner of these transitions. Enabled by default.
5. **retry_failed_imports** - Default: every 6 hours, re-attempts 'awaiting_import' status (limit 50), enabled by default
6. **cleanup_seeded_torrents** - Default: every 30 mins, deletes torrents after seeding requirements met, respects `seeding_time_minutes` config (0 = never), enabled by default
7. **monitor_rss_feeds** - Default: every 15 mins, checks RSS feeds from enabled indexers, matches against 'awaiting_search' requests (audiobook and ebook, limit 100), triggers appropriate search jobs for matches, enabled by default
6. **find_missing_ebooks** - Default: daily midnight, scans `downloaded` `available` audiobook requests (limit 50) for missing ebook companions and triggers the existing ebook fetch flow (`addSearchEbookJob`). Gated by `ebook_auto_grab_enabled` AND at least one ebook source enabled (`ebook_annas_archive_enabled` or `ebook_indexer_search_enabled`; legacy `ebook_sidecar_enabled` accepted as Anna's fallback). Skips ebook children in-flight (`pending`, `awaiting_approval`, `searching`, `downloading`, `processing`, `awaiting_search`, `awaiting_release`) or `cancelled`. Retries `failed`/`warn` children up to **5 lifetime auto-retries** per audiobook, tracked in `Request.ebookAutoRetryCount` (nullable; processor-private — manual "Fetch Ebook" never reads/writes it). Per-candidate writes are wrapped in `prisma.$transaction` for race-safety with concurrent auto-grab; counter rolls back if `addSearchEbookJob` throws. Enabled by default. Returns `{ scanned, gapsFound, triggered, created, retried, skippedInFlight, skippedCancelled, skippedCapHit }`.
7. **cleanup_seeded_torrents** - Default: every 30 mins, deletes torrents after seeding requirements met. Respects per-indexer `seedingTimeMinutes` AND `ratioLimit` (BOTH required when set; `0` disables that criterion; both `0` = never cleaned up). Undefined ratio with `ratioLimit > 0` = not met (safe-deny). Enabled by default.
8. **monitor_rss_feeds** - Default: every 15 mins, checks RSS feeds from enabled indexers, matches against `awaiting_search` requests (audiobook and ebook, limit 100). Query is unchanged — release-date gate is applied AFTER a match is found: if matched book is unreleased + `indexer.skip_unreleased` ON, the match is skipped and request status is NOT mutated (retry job owns transitions). Enabled by default.
## Architecture: Bull + Cron
@@ -155,6 +157,7 @@ interface ScheduledJob {
- ✅ Failed requests blocking re-requests → allow re-requesting failed/warn/cancelled
- ✅ Files deleted immediately → kept until seeding requirements met
- ✅ No seeding time config → added `seeding_time_minutes`
- ✅ No ratio-based seeding policy → added per-indexer `ratioLimit` (AND-semantics with `seedingTimeMinutes`; `0` disables; undefined client ratio = safe-deny)
- ✅ Scheduled jobs not running on schedule → implemented Bull repeatable jobs with cron scheduling
- ✅ MaxListenersExceededWarning → increased maxListeners to 20 on both Redis client and Bull queue
- ✅ Cron expressions not user-friendly → added human-readable descriptions and visual schedule builder
+1 -1
View File
@@ -42,7 +42,7 @@ Users customize their home page by adding/removing/reordering sections. Each sec
- **Config Modal:** `src/components/home/HomeSectionConfigModal.tsx` — drag-and-drop (desktop), up/down arrows (mobile), auto-save with debounce
- **Section Component:** `src/components/home/HomeSection.tsx` — renders individual section with color-coded header
- **Home Page:** `src/app/page.tsx` — dynamic sections from user config, gear icon for customize
- **Pagination:** `src/components/ui/UnifiedPagination.tsx`updated to support 1-12 dynamic sections
- **Pagination:** `src/components/ui/UnifiedPagination.tsx`controlled by `HomePage` for `activeIndex`; observer reports dominant section but parent gates updates via `lockedTo` state. Lock set on Prev/Next/jump; released on user scroll input (`wheel` / `touchstart` / Arrow / Page / Home / End keys) or any dot click. Fit-aware scroll via `src/lib/utils/paginationScroll.ts` — no scroll when section fits viewport, otherwise snaps top under sticky header with clamps that structurally prevent scrolling the section out of view. Pill is shown anywhere on main content; only the footer hides it.
## Key Decisions
- 10 section limit per user (total)
@@ -154,6 +154,12 @@ model Audiobook {
- Hash generated AFTER merging
- **Works correctly:** Hash reflects final organized state
### Coerced Files (Plex Format Coercion)
- Files renamed from `.mp4``.m4b` (or single-file `.m4a``.m4b`) by Plex format coercion
- Hash generated AFTER coercion → reflects post-coercion filenames
- **Works correctly going forward:** ABS sees post-coercion names, hash matches
- **Pre-existing library entries** hashed before coercion was enabled will NOT match post-coercion files — retroactive library sweep is out of scope (see issue #166)
### Multiple Downloads (Same Book)
- User re-downloads same audiobook (different edition/request)
- Multiple records with same `filesHash`
+21 -5
View File
@@ -33,10 +33,10 @@ src/components/
- **AudiobookDetailsModal** ✅ - Full-screen modal with comprehensive metadata (description, genres, rating, release date, narrator, language, format, publisher, request functionality). Shows requesting user's name when applicable
**Requests**
- **RequestCard** ✅ - Cover, title, author, status badge, progress bar, timestamps, action buttons (cancel, manual search, interactive search)
- **StatusBadge** - Color-coded status (pending=yellow, searching=blue, downloading=purple, downloaded=green, processing=orange, available=green, completed=green, failed=red, warn=orange, cancelled=gray). Shows "Initializing..." when downloading with 0% progress (fetching torrent info), "Downloading" when progress > 0%
- **RequestCard** ✅ - Cover, title, author, status badge, progress bar, timestamps, action buttons (cancel, manual search, interactive search). When status=`awaiting_release` and `releaseDate` is set, shows "Releases &lt;Mon DD, YYYY&gt;" next to the status badge (UTC-formatted)
- **StatusBadge** - Color-coded status (pending=yellow, awaiting_search=yellow, searching=blue, downloading=purple, downloaded=green, processing=orange, awaiting_import=orange, available=green, completed=green, failed=red, warn=orange, cancelled=gray, awaiting_approval=yellow, awaiting_release=teal "Awaiting Release", denied=red). Shows "Initializing..." when downloading with 0% progress (fetching torrent info), "Downloading" when progress > 0%
- **ProgressBar** - Animated fill with percentage
- **InteractiveTorrentSearchModal** ✅ - Responsive table of ranked torrent results, uses ConfirmModal for downloads, hides columns on smaller screens (size on mobile, seeds on tablet, indexer on desktop)
- **InteractiveTorrentSearchModal** ✅ - Responsive table of ranked torrent results, uses ConfirmModal for downloads, hides columns on smaller screens (size on mobile, seeds on tablet, indexer on desktop). Titles render verbatim; bracketed tags (e.g. `[German]`, `[Unabridged]`) parsed via `extractTitleTags` render as slate chips in the metadata row (de-duped vs `displayFormat`); an explicit chevron-disclosure button toggles per-`guid` expand only when the title is truncated (via `useIsTruncated`), state resets on close
- Active indicator: "Setting up..." with spinner when progress = 0%, "Active" with pulsing dot when progress > 0%
**Forms**
@@ -71,8 +71,12 @@ src/components/
- Floating pagination pill at bottom center of viewport
- Minimal design: section label | ← | Page X of Y | →
- Quick jump input (type page number + Enter)
- Auto-shows when scrolling through a section (IntersectionObserver)
- Auto-scrolls to section top on page change
- Free-scroll tracking via IntersectionObserver (reports dominant section to parent)
- Controlled `activeIndex` lives on the home page; pill is observer-aware but parent-decided
- **Lock-to-section on Prev/Next/jump:** pill stays anchored to the paged section until the user generates a scroll input (`wheel`, `touchstart`, `ArrowUp/Down`, `PageUp/Down`, `Home`, `End`) or clicks another section's dot. 30s safety auto-release.
- **Fit-aware scroll:** if the section already fits below the sticky header, paging swaps cards in place (no scroll). Otherwise snaps the section top under the header with breathing room (8px top, 24px bottom). Target Y is clamped to `[0, maxScrollY]` so paging can never scroll the section out of the viewport.
- Dot click on a different section always scrolls (intentional navigation) and releases any active lock.
- Visibility: pill is shown anywhere on homepage main content; hidden only when the footer enters view. Stays visible over the CTA card gap between the last section and the footer.
- Rounded-full design with backdrop blur and subtle shadow
- Responsive grid layouts (1/2/3/4 cols)
- Enhanced CTA section with gradient background (blue-to-indigo)
@@ -109,10 +113,15 @@ interface AudiobookDetailsModalProps {
isOpen: boolean;
onClose: () => void;
onRequestSuccess?: () => void;
onStatusChange?: (newStatus: string) => void;
onIgnoreChange?: (isIgnored: boolean) => void;
isRequested?: boolean;
requestStatus?: string | null;
isAvailable?: boolean;
requestedByUsername?: string | null;
hideRequestActions?: boolean; // Hides sticky action bar for read-only contexts (BookDate, ShelvesSection)
hasReportedIssue?: boolean;
aiReason?: string | null;
adminActions?: React.ReactNode; // Optional admin buttons (Approve/Search/Deny) rendered as second row in action bar
}
@@ -163,6 +172,13 @@ interface StickyPaginationProps {
sectionRef: React.RefObject<HTMLElement | null>;
label: string;
}
interface UnifiedPaginationProps {
sections: PaginationSection[];
footerRef?: React.RefObject<HTMLElement | null>;
activeIndex: number; // controlled by parent
onDominantSectionChange: (idx: number) => void; // observer guess; parent decides
}
```
## Custom Hooks
@@ -72,6 +72,10 @@ Ebooks are first-class citizens in RMAB, with their own request type, tracking,
- *Auto-grab is automatically disabled if no ebook sources are enabled. Manual fetch via admin buttons still works.*
- *Kindle fix toggle only visible when preferred format is EPUB.*
### Safety-Net: Find Missing Ebooks Job
A scheduled `find_missing_ebooks` job (daily midnight, enabled by default) backstops the auto-grab path for cases where it silently misses books (race conditions, transient indexer failures, requests created before sources were configured, books from Goodreads/Hardcover sync). Per run it scans up to 50 audiobook requests in `downloaded`/`available` status and triggers the existing ebook fetch flow for any audiobook missing a successful ebook companion. **Lifetime auto-retry cap: 5 per audiobook** — after 5 failed auto-attempts the job stops retrying that audiobook (admin Manual "Fetch Ebook" remains available). Counter is tracked in `Request.ebookAutoRetryCount` and is **processor-private**: manual Fetch Ebook routes never read, write, or reset it. Gated by `ebook_auto_grab_enabled` AND at least one source enabled; logs no-op runs honestly. See `documentation/backend/services/scheduler.md` for full details.
### Kindle EPUB Fix
**Purpose:** Apply compatibility fixes to EPUB files before organizing, ensuring successful Kindle import.
+61 -4
View File
@@ -44,10 +44,11 @@ Result: Douglas Adams/Stephen Fry/The Hitchhiker's Guide to the Galaxy/
5. **Copy** files (not move - originals stay for seeding)
6. **Tag metadata** (if enabled) - writes correct title, author, narrator, ASIN to audio files
7. Copy cover art if found, else download from Audible
8. **Generate file hash** - SHA256 of sorted audio filenames for library matching (see: [fixes/file-hash-matching.md](../fixes/file-hash-matching.md))
9. Update request status to `downloaded` and store file hash in `audiobooks.files_hash`
10. **Trigger filesystem scan** (if enabled) - tells Plex/ABS to scan for new files
11. Originals remain until seeding requirements met
8. **Coerce file formats** (if enabled) - rename .mp4 → .m4b and single-file .m4a → .m4b for Plex compatibility (see: Plex Format Coercion below)
9. **Generate file hash** - SHA256 of sorted audio filenames for library matching (see: [fixes/file-hash-matching.md](../fixes/file-hash-matching.md))
10. Update request status to `downloaded` and store file hash in `audiobooks.files_hash`
11. **Trigger filesystem scan** (if enabled) - tells Plex/ABS to scan for new files
12. Originals remain until seeding requirements met
## Filesystem Scan Triggering
@@ -150,6 +151,61 @@ exiftool "audiobook.m4b" | grep -i asin
- Multi-container: `docker exec readmeabook ffmpeg -version`
- Unified: `docker exec readmeabook-unified ffmpeg -version`
## Plex Format Coercion
**Status:** ✅ Implemented | Issue #166
**Purpose:** Rename audiobook files to Plex-recognized extensions before the library scan. Plex silently ignores `.mp4` files in audiobook libraries; this step prevents that silent-failure mode. Rename-only — no transcoding.
**When:** After file organization and metadata tagging, before file-hash generation and before library scan trigger.
**Scope:** Audio path only. Not applied to ebook organization.
**Coercion Table:**
| Source ext | Action |
|---|---|
| `.mp4` | Rename to `.m4b` |
| `.m4a` (single audio file in folder) | Rename to `.m4b` |
| `.m4a` (multi-file folder) | No-op |
| `.m4b`, `.mp3`, `.flac`, `.aac`, `.wav`, `.alac` | No-op |
| `.aa`, `.aax` | No-op + warn ("DRM, Plex cannot import") |
| `.ogg`, `.opus`, `.wma`, other | No-op + warn ("requires transcode, not supported in v1") |
**Configuration:**
- Key: `plex_format_coercion_enabled` (Configuration table)
- Default: `true`
- Read contract: `value !== 'false'` enables (default-on semantics)
- Configurable in: Setup wizard (Paths step), Admin settings (Paths tab)
**Behavior:**
- Each audio file evaluated independently (mixed-format folders supported).
- Pre-rename collision check: if target exists → no-op + info log. Never overwrites.
- Idempotent: re-running on already-coerced folder is a no-op (extension is the signal — no marker files).
- Operates on `targetPath` (organized library files) only — never touches `/downloads` (seeding-safe).
**Failure Isolation:**
- Coercion wrapped in try/catch at processor level.
- Any failure (e.g., EPERM) logs a warning; request remains organized; original file untouched.
- A failed rename never regresses the request to "stuck."
**Tech Stack:**
- `src/lib/utils/format-coercion.ts` — coercion module
- `src/lib/constants/audio-formats.ts``PLEX_COMPATIBLE_EXTENSIONS`, `COERCION_RENAME_MAP`, `DRM_EXTENSIONS`, `TRANSCODE_REQUIRED_EXTENSIONS`
- Invoked from `src/lib/processors/organize-files.processor.ts` between file organization and `generateFilesHash`
- `fs.rename` (same filesystem — no cross-mount issues)
**Hash Interaction:**
- File hash (`audiobooks.files_hash`) is generated AFTER coercion → reflects post-coercion filenames.
- See: [fixes/file-hash-matching.md](../fixes/file-hash-matching.md) for hash semantics.
**Out of Scope (v1):**
- Transcoding (`.ogg`, `.opus`, `.wma`)
- DRM decoding (`.aa`, `.aax`)
- FLAC → M4B (already Plex-recognized)
- Per-request override UI
- Retroactive library sweep (new downloads only)
## Seeding Support
**Config:** `seeding_time_minutes` (0 = unlimited, never cleanup)
@@ -203,6 +259,7 @@ async function organize(
- **Path template:** Read from database config key `audiobook_path_template` (default: `{author}/{title} {asin}`)
- **Metadata tagging:** `metadata_tagging_enabled` (boolean, default: true)
- **Chapter merging:** `chapter_merging_enabled` (boolean, default: false)
- **Plex format coercion:** `plex_format_coercion_enabled` (boolean, default: true)
- **Fallback:** `/media/audiobooks` if media_dir not configured
- **Temp directory:** `/tmp/readmeabook` (or `TEMP_DIR` env var)
+53 -2
View File
@@ -68,7 +68,7 @@ src/app/admin/settings/
2. **Audiobookshelf** - URL, API token (masked), library ID, Audible region, filesystem scan trigger toggle
3. **Prowlarr** - URL, API key (masked), indexer selection with priority, seeding time, RSS monitoring toggle, **audiobook/ebook categories per indexer**
4. **Download Client** - Type (qBittorrent, Transmission, SABnzbd), URL, credentials (masked), custom download path (per-client relative sub-path with live preview)
5. **Paths** - Download + media directories, audiobook organization template, metadata tagging toggle, chapter merging toggle
5. **Paths** - Download + media directories, audiobook organization template, metadata tagging toggle, chapter merging toggle, Plex format coercion toggle
6. **E-book Sidecar** - Multi-source ebook downloads (Anna's Archive + Indexer Search), preferred format
7. **BookDate** - AI provider, API key (encrypted), model selection, library scope, custom prompt, swipe history
8. **Notifications** - Multiple backends (Discord, Pushover), event subscriptions, test functionality
@@ -130,6 +130,25 @@ src/app/admin/settings/
}
```
## Auto-Search Behavior (Indexers tab)
**Purpose:** Control how ReadMeABook performs automatic indexer searches. Lives on the Indexers tab between the Prowlarr connection block and the IndexerManagement list.
**Toggle:** Skip unreleased books in automatic searches
- When ON: auto-search skips books whose release date is in the future. Those requests automatically start searching once the book is released. Manual searches are unaffected.
- When OFF: auto-search proceeds regardless of release date.
**Configuration Key:**
| Key | Default | Category | Description |
|-----|---------|----------|-------------|
| `indexer.skip_unreleased` | `true` (ON) | `indexer` | Skip auto-searches for books with future release dates |
**Read contract (consumed by background workers):**
- `value !== 'false'` → ON (skip enabled). Missing key OR any non-`'false'` value → ON.
- Only the exact string `'false'` disables the toggle. Workers MUST match this.
**API:** Persisted via `PUT /api/admin/settings/indexer-options`. Saved alongside Prowlarr connection + indexer config when the Indexers tab Save button is clicked.
## Audible Region
**Purpose:** Configure which Audible region to use for metadata and search to ensure accurate ASIN matching with your metadata engine.
@@ -203,6 +222,27 @@ src/app/admin/settings/
- When disabled: User relies on media server's filesystem watcher or manual scans
- Error handling: Scan failures logged but don't fail organize job (graceful degradation)
## Plex Format Coercion
**Purpose:** Rename audiobook files to Plex-recognized extensions (`.mp4` → `.m4b`, single-file `.m4a` → `.m4b`) before the library scan. Prevents Plex silently ignoring `.mp4` audiobooks. Rename-only — no transcoding. See: [phase3/file-organization.md](phase3/file-organization.md#plex-format-coercion).
**Configuration:**
- Key: `plex_format_coercion_enabled` (boolean, default: `true`)
- Read contract: `value !== 'false'` enables (default-on)
- Configurable in: Setup wizard (Paths step), Admin settings (Paths tab)
**UI:**
- Checkbox toggle in PathsTab, between metadata tagging and chapter merging
- Default: Checked (enabled)
- Label: "Coerce file formats for Plex compatibility"
- Sub-text: "Rename .mp4 audiobook files (and single-file .m4a) to .m4b before Plex scans. No re-encoding."
**Behavior:**
- When enabled: After organize, rename files per coercion table before scan trigger
- When disabled: Files left as-is (Plex may silently skip `.mp4`)
- Failure isolation: Rename errors logged but don't fail organize job
- Universal (Plex + ABS) — rename is lossless, no per-backend distinction
## Validation Flow
**Plex, Download Client, Paths:**
@@ -279,6 +319,17 @@ src/app/admin/settings/
- No test required if URL/API key unchanged
- Saves only enabled indexers to database
**GET /api/admin/settings/indexer-options**
- Returns `{ skipUnreleased: boolean }`
- Default ON: missing or non-`'false'` value resolves to `true`
- Admin auth required
**PUT /api/admin/settings/indexer-options**
- Updates indexer-wide auto-search options
- Body: `{ skipUnreleased: boolean }` (strict boolean validation)
- Persists `indexer.skip_unreleased` (category: `indexer`)
- No connection test required
**PUT /api/admin/settings/download-client**
- Updates download client config
- Requires prior successful test if credentials changed
@@ -323,7 +374,7 @@ src/app/admin/settings/
## Validation
**Plex:** Valid HTTP/HTTPS URL, non-empty token, library ID selected
**Prowlarr:** Valid URL, non-empty API key, ≥1 indexer configured, priority 1-25, seedingTimeMinutes ≥0, rssEnabled boolean
**Prowlarr:** Valid URL, non-empty API key, ≥1 indexer configured, priority 1-25, seedingTimeMinutes ≥0, ratioLimit ≥0 (torrents only; decimal, `0` = no requirement), rssEnabled boolean
**Download Client:** Valid URL, credentials required, type must be 'qbittorrent', 'transmission', or 'sabnzbd'
**Paths:** Absolute paths, exist or creatable, writable, cannot be same directory, template must contain `{author}` or `{title}`
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "readmeabook",
"version": "1.2.0",
"version": "1.2.1",
"private": true,
"scripts": {
"dev": "next dev",
@@ -0,0 +1,18 @@
-- Add Plex format coercion configuration
-- This allows admin to enable/disable post-organization file-extension rename to Plex-compatible formats
-- Motivation: issue #166 — Plex silently fails to import .mp4 (and some .m4a) audiobook files
-- Coercion is extension-swap only — no re-encoding, no metadata changes
-- Insert default configuration for Plex format coercion (enabled by default)
INSERT INTO configuration (id, key, value, encrypted, category, description, created_at, updated_at)
VALUES (
gen_random_uuid(),
'plex_format_coercion_enabled',
'true',
false,
'automation',
'Rename audio files to Plex-compatible extensions after organization (e.g., .mp4 → .m4b). No re-encoding. Prevents the silent-import failure described in issue #166.',
NOW(),
NOW()
)
ON CONFLICT (key) DO NOTHING;
@@ -0,0 +1,5 @@
-- Add lifetime auto-retry counter for the find_missing_ebooks scheduled job.
-- Nullable: NULL distinguishes "never touched by this job" from 0.
-- Only the find-missing-ebooks processor reads/writes/increments this column.
-- Manual Fetch Ebook routes do not touch it (counter is sacred per engineering brief).
ALTER TABLE "requests" ADD COLUMN "ebook_auto_retry_count" INTEGER;
+40 -1
View File
@@ -223,7 +223,7 @@ model Request {
userId String @map("user_id")
audiobookId String @map("audiobook_id")
status String @default("pending")
// Status values: pending, awaiting_approval, denied, searching, downloading, processing, downloaded, available, failed, cancelled, awaiting_search, awaiting_import, warn
// Status values: pending, awaiting_approval, denied, searching, downloading, processing, downloaded, available, failed, cancelled, awaiting_search, awaiting_import, awaiting_release, warn
// Flow (audiobook): pending → searching → downloading → processing → downloaded → available (when matched in Plex)
// Flow (ebook): pending → searching → downloading → processing → downloaded (terminal - no available state)
progress Int @default(0) // 0-100
@@ -234,12 +234,14 @@ model Request {
downloadAttempts Int @default(0) @map("download_attempts")
importAttempts Int @default(0) @map("import_attempts")
maxImportRetries Int @default(5) @map("max_import_retries")
ebookAutoRetryCount Int? @map("ebook_auto_retry_count")
lastSearchAt DateTime? @map("last_search_at")
customSearchTerms String? @map("custom_search_terms") @db.Text
lastImportAt DateTime? @map("last_import_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
completedAt DateTime? @map("completed_at")
releaseDate DateTime? @map("release_date") @db.Date // Book release date (copied from Audnexus on creation). Used by skip-unreleased-auto-search gate.
// Request type: 'audiobook' (default) or 'ebook'
// Ebook requests are created automatically when an audiobook is organized (if ebook downloads enabled)
@@ -257,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])
@@ -268,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,
};
}
+214
View File
@@ -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 &ldquo;{searchValue}&rdquo;.
</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>
);
}
+185
View File
@@ -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 {
@@ -55,6 +57,7 @@ const STATUS_OPTIONS = [
{ value: 'pending', label: 'Pending' },
{ value: 'awaiting_approval', label: 'Awaiting Approval' },
{ value: 'awaiting_search', label: 'Awaiting Search' },
{ value: 'awaiting_release', label: 'Awaiting Release' },
{ value: 'searching', label: 'Searching' },
{ value: 'downloading', label: 'Downloading' },
{ value: 'processing', label: 'Processing' },
@@ -78,6 +81,7 @@ function getStatusBadge(status: string) {
pending: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200',
awaiting_approval: 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200',
awaiting_search: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200',
awaiting_release: 'bg-teal-100 text-teal-800 dark:bg-teal-900 dark:text-teal-200',
searching: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200',
downloading: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200',
downloaded: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
@@ -95,6 +99,7 @@ function getStatusBadge(status: string) {
const labels: Record<string, string> = {
awaiting_search: 'Awaiting Search',
awaiting_release: 'Awaiting Release',
awaiting_import: 'Awaiting Import',
awaiting_approval: 'Awaiting Approval',
};
@@ -674,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}
@@ -69,8 +69,8 @@ export function RequestActionsDropdown({
const canViewDetails = !isEbook && !!request.asin && !!onViewDetails;
// Determine available actions based on status
const canSearch = ['pending', 'failed', 'awaiting_search'].includes(request.status);
const canAdjustSearchTerms = ['pending', 'failed', 'awaiting_search', 'searching'].includes(request.status);
const canSearch = ['pending', 'failed', 'awaiting_search', 'awaiting_release'].includes(request.status);
const canAdjustSearchTerms = ['pending', 'failed', 'awaiting_search', 'awaiting_release', 'searching'].includes(request.status);
const canRetryDownload = request.status === 'failed' && (request.downloadAttempts ?? 0) > 0 && !!onRetryDownload;
const canCancel = (CANCELLABLE_STATUSES as readonly string[]).includes(request.status);
const canDelete = true; // Admins can always delete
+18 -15
View File
@@ -28,6 +28,22 @@ interface ScheduledJob {
nextRun: string | null;
}
// Plain-English subtitle shown under each job's name on /admin/jobs.
// Keyed by ScheduledJobType. Unknown types render no subtitle (silent absence —
// we never leak raw type keys like `plex_library_scan` into the UI).
const JOB_DESCRIPTIONS: Record<string, string> = {
plex_library_scan: 'Scans your full media library to detect newly added audiobooks.',
plex_recently_added_check: 'Checks for the newest items added to your library since the last scan.',
audible_refresh: 'Refreshes popular & new-release audiobooks from Audible.',
retry_missing_torrents: 'Retries searches for requests that previously found no results.',
retry_failed_imports: 'Re-attempts import for downloads that failed to organize.',
find_missing_ebooks: 'Looks for ebook companions to audiobooks you already have.',
cleanup_seeded_torrents: "Removes torrents once they've met your seeding requirements.",
monitor_rss_feeds: 'Watches indexer RSS feeds for matches against pending requests.',
sync_reading_shelves: 'Pulls new books from your Goodreads/Hardcover shelves.',
check_watched_lists: 'Checks watched series & authors for new releases.',
};
function AdminJobsPageContent() {
const [jobs, setJobs] = useState<ScheduledJob[]>([]);
const [loading, setLoading] = useState(true);
@@ -214,7 +230,7 @@ function AdminJobsPageContent() {
{job.name}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
{job.type}
{JOB_DESCRIPTIONS[job.type] ?? ''}
</div>
</div>
<span
@@ -322,7 +338,7 @@ function AdminJobsPageContent() {
{job.name}
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
{job.type}
{JOB_DESCRIPTIONS[job.type] ?? ''}
</div>
</td>
<td className="px-6 py-4">
@@ -395,19 +411,6 @@ function AdminJobsPageContent() {
)}
</div>
{/* Info Box */}
<div className="mt-6 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<h3 className="text-sm font-medium text-blue-800 dark:text-blue-200 mb-2">
About Scheduled Jobs
</h3>
<ul className="text-sm text-blue-700 dark:text-blue-300 space-y-1">
<li> <strong>Library Scan:</strong> Automatically scans your media library for new audiobooks</li>
<li> <strong>Audible Data Refresh:</strong> Caches popular and new release audiobooks from Audible</li>
<li> Trigger jobs manually using the &quot;Trigger Now&quot; button</li>
<li> Schedule format follows cron syntax (minute hour day month weekday)</li>
</ul>
</div>
{/* Confirmation Dialog */}
{confirmDialog.isOpen && (
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center bg-black bg-opacity-50 p-4">
@@ -0,0 +1,147 @@
/**
* Component: Admin Logs — Active Filter Chips
* Documentation: documentation/admin-dashboard.md
*
* Dismissable chip strip showing every active (non-default) filter PLUS
* the search term and the Errors-only flag. Each chip is a real <button>
* with aria-label="Remove filter: <name>" and a visible × glyph.
*
* Not sticky — scrolls away with content (Zach Resolution #6).
*
* Consumes useLogsUrlState() directly; chips drive removal via removeFilter
* (with a small atomic exception for the date-range chip which clears both
* dateFrom and dateTo at once via setFilters).
*/
'use client';
import { JOB_TYPE_LABELS } from '@/lib/constants/job-labels';
import { getActivePresetId, getStatusLabel, DATE_PRESETS } from '@/lib/constants/log-filters';
import { useLogsUrlState } from '../hooks/useLogsUrlState';
import { useUserSearch } from '../hooks/useUserSearch';
export default function ActiveFilterChips() {
const { filters, setFilters, removeFilter } = useLogsUrlState();
const { findUserById } = useUserSearch();
const chips: ChipDescriptor[] = [];
if (filters.search !== '') {
chips.push({
key: 'search',
name: 'search',
label: `Search: "${filters.search}"`,
onRemove: () => removeFilter('search'),
});
}
if (filters.hasError) {
chips.push({
key: 'hasError',
name: 'errors only',
label: 'Errors only',
onRemove: () => removeFilter('hasError'),
});
}
if (filters.status !== 'all') {
chips.push({
key: 'status',
name: 'status',
label: `Status: ${getStatusLabel(filters.status)}`,
onRemove: () => removeFilter('status'),
});
}
if (filters.type !== 'all') {
const typeLabel = JOB_TYPE_LABELS[filters.type] ?? filters.type;
chips.push({
key: 'type',
name: 'job type',
label: `Type: ${typeLabel}`,
onRemove: () => removeFilter('type'),
});
}
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 (filters.userId !== null) {
const user = findUserById(filters.userId);
chips.push({
key: 'userId',
name: 'user',
label: `User: ${user?.plexUsername ?? filters.userId}`,
onRemove: () => removeFilter('userId'),
});
}
if (filters.audiobookQuery !== '') {
chips.push({
key: 'audiobookQuery',
name: 'audiobook',
label: `Book: "${filters.audiobookQuery}"`,
onRemove: () => removeFilter('audiobookQuery'),
});
}
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,144 @@
/**
* Component: Admin Logs — Date Range Picker
* Documentation: documentation/admin-dashboard.md
*
* Compact preset <select> over DATE_PRESETS plus an optional pair of
* <input type="datetime-local"> fields for Custom mode. Local times entered
* are converted to UTC ISO before being emitted on the wire.
*
* Pause-on-interact: registers `'logs-date-picker'` while the picker subtree
* has focus.
*/
'use client';
import { useMemo, useState } from 'react';
import {
DATE_PRESETS,
getActivePresetId,
presetToRange,
type DatePresetId,
} from '@/lib/constants/log-filters';
import { useRegisterPauseReason } from '../hooks/useAutoRefreshControl';
import { INPUT_CLASS, LABEL_CLASS } from './filter-styles';
interface DateRangePickerProps {
dateFrom: string | null;
dateTo: string | null;
onChange: (next: { dateFrom: string | null; dateTo: string | null }) => void;
}
export default function DateRangePicker({ dateFrom, dateTo, onChange }: DateRangePickerProps) {
const [focused, setFocused] = useState(false);
useRegisterPauseReason('logs-date-picker', focused);
// Force-custom keeps the datetime-local inputs visible while the user is
// entering values — without it, derived state (both null) would snap back
// to "all_time" the moment they pick Custom but before they type anything.
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
onFocus={() => setFocused(true)}
onBlur={(e) => {
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) {
setFocused(false);
}
}}
>
<label className={LABEL_CLASS} htmlFor="logs-date-preset">Date Range</label>
<select
id="logs-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,314 @@
/**
* Component: LogDetailPanel
* Documentation: documentation/admin-dashboard.md
*
* Three collapsible sub-sections (Event Log / Result / Error) with count badges.
* Per-event level filter. Copy-to-clipboard on each event, full event log,
* result JSON, error block, and Bull Job ID. Toast confirmations.
* Default open on desktop (`defaultOpen` prop), collapsed on mobile.
*
* NO "View related request" link — no admin request detail page exists (Zach #4).
*/
'use client';
import { useMemo, useState } from 'react';
import { useToast } from '@/components/ui/Toast';
import { JobEvent, Log } from '../types';
type Level = 'all' | 'info' | 'warn' | 'error';
// ===========================================================================
// CopyButton — extracted because used 5+ times
// ===========================================================================
interface CopyButtonProps {
text: string;
label: string;
className?: string;
/** When true, render as a compact icon-only button. */
iconOnly?: boolean;
}
function CopyButton({ text, label, className, iconOnly = false }: CopyButtonProps) {
const toast = useToast();
const handleClick = async () => {
const ok = await copyToClipboard(text);
if (ok) toast.success(`Copied ${label}`);
else toast.error('Copy unavailable on insecure connection');
};
return (
<button
type="button"
onClick={handleClick}
aria-label={`Copy ${label}`}
className={
className ??
'inline-flex items-center gap-1 px-2 py-1 text-xs font-medium text-gray-600 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors'
}
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
{!iconOnly && <span>Copy</span>}
</button>
);
}
async function copyToClipboard(text: string): Promise<boolean> {
if (typeof navigator !== 'undefined' && navigator.clipboard && window.isSecureContext) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
// fall through to textarea fallback
}
}
try {
const ta = document.createElement('textarea');
ta.value = text;
ta.setAttribute('readonly', '');
ta.style.position = 'fixed';
ta.style.top = '0';
ta.style.left = '0';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
const ok = document.execCommand('copy');
document.body.removeChild(ta);
return ok;
} catch {
return false;
}
}
// ===========================================================================
// EventLine — single row in the event log
// ===========================================================================
function levelColorClass(level: string): string {
if (level === 'error') return 'text-red-400';
if (level === 'warn') return 'text-amber-400';
return 'text-emerald-400';
}
function formatEventLine(e: JobEvent): string {
const ts = (() => {
try {
return new Date(e.createdAt).toISOString().split('T')[1].split('.')[0];
} catch {
return e.createdAt;
}
})();
const meta = e.metadata && Object.keys(e.metadata).length > 0
? '\n' + JSON.stringify(e.metadata, null, 2)
: '';
return `${ts} [${e.level.toUpperCase()}] [${e.context}] ${e.message}${meta}`;
}
function EventLine({ event }: { event: JobEvent }) {
const ts = (() => {
try {
return new Date(event.createdAt).toISOString().split('T')[1].split('.')[0];
} catch {
return event.createdAt;
}
})();
return (
<div className="group relative text-gray-300 leading-relaxed pr-10">
<span className={levelColorClass(event.level)}>[{event.context}]</span>{' '}
<span className="break-words">{event.message}</span>
<span className="text-gray-500 ml-2">{ts}</span>
{event.metadata && Object.keys(event.metadata).length > 0 && (
<pre className="ml-4 mt-1 text-gray-400 text-xs overflow-x-auto">
{JSON.stringify(event.metadata, null, 2)}
</pre>
)}
<div className="absolute top-0 right-0 opacity-0 group-hover:opacity-100 focus-within:opacity-100 transition-opacity">
<CopyButton text={formatEventLine(event)} label="event" iconOnly />
</div>
</div>
);
}
// ===========================================================================
// Collapsible — a sub-section with title, count badge, chevron toggle
// ===========================================================================
interface CollapsibleProps {
title: string;
count?: number;
defaultOpen: boolean;
children: React.ReactNode;
headerRight?: React.ReactNode;
}
function Collapsible({ title, count, defaultOpen, children, headerRight }: CollapsibleProps) {
const [open, setOpen] = useState(defaultOpen);
return (
<div>
<div className="flex items-center justify-between gap-2 mb-2">
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
className="inline-flex items-center gap-1.5 min-h-[44px] py-1 text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide hover:text-gray-900 dark:hover:text-gray-100"
>
<svg
className={`w-3.5 h-3.5 transition-transform duration-200 ${open ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
<span>{title}</span>
{typeof count === 'number' && (
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded-full bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 normal-case tracking-normal">
{count}
</span>
)}
</button>
{open && headerRight}
</div>
{open && children}
</div>
);
}
// ===========================================================================
// LogDetailPanel
// ===========================================================================
interface LogDetailPanelProps {
log: Log;
/** Default-open state for the three sub-sections. Desktop: true; Mobile: false. */
defaultOpen: boolean;
}
export function LogDetailPanel({ log, defaultOpen }: LogDetailPanelProps) {
const [level, setLevel] = useState<Level>('all');
const filteredEvents = useMemo(() => {
if (level === 'all') return log.events;
return log.events.filter((e) => e.level === level);
}, [log.events, level]);
const fullEventLog = useMemo(
() => log.events.map(formatEventLine).join('\n'),
[log.events]
);
const resultText = useMemo(
() => (log.result ? JSON.stringify(log.result, null, 2) : ''),
[log.result]
);
const hasResult = !!(log.result && Object.keys(log.result).length > 0);
return (
<div className="space-y-4">
{log.bullJobId && (
<div className="flex flex-wrap gap-1.5 items-center">
<span className="text-xs font-medium text-gray-500 dark:text-gray-400">
Bull Job ID:
</span>
<span className="text-xs text-gray-700 dark:text-gray-300 font-mono break-all">
{log.bullJobId}
</span>
<CopyButton text={log.bullJobId} label="Bull Job ID" />
</div>
)}
{log.events.length > 0 && (
<Collapsible
title="Event Log"
count={log.events.length}
defaultOpen={defaultOpen}
headerRight={
<div className="flex items-center gap-2">
<LevelFilterPills value={level} onChange={setLevel} />
<CopyButton text={fullEventLog} label="full event log" />
</div>
}
>
{filteredEvents.length === 0 ? (
<div className="text-xs text-gray-500 dark:text-gray-400 italic">
No events at level &quot;{level}&quot;.
</div>
) : (
<div className="space-y-px max-h-72 sm:max-h-96 overflow-y-auto bg-gray-950 dark:bg-black/60 rounded-xl p-3 font-mono text-xs">
{filteredEvents.map((event) => (
<EventLine key={event.id} event={event} />
))}
</div>
)}
</Collapsible>
)}
{hasResult && (
<Collapsible
title="Job Result"
defaultOpen={defaultOpen}
headerRight={<CopyButton text={resultText} label="result" />}
>
<pre className="p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-xl text-xs text-blue-900 dark:text-blue-300 font-mono overflow-x-auto max-h-48">
{resultText}
</pre>
</Collapsible>
)}
{log.errorMessage && (
<Collapsible
title="Error"
defaultOpen={defaultOpen}
headerRight={<CopyButton text={log.errorMessage} label="error" />}
>
<div className="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-xl text-xs text-red-700 dark:text-red-300 font-mono whitespace-pre-wrap break-words max-h-72 overflow-y-auto">
{log.errorMessage}
</div>
</Collapsible>
)}
</div>
);
}
// ===========================================================================
// LevelFilterPills — small group toggle
// ===========================================================================
function LevelFilterPills({
value,
onChange,
}: {
value: Level;
onChange: (next: Level) => void;
}) {
const options: { key: Level; label: string }[] = [
{ key: 'all', label: 'All' },
{ key: 'info', label: 'Info' },
{ key: 'warn', label: 'Warn' },
{ key: 'error', label: 'Error' },
];
return (
<div className="inline-flex rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
{options.map((opt) => (
<button
key={opt.key}
type="button"
onClick={() => onChange(opt.key)}
aria-pressed={value === opt.key}
className={`px-2 py-1 text-xs font-medium transition-colors ${
value === opt.key
? 'bg-blue-600 text-white'
: 'bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700'
}`}
>
{opt.label}
</button>
))}
</div>
);
}
+318
View File
@@ -0,0 +1,318 @@
/**
* Component: LogRow (Desktop + Mobile wrappers + shared cell helpers)
* Documentation: documentation/admin-dashboard.md
*
* One file, one source of truth for cell logic, two layout shells:
* - <LogRow.Desktop> → renders <tr> (inside the desktop table)
* - <LogRow.Mobile> → renders <div> (inside the mobile card list)
* Cell helpers (<RowTime>, <RowType>, <RowStatus>, etc.) are pure and used
* by both shells. No duplicated logic; layout split is just JSX containers.
*
* Disclosure: real <button> with rotating chevron. NOT a "Show Details"
* text link, NOT a whole-row click. 44×44 min touch target.
*/
'use client';
import { useEffect, useState } from 'react';
import { JOB_TYPE_LABELS } from '@/lib/constants/job-labels';
import { Log, logHasDetails } from '../types';
import { LogDetailPanel } from './LogDetailPanel';
import { useAutoRefreshControl } from '../hooks/useAutoRefreshControl';
// ===========================================================================
// Formatters
// ===========================================================================
function formatJobType(type: string): string {
return (
JOB_TYPE_LABELS[type] ??
type.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase())
);
}
function formatDuration(startedAt: string | null, completedAt: string | null): string {
if (!startedAt) return 'N/A';
if (!completedAt) return 'Running…';
const ms = new Date(completedAt).getTime() - new Date(startedAt).getTime();
const s = Math.floor(ms / 1000);
const m = Math.floor(s / 60);
const h = Math.floor(m / 60);
if (h > 0) return `${h}h ${m % 60}m`;
if (m > 0) return `${m}m ${s % 60}s`;
return `${s}s`;
}
function formatRelativeTime(iso: string, now: number): string {
const t = new Date(iso).getTime();
if (Number.isNaN(t)) return iso;
const elapsed = Math.max(0, now - t);
const s = Math.floor(elapsed / 1000);
if (s < 60) return `${s}s ago`;
const m = Math.floor(s / 60);
if (m < 60) return `${m}m ago`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h ago`;
const d = Math.floor(h / 24);
return `${d}d ago`;
}
function formatAbsoluteTime(iso: string): string {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return d.toLocaleString();
}
// ===========================================================================
// Status badge (lifted from previous logs page; same visual)
// ===========================================================================
function StatusBadge({ status }: { status: string }) {
const config: Record<string, { dot: string; text: string; bg: string }> = {
completed: { dot: 'bg-emerald-500', text: 'text-emerald-700 dark:text-emerald-400', bg: 'bg-emerald-500/10' },
failed: { dot: 'bg-red-500', text: 'text-red-700 dark:text-red-400', bg: 'bg-red-500/10' },
active: { dot: 'bg-blue-500', text: 'text-blue-700 dark:text-blue-400', bg: 'bg-blue-500/10' },
pending: { dot: 'bg-amber-500', text: 'text-amber-700 dark:text-amber-400', bg: 'bg-amber-500/10' },
delayed: { dot: 'bg-orange-500', text: 'text-orange-700 dark:text-orange-400', bg: 'bg-orange-500/10' },
stuck: { dot: 'bg-purple-500', text: 'text-purple-700 dark:text-purple-400', bg: 'bg-purple-500/10' },
};
const c = config[status] ?? { dot: 'bg-gray-400', text: 'text-gray-600 dark:text-gray-400', bg: 'bg-gray-500/10' };
return (
<span className={`inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium ${c.bg} ${c.text}`}>
<span className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${c.dot}`} />
{status.charAt(0).toUpperCase() + status.slice(1)}
</span>
);
}
// ===========================================================================
// Shared cell helpers — used by BOTH desktop tr and mobile div
// ===========================================================================
function RowTime({ log, now }: { log: Log; now: number }) {
return (
<span
className="text-sm text-gray-900 dark:text-gray-100"
title={formatAbsoluteTime(log.createdAt)}
>
{formatRelativeTime(log.createdAt, now)}
</span>
);
}
function RowType({ log }: { log: Log }) {
return (
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
{formatJobType(log.type)}
</span>
);
}
function RowRelatedItem({ log }: { log: Log }) {
if (!log.request?.audiobook) {
return <span className="text-sm text-gray-500 dark:text-gray-400">System job</span>;
}
return (
<div className="text-sm">
<div className="font-medium text-gray-900 dark:text-gray-100">
{log.request.audiobook.title}
</div>
<div className="text-gray-500 dark:text-gray-400">
by {log.request.audiobook.author}
</div>
<div className="text-xs text-gray-400 dark:text-gray-500">
User: {log.request.user.plexUsername}
</div>
</div>
);
}
function RowDuration({ log }: { log: Log }) {
return (
<span className="text-sm text-gray-500 dark:text-gray-400">
{formatDuration(log.startedAt, log.completedAt)}
</span>
);
}
function RowAttempts({ log }: { log: Log }) {
return (
<span className="text-sm text-gray-500 dark:text-gray-400">
{log.attempts}/{log.maxAttempts}
</span>
);
}
interface DisclosureButtonProps {
log: Log;
expanded: boolean;
detailPanelId: string;
onToggle: () => void;
}
function RowDisclosureButton({ log, expanded, detailPanelId, onToggle }: DisclosureButtonProps) {
if (!logHasDetails(log)) return null;
return (
<button
type="button"
onClick={onToggle}
aria-expanded={expanded}
aria-controls={detailPanelId}
aria-label={expanded ? 'Hide details' : 'Show details'}
className="inline-flex items-center justify-center min-w-[44px] min-h-[44px] w-11 h-11 rounded-lg text-blue-600 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/20 transition-colors"
>
<svg
className={`w-5 h-5 transition-transform duration-200 ${expanded ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
);
}
// ===========================================================================
// Shared expansion + clock state hook
// ===========================================================================
function useRowState(log: Log) {
const [expanded, setExpanded] = useState(false);
const { register, unregister } = useAutoRefreshControl();
// While this row is expanded, register a pause reason.
useEffect(() => {
if (!expanded) return;
const reason = `row-expanded:${log.id}`;
register(reason);
return () => unregister(reason);
}, [expanded, log.id, register, unregister]);
const detailPanelId = `log-detail-${log.id}`;
const toggle = () => setExpanded((v) => !v);
return { expanded, toggle, detailPanelId };
}
function useNowTick(intervalMs = 30_000): number {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
const id = setInterval(() => setNow(Date.now()), intervalMs);
return () => clearInterval(id);
}, [intervalMs]);
return now;
}
// ===========================================================================
// Desktop wrapper — <tr>
// ===========================================================================
interface RowProps {
log: Log;
}
function LogRowDesktop({ log }: RowProps) {
const { expanded, toggle, detailPanelId } = useRowState(log);
const now = useNowTick();
return (
<>
<tr className="hover:bg-gray-50 dark:hover:bg-gray-700">
<td className="px-6 py-4 whitespace-nowrap">
<RowTime log={log} now={now} />
</td>
<td className="px-6 py-4 whitespace-nowrap">
<RowType log={log} />
</td>
<td className="px-6 py-4 whitespace-nowrap">
<StatusBadge status={log.status} />
</td>
<td className="px-6 py-4">
<RowRelatedItem log={log} />
</td>
<td className="px-6 py-4 whitespace-nowrap">
<RowDuration log={log} />
</td>
<td className="px-6 py-4 whitespace-nowrap">
<RowAttempts log={log} />
</td>
<td className="px-6 py-4 whitespace-nowrap text-right">
<RowDisclosureButton
log={log}
expanded={expanded}
detailPanelId={detailPanelId}
onToggle={toggle}
/>
</td>
</tr>
{expanded && (
<tr>
<td colSpan={7} id={detailPanelId} className="px-6 py-4 bg-gray-50 dark:bg-gray-900">
<LogDetailPanel log={log} defaultOpen={true} />
</td>
</tr>
)}
</>
);
}
// ===========================================================================
// Mobile wrapper — <div>
// ===========================================================================
function LogRowMobile({ log }: RowProps) {
const { expanded, toggle, detailPanelId } = useRowState(log);
const now = useNowTick();
const hasDetails = logHasDetails(log);
return (
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
<div className="px-4 py-3">
<div className="flex items-start justify-between gap-3 mb-2">
<div className="font-semibold text-gray-900 dark:text-gray-100 text-sm leading-snug">
<RowType log={log} />
</div>
<StatusBadge status={log.status} />
</div>
<div className="mb-2">
<RowRelatedItem log={log} />
</div>
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-gray-500 dark:text-gray-400">
<RowTime log={log} now={now} />
<span>Duration: {formatDuration(log.startedAt, log.completedAt)}</span>
<span>Attempts: {log.attempts}/{log.maxAttempts}</span>
</div>
</div>
{hasDetails && (
<>
<div className="flex items-center justify-between px-2 py-1 border-t border-gray-100 dark:border-gray-700/60">
<span className="text-xs font-medium text-gray-500 dark:text-gray-400 px-2">
{expanded ? 'Hide details' : 'Show details'}
</span>
<RowDisclosureButton
log={log}
expanded={expanded}
detailPanelId={detailPanelId}
onToggle={toggle}
/>
</div>
{expanded && (
<div
id={detailPanelId}
className="px-4 pb-4 pt-3 bg-gray-50 dark:bg-gray-900/50 border-t border-gray-100 dark:border-gray-700/60"
>
<LogDetailPanel log={log} defaultOpen={false} />
</div>
)}
</>
)}
</div>
);
}
// ===========================================================================
// Public exports
// ===========================================================================
export const LogRow = {
Desktop: LogRowDesktop,
Mobile: LogRowMobile,
};
@@ -0,0 +1,82 @@
/**
* Component: LogSkeleton
* Documentation: documentation/admin-dashboard.md
*
* Shape-matched skeleton rows. Shown only on initial load (`!data`) or on
* filter-key transition — never during auto-refresh (which preserves rows).
*
* Layout intentionally mirrors LogRow so swap is reflow-free.
*/
'use client';
interface LogSkeletonProps {
/** How many skeleton rows to render. Default 6. */
count?: number;
}
export function LogSkeleton({ count = 6 }: LogSkeletonProps) {
const items = Array.from({ length: count }, (_, i) => i);
return (
<>
{/* Mobile card skeletons */}
<div className="space-y-3 sm:hidden" data-testid="log-skeleton-mobile">
{items.map((i) => (
<div
key={i}
className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4 animate-pulse"
>
<div className="flex items-start justify-between gap-3 mb-3">
<div className="h-4 w-32 rounded bg-gray-200 dark:bg-gray-700" />
<div className="h-5 w-20 rounded-full bg-gray-200 dark:bg-gray-700" />
</div>
<div className="h-3 w-48 rounded bg-gray-200 dark:bg-gray-700 mb-1.5" />
<div className="h-3 w-36 rounded bg-gray-200 dark:bg-gray-700 mb-3" />
<div className="flex gap-4">
<div className="h-3 w-14 rounded bg-gray-200 dark:bg-gray-700" />
<div className="h-3 w-20 rounded bg-gray-200 dark:bg-gray-700" />
<div className="h-3 w-16 rounded bg-gray-200 dark:bg-gray-700" />
</div>
</div>
))}
</div>
{/* Desktop table skeletons */}
<div
className="hidden sm:block bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden"
data-testid="log-skeleton-desktop"
>
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
{items.map((i) => (
<tr key={i} className="animate-pulse">
<td className="px-6 py-4 whitespace-nowrap">
<div className="h-4 w-24 rounded bg-gray-200 dark:bg-gray-700" />
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="h-4 w-32 rounded bg-gray-200 dark:bg-gray-700" />
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="h-5 w-20 rounded-full bg-gray-200 dark:bg-gray-700" />
</td>
<td className="px-6 py-4">
<div className="h-4 w-48 rounded bg-gray-200 dark:bg-gray-700 mb-1" />
<div className="h-3 w-32 rounded bg-gray-200 dark:bg-gray-700" />
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="h-3 w-12 rounded bg-gray-200 dark:bg-gray-700" />
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="h-3 w-10 rounded bg-gray-200 dark:bg-gray-700" />
</td>
<td className="px-6 py-4 whitespace-nowrap text-right">
<div className="h-8 w-8 rounded-lg bg-gray-200 dark:bg-gray-700 ml-auto" />
</td>
</tr>
))}
</tbody>
</table>
</div>
</>
);
}
@@ -0,0 +1,171 @@
/**
* Component: Admin Logs — Filter Picker Row
* Documentation: documentation/admin-dashboard.md
*
* Composition of five picker controls in a responsive grid plus a
* "Clear all filters" affordance. Heavier controls (DateRangePicker and
* UserTypeahead) live in sibling files to keep this composition file
* comfortably under the per-file size cap.
*
* Status select · Job Type select · Date Range · User typeahead · Audiobook text
*
* Each control registers a unique pause-on-interact reason so the page-level
* auto-refresh halts while the admin is mid-interaction.
*
* Consumes useLogsUrlState() directly — no prop drilling.
*/
'use client';
import { useState } from 'react';
import { JOB_TYPE_LABELS } from '@/lib/constants/job-labels';
import { STATUS_OPTIONS } from '@/lib/constants/log-filters';
import { hasActiveFilters, hasActiveSearch } from '../types';
import { useRegisterPauseReason } from '../hooks/useAutoRefreshControl';
import { useLogsUrlState } from '../hooks/useLogsUrlState';
import DateRangePicker from './DateRangePicker';
import UserTypeahead from './UserTypeahead';
import { INPUT_CLASS, LABEL_CLASS } from './filter-styles';
export default function LogsFilters() {
const { filters, setFilters, clearAll } = useLogsUrlState();
const showClearAll = hasActiveFilters(filters) || hasActiveSearch(filters);
return (
<div className="mb-4">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-3 sm:gap-4">
<StatusDropdown
value={filters.status}
onChange={(value) => setFilters({ status: value })}
/>
<JobTypeDropdown
value={filters.type}
onChange={(value) => setFilters({ type: value })}
/>
<DateRangePicker
dateFrom={filters.dateFrom}
dateTo={filters.dateTo}
onChange={(next) => setFilters(next)}
/>
<UserTypeahead
userId={filters.userId}
onChange={(id) => setFilters({ userId: id })}
/>
<AudiobookInput
value={filters.audiobookQuery}
onChange={(value) => setFilters({ audiobookQuery: value })}
/>
</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>
);
}
// ---------------------------------------------------------------------------
// Status dropdown
// ---------------------------------------------------------------------------
function StatusDropdown({
value,
onChange,
}: {
value: string;
onChange: (value: string) => void;
}) {
const [focused, setFocused] = useState(false);
useRegisterPauseReason('logs-status-dropdown', focused);
return (
<div>
<label className={LABEL_CLASS} htmlFor="logs-status-filter">Status</label>
<select
id="logs-status-filter"
value={value}
onChange={(e) => onChange(e.target.value)}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
className={INPUT_CLASS}
>
{STATUS_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
);
}
// ---------------------------------------------------------------------------
// Job-type dropdown
// ---------------------------------------------------------------------------
function JobTypeDropdown({
value,
onChange,
}: {
value: string;
onChange: (value: string) => void;
}) {
const [focused, setFocused] = useState(false);
useRegisterPauseReason('logs-type-dropdown', focused);
return (
<div>
<label className={LABEL_CLASS} htmlFor="logs-type-filter">Job Type</label>
<select
id="logs-type-filter"
value={value}
onChange={(e) => onChange(e.target.value)}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
className={INPUT_CLASS}
>
<option value="all">All Types</option>
{Object.entries(JOB_TYPE_LABELS).map(([key, label]) => (
<option key={key} value={key}>
{label}
</option>
))}
</select>
</div>
);
}
// ---------------------------------------------------------------------------
// Audiobook free-text input (matches title OR author server-side)
// ---------------------------------------------------------------------------
function AudiobookInput({
value,
onChange,
}: {
value: string;
onChange: (value: string) => void;
}) {
const [focused, setFocused] = useState(false);
useRegisterPauseReason('logs-book-input', focused);
return (
<div>
<label className={LABEL_CLASS} htmlFor="logs-audiobook-input">Audiobook</label>
<input
id="logs-audiobook-input"
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
placeholder="Title or author"
className={INPUT_CLASS}
/>
</div>
);
}
@@ -0,0 +1,140 @@
/**
* Component: LogsPagination
* Documentation: documentation/admin-dashboard.md
*
* Prev/next + jump-to-page + page-size selector + "Page X of Y · N total logs".
* Keyboard accessible. Each interactive element ≥ 44×44 touch target.
* Reading the page-size opens registers a pause-on-interact reason.
*/
'use client';
import { useEffect, useState } from 'react';
import { VALID_LIMITS, ValidLimit, LogsPagination as PaginationData } from '../types';
import { useAutoRefreshControl } from '../hooks/useAutoRefreshControl';
interface LogsPaginationProps {
pagination: PaginationData;
onPageChange: (next: number) => void;
onLimitChange: (next: ValidLimit) => void;
}
export function LogsPagination({
pagination,
onPageChange,
onLimitChange,
}: LogsPaginationProps) {
const { page, limit, total, totalPages } = pagination;
const [jumpValue, setJumpValue] = useState(String(page));
const [limitFocused, setLimitFocused] = useState(false);
const { register, unregister } = useAutoRefreshControl();
// Keep jump input in sync when page changes from outside.
useEffect(() => {
setJumpValue(String(page));
}, [page]);
// Pause auto-refresh while the limit dropdown is focused/open.
useEffect(() => {
if (limitFocused) register('page-size-dropdown');
else unregister('page-size-dropdown');
return () => unregister('page-size-dropdown');
}, [limitFocused, register, unregister]);
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">
{/* Summary + limit */}
<div className="flex flex-wrap items-center gap-3 sm:gap-4 text-sm text-gray-600 dark:text-gray-400">
<span data-testid="logs-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 ? 'log' : 'logs'}
</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)}
onFocus={() => setLimitFocused(true)}
onBlur={() => setLimitFocused(false)}
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>
{/* Nav controls */}
<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">
<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">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
</div>
);
}
@@ -0,0 +1,203 @@
/**
* Component: LogsToolbar
* Documentation: documentation/admin-dashboard.md
*
* Sticky header. Three rows on mobile, condensed to two on sm+:
* 1. Title + description (left), Back-to-dashboard (right)
* 2. Errors-only pill, Live indicator, Refresh now, Auto-refresh toggle
* 3. Search input (always visible on mobile, debounced 300ms via the URL hook)
*
* Chips (ben-filters) and filter dropdowns (ben-filters) render OUTSIDE this
* toolbar (in page.tsx) so they scroll away on mobile per Zach resolution #6.
*/
'use client';
import Link from 'next/link';
import { useEffect, useState } from 'react';
import { useLogsUrlState } from '../hooks/useLogsUrlState';
import { useAutoRefreshControl } from '../hooks/useAutoRefreshControl';
function formatRelativeSeconds(ts: number, now: number): string {
if (ts === 0) return '—';
const elapsedMs = Math.max(0, now - ts);
const s = Math.floor(elapsedMs / 1000);
if (s < 60) return `${s}s ago`;
const m = Math.floor(s / 60);
if (m < 60) return `${m}m ago`;
const h = Math.floor(m / 60);
return `${h}h ago`;
}
export function LogsToolbar() {
const { filters, setFilters, searchInput, setSearchInput, removeFilter } =
useLogsUrlState();
const {
isPaused,
isRunning,
pauseReasons,
enabled,
setEnabled,
manualRefresh,
lastUpdatedAt,
register,
unregister,
} = useAutoRefreshControl();
const [searchFocused, setSearchFocused] = useState(false);
useEffect(() => {
if (searchFocused) register('search-input');
else unregister('search-input');
return () => unregister('search-input');
}, [searchFocused, register, unregister]);
// Tick once a second so "updated Xs ago" stays fresh.
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
const id = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(id);
}, []);
const errorsOnlyActive = filters.hasError;
const indicatorText = isPaused
? 'Paused'
: `Live · updated ${formatRelativeSeconds(lastUpdatedAt, now)}`;
const indicatorTitle = isPaused
? pauseReasons.length > 0
? `Paused: ${pauseReasons.join(', ')}`
: 'Paused'
: `Auto-refreshing every 10s${
lastUpdatedAt
? ` · last update ${new Date(lastUpdatedAt).toLocaleTimeString()}`
: ''
}`;
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">
System Logs
</h1>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
View background jobs and system activity
</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">
<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: errors-only pill + live indicator + refresh + auto-toggle */}
<div className="flex flex-wrap items-center gap-2 mt-4">
<button
type="button"
onClick={() => {
if (errorsOnlyActive) removeFilter('hasError');
else setFilters({ hasError: true });
}}
aria-pressed={errorsOnlyActive}
className={`inline-flex items-center gap-1.5 min-h-[44px] px-3.5 py-2 rounded-full text-sm font-medium transition-colors ${
errorsOnlyActive
? 'bg-red-600 text-white hover:bg-red-700'
: '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'
}`}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
</svg>
Errors only
</button>
<div
className="inline-flex items-center gap-1.5 min-h-[44px] px-3 py-2 rounded-full text-sm font-medium bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700"
title={indicatorTitle}
aria-label={indicatorTitle}
data-testid="logs-live-indicator"
data-state={isPaused ? 'paused' : 'running'}
>
<span
className={`w-2 h-2 rounded-full flex-shrink-0 ${
isRunning ? 'bg-green-500 animate-pulse' : 'bg-amber-500'
}`}
/>
<span className="text-gray-700 dark:text-gray-300">{indicatorText}</span>
</div>
<button
type="button"
onClick={manualRefresh}
className="inline-flex items-center gap-1.5 min-h-[44px] px-3.5 py-2 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
aria-label="Refresh now"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" />
</svg>
<span className="hidden sm:inline">Refresh now</span>
<span className="sm:hidden">Refresh</span>
</button>
<label className="inline-flex items-center gap-2 ml-auto cursor-pointer">
<span className="text-sm text-gray-600 dark:text-gray-400">Auto-refresh</span>
<button
type="button"
role="switch"
aria-checked={enabled}
aria-label="Auto-refresh"
onClick={() => setEnabled(!enabled)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
enabled ? 'bg-blue-600' : 'bg-gray-300 dark:bg-gray-600'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
enabled ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</label>
</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">
<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)}
onFocus={() => setSearchFocused(true)}
onBlur={() => setSearchFocused(false)}
placeholder="Search by job ID, error, event, book, or user…"
aria-label="Search logs"
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">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</div>
</div>
);
}
@@ -0,0 +1,165 @@
/**
* Component: Admin Logs — User Typeahead
* Documentation: documentation/admin-dashboard.md
*
* Combobox input + suggestion popover sourced from useUserSearch (fetch-once,
* SWR-cached, in-memory filter). Keyboard-navigable: ArrowUp/ArrowDown +
* Enter + Escape. Selection emits the user's id; the clear × button emits
* null so the filter resets.
*
* Pause-on-interact: registers `'logs-user-typeahead'` while the popover is open.
*/
'use client';
import { useEffect, useId, useMemo, useRef, useState } from 'react';
import { useRegisterPauseReason } from '../hooks/useAutoRefreshControl';
import { useUserSearch, type UserSearchUser } from '../hooks/useUserSearch';
import { INPUT_CLASS, LABEL_CLASS } from './filter-styles';
interface UserTypeaheadProps {
userId: string | null;
onChange: (id: string | null) => void;
}
export default function UserTypeahead({ userId, onChange }: UserTypeaheadProps) {
const { filterByQuery, findUserById, isLoading } = useUserSearch();
const selected = findUserById(userId);
const [query, setQuery] = useState<string>(selected?.plexUsername ?? '');
const [open, setOpen] = useState(false);
const [activeIdx, setActiveIdx] = useState<number>(-1);
const containerRef = useRef<HTMLDivElement | null>(null);
const listboxId = useId();
useRegisterPauseReason('logs-user-typeahead', open);
// Sync visible text if userId changes externally (e.g. chip dismissal).
useEffect(() => {
setQuery(selected?.plexUsername ?? '');
}, [selected?.plexUsername]);
const suggestions = useMemo(
() => (open ? filterByQuery(query) : []),
[open, query, filterByQuery]
);
const handleSelect = (user: UserSearchUser) => {
onChange(user.id);
setQuery(user.plexUsername);
setOpen(false);
setActiveIdx(-1);
};
const handleClear = () => {
onChange(null);
setQuery('');
setActiveIdx(-1);
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
setOpen(true);
setActiveIdx((idx) => Math.min(idx + 1, suggestions.length - 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setActiveIdx((idx) => Math.max(idx - 1, 0));
} else if (e.key === 'Enter') {
if (activeIdx >= 0 && suggestions[activeIdx]) {
e.preventDefault();
handleSelect(suggestions[activeIdx]);
}
} else if (e.key === 'Escape') {
setOpen(false);
setActiveIdx(-1);
}
};
// Close on outside click.
useEffect(() => {
if (!open) return;
const onDocClick = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false);
setActiveIdx(-1);
}
};
document.addEventListener('mousedown', onDocClick);
return () => document.removeEventListener('mousedown', onDocClick);
}, [open]);
return (
<div ref={containerRef} className="relative">
<label className={LABEL_CLASS} htmlFor="logs-user-typeahead">User</label>
<div className="relative">
<input
id="logs-user-typeahead"
type="text"
role="combobox"
aria-expanded={open}
aria-controls={listboxId}
aria-autocomplete="list"
value={query}
placeholder={isLoading ? 'Loading users…' : 'Search by plex username'}
onChange={(e) => {
setQuery(e.target.value);
setOpen(true);
setActiveIdx(-1);
}}
onFocus={() => setOpen(true)}
onKeyDown={handleKeyDown}
className={`${INPUT_CLASS} pr-9`}
/>
{query && (
<button
type="button"
onClick={handleClear}
aria-label="Clear user filter"
className="absolute right-2 top-1/2 -translate-y-1/2 p-1.5 text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700/60"
>
<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>
{open && suggestions.length > 0 && (
<ul
id={listboxId}
role="listbox"
className="absolute z-20 mt-1 w-full max-h-64 overflow-y-auto bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg"
>
{suggestions.map((user, idx) => {
const isActive = idx === activeIdx;
return (
<li
key={user.id}
role="option"
aria-selected={isActive}
className={`px-3 py-2 text-sm cursor-pointer ${
isActive
? 'bg-blue-50 dark:bg-blue-900/30 text-blue-900 dark:text-blue-200'
: 'text-gray-900 dark:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-700/60'
}`}
onMouseDown={(e) => {
// onMouseDown so the input's blur doesn't fire first and close us.
e.preventDefault();
handleSelect(user);
}}
onMouseEnter={() => setActiveIdx(idx)}
>
<span className="font-medium">{user.plexUsername}</span>
<span className="ml-2 text-xs text-gray-500 dark:text-gray-400">{user.role}</span>
</li>
);
})}
</ul>
)}
{open && !isLoading && suggestions.length === 0 && query.trim() !== '' && (
<div className="absolute z-20 mt-1 w-full px-3 py-2 text-sm text-gray-500 dark:text-gray-400 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg">
No users match &ldquo;{query}&rdquo;
</div>
)}
</div>
);
}
@@ -0,0 +1,16 @@
/**
* Component: Admin Logs — Shared Filter Control Styles
* Documentation: documentation/admin-dashboard.md
*
* One source of truth for the input / label class strings used by every
* picker in LogsFilters and its split-out siblings (DateRangePicker,
* UserTypeahead). Centralized so the five controls stay visually identical.
*/
export const INPUT_CLASS =
'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-blue-500 ' +
'focus:outline-none text-sm min-h-[44px]';
export const LABEL_CLASS =
'block text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-1.5';
@@ -0,0 +1,210 @@
/**
* Component: useAutoRefreshControl Hook
* Documentation: documentation/admin-dashboard.md
*
* Pause-on-interact registry shared across the logs page:
* - Components call register(reason) on focus/open and unregister(reason) on blur/close.
* - Non-empty reasons → paused (SWR refreshInterval=0). Empty → 10s polling.
* - 250ms debounce on pause-EXIT prevents "Paused" indicator flicker when a
* dropdown is opened-and-immediately-closed.
* - User-controlled off toggle persists to sessionStorage (per-tab).
* - manualRefresh() is provided to fire an out-of-band refetch.
*
* Singleton pattern: the page calls `useAutoRefreshControlProvider()` to OWN
* the state, child components call `useAutoRefreshControl()` to CONSUME it
* via the shared context.
*/
'use client';
import {
createContext,
ReactNode,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
createElement,
} from 'react';
const REFRESH_INTERVAL_MS = 10_000;
const PAUSE_EXIT_DEBOUNCE_MS = 250;
const STORAGE_KEY = 'admin-logs:auto-refresh-enabled';
export interface AutoRefreshControl {
/** True when auto-refresh is currently effectively running (not paused, user-enabled). */
isRunning: boolean;
/** True when paused for any reason (interaction OR user toggle off). */
isPaused: boolean;
/** Stable list of human-readable pause reasons for the tooltip. */
pauseReasons: string[];
/** User toggle — when false, auto-refresh is forced off regardless of interactions. */
enabled: boolean;
setEnabled: (next: boolean) => void;
/** SWR refreshInterval value to pass: REFRESH_INTERVAL_MS when running, 0 when paused. */
effectiveInterval: number;
/** Register a pause reason (idempotent by reason key). */
register: (reason: string) => void;
/** Unregister a pause reason (idempotent). */
unregister: (reason: string) => void;
/** Trigger a one-shot refresh now (consumer wires this to SWR `mutate`). */
manualRefresh: () => void;
/** Setter the consumer (page.tsx) uses to wire the mutate fn into the registry. */
setMutate: (fn: (() => Promise<unknown> | void) | null) => void;
/** Setter the consumer uses to broadcast "we just got fresh data at <Date>". */
setLastUpdatedAt: (ts: number) => void;
/** Timestamp of last successful refresh (ms since epoch); 0 if never. */
lastUpdatedAt: number;
}
const AutoRefreshContext = createContext<AutoRefreshControl | null>(null);
// ---------------------------------------------------------------------------
// Provider — owns state; rendered by page.tsx so all children share it.
// ---------------------------------------------------------------------------
export function AutoRefreshControlProvider({ children }: { children: ReactNode }) {
const value = useAutoRefreshControlImpl();
return createElement(AutoRefreshContext.Provider, { value }, children);
}
// ---------------------------------------------------------------------------
// Consumer hook — used by every component that wants to read state OR
// register/unregister pause reasons.
// ---------------------------------------------------------------------------
export function useAutoRefreshControl(): AutoRefreshControl {
const ctx = useContext(AutoRefreshContext);
if (!ctx) {
throw new Error(
'useAutoRefreshControl must be used inside <AutoRefreshControlProvider>'
);
}
return ctx;
}
// ---------------------------------------------------------------------------
// Implementation — only called once by the provider.
// ---------------------------------------------------------------------------
function useAutoRefreshControlImpl(): AutoRefreshControl {
// User toggle, hydrated from sessionStorage post-mount (SSR-safe).
const [enabled, setEnabledState] = useState(true);
useEffect(() => {
if (typeof window === 'undefined') return;
try {
const stored = window.sessionStorage.getItem(STORAGE_KEY);
if (stored === '0') setEnabledState(false);
} catch {
// sessionStorage can throw in private mode — fall through with default.
}
}, []);
const setEnabled = useCallback((next: boolean) => {
setEnabledState(next);
if (typeof window === 'undefined') return;
try {
window.sessionStorage.setItem(STORAGE_KEY, next ? '1' : '0');
} catch {
// ignore
}
}, []);
// Pause reasons — a Set kept in a ref so register/unregister don't churn
// React state on every effect mount/unmount. We mirror SIZE/CONTENT into a
// version counter + a debounced visible-reasons state for rendering.
const reasonsRef = useRef<Set<string>>(new Set());
const [visibleReasons, setVisibleReasons] = useState<string[]>([]);
const exitDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const flushVisible = useCallback(() => {
setVisibleReasons(Array.from(reasonsRef.current).sort());
}, []);
const register = useCallback(
(reason: string) => {
if (reasonsRef.current.has(reason)) return;
reasonsRef.current.add(reason);
// Entry → reflect immediately (no flicker concern when ADDING a reason).
if (exitDebounceRef.current) {
clearTimeout(exitDebounceRef.current);
exitDebounceRef.current = null;
}
flushVisible();
},
[flushVisible]
);
const unregister = useCallback(
(reason: string) => {
if (!reasonsRef.current.has(reason)) return;
reasonsRef.current.delete(reason);
// Exit → debounce so brief blips (dropdown opened-then-closed) don't flash.
if (exitDebounceRef.current) clearTimeout(exitDebounceRef.current);
exitDebounceRef.current = setTimeout(() => {
exitDebounceRef.current = null;
flushVisible();
}, PAUSE_EXIT_DEBOUNCE_MS);
},
[flushVisible]
);
// Clean up any pending debounce on unmount.
useEffect(() => {
return () => {
if (exitDebounceRef.current) clearTimeout(exitDebounceRef.current);
};
}, []);
// Manual refresh — page.tsx wires SWR's `mutate` in via setMutate.
const mutateRef = useRef<(() => Promise<unknown> | void) | null>(null);
const setMutate = useCallback((fn: (() => Promise<unknown> | void) | null) => {
mutateRef.current = fn;
}, []);
const manualRefresh = useCallback(() => {
const fn = mutateRef.current;
if (fn) fn();
}, []);
// lastUpdatedAt — page.tsx broadcasts when SWR data lands.
const [lastUpdatedAt, setLastUpdatedAt] = useState(0);
const isInteractionPaused = visibleReasons.length > 0;
const isPaused = !enabled || isInteractionPaused;
const isRunning = !isPaused;
const effectiveInterval = isRunning ? REFRESH_INTERVAL_MS : 0;
const pauseReasons = useMemo(() => {
const out: string[] = [];
if (!enabled) out.push('Auto-refresh off');
out.push(...visibleReasons);
return out;
}, [enabled, visibleReasons]);
return {
isRunning,
isPaused,
pauseReasons,
enabled,
setEnabled,
effectiveInterval,
register,
unregister,
manualRefresh,
setMutate,
setLastUpdatedAt,
lastUpdatedAt,
};
}
// ---------------------------------------------------------------------------
// Convenience: useRegisterPauseReason — fire-and-forget register/unregister
// based on a boolean flag (used by components that want declarative usage).
// ---------------------------------------------------------------------------
export function useRegisterPauseReason(reason: string, active: boolean): void {
const { register, unregister } = useAutoRefreshControl();
useEffect(() => {
if (active) register(reason);
else unregister(reason);
return () => unregister(reason);
}, [active, reason, register, unregister]);
}
+278
View File
@@ -0,0 +1,278 @@
/**
* Component: useLogsUrlState Hook
* Documentation: documentation/admin-dashboard.md
*
* URL ↔ typed filter state. URL is the single source of truth.
* - reads URL params on every render (validated; invalid values silently dropped)
* - writes URL via router.replace (no history pollution)
* - search input writes are debounced (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 { JOB_TYPE_LABELS } from '@/lib/constants/job-labels';
import { DEFAULT_DATE_PRESET_ID, presetToRange } from '@/lib/constants/log-filters';
import {
DEFAULT_FILTER_STATE,
DEFAULT_LIMIT,
DEFAULT_PAGE,
LOG_PARAMS,
LogsFilterState,
VALID_LIMITS,
VALID_STATUSES,
ValidLimit,
} from '../types';
const SEARCH_DEBOUNCE_MS = 300;
// ---------------------------------------------------------------------------
// URL → typed state (silently drops invalid values)
// ---------------------------------------------------------------------------
function parseFromUrl(params: URLSearchParams): LogsFilterState {
const status = params.get(LOG_PARAMS.status);
const type = params.get(LOG_PARAMS.type);
const dateFrom = params.get(LOG_PARAMS.dateFrom);
const dateTo = params.get(LOG_PARAMS.dateTo);
const hasError = params.get(LOG_PARAMS.hasError);
const userId = params.get(LOG_PARAMS.userId);
const audiobookQuery = params.get(LOG_PARAMS.audiobookQuery);
const search = params.get(LOG_PARAMS.search);
const pageRaw = params.get(LOG_PARAMS.page);
const limitRaw = params.get(LOG_PARAMS.limit);
// Page: positive int or default
let page = DEFAULT_PAGE;
if (pageRaw) {
const parsed = Number.parseInt(pageRaw, 10);
if (Number.isFinite(parsed) && parsed >= 1) page = parsed;
}
// Limit: must be in VALID_LIMITS or default
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;
}
}
// Status: must be in VALID_STATUSES or default to 'all'
const validStatus =
status && (VALID_STATUSES as readonly string[]).includes(status) ? status : 'all';
// Type: must be in JOB_TYPE_LABELS or default to 'all'
const validType = type && (type === 'all' || type in JOB_TYPE_LABELS) ? type : 'all';
// Date: must parse as a valid date or null
const validDateFrom = isValidIsoDate(dateFrom) ? dateFrom : null;
const validDateTo = isValidIsoDate(dateTo) ? dateTo : null;
return {
search: search ?? '',
status: validStatus,
type: validType,
dateFrom: validDateFrom,
dateTo: validDateTo,
hasError: hasError === '1' || hasError === 'true',
userId: userId && userId.length > 0 ? userId : null,
audiobookQuery: audiobookQuery ?? '',
page,
limit,
};
}
function isValidIsoDate(value: string | null): value is string {
if (!value) return false;
const d = new Date(value);
return !Number.isNaN(d.getTime());
}
// ---------------------------------------------------------------------------
// typed state → URLSearchParams (omits defaults so URLs stay short)
// ---------------------------------------------------------------------------
function serializeToUrl(state: LogsFilterState): URLSearchParams {
const params = new URLSearchParams();
if (state.page !== DEFAULT_PAGE) params.set(LOG_PARAMS.page, String(state.page));
if (state.limit !== DEFAULT_LIMIT) params.set(LOG_PARAMS.limit, String(state.limit));
if (state.status && state.status !== 'all') params.set(LOG_PARAMS.status, state.status);
if (state.type && state.type !== 'all') params.set(LOG_PARAMS.type, state.type);
if (state.search) params.set(LOG_PARAMS.search, state.search);
if (state.dateFrom) params.set(LOG_PARAMS.dateFrom, state.dateFrom);
if (state.dateTo) params.set(LOG_PARAMS.dateTo, state.dateTo);
if (state.hasError) params.set(LOG_PARAMS.hasError, '1');
if (state.userId) params.set(LOG_PARAMS.userId, state.userId);
if (state.audiobookQuery) params.set(LOG_PARAMS.audiobookQuery, state.audiobookQuery);
return params;
}
// ---------------------------------------------------------------------------
// Public hook
// ---------------------------------------------------------------------------
export interface UseLogsUrlStateResult {
filters: LogsFilterState;
/** Merge partial state; any non-page change resets page to 1. */
setFilters: (partial: Partial<LogsFilterState>) => void;
/** Set the search string; debounced URL write (300ms). UI value is immediate. */
setSearchInput: (value: string) => void;
/** The non-debounced search value (what the user is currently typing). */
searchInput: string;
/** Reset to DEFAULT_FILTER_STATE. */
clearAll: () => void;
/** Remove a single filter (reset to its default). Resets page to 1. */
removeFilter: (key: keyof LogsFilterState) => void;
/**
* True iff the current `filters.dateFrom`/`dateTo` come from the Zach #1
* hydrate-time "Last 7 days" default (URL had neither bound and user hasn't
* touched anything yet). Page uses this to pick "fresh" vs "filters-too-tight"
* empty-state copy — the hydrate default shouldn't be treated as a
* user-applied filter.
*/
usingHydrateDateDefault: boolean;
}
export function useLogsUrlState(): UseLogsUrlStateResult {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
// Zach Resolution #1: on FIRST mount, if the URL has neither dateFrom nor
// dateTo, apply "Last 7 days" as the active range — but do NOT write those
// values to the URL (keeps shareable links clean). The default lives only
// in this hook's memory; the user's NEXT action (click All-time, change any
// other filter, etc.) writes the URL with the then-effective values.
//
// Mechanism: a one-shot hydrate range stored in a ref. It's used to backfill
// dates ONLY while:
// (a) the user hasn't taken an action that touched the date filter, AND
// (b) the URL still has neither dateFrom nor dateTo.
// Either condition flipping false retires the hydrate default forever.
const hydrateRangeRef = useRef<{ dateFrom: string | null; dateTo: string | null } | null>(
null
);
const dateInteractedRef = useRef(false);
if (hydrateRangeRef.current === null && !dateInteractedRef.current) {
hydrateRangeRef.current = presetToRange(DEFAULT_DATE_PRESET_ID);
}
// Parse from URL on every render — URL is the source of truth.
// Then layer the hydrate default on top when applicable.
const { filters, usingHydrateDateDefault } = useMemo(() => {
const parsed = parseFromUrl(new URLSearchParams(searchParams?.toString() ?? ''));
const hydrate = hydrateRangeRef.current;
if (
hydrate &&
!dateInteractedRef.current &&
parsed.dateFrom === null &&
parsed.dateTo === null
) {
return {
filters: {
...parsed,
dateFrom: hydrate.dateFrom,
dateTo: hydrate.dateTo,
},
usingHydrateDateDefault: true,
};
}
return { filters: parsed, usingHydrateDateDefault: false };
}, [searchParams]);
// Local "search input" mirrors URL but updates immediately for typing feel.
const [searchInput, setSearchInputState] = useState(filters.search);
const searchDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Re-sync local search input if the URL search changes externally
// (e.g. user clicks the search chip's × — chip dismissal sets URL,
// we need to mirror that back to the input).
useEffect(() => {
setSearchInputState(filters.search);
// We only want to sync from URL → input when the URL changes —
// not when the user is mid-type.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [filters.search]);
const writeUrl = useCallback(
(nextState: LogsFilterState) => {
// Any user-driven URL write retires the hydrate default. The just-written
// URL is now authoritative — either it carries the hydrate dates (if the
// user touched something else and the merge preserved them) or it
// doesn't (if the user explicitly cleared them). Either way, subsequent
// renders must trust the URL, not re-apply the default.
dateInteractedRef.current = true;
const qs = serializeToUrl(nextState).toString();
const url = qs ? `${pathname}?${qs}` : pathname;
router.replace(url, { scroll: false });
},
[pathname, router]
);
const setFilters = useCallback(
(partial: Partial<LogsFilterState>) => {
// Any non-page change resets page to 1.
const isOnlyPageChange =
Object.keys(partial).length === 1 && Object.prototype.hasOwnProperty.call(partial, 'page');
const next: LogsFilterState = {
...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: LogsFilterState = {
...filters,
search: value,
page: DEFAULT_PAGE,
};
writeUrl(next);
}, SEARCH_DEBOUNCE_MS);
},
[filters, writeUrl]
);
// Clear any pending debounce on unmount.
useEffect(() => {
return () => {
if (searchDebounceRef.current) clearTimeout(searchDebounceRef.current);
};
}, []);
const clearAll = useCallback(() => {
writeUrl(DEFAULT_FILTER_STATE);
setSearchInputState('');
}, [writeUrl]);
const removeFilter = useCallback(
(key: keyof LogsFilterState) => {
const defaultValue = DEFAULT_FILTER_STATE[key];
const next: LogsFilterState = {
...filters,
[key]: defaultValue,
page: DEFAULT_PAGE,
} as LogsFilterState;
writeUrl(next);
if (key === 'search') setSearchInputState('');
},
[filters, writeUrl]
);
return {
filters,
setFilters,
setSearchInput,
searchInput,
clearAll,
removeFilter,
usingHydrateDateDefault,
};
}
+88
View File
@@ -0,0 +1,88 @@
/**
* Component: useUserSearch Hook (admin logs user typeahead)
* Documentation: documentation/admin-dashboard.md
*
* Fetch-once-and-cache user directory from /api/admin/users for the user
* typeahead in LogsFilters. SWR caches the response for the session so every
* keystroke filters in-memory — no per-keystroke network round-trip.
*
* Assumes installs have <500 users (Zach Resolution #3 — fine for self-hosted).
*/
'use client';
import { useCallback, useMemo } from 'react';
import useSWR from 'swr';
import { authenticatedFetcher } from '@/lib/utils/api';
const USERS_URL = '/api/admin/users';
const MAX_SUGGESTIONS = 10;
// One-time-per-session cache: dedupe identical fetches for an hour.
const DEDUPING_INTERVAL_MS = 60 * 60 * 1000;
export interface UserSearchUser {
id: string;
plexUsername: string;
role: string;
}
interface UsersApiResponse {
users: UserSearchUser[];
}
export interface UseUserSearchResult {
users: UserSearchUser[];
filterByQuery: (q: string) => UserSearchUser[];
/** Resolve a user by id — handy for chip label rendering. */
findUserById: (id: string | null | undefined) => UserSearchUser | undefined;
isLoading: boolean;
error: Error | null;
}
export function useUserSearch(): UseUserSearchResult {
const { data, error, isLoading } = useSWR<UsersApiResponse>(
USERS_URL,
authenticatedFetcher,
{
revalidateOnFocus: false,
revalidateIfStale: false,
revalidateOnReconnect: false,
dedupingInterval: DEDUPING_INTERVAL_MS,
}
);
const users = useMemo<UserSearchUser[]>(() => data?.users ?? [], [data]);
const filterByQuery = useCallback(
(q: string): UserSearchUser[] => {
if (users.length === 0) return [];
const trimmed = q.trim().toLowerCase();
if (!trimmed) return users.slice(0, MAX_SUGGESTIONS);
const out: UserSearchUser[] = [];
for (const u of users) {
if (u.plexUsername.toLowerCase().includes(trimmed)) {
out.push(u);
if (out.length >= MAX_SUGGESTIONS) break;
}
}
return out;
},
[users]
);
const findUserById = useCallback(
(id: string | null | undefined): UserSearchUser | undefined => {
if (!id) return undefined;
return users.find((u) => u.id === id);
},
[users]
);
return {
users,
filterByQuery,
findUserById,
isLoading,
error: (error as Error | null) ?? null,
};
}
+215 -468
View File
@@ -1,504 +1,251 @@
/**
* Component: Admin System Logs Page
* Documentation: documentation/admin-dashboard.md
*
* Thin orchestrator: reads URL via useLogsUrlState, owns SWR + pause registry,
* composes sub-components. Empty-state copy as a pure function of
* { totalResults, hasActiveFilters, hasActiveSearch }.
*/
'use client';
import { useState } from 'react';
import { Suspense, useEffect, useRef, useState } from 'react';
import useSWR from 'swr';
import Link from 'next/link';
import { ToastProvider } from '@/components/ui/Toast';
import { authenticatedFetcher } from '@/lib/utils/api';
import {
buildLogsApiKey,
computeEmptyState,
hasActiveFilters,
hasActiveSearch,
Log,
LogsData,
ValidLimit,
} from './types';
import { useLogsUrlState } from './hooks/useLogsUrlState';
import {
AutoRefreshControlProvider,
useAutoRefreshControl,
} from './hooks/useAutoRefreshControl';
import { LogsToolbar } from './components/LogsToolbar';
import { LogSkeleton } from './components/LogSkeleton';
import { LogsPagination } from './components/LogsPagination';
import { LogRow } from './components/LogRow';
import LogsFilters from './components/LogsFilters';
import ActiveFilterChips from './components/ActiveFilterChips';
interface JobEvent {
id: string;
level: string;
context: string;
message: string;
metadata: any;
createdAt: string;
}
interface Log {
id: string;
bullJobId: string | null;
type: string;
status: string;
priority: number;
attempts: number;
maxAttempts: number;
errorMessage: string | null;
startedAt: string | null;
completedAt: string | null;
createdAt: string;
updatedAt: string;
result: any;
events: JobEvent[];
request: {
id: string;
audiobook: {
title: string;
author: string;
} | null;
user: {
plexUsername: string;
};
} | null;
}
interface LogsData {
logs: Log[];
pagination: {
page: number;
limit: number;
total: number;
totalPages: number;
};
}
function StatusBadge({ status }: { status: string }) {
const config: Record<string, { dot: string; text: string; bg: string }> = {
completed: { dot: 'bg-emerald-500', text: 'text-emerald-700 dark:text-emerald-400', bg: 'bg-emerald-500/10' },
failed: { dot: 'bg-red-500', text: 'text-red-700 dark:text-red-400', bg: 'bg-red-500/10' },
active: { dot: 'bg-blue-500', text: 'text-blue-700 dark:text-blue-400', bg: 'bg-blue-500/10' },
pending: { dot: 'bg-amber-500', text: 'text-amber-700 dark:text-amber-400', bg: 'bg-amber-500/10' },
delayed: { dot: 'bg-orange-500', text: 'text-orange-700 dark:text-orange-400', bg: 'bg-orange-500/10' },
stuck: { dot: 'bg-purple-500', text: 'text-purple-700 dark:text-purple-400', bg: 'bg-purple-500/10' },
};
const c = config[status] ?? { dot: 'bg-gray-400', text: 'text-gray-600 dark:text-gray-400', bg: 'bg-gray-500/10' };
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 background jobs have run yet.
</p>
<p className="text-gray-500 dark:text-gray-400 text-sm mt-1">
New jobs will appear here as they start.
</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 &ldquo;{searchValue}&rdquo;.
</p>
<button
type="button"
onClick={onClearSearch}
aria-label="Clear search and show all logs"
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 (
<span className={`inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium ${c.bg} ${c.text}`}>
<span className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${c.dot}`} />
{status.charAt(0).toUpperCase() + status.slice(1)}
</span>
);
}
function LogDetails({ log }: { log: Log }) {
return (
<div className="space-y-4">
{log.bullJobId && (
<div className="flex flex-wrap gap-1.5 items-baseline">
<span className="text-xs font-medium text-gray-500 dark:text-gray-400">Bull Job ID:</span>
<span className="text-xs text-gray-700 dark:text-gray-300 font-mono break-all">{log.bullJobId}</span>
</div>
)}
{log.events.length > 0 && (
<div>
<h4 className="text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide mb-2">
Event Log
</h4>
<div className="space-y-px max-h-72 sm:max-h-96 overflow-y-auto bg-gray-950 dark:bg-black/60 rounded-xl p-3 font-mono text-xs">
{log.events.map((event) => {
const timestamp = new Date(event.createdAt).toISOString().split('T')[1].split('.')[0];
const levelColor = event.level === 'error'
? 'text-red-400'
: event.level === 'warn'
? 'text-amber-400'
: 'text-emerald-400';
return (
<div key={event.id} className="text-gray-300 leading-relaxed">
<span className={levelColor}>[{event.context}]</span>
{' '}
<span className="break-words">{event.message}</span>
<span className="text-gray-500 ml-2">{timestamp}</span>
{event.metadata && Object.keys(event.metadata).length > 0 && (
<pre className="ml-4 mt-1 text-gray-400 text-xs overflow-x-auto">
{JSON.stringify(event.metadata, null, 2)}
</pre>
)}
</div>
);
})}
</div>
</div>
)}
{log.result && Object.keys(log.result).length > 0 && (
<div>
<h4 className="text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide mb-2">
Job Result
</h4>
<pre className="p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-xl text-xs text-blue-900 dark:text-blue-300 font-mono overflow-x-auto max-h-48">
{JSON.stringify(log.result, null, 2)}
</pre>
</div>
)}
{log.errorMessage && (
<div>
<h4 className="text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide mb-2">
Error
</h4>
<div className="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-xl text-xs text-red-700 dark:text-red-300 font-mono whitespace-pre-wrap break-words">
{log.errorMessage}
</div>
</div>
)}
<div className="text-center py-16">
<p className="text-gray-700 dark:text-gray-300 text-base font-medium">
No logs 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 formatDuration(startedAt: string | null, completedAt: string | null) {
if (!startedAt) return 'N/A';
if (!completedAt) return 'Running…';
const durationMs = new Date(completedAt).getTime() - new Date(startedAt).getTime();
const seconds = Math.floor(durationMs / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
if (hours > 0) return `${hours}h ${minutes % 60}m`;
if (minutes > 0) return `${minutes}m ${seconds % 60}s`;
return `${seconds}s`;
}
function AdminLogsPageContent() {
const { filters, setFilters, clearAll, usingHydrateDateDefault } = useLogsUrlState();
const { effectiveInterval, setMutate, setLastUpdatedAt } = useAutoRefreshControl();
function formatType(type: string) {
return type.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase());
}
const key = buildLogsApiKey(filters);
function formatDateShort(dateStr: string) {
const d = new Date(dateStr);
const now = new Date();
const isToday = d.toDateString() === now.toDateString();
if (isToday) {
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
}
return d.toLocaleDateString([], { month: 'short', day: 'numeric' }) + ' ' +
d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
// Track previous key to distinguish initial-load / filter-change skeleton
// from auto-refresh (which preserves rows).
const previousKeyRef = useRef<string>(key);
const [keyChanging, setKeyChanging] = useState(false);
export default function AdminLogsPage() {
const [page, setPage] = useState(1);
const [statusFilter, setStatusFilter] = useState('all');
const [typeFilter, setTypeFilter] = useState('all');
const [expandedLog, setExpandedLog] = useState<string | null>(null);
const { data, error, mutate } = useSWR<LogsData>(key, authenticatedFetcher, {
refreshInterval: effectiveInterval,
keepPreviousData: true,
});
const { data, error } = useSWR<LogsData>(
`/api/admin/logs?page=${page}&limit=50&status=${statusFilter}&type=${typeFilter}`,
authenticatedFetcher,
{ refreshInterval: 10000 }
);
// Wire SWR's mutate into the auto-refresh control so "Refresh now" works.
useEffect(() => {
setMutate(() => mutate());
return () => setMutate(null);
}, [mutate, setMutate]);
const isLoading = !data && !error;
// Broadcast a "fresh data" timestamp when SWR data lands.
useEffect(() => {
if (data) setLastUpdatedAt(Date.now());
}, [data, setLastUpdatedAt]);
if (isLoading) {
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 p-8">
<div className="max-w-7xl mx-auto">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
</div>
</div>
);
}
// Skeleton-vs-rows decision:
// - !data → initial skeleton.
// - key changed AND no data for the new key yet → skeleton on transition.
// SWR's `keepPreviousData` makes data === previous response until the new
// one lands, so we explicitly track key changes.
useEffect(() => {
if (previousKeyRef.current !== key) {
previousKeyRef.current = key;
setKeyChanging(true);
}
}, [key]);
if (error) {
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 p-8">
<div className="max-w-7xl mx-auto">
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
<h3 className="text-sm font-medium text-red-800 dark:text-red-200">Error Loading Logs</h3>
<p className="text-sm text-red-700 dark:text-red-300 mt-1">
{error?.message || 'Failed to load system logs'}
</p>
</div>
</div>
</div>
);
}
useEffect(() => {
if (keyChanging && data) setKeyChanging(false);
}, [data, keyChanging]);
const logs = data?.logs || [];
const pagination = data?.pagination;
const hasDetails = (log: Log) => log.events.length > 0 || !!log.errorMessage || !!log.bullJobId || (log.result && Object.keys(log.result).length > 0);
const showSkeleton = !data || keyChanging;
const logs: Log[] = data?.logs ?? [];
const pagination = data?.pagination ?? { page: filters.page, limit: filters.limit, total: 0, totalPages: 1 };
// When the hydrate-time "Last 7 days" default is in effect (the user hasn't
// explicitly chosen a date range), don't count it as a user-applied filter
// for empty-state branching — show the "fresh" message, not "filters too
// tight". hasActiveFilters() is otherwise the canonical check.
const filtersForEmptyState = usingHydrateDateDefault
? { ...filters, dateFrom: null, dateTo: null }
: filters;
const emptyKind = computeEmptyState({
total: pagination.total,
hasFilters: hasActiveFilters(filtersForEmptyState),
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">
<LogsToolbar />
{/* Header — stacks on mobile, row on sm+ */}
<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">
<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">
System Logs
</h1>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
View background jobs and system activity
</p>
</div>
<Link
href="/admin"
className="inline-flex items-center gap-2 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">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
<span>Back to Dashboard</span>
</Link>
</div>
</div>
{/* Filter dropdowns + chip strip — owned by ben-filters, rendered here. */}
<LogsFilters />
<ActiveFilterChips />
{/* Filters — full-width stacked on mobile */}
<div className="mb-6 grid grid-cols-1 sm:grid-cols-2 gap-3 sm:gap-4">
<div>
<label className="block text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-1.5">
Status
</label>
<select
value={statusFilter}
onChange={(e) => { setStatusFilter(e.target.value); setPage(1); }}
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-blue-500 text-sm"
>
<option value="all">All Statuses</option>
<option value="pending">Pending</option>
<option value="active">Active</option>
<option value="completed">Completed</option>
<option value="failed">Failed</option>
<option value="delayed">Delayed</option>
<option value="stuck">Stuck</option>
</select>
</div>
<div>
<label className="block text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-1.5">
Job Type
</label>
<select
value={typeFilter}
onChange={(e) => { setTypeFilter(e.target.value); setPage(1); }}
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-blue-500 text-sm"
>
<option value="all">All Types</option>
<option value="search_indexers">Search Indexers</option>
<option value="download_torrent">Download Torrent</option>
<option value="monitor_download">Monitor Download</option>
<option value="organize_files">Organize Files</option>
<option value="scan_plex">Library Scan</option>
<option value="match_plex">Library Match</option>
<option value="plex_library_scan">Library Scan (Scheduled)</option>
<option value="plex_recently_added_check">Recently Added Check</option>
<option value="audible_refresh">Audible Refresh</option>
<option value="retry_missing_torrents">Retry Missing Torrents</option>
<option value="retry_failed_imports">Retry Failed Imports</option>
<option value="cleanup_seeded_torrents">Cleanup Seeded Torrents</option>
<option value="monitor_rss_feeds">Monitor RSS Feeds</option>
</select>
</div>
</div>
{/* Mobile card list — hidden on sm+ */}
<div className="space-y-3 sm:hidden">
{logs.map((log) => (
<div
key={log.id}
className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden"
>
{/* Card header */}
<div className="px-4 py-3">
<div className="flex items-start justify-between gap-3 mb-2">
<div className="font-semibold text-gray-900 dark:text-gray-100 text-sm leading-snug">
{formatType(log.type)}
</div>
<StatusBadge status={log.status} />
</div>
{/* Related item */}
{log.request?.audiobook ? (
<div className="text-sm mb-2">
<div className="text-gray-700 dark:text-gray-300 font-medium leading-snug">
{log.request.audiobook.title}
</div>
<div className="text-gray-500 dark:text-gray-400 text-xs">
by {log.request.audiobook.author} &middot; {log.request.user.plexUsername}
</div>
</div>
) : (
<div className="text-xs text-gray-500 dark:text-gray-400 mb-2">System job</div>
)}
{/* Meta row */}
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-gray-500 dark:text-gray-400">
<span>{formatDateShort(log.createdAt)}</span>
<span>Duration: {formatDuration(log.startedAt, log.completedAt)}</span>
<span>Attempts: {log.attempts}/{log.maxAttempts}</span>
</div>
</div>
{/* Expandable details */}
{hasDetails(log) && (
<>
<button
onClick={() => setExpandedLog(expandedLog === log.id ? null : log.id)}
className="w-full flex items-center justify-between px-4 py-2.5 border-t border-gray-100 dark:border-gray-700/60 text-xs font-medium text-blue-600 dark:text-blue-400 hover:bg-gray-50 dark:hover:bg-gray-700/40 transition-colors"
>
<span>{expandedLog === log.id ? 'Hide Details' : 'Show Details'}</span>
<svg
className={`w-4 h-4 transition-transform duration-200 ${expandedLog === log.id ? 'rotate-180' : ''}`}
fill="none" stroke="currentColor" viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{expandedLog === log.id && (
<div className="px-4 pb-4 pt-3 bg-gray-50 dark:bg-gray-900/50 border-t border-gray-100 dark:border-gray-700/60">
<LogDetails log={log} />
</div>
)}
</>
)}
</div>
))}
{logs.length === 0 && (
<div className="text-center py-12">
<p className="text-gray-500 dark:text-gray-400">No logs found</p>
</div>
)}
</div>
{/* Desktop table — hidden on mobile */}
<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>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Time
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Type
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Status
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Related Item
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Duration
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Attempts
</th>
<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">
{logs.map((log) => (
<>
<tr key={log.id} className="hover:bg-gray-50 dark:hover:bg-gray-700">
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
{new Date(log.createdAt).toLocaleString()}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
{formatType(log.type)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<StatusBadge status={log.status} />
</td>
<td className="px-6 py-4">
{log.request?.audiobook ? (
<div className="text-sm">
<div className="font-medium text-gray-900 dark:text-gray-100">
{log.request.audiobook.title}
</div>
<div className="text-gray-500 dark:text-gray-400">
by {log.request.audiobook.author}
</div>
<div className="text-xs text-gray-400 dark:text-gray-500">
User: {log.request.user.plexUsername}
</div>
</div>
) : (
<span className="text-sm text-gray-500 dark:text-gray-400">System job</span>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
{formatDuration(log.startedAt, log.completedAt)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
{log.attempts}/{log.maxAttempts}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
{hasDetails(log) && (
<button
onClick={() => setExpandedLog(expandedLog === log.id ? null : log.id)}
className="text-blue-600 hover:text-blue-900 dark:text-blue-400 dark:hover:text-blue-300"
>
{expandedLog === log.id ? 'Hide Details' : 'Show Details'}
</button>
)}
</td>
</tr>
{expandedLog === log.id && (
<tr>
<td colSpan={7} className="px-6 py-4 bg-gray-50 dark:bg-gray-900">
<LogDetails log={log} />
</td>
</tr>
)}
</>
))}
</tbody>
</table>
</div>
{logs.length === 0 && (
<div className="text-center py-12">
<p className="text-gray-500 dark:text-gray-400">No logs found</p>
</div>
)}
</div>
{/* Pagination */}
{pagination && pagination.totalPages > 1 && (
<div className="mt-6 flex flex-col sm:flex-row items-center gap-3 sm:justify-between">
<div className="text-sm text-gray-600 dark:text-gray-400 order-2 sm:order-1">
Page {pagination.page} of {pagination.totalPages}
<span className="hidden sm:inline"> ({pagination.total} total logs)</span>
</div>
<div className="flex gap-2 order-1 sm:order-2">
<button
onClick={() => setPage(page - 1)}
disabled={page === 1}
className="flex items-center gap-1.5 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"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
Previous
</button>
<button
onClick={() => setPage(page + 1)}
disabled={page === pagination.totalPages}
className="flex items-center gap-1.5 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"
>
Next
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
{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 Logs
</h3>
<p className="text-sm text-red-700 dark:text-red-300 mt-1">
{error?.message || 'Failed to load system logs'}
</p>
</div>
)}
{/* Info Box */}
<div className="mt-6 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<h3 className="text-sm font-medium text-blue-800 dark:text-blue-200 mb-2">
About System Logs
</h3>
<ul className="text-sm text-blue-700 dark:text-blue-300 space-y-1">
<li> Logs are automatically refreshed every 10 seconds</li>
<li> Tap &quot;Show Details&quot; to view event logs, job results, and errors</li>
<li> Event logs show all internal operations with timestamps</li>
<li> Jobs are retried automatically based on their max attempts setting</li>
<li> Use filters to find specific job types or statuses</li>
</ul>
</div>
{showSkeleton ? (
<LogSkeleton />
) : emptyKind ? (
<EmptyState
kind={emptyKind}
onClearFilters={clearAll}
onClearSearch={() => setFilters({ search: '' })}
searchValue={filters.search}
/>
) : (
<>
{/* Mobile cards */}
<div className="space-y-3 sm:hidden">
{logs.map((log) => (
<LogRow.Mobile key={log.id} log={log} />
))}
</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>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Time
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Type
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Status
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Related Item
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Duration
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Attempts
</th>
<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">
{logs.map((log) => (
<LogRow.Desktop key={log.id} log={log} />
))}
</tbody>
</table>
</div>
</div>
<LogsPagination
pagination={pagination}
onPageChange={(page) => setFilters({ page })}
onLimitChange={(limit: ValidLimit) => setFilters({ limit })}
/>
</>
)}
</div>
</div>
);
}
export default function AdminLogsPage() {
return (
<Suspense fallback={null}>
<ToastProvider>
<AutoRefreshControlProvider>
<AdminLogsPageContent />
</AutoRefreshControlProvider>
</ToastProvider>
</Suspense>
);
}
+200
View File
@@ -0,0 +1,200 @@
/**
* Component: Admin Logs — Shared Types & Filter Contract
* Documentation: documentation/admin-dashboard.md
*
* Stage 0 contract: filter state shape + URL/API param names + SWR key helper.
* URL param names === API param names — no translation layer.
* `buildLogsApiKey` is the SWR key/test seam (frontend only — backend tests
* assert against parsed URLSearchParams / where-clause).
*/
// ---------------------------------------------------------------------------
// Param names — used as BOTH URL search params AND API query string params.
// ---------------------------------------------------------------------------
export const LOG_PARAMS = {
search: 'search',
status: 'status',
type: 'type',
dateFrom: 'dateFrom',
dateTo: 'dateTo',
hasError: 'hasError',
userId: 'userId',
audiobookQuery: 'audiobookQuery',
page: 'page',
limit: 'limit',
} as const;
export type LogParamKey = keyof typeof LOG_PARAMS;
// ---------------------------------------------------------------------------
// Valid value sets
// ---------------------------------------------------------------------------
export const VALID_LIMITS = [25, 50, 100] as const;
export type ValidLimit = typeof VALID_LIMITS[number];
export const VALID_STATUSES = [
'all',
'pending',
'active',
'completed',
'failed',
'delayed',
'stuck',
] as const;
export type LogStatus = typeof VALID_STATUSES[number];
export const DEFAULT_LIMIT: ValidLimit = 50;
export const DEFAULT_PAGE = 1;
// ---------------------------------------------------------------------------
// Filter state — single source of truth, both URL hydration target and API input
// ---------------------------------------------------------------------------
export interface LogsFilterState {
search: string; // '' = no search
status: string; // 'all' default; validated against VALID_STATUSES on read
type: string; // 'all' default; validated against JOB_TYPE_LABELS keys on read
dateFrom: string | null; // ISO UTC; null = no lower bound
dateTo: string | null; // ISO UTC; null = no upper bound
hasError: boolean; // false default
userId: string | null; // null = any user
audiobookQuery: string; // '' = no book filter
page: number; // 1-based
limit: ValidLimit; // 25 | 50 | 100
}
export const DEFAULT_FILTER_STATE: LogsFilterState = {
search: '',
status: 'all',
type: 'all',
dateFrom: null,
dateTo: null,
hasError: false,
userId: null,
audiobookQuery: '',
page: DEFAULT_PAGE,
limit: DEFAULT_LIMIT,
};
// ---------------------------------------------------------------------------
// Log data types — match the existing API response shape
// (which mirrors prisma Job + JobEvent + Request joins)
// ---------------------------------------------------------------------------
export interface JobEvent {
id: string;
level: 'info' | 'warn' | 'error' | string;
context: string;
message: string;
metadata: Record<string, unknown> | null;
createdAt: string;
}
export interface LogRequestRelation {
id: string;
audiobook: {
title: string;
author: string;
} | null;
user: {
plexUsername: string;
};
}
export interface Log {
id: string;
bullJobId: string | null;
type: string;
status: string;
priority: number;
attempts: number;
maxAttempts: number;
errorMessage: string | null;
startedAt: string | null;
completedAt: string | null;
createdAt: string;
updatedAt: string;
result: Record<string, unknown> | null;
events: JobEvent[];
request: LogRequestRelation | null;
}
export interface LogsPagination {
page: number;
limit: number;
total: number;
totalPages: number;
}
export interface LogsData {
logs: Log[];
pagination: LogsPagination;
}
// ---------------------------------------------------------------------------
// API key / URL builder — single source of truth shared by SWR and tests.
// Omits params at their default values so the key stays stable & short.
// ---------------------------------------------------------------------------
export function buildLogsApiKey(state: LogsFilterState): string {
const params = new URLSearchParams();
// page + limit are always present so SWR cache keys are deterministic
params.set(LOG_PARAMS.page, String(state.page));
params.set(LOG_PARAMS.limit, String(state.limit));
if (state.status && state.status !== 'all') params.set(LOG_PARAMS.status, state.status);
if (state.type && state.type !== 'all') params.set(LOG_PARAMS.type, state.type);
if (state.search) params.set(LOG_PARAMS.search, state.search);
if (state.dateFrom) params.set(LOG_PARAMS.dateFrom, state.dateFrom);
if (state.dateTo) params.set(LOG_PARAMS.dateTo, state.dateTo);
if (state.hasError) params.set(LOG_PARAMS.hasError, '1');
if (state.userId) params.set(LOG_PARAMS.userId, state.userId);
if (state.audiobookQuery) params.set(LOG_PARAMS.audiobookQuery, state.audiobookQuery);
return `/api/admin/logs?${params.toString()}`;
}
// ---------------------------------------------------------------------------
// Detail-panel predicate — does this log have anything worth disclosing?
// ---------------------------------------------------------------------------
export function logHasDetails(log: Log): boolean {
return (
log.events.length > 0 ||
!!log.errorMessage ||
!!log.bullJobId ||
(log.result != null && Object.keys(log.result).length > 0)
);
}
// ---------------------------------------------------------------------------
// Active-filter detection — drives empty-state copy + "Clear all" affordance
// ---------------------------------------------------------------------------
export function hasActiveFilters(state: LogsFilterState): boolean {
return (
state.status !== 'all' ||
state.type !== 'all' ||
state.dateFrom !== null ||
state.dateTo !== null ||
state.hasError ||
state.userId !== null ||
state.audiobookQuery !== ''
);
}
export function hasActiveSearch(state: LogsFilterState): boolean {
return state.search !== '';
}
export type EmptyStateKind =
| 'fresh' // no rows, no filters, no search
| 'filters-too-tight' // no rows, filters active, no search
| 'search-no-match'; // no rows, search active (filters may or may not be active)
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';
}
+25 -1
View File
@@ -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"
+11
View File
@@ -113,6 +113,17 @@ export const saveTabSettings = async (
}).then(res => {
if (!res.ok) throw new Error('Failed to save indexer configuration');
});
// Save indexer-wide options (auto-search behavior, etc.)
await fetchWithAuth('/api/admin/settings/indexer-options', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
skipUnreleased: settings.indexerOptions.skipUnreleased,
}),
}).then(res => {
if (!res.ok) throw new Error('Failed to save indexer options');
});
break;
case 'download':
+17
View File
@@ -16,6 +16,7 @@ export interface Settings {
oidc: OIDCSettings;
registration: RegistrationSettings;
prowlarr: ProwlarrSettings;
indexerOptions: IndexerOptionsSettings;
downloadClient: DownloadClientSettings;
paths: PathsSettings;
ebook: EbookSettings;
@@ -76,6 +77,19 @@ export interface ProwlarrSettings {
apiKey: string;
}
/**
* Indexer-wide behavioral options (not tied to a specific indexer connection).
* Persisted via `/api/admin/settings/indexer-options`.
*/
export interface IndexerOptionsSettings {
/**
* When true, automatic indexer searches skip books whose release date is
* in the future. Default ON. Manual searches are unaffected.
* Backing config key: `indexer.skip_unreleased`.
*/
skipUnreleased: boolean;
}
/**
* Download client (qBittorrent) configuration
*/
@@ -99,6 +113,7 @@ export interface PathsSettings {
audiobookPathTemplate?: string;
ebookPathTemplate?: string;
metadataTaggingEnabled: boolean;
plexFormatCoercionEnabled: boolean;
chapterMergingEnabled: boolean;
fileRenameEnabled: boolean;
fileRenameTemplate?: string;
@@ -154,6 +169,7 @@ export interface IndexerConfig {
enabled: boolean;
priority: number;
seedingTimeMinutes?: number; // Torrents only
ratioLimit?: number; // Torrents only (0 = no ratio requirement)
removeAfterProcessing?: boolean; // Usenet only
rssEnabled: boolean;
audiobookCategories?: number[]; // Category IDs for audiobook searches (default: [3030])
@@ -170,6 +186,7 @@ export interface SavedIndexerConfig {
protocol: string;
priority: number;
seedingTimeMinutes?: number; // Torrents only
ratioLimit?: number; // Torrents only (0 = no ratio requirement)
removeAfterProcessing?: boolean; // Usenet only
rssEnabled: boolean;
audiobookCategories: number[]; // Category IDs for audiobook searches (default: [3030])
@@ -136,6 +136,48 @@ export function IndexersTab({
)}
</div>
<div className="border-t border-gray-200 dark:border-gray-700 pt-6">
<div className="mb-4">
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">
Auto-Search Behavior
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">
Control how ReadMeABook performs automatic background searches across your indexers.
</p>
</div>
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
<div className="flex items-start gap-4">
<input
type="checkbox"
id="indexer-skip-unreleased"
checked={settings.indexerOptions.skipUnreleased}
onChange={(e) =>
onChange({
...settings,
indexerOptions: {
...settings.indexerOptions,
skipUnreleased: e.target.checked,
},
})
}
className="mt-1 h-5 w-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<div className="flex-1">
<label
htmlFor="indexer-skip-unreleased"
className="block text-sm font-medium text-gray-900 dark:text-gray-100 cursor-pointer"
>
Skip unreleased books in automatic searches
</label>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
When ON, ReadMeABook will not search indexers for books whose release date is in the future. These requests will automatically begin searching once the book is released. Manual searches are not affected.
</p>
</div>
</div>
</div>
</div>
<div className="border-t border-gray-200 dark:border-gray-700 pt-6">
<IndexerManagement
prowlarrUrl={settings.prowlarr.url}
@@ -414,6 +414,30 @@ export function PathsTab({ paths, onChange, onValidationChange }: PathsTabProps)
</div>
</div>
{/* Plex Format Coercion Toggle */}
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
<div className="flex items-start gap-4">
<input
type="checkbox"
id="plex-format-coercion-settings"
checked={paths.plexFormatCoercionEnabled}
onChange={(e) => updatePath('plexFormatCoercionEnabled', e.target.checked)}
className="mt-1 h-5 w-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<div className="flex-1">
<label
htmlFor="plex-format-coercion-settings"
className="block text-sm font-medium text-gray-900 dark:text-gray-100 cursor-pointer"
>
Coerce file formats for Plex compatibility
</label>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
Rename .mp4 audiobook files (and single-file .m4a) to .m4b before Plex scans. No re-encoding.
</p>
</div>
</div>
</div>
{/* Chapter Merging Toggle */}
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
<div className="flex items-start gap-4">
+3 -1
View File
@@ -131,7 +131,9 @@ export default function ApiDocsPage() {
{/* Footer note */}
<div className="mt-10 text-center">
<p className="text-xs text-gray-400 dark:text-gray-500">
API tokens are restricted to the endpoints listed above.
API tokens are restricted to the endpoints listed above. Endpoints
flagged <span className="font-semibold text-amber-600 dark:text-amber-400">Write</span> mutate
state on behalf of the token owner keep your tokens private.
JWT session authentication has access to all endpoints.
</p>
</div>
+51
View File
@@ -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 }
);
}
});
});
}
+213
View File
@@ -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 }
);
}
});
});
}
+133 -13
View File
@@ -10,27 +10,147 @@ import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('API.Admin.Logs');
const VALID_LIMITS = [25, 50, 100] as const;
const DEFAULT_LIMIT = 50;
const ERROR_STATUSES = ['failed', 'stuck'] as const;
export interface LogsWhereParams {
status?: string | null;
type?: string | null;
search?: string | null;
dateFrom?: string | null;
dateTo?: string | null;
hasError?: string | null;
userId?: string | null;
audiobookQuery?: 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 isTruthy(raw: string | null | undefined): boolean {
if (!raw) return false;
const v = raw.toLowerCase();
return v === 'true' || v === '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;
}
export function buildLogsWhere(params: LogsWhereParams): Record<string, any> {
const where: Record<string, any> = {};
const status = params.status ?? 'all';
if (status !== 'all' && status !== '') {
where.status = status;
}
const type = params.type ?? 'all';
if (type !== 'all' && type !== '') {
where.type = type;
}
const from = parseDate(params.dateFrom);
const to = parseDate(params.dateTo);
if (from || to) {
where.createdAt = {
...(from ? { gte: from } : {}),
...(to ? { lte: to } : {}),
};
}
const userId = trim(params.userId);
if (userId) {
where.request = { is: { userId } };
}
const audiobookQuery = trim(params.audiobookQuery);
if (audiobookQuery) {
where.request = {
is: {
...(where.request?.is ?? {}),
audiobook: {
is: {
OR: [
{ title: { contains: audiobookQuery, mode: 'insensitive' } },
{ author: { contains: audiobookQuery, mode: 'insensitive' } },
],
},
},
},
};
}
const errorsOnly = isTruthy(params.hasError);
const search = trim(params.search);
const errorsOr = errorsOnly
? [
{ status: { in: [...ERROR_STATUSES] } },
{ errorMessage: { not: null } },
]
: null;
const searchOr = search
? [
{ bullJobId: { startsWith: search } },
{ errorMessage: { contains: search, mode: 'insensitive' } },
// TODO: revisit if slow — consider denormalized lastEventMessage on Job
{ events: { some: { message: { contains: search, mode: 'insensitive' } } } },
{ request: { is: { audiobook: { is: { title: { contains: search, mode: 'insensitive' } } } } } },
{ request: { is: { audiobook: { is: { author: { contains: search, mode: 'insensitive' } } } } } },
{ request: { is: { user: { is: { plexUsername: { contains: search, mode: 'insensitive' } } } } } },
]
: null;
if (errorsOr && searchOr) {
where.AND = [{ OR: errorsOr }, { OR: searchOr }];
} else if (errorsOr) {
where.OR = errorsOr;
} else if (searchOr) {
where.OR = searchOr;
}
return where;
}
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 = parseInt(searchParams.get('page') || '1');
const limit = parseInt(searchParams.get('limit') || '100');
const status = searchParams.get('status') || 'all';
const type = searchParams.get('type') || 'all';
const page = parsePage(searchParams.get('page'));
const limit = parseLimit(searchParams.get('limit'));
const where = buildLogsWhere({
status: searchParams.get('status'),
type: searchParams.get('type'),
search: searchParams.get('search'),
dateFrom: searchParams.get('dateFrom'),
dateTo: searchParams.get('dateTo'),
hasError: searchParams.get('hasError'),
userId: searchParams.get('userId'),
audiobookQuery: searchParams.get('audiobookQuery'),
});
const skip = (page - 1) * limit;
// Build where clause
const where: any = {};
if (status !== 'all') {
where.status = status;
}
if (type !== 'all') {
where.type = type;
}
const [logs, totalCount] = await Promise.all([
prisma.job.findMany({
where,
+6
View File
@@ -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({
@@ -0,0 +1,114 @@
/**
* Component: Admin Indexer Options Settings API
* Documentation: documentation/settings-pages.md
*
* Manages indexer-wide behavioral options that are not tied to a specific
* indexer connection (e.g., auto-search behavior toggles).
*
* Read contract (consumed by background auto-search workers):
* - Config key: `indexer.skip_unreleased`
* - Category: `indexer`
* - Value: string `'true'` | `'false'`
* - Default: ON when the key is missing OR its value is anything other
* than the exact string `'false'`. In other words, skipping
* unreleased books is enabled unless the admin explicitly
* opted out. Workers MUST match this contract:
*
* const skip = (await config.get('indexer.skip_unreleased')) !== 'false';
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { getConfigService } from '@/lib/services/config.service';
import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('API.Admin.Settings.IndexerOptions');
const CONFIG_KEY = 'indexer.skip_unreleased';
/**
* GET /api/admin/settings/indexer-options
* Returns the current indexer-wide options.
*/
export async function GET(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
const configService = getConfigService();
const value = await configService.get(CONFIG_KEY);
// Default ON: missing or any value other than 'false' is treated as enabled.
const skipUnreleased = value !== 'false';
return NextResponse.json({ skipUnreleased });
} catch (error) {
logger.error('Failed to fetch indexer options', {
error: error instanceof Error ? error.message : String(error),
});
return NextResponse.json(
{ error: 'Failed to fetch indexer options' },
{ status: 500 }
);
}
});
});
}
/**
* PUT /api/admin/settings/indexer-options
* Persists indexer-wide options. Body: { skipUnreleased: boolean }
*/
export async function PUT(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
const body = await request.json();
const { skipUnreleased } = body ?? {};
if (typeof skipUnreleased !== 'boolean') {
return NextResponse.json(
{ error: 'skipUnreleased must be a boolean' },
{ status: 400 }
);
}
const configService = getConfigService();
await configService.setMany([
{
key: CONFIG_KEY,
value: String(skipUnreleased),
category: 'indexer',
description:
'Skip auto-searches for books with future release dates',
},
]);
// Explicitly clear cache for the key after write. `setMany` already
// does this, but we make it visible here to guarantee fresh reads
// by any sibling service that has cached the value.
configService.clearCache(CONFIG_KEY);
logger.info('Indexer options updated', { skipUnreleased });
return NextResponse.json({
success: true,
message: 'Indexer options updated successfully',
});
} catch (error) {
logger.error('Failed to update indexer options', {
error: error instanceof Error ? error.message : String(error),
});
return NextResponse.json(
{
success: false,
error:
error instanceof Error
? error.message
: 'Failed to update indexer options',
},
{ status: 500 }
);
}
});
});
}
+15 -1
View File
@@ -15,7 +15,7 @@ export async function PUT(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
const { downloadDir, mediaDir, audiobookPathTemplate, ebookPathTemplate, metadataTaggingEnabled, chapterMergingEnabled, fileRenameEnabled, fileRenameTemplate, fileChmod, dirChmod } = await request.json();
const { downloadDir, mediaDir, audiobookPathTemplate, ebookPathTemplate, metadataTaggingEnabled, chapterMergingEnabled, plexFormatCoercionEnabled, fileRenameEnabled, fileRenameTemplate, fileChmod, dirChmod } = await request.json();
if (!downloadDir || !mediaDir) {
return NextResponse.json(
@@ -112,6 +112,19 @@ export async function PUT(request: NextRequest) {
},
});
// Update Plex format coercion setting (issue #166: silently rename .mp4/.m4a to .m4b
// post-organize so Plex's audiobook library recognizes them without transcoding)
await prisma.configuration.upsert({
where: { key: 'plex_format_coercion_enabled' },
update: { value: String(plexFormatCoercionEnabled ?? true) },
create: {
key: 'plex_format_coercion_enabled',
value: String(plexFormatCoercionEnabled ?? true),
category: 'automation',
description: 'Rename audio files to Plex-compatible extensions (.mp4/.m4a -> .m4b) after organization to avoid silent library-scan failures',
},
});
// Update file rename setting
await prisma.configuration.upsert({
where: { key: 'file_rename_enabled' },
@@ -176,6 +189,7 @@ export async function PUT(request: NextRequest) {
configService.clearCache('ebook_path_template');
configService.clearCache('metadata_tagging_enabled');
configService.clearCache('chapter_merging_enabled');
configService.clearCache('plex_format_coercion_enabled');
configService.clearCache('file_rename_enabled');
configService.clearCache('file_rename_template');
configService.clearCache('file_chmod');
@@ -17,6 +17,7 @@ interface SavedIndexerConfig {
protocol: string;
priority: number;
seedingTimeMinutes?: number; // Torrents only
ratioLimit?: number; // Torrents only (0 = no ratio requirement)
removeAfterProcessing?: boolean; // Usenet only
rssEnabled?: boolean;
audiobookCategories?: number[]; // Array of category IDs for audiobooks (default: [3030])
@@ -79,6 +80,7 @@ export async function GET(request: NextRequest) {
// Add protocol-specific fields
if (isTorrent) {
config.seedingTimeMinutes = saved?.seedingTimeMinutes ?? 0;
config.ratioLimit = saved?.ratioLimit ?? 0;
} else {
config.removeAfterProcessing = saved?.removeAfterProcessing ?? true;
}
@@ -134,6 +136,7 @@ export async function PUT(request: NextRequest) {
const isTorrent = indexer.protocol?.toLowerCase() === 'torrent';
if (isTorrent) {
config.seedingTimeMinutes = indexer.seedingTimeMinutes ?? 0;
config.ratioLimit = indexer.ratioLimit ?? 0;
} else {
config.removeAfterProcessing = indexer.removeAfterProcessing ?? true;
}
+7
View File
@@ -81,6 +81,12 @@ export async function GET(request: NextRequest) {
url: configMap.get('prowlarr_url') || '',
apiKey: maskValue('api_key', configMap.get('prowlarr_api_key')),
},
indexerOptions: {
// Default ON: missing or any value other than 'false' is treated as enabled.
// Must stay in lock-step with /api/admin/settings/indexer-options read contract
// and any background worker that reads `indexer.skip_unreleased` directly.
skipUnreleased: configMap.get('indexer.skip_unreleased') !== 'false',
},
// downloadClient is populated from multi-client format for backward compatibility
// The DownloadTab component now uses DownloadClientManagement which reads from /api/admin/settings/download-clients
downloadClient: (() => {
@@ -128,6 +134,7 @@ export async function GET(request: NextRequest) {
ebookPathTemplate: configMap.get('ebook_path_template') || configMap.get('audiobook_path_template') || '{author}/{title} {asin}',
metadataTaggingEnabled: configMap.get('metadata_tagging_enabled') === 'true',
chapterMergingEnabled: configMap.get('chapter_merging_enabled') === 'true',
plexFormatCoercionEnabled: configMap.get('plex_format_coercion_enabled') !== 'false',
fileRenameEnabled: configMap.get('file_rename_enabled') === 'true',
fileRenameTemplate: configMap.get('file_rename_template') || '{title}',
fileChmod: configMap.get('file_chmod') || '664',
+3 -3
View File
@@ -8,7 +8,7 @@ import { getAudibleService } from '@/lib/integrations/audible.service';
import { enrichAudiobooksWithMatches } from '@/lib/utils/audiobook-matcher';
import { deduplicateAndCollectGroups } from '@/lib/utils/deduplicate-audiobooks';
import { persistDedupGroups, collapseByExistingWorks } from '@/lib/services/works.service';
import { getCurrentUser } from '@/lib/middleware/auth';
import { getCurrentUserAsync } from '@/lib/middleware/auth';
import { RMABLogger } from '@/lib/utils/logger';
import { annotateWithIgnoreStatus } from '@/lib/utils/ignored-audiobooks';
@@ -37,8 +37,8 @@ export async function GET(request: NextRequest) {
const audibleService = getAudibleService();
const results = await audibleService.search(query, page);
// Get current user (optional - for request status enrichment)
const currentUser = getCurrentUser(request);
// Get current user (optional — JWT or API token — for request-status enrichment)
const currentUser = await getCurrentUserAsync(request);
const userId = currentUser?.sub || undefined;
// Two-pass dedup: local title/narrator/duration matching first, then collapse
+52 -13
View File
@@ -7,7 +7,9 @@ import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
import { getAudibleService } from '@/lib/integrations/audible.service';
import { getConfigService } from '@/lib/services/config.service';
import { RMABLogger } from '@/lib/utils/logger';
import { shouldSkipAutoSearch } from '@/lib/utils/release-date';
const logger = RMABLogger.create('API.BookDateSwipe');
@@ -67,16 +69,21 @@ async function handler(req: AuthenticatedRequest) {
let year: number | undefined;
let series: string | undefined;
let seriesPart: string | undefined;
let releaseDate: Date | null = null;
try {
const audibleService = getAudibleService();
const audnexusData = await audibleService.getAudiobookDetails(recommendation.audnexusAsin);
if (audnexusData?.releaseDate) {
try {
const releaseYear = new Date(audnexusData.releaseDate).getFullYear();
if (!isNaN(releaseYear)) {
year = releaseYear;
logger.debug(`Extracted year ${year} from Audnexus releaseDate: ${audnexusData.releaseDate}`);
const parsed = new Date(audnexusData.releaseDate);
if (!isNaN(parsed.getTime())) {
releaseDate = parsed;
const releaseYear = parsed.getFullYear();
if (!isNaN(releaseYear)) {
year = releaseYear;
logger.debug(`Extracted year ${year} from Audnexus releaseDate: ${audnexusData.releaseDate}`);
}
}
} catch (error) {
logger.warn(`Failed to parse Audnexus releaseDate "${audnexusData.releaseDate}": ${error instanceof Error ? error.message : 'Unknown error'}`);
@@ -181,8 +188,28 @@ async function handler(req: AuthenticatedRequest) {
}
}
// Evaluate release-date gate (only when not pending approval)
let releaseGateSkip = false;
if (!needsApproval) {
try {
const configService = getConfigService();
const skipUnreleasedSetting = (await configService.get('indexer.skip_unreleased')) !== 'false';
const gate = shouldSkipAutoSearch({ releaseDate }, skipUnreleasedSetting);
releaseGateSkip = gate.skip;
} catch (error) {
logger.warn(`Failed to evaluate release-date gate: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
// Determine initial status
const initialStatus = needsApproval ? 'awaiting_approval' : 'pending';
let initialStatus: string;
if (needsApproval) {
initialStatus = 'awaiting_approval';
} else if (releaseGateSkip) {
initialStatus = 'awaiting_release';
} else {
initialStatus = 'pending';
}
const newRequest = await prisma.request.create({
data: {
@@ -191,11 +218,21 @@ async function handler(req: AuthenticatedRequest) {
status: initialStatus,
type: 'audiobook', // Explicit type for user-created requests
priority: 0,
releaseDate,
},
});
logger.info(`Created request for "${recommendation.title}" with status: ${initialStatus}`);
if (releaseGateSkip) {
logger.info(`Skipped auto-search for unreleased book`, {
gateSource: 'BookDateSwipe',
requestId: newRequest.id,
audiobookTitle: audiobook.title,
releaseDate: releaseDate?.toISOString() ?? null,
});
}
// Import job queue service
const { getJobQueueService } = await import('@/lib/services/job-queue.service');
const jobQueue = getJobQueueService();
@@ -224,15 +261,17 @@ async function handler(req: AuthenticatedRequest) {
logger.error('Failed to queue notification', { error: error instanceof Error ? error.message : String(error) });
});
// Trigger search job only if auto-approved
await jobQueue.addSearchJob(newRequest.id, {
id: audiobook.id,
title: audiobook.title,
author: audiobook.author,
asin: audiobook.audibleAsin || undefined,
});
// Trigger search job only if auto-approved AND not gated by release date
if (!releaseGateSkip) {
await jobQueue.addSearchJob(newRequest.id, {
id: audiobook.id,
title: audiobook.title,
author: audiobook.author,
asin: audiobook.audibleAsin || undefined,
});
logger.info(`Triggered search job for request ${newRequest.id}`);
logger.info(`Triggered search job for request ${newRequest.id}`);
}
}
}
@@ -52,8 +52,8 @@ export async function POST(
);
}
// Only allow manual search for pending, failed, awaiting_search statuses
const searchableStatuses = ['pending', 'failed', 'awaiting_search'];
// Only allow manual search for pending, failed, awaiting_search, awaiting_release statuses
const searchableStatuses = ['pending', 'failed', 'awaiting_search', 'awaiting_release'];
if (!searchableStatuses.includes(requestRecord.status)) {
return NextResponse.json(
{
+1 -1
View File
@@ -182,7 +182,7 @@ export async function PATCH(
} else if (action === 'retry') {
// Retry failed request - allow users to retry their own warn/failed requests
// Only allow retry for failed, warn, or awaiting_* statuses
const retryableStatuses = ['failed', 'warn', 'awaiting_search', 'awaiting_import'];
const retryableStatuses = ['failed', 'warn', 'awaiting_search', 'awaiting_import', 'awaiting_release'];
if (!retryableStatuses.includes(requestRecord.status)) {
return NextResponse.json(
+1 -1
View File
@@ -101,7 +101,7 @@ export async function POST(request: NextRequest) {
// Status groups for server-side filtering and count aggregation
const STATUS_GROUPS: Record<string, string[]> = {
active: ['pending', 'searching', 'downloading', 'processing'],
waiting: ['awaiting_search', 'awaiting_import', 'awaiting_approval'],
waiting: ['awaiting_search', 'awaiting_import', 'awaiting_approval', 'awaiting_release'],
completed: ['available', 'downloaded'],
failed: ['failed'],
cancelled: ['cancelled', 'denied'],
+13
View File
@@ -469,6 +469,19 @@ export async function POST(request: NextRequest) {
},
});
// Plex format coercion configuration (issue #166: silently rename .mp4/.m4a to .m4b
// post-organize so Plex's audiobook library recognizes them without transcoding)
await prisma.configuration.upsert({
where: { key: 'plex_format_coercion_enabled' },
update: { value: String(paths.plex_format_coercion_enabled ?? true) },
create: {
key: 'plex_format_coercion_enabled',
value: String(paths.plex_format_coercion_enabled ?? true),
category: 'automation',
description: 'Rename audio files to Plex-compatible extensions (.mp4/.m4a -> .m4b) after organization to avoid silent library-scan failures'
},
});
// BookDate configuration (optional, global for all users)
// Note: libraryScope and customPrompt are now per-user settings, not required here
if (bookdate && bookdate.provider && bookdate.apiKey && bookdate.model) {
+124 -10
View File
@@ -14,6 +14,18 @@ import { HomeSectionConfigModal } from '@/components/home/HomeSectionConfigModal
import { useHomeSections } from '@/lib/hooks/useHomeSections';
import { usePreferences } from '@/contexts/PreferencesContext';
import { Cog6ToothIcon } from '@heroicons/react/24/outline';
import { decideScrollForPageChange } from '@/lib/utils/paginationScroll';
const FALLBACK_HEADER_HEIGHT = 64;
const LOCK_SAFETY_RELEASE_MS = 30_000;
const RELEASE_SCROLL_KEYS = new Set([
'ArrowUp',
'ArrowDown',
'PageUp',
'PageDown',
'Home',
'End',
]);
function getSectionTitle(sectionType: string, categoryName?: string | null): string {
if (sectionType === 'popular') return 'Popular Audiobooks';
@@ -21,6 +33,14 @@ function getSectionTitle(sectionType: string, categoryName?: string | null): str
return categoryName || 'Category';
}
function measureHeaderHeight(): number {
if (typeof document === 'undefined') return FALLBACK_HEADER_HEIGHT;
const header = document.querySelector<HTMLElement>('header.sticky');
if (!header) return FALLBACK_HEADER_HEIGHT;
const h = header.getBoundingClientRect().height;
return h > 0 ? h : FALLBACK_HEADER_HEIGHT;
}
export default function HomePage() {
const { sections, nextRefresh, isLoading: sectionsLoading, saveSections } = useHomeSections();
const { cardSize, setCardSize, squareCovers, setSquareCovers, hideAvailable, setHideAvailable } = usePreferences();
@@ -30,6 +50,12 @@ export default function HomePage() {
const [totalPagesMap, setTotalPagesMap] = useState<Record<string, number>>({});
const [configOpen, setConfigOpen] = useState(false);
// Controlled paginator-pill state
const [activeIndex, setActiveIndex] = useState(0);
const [lockedTo, setLockedTo] = useState<number | null>(null);
const lockedToRef = useRef<number | null>(null);
lockedToRef.current = lockedTo;
const footerRef = useRef<HTMLElement>(null);
// Create stable refs for each section
@@ -52,6 +78,38 @@ export default function HomePage() {
setTotalPagesMap({});
}, [hideAvailable]);
// Clamp activeIndex if the section list shrinks
useEffect(() => {
if (sections.length === 0) return;
if (activeIndex >= sections.length) {
setActiveIndex(0);
setLockedTo(null);
}
}, [sections.length, activeIndex]);
// Release the lock on the user's next intentional scroll input.
// wheel / touchstart always release; keydown releases only for known page-scroll keys.
useEffect(() => {
if (lockedTo === null) return;
const release = () => setLockedTo(null);
const onKeyDown = (e: KeyboardEvent) => {
if (RELEASE_SCROLL_KEYS.has(e.key)) release();
};
window.addEventListener('wheel', release, { passive: true });
window.addEventListener('touchstart', release, { passive: true });
window.addEventListener('keydown', onKeyDown);
const safetyTimer = window.setTimeout(release, LOCK_SAFETY_RELEASE_MS);
return () => {
window.removeEventListener('wheel', release);
window.removeEventListener('touchstart', release);
window.removeEventListener('keydown', onKeyDown);
window.clearTimeout(safetyTimer);
};
}, [lockedTo]);
const getPage = (key: string) => pages[key] || 1;
const setPage = useCallback((key: string, page: number) => {
setPages((prev) => ({ ...prev, [key]: page }));
@@ -63,6 +121,68 @@ export default function HomePage() {
});
}, []);
// Pill-driven Prev/Next/jump. Fit-aware scroll, lock pill to this section.
const handlePageChange = useCallback(
(index: number, key: string, page: number, ref: React.RefObject<HTMLElement | null>) => {
setPage(key, page);
setActiveIndex(index);
setLockedTo(index);
const section = ref.current;
if (!section || typeof window === 'undefined') return;
const rect = section.getBoundingClientRect();
const headerHeight = measureHeaderHeight();
const maxScrollY = Math.max(
0,
document.documentElement.scrollHeight - window.innerHeight
);
const decision = decideScrollForPageChange({
sectionTop: rect.top,
sectionHeight: rect.height,
viewportHeight: window.innerHeight,
headerHeight,
scrollY: window.scrollY,
maxScrollY,
});
if (decision.action === 'scroll') {
window.scrollTo({ top: decision.targetY, behavior: 'smooth' });
}
},
[setPage]
);
// Dot click on a non-active section. Always scrolls (intentional navigation).
// Releases any active lock and immediately switches the pill to that section.
const handleScrollToSection = useCallback(
(index: number, ref: React.RefObject<HTMLElement | null>) => {
setLockedTo(null);
setActiveIndex(index);
const section = ref.current;
if (!section || typeof window === 'undefined') return;
const rect = section.getBoundingClientRect();
const headerHeight = measureHeaderHeight();
const maxScrollY = Math.max(
0,
document.documentElement.scrollHeight - window.innerHeight
);
const desired = rect.top + window.scrollY - headerHeight - 8;
const targetY = Math.min(Math.max(0, desired), maxScrollY);
window.scrollTo({ top: targetY, behavior: 'smooth' });
},
[]
);
// Observer-driven "dominant section" guess from the pill. Honored only when unlocked.
const handleDominantSectionChange = useCallback((index: number) => {
if (lockedToRef.current !== null) return;
setActiveIndex(index);
}, []);
// Build pagination sections for the floating pill
const paginationSections: PaginationSection[] = sections.map((s, i) => {
const key = getSectionKey(s);
@@ -72,13 +192,9 @@ export default function HomePage() {
accentColor: SECTION_DOT_COLORS[i % SECTION_DOT_COLORS.length],
currentPage: getPage(key),
totalPages: totalPagesMap[key] || 1,
onPageChange: (page: number) => {
setPage(key, page);
ref.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
},
onPageChange: (page: number) => handlePageChange(i, key, page, ref),
sectionRef: ref,
onScrollToSection: () =>
ref.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }),
onScrollToSection: () => handleScrollToSection(i, ref),
};
});
@@ -125,10 +241,6 @@ export default function HomePage() {
categoryName={section.categoryName}
colorIndex={index}
page={getPage(key)}
onPageChange={(page) => {
setPage(key, page);
ref.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}}
sectionRef={ref}
cardSize={cardSize}
squareCovers={squareCovers}
@@ -174,6 +286,8 @@ export default function HomePage() {
<UnifiedPagination
footerRef={footerRef}
sections={paginationSections}
activeIndex={Math.min(activeIndex, paginationSections.length - 1)}
onDominantSectionChange={handleDominantSectionChange}
/>
)}
+4
View File
@@ -87,6 +87,7 @@ interface SetupState {
downloadDir: string;
mediaDir: string;
metadataTaggingEnabled: boolean;
plexFormatCoercionEnabled: boolean;
chapterMergingEnabled: boolean;
bookdateProvider: string;
bookdateApiKey: string;
@@ -161,6 +162,7 @@ export default function SetupWizard() {
downloadDir: '/downloads',
mediaDir: '/media/audiobooks',
metadataTaggingEnabled: true,
plexFormatCoercionEnabled: true,
chapterMergingEnabled: false,
bookdateProvider: 'openai',
bookdateApiKey: '',
@@ -237,6 +239,7 @@ export default function SetupWizard() {
download_dir: state.downloadDir,
media_dir: state.mediaDir,
metadata_tagging_enabled: state.metadataTaggingEnabled,
plex_format_coercion_enabled: state.plexFormatCoercionEnabled,
chapter_merging_enabled: state.chapterMergingEnabled,
},
bookdate: state.bookdateConfigured ? {
@@ -537,6 +540,7 @@ export default function SetupWizard() {
downloadDir={state.downloadDir}
mediaDir={state.mediaDir}
metadataTaggingEnabled={state.metadataTaggingEnabled}
plexFormatCoercionEnabled={state.plexFormatCoercionEnabled}
chapterMergingEnabled={state.chapterMergingEnabled}
pathsTested={state.pathsTested}
onUpdate={updateField}
+26
View File
@@ -13,6 +13,7 @@ interface PathsStepProps {
downloadDir: string;
mediaDir: string;
metadataTaggingEnabled: boolean;
plexFormatCoercionEnabled: boolean;
chapterMergingEnabled: boolean;
pathsTested: boolean;
onUpdate: (field: string, value: any) => void;
@@ -24,6 +25,7 @@ export function PathsStep({
downloadDir,
mediaDir,
metadataTaggingEnabled,
plexFormatCoercionEnabled,
chapterMergingEnabled,
pathsTested,
onUpdate,
@@ -246,6 +248,30 @@ export function PathsStep({
</div>
</div>
{/* Plex Format Coercion Toggle */}
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
<div className="flex items-start gap-4">
<input
type="checkbox"
id="plex-format-coercion"
checked={plexFormatCoercionEnabled}
onChange={(e) => onUpdate('plexFormatCoercionEnabled', e.target.checked)}
className="mt-1 h-5 w-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<div className="flex-1">
<label
htmlFor="plex-format-coercion"
className="block text-sm font-medium text-gray-900 dark:text-gray-100 cursor-pointer"
>
Coerce file formats for Plex compatibility
</label>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
Rename .mp4 audiobook files (and single-file .m4a) to .m4b before Plex scans. No re-encoding.
</p>
</div>
</div>
</div>
{/* Chapter Merging Toggle */}
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
<div className="flex items-start gap-4">
+1
View File
@@ -25,6 +25,7 @@ interface SelectedIndexer {
protocol: string;
priority: number;
seedingTimeMinutes?: number; // Torrents only
ratioLimit?: number; // Torrents only (0 = no ratio requirement)
removeAfterProcessing?: boolean; // Usenet only
rssEnabled: boolean;
audiobookCategories: number[]; // Categories for audiobook searches
@@ -1,9 +1,6 @@
/**
* Component: Indexer Configuration Modal
* Documentation: documentation/frontend/components.md
*
* Supports separate category configurations for AudioBook and EBook searches
* via tabbed interface in the Categories section.
*/
'use client';
@@ -13,6 +10,7 @@ import { Modal } from '@/components/ui/Modal';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { CategoryTreeView } from './CategoryTreeView';
import { TorrentSeedingFields } from './IndexerConfigModalTorrentFields';
import { DEFAULT_AUDIOBOOK_CATEGORIES, DEFAULT_EBOOK_CATEGORIES } from '@/lib/utils/torrent-categories';
type CategoryTab = 'audiobook' | 'ebook';
@@ -30,6 +28,7 @@ interface IndexerConfigModalProps {
initialConfig?: {
priority: number;
seedingTimeMinutes?: number;
ratioLimit?: number;
removeAfterProcessing?: boolean;
rssEnabled: boolean;
audiobookCategories: number[];
@@ -41,6 +40,7 @@ interface IndexerConfigModalProps {
protocol: string;
priority: number;
seedingTimeMinutes?: number;
ratioLimit?: number;
removeAfterProcessing?: boolean;
rssEnabled: boolean;
audiobookCategories: number[];
@@ -61,6 +61,7 @@ export function IndexerConfigModal({
const defaults = {
priority: 10,
seedingTimeMinutes: 0,
ratioLimit: 0,
removeAfterProcessing: true, // Default to true for Usenet
rssEnabled: indexer.supportsRss,
audiobookCategories: DEFAULT_AUDIOBOOK_CATEGORIES,
@@ -74,6 +75,9 @@ export function IndexerConfigModal({
const [seedingTimeMinutes, setSeedingTimeMinutes] = useState(
initialConfig?.seedingTimeMinutes ?? defaults.seedingTimeMinutes
);
const [ratioLimit, setRatioLimit] = useState(
initialConfig?.ratioLimit ?? defaults.ratioLimit
);
const [removeAfterProcessing, setRemoveAfterProcessing] = useState(
initialConfig?.removeAfterProcessing ?? defaults.removeAfterProcessing
);
@@ -89,21 +93,20 @@ export function IndexerConfigModal({
initialConfig?.ebookCategories ?? defaults.ebookCategories
);
// Tab state for categories
const [activeTab, setActiveTab] = useState<CategoryTab>('audiobook');
// Validation errors
const [errors, setErrors] = useState<{
priority?: string;
seedingTimeMinutes?: string;
ratioLimit?: string;
}>({});
// Reset form when modal opens or indexer changes
useEffect(() => {
if (isOpen) {
if (mode === 'add') {
setPriority(defaults.priority);
setSeedingTimeMinutes(defaults.seedingTimeMinutes);
setRatioLimit(defaults.ratioLimit);
setRemoveAfterProcessing(defaults.removeAfterProcessing);
setRssEnabled(defaults.rssEnabled);
setAudiobookCategories(defaults.audiobookCategories);
@@ -111,6 +114,7 @@ export function IndexerConfigModal({
} else {
setPriority(initialConfig?.priority ?? defaults.priority);
setSeedingTimeMinutes(initialConfig?.seedingTimeMinutes ?? defaults.seedingTimeMinutes);
setRatioLimit(initialConfig?.ratioLimit ?? defaults.ratioLimit);
setRemoveAfterProcessing(initialConfig?.removeAfterProcessing ?? defaults.removeAfterProcessing);
setRssEnabled(initialConfig?.rssEnabled ?? defaults.rssEnabled);
setAudiobookCategories(initialConfig?.audiobookCategories ?? defaults.audiobookCategories);
@@ -132,6 +136,10 @@ export function IndexerConfigModal({
newErrors.seedingTimeMinutes = 'Seeding time cannot be negative';
}
if (isTorrent && ratioLimit < 0) {
newErrors.ratioLimit = 'Ratio limit cannot be negative';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
@@ -154,6 +162,7 @@ export function IndexerConfigModal({
// Add protocol-specific fields
if (isTorrent) {
config.seedingTimeMinutes = seedingTimeMinutes;
config.ratioLimit = ratioLimit;
} else {
config.removeAfterProcessing = removeAfterProcessing;
}
@@ -183,6 +192,17 @@ export function IndexerConfigModal({
}
};
const handleRatioLimitChange = (value: string) => {
if (value === '') {
setRatioLimit(0);
} else {
const parsed = parseFloat(value);
if (!isNaN(parsed)) {
setRatioLimit(Math.max(0, parsed));
}
}
};
// Get the current categories based on active tab
const currentCategories = activeTab === 'audiobook' ? audiobookCategories : ebookCategories;
const setCurrentCategories = activeTab === 'audiobook' ? setAudiobookCategories : setEbookCategories;
@@ -238,30 +258,15 @@ export function IndexerConfigModal({
)}
</div>
{/* Seeding Time (Torrents only) */}
{/* Seeding Time + Ratio Limit (Torrents only) */}
{isTorrent && (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Seeding Time (minutes)
</label>
<Input
type="number"
min="0"
step="1"
value={seedingTimeMinutes}
onChange={(e) => handleSeedingTimeChange(e.target.value)}
placeholder="0"
className={errors.seedingTimeMinutes ? 'border-red-500' : ''}
/>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
0 = unlimited seeding (files remain seeded indefinitely)
</p>
{errors.seedingTimeMinutes && (
<p className="text-sm text-red-600 dark:text-red-400 mt-1">
{errors.seedingTimeMinutes}
</p>
)}
</div>
<TorrentSeedingFields
seedingTimeMinutes={seedingTimeMinutes}
ratioLimit={ratioLimit}
errors={errors}
onSeedingTimeChange={handleSeedingTimeChange}
onRatioLimitChange={handleRatioLimitChange}
/>
)}
{/* Remove After Processing (Usenet only) */}
@@ -0,0 +1,78 @@
/**
* Component: Indexer Config Modal Torrent Seeding Fields
* Documentation: documentation/frontend/components.md
*
* Renders the torrent-only "Seeding Time" + "Ratio Limit" inputs used by
* IndexerConfigModal. Extracted to keep the parent modal under 400 lines.
*/
'use client';
import React from 'react';
import { Input } from '@/components/ui/Input';
export interface TorrentSeedingFieldsProps {
seedingTimeMinutes: number;
ratioLimit: number;
errors: { seedingTimeMinutes?: string; ratioLimit?: string };
onSeedingTimeChange: (value: string) => void;
onRatioLimitChange: (value: string) => void;
}
export function TorrentSeedingFields({
seedingTimeMinutes,
ratioLimit,
errors,
onSeedingTimeChange,
onRatioLimitChange,
}: TorrentSeedingFieldsProps) {
return (
<>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Seeding Time (minutes)
</label>
<Input
type="number"
min="0"
step="1"
value={seedingTimeMinutes}
onChange={(e) => onSeedingTimeChange(e.target.value)}
placeholder="0"
className={errors.seedingTimeMinutes ? 'border-red-500' : ''}
/>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
0 = unlimited seeding (files remain seeded indefinitely)
</p>
{errors.seedingTimeMinutes && (
<p className="text-sm text-red-600 dark:text-red-400 mt-1">
{errors.seedingTimeMinutes}
</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Ratio Limit
</label>
<Input
type="number"
min="0"
step="0.01"
value={ratioLimit}
onChange={(e) => onRatioLimitChange(e.target.value)}
placeholder="0"
className={errors.ratioLimit ? 'border-red-500' : ''}
/>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Minimum upload/download ratio before files are cleaned up. 0 = no ratio requirement.
</p>
{errors.ratioLimit && (
<p className="text-sm text-red-600 dark:text-red-400 mt-1">
{errors.ratioLimit}
</p>
)}
</div>
</>
);
}
@@ -26,6 +26,7 @@ interface SavedIndexerConfig {
protocol: string;
priority: number;
seedingTimeMinutes?: number; // Torrents only
ratioLimit?: number; // Torrents only (0 = no ratio requirement)
removeAfterProcessing?: boolean; // Usenet only
rssEnabled: boolean;
audiobookCategories: number[]; // Categories for audiobook searches
+31 -18
View File
@@ -90,6 +90,11 @@ export function EndpointCard({ endpoint, token, useSession }: EndpointCardProps)
Admin
</span>
)}
{endpoint.isWrite && (
<span className="inline-flex items-center px-2 py-0.5 rounded-md text-[10px] font-semibold uppercase tracking-wider bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300">
Write
</span>
)}
</div>
<h3 className="text-base font-semibold text-gray-900 dark:text-white mb-1">
{endpoint.title}
@@ -99,25 +104,33 @@ export function EndpointCard({ endpoint, token, useSession }: EndpointCardProps)
</p>
</div>
<button
onClick={handleTryIt}
disabled={loading}
className="flex-shrink-0 inline-flex items-center gap-1.5 px-4 py-2 rounded-xl text-sm font-semibold bg-gray-900 dark:bg-white text-white dark:text-gray-900 hover:bg-gray-800 dark:hover:bg-gray-100 disabled:opacity-50 transition-all active:scale-[0.97]"
>
{loading ? (
<>
<div className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-white/30 dark:border-gray-900/30 border-t-white dark:border-t-gray-900" />
Running
</>
) : (
<>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
</svg>
Try it
</>
<div className="flex-shrink-0 flex flex-col items-end gap-1">
<button
onClick={handleTryIt}
disabled={loading || endpoint.isWrite}
title={endpoint.isWrite ? 'Use curl or your API client — write actions disabled here for safety' : undefined}
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-xl text-sm font-semibold bg-gray-900 dark:bg-white text-white dark:text-gray-900 hover:bg-gray-800 dark:hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed transition-all active:scale-[0.97]"
>
{loading ? (
<>
<div className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-white/30 dark:border-gray-900/30 border-t-white dark:border-t-gray-900" />
Running
</>
) : (
<>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
</svg>
Try it
</>
)}
</button>
{endpoint.isWrite && (
<span className="text-[11px] text-gray-500 dark:text-gray-400 max-w-[180px] text-right leading-snug">
Use curl or your API client write actions disabled here for safety.
</span>
)}
</button>
</div>
</div>
{/* Expandable response area */}
+1 -1
View File
@@ -34,7 +34,7 @@ const getStatusConfig = (audiobook: Audiobook) => {
return { type: 'processing', label: 'Processing', color: 'amber' };
}
const pendingStatuses = ['pending', 'awaiting_search', 'searching', 'awaiting_approval'];
const pendingStatuses = ['pending', 'awaiting_search', 'awaiting_release', 'searching', 'awaiting_approval'];
if (audiobook.requestStatus && pendingStatuses.includes(audiobook.requestStatus)) {
return { type: 'pending', label: 'Requested', color: 'blue' };
}
@@ -53,7 +53,7 @@ const getStatusInfo = (isAvailable: boolean, requestStatus: string | null, reque
return { type: 'processing', label: 'Processing', canRequest: false };
}
const pendingStatuses = ['pending', 'awaiting_search', 'searching', 'awaiting_approval'];
const pendingStatuses = ['pending', 'awaiting_search', 'awaiting_release', 'searching', 'awaiting_approval'];
if (requestStatus && pendingStatuses.includes(requestStatus)) {
const label = requestStatus === 'awaiting_approval'
? requestedByUsername ? `Pending Approval (${requestedByUsername})` : 'Pending Approval'
-2
View File
@@ -89,7 +89,6 @@ interface HomeSectionProps {
categoryName: string | null;
colorIndex: number;
page: number;
onPageChange: (page: number) => void;
sectionRef: React.RefObject<HTMLElement | null>;
cardSize: number;
squareCovers: boolean;
@@ -226,7 +225,6 @@ export function HomeSection({
categoryName,
colorIndex,
page,
onPageChange,
sectionRef,
cardSize,
squareCovers,
@@ -27,6 +27,10 @@ export function ApiTokensSection() {
View API documentation
</Link>
</p>
<p className="mt-2 text-xs text-amber-700 dark:text-amber-400">
API tokens act with your full user-level permissions, including creating audiobook
requests on your behalf. Keep them private.
</p>
</div>
</div>
@@ -12,6 +12,8 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { TorrentResult, RankedTorrent } from '@/lib/utils/ranking-algorithm';
import { extractTitleTags } from '@/lib/utils/title-tags';
import { useIsTruncated } from '@/lib/hooks/useIsTruncated';
import {
useInteractiveSearch,
useSelectTorrent,
@@ -24,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;
@@ -116,9 +132,18 @@ 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);
const [expandedGuids, setExpandedGuids] = useState<Set<string>>(() => new Set());
// Stable close handler via ref
const onCloseRef = useRef(onClose);
@@ -157,8 +182,45 @@ export function InteractiveTorrentSearchModal({
useEffect(() => {
setSearchTitle(customSearchTerms || audiobook.title);
setResults([]);
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) {
@@ -189,6 +251,7 @@ export function InteractiveTorrentSearchModal({
const performSearch = async () => {
setResults([]);
setExpandedGuids(new Set());
try {
let data;
if (isEbookMode) {
@@ -380,125 +443,25 @@ export function InteractiveTorrentSearchModal({
{/* Results List */}
{!isSearching && results.length > 0 && (
<div className="space-y-0.5">
{results.map((result) => {
const score = Math.round(result.score);
const style = getScoreStyle(score);
const isUsenet = result.protocol === 'usenet';
const isAnnasArchive = isEbookMode && result.source === 'annas_archive';
const displayFormat = result.format || result.ebookFormat;
return (
<div
key={result.guid}
className="flex items-center gap-3 px-3 py-3 rounded-xl hover:bg-gray-50/80 dark:hover:bg-white/[0.03] transition-colors group"
>
{/* Score Badge */}
<div
className={`flex-shrink-0 w-11 h-11 rounded-xl ${style.bg} flex flex-col items-center justify-center`}
title={`Score: ${score} (Match: ${Math.round(result.breakdown?.matchScore ?? 0)}, Format: ${Math.round(result.breakdown?.formatScore ?? 0)}, Size: ${Math.round(result.breakdown?.sizeScore ?? 0)}, Seeds: ${Math.round(result.breakdown?.seederScore ?? 0)})`}
>
<span className={`text-[15px] font-bold leading-none tabular-nums ${style.text}`}>
{score}
</span>
</div>
{/* Content */}
<div className="flex-1 min-w-0">
{/* Title Row */}
<div className="flex items-center gap-1.5">
<a
href={result.infoUrl || result.guid}
target="_blank"
rel="noopener noreferrer"
className="text-sm font-medium text-gray-900 dark:text-white truncate hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
title={result.title}
>
{result.title}
</a>
</div>
{/* Metadata Row */}
<div className="flex items-center gap-1 mt-0.5 text-xs text-gray-500 dark:text-gray-400 flex-wrap">
{/* Rank */}
<span className="text-gray-400 dark:text-gray-500 font-medium">#{result.rank}</span>
<span className="text-gray-300 dark:text-gray-600 select-none">&middot;</span>
{/* Indexer / Source */}
{isAnnasArchive ? (
<span className="text-orange-600 dark:text-orange-400 font-medium">Anna&apos;s Archive</span>
) : (
<span>{result.indexer}</span>
)}
{/* Size */}
{result.size > 0 && (
<>
<span className="text-gray-300 dark:text-gray-600 select-none">&middot;</span>
<span>{formatSize(result.size)}</span>
</>
)}
{/* Format */}
{displayFormat && (
<>
<span className="text-gray-300 dark:text-gray-600 select-none">&middot;</span>
<span className="px-1 py-px text-[10px] font-semibold uppercase tracking-wide rounded bg-purple-100 dark:bg-purple-500/15 text-purple-700 dark:text-purple-300">
{displayFormat}
</span>
</>
)}
{/* Protocol (torrent vs usenet) - only show for non-Anna's Archive */}
{!isAnnasArchive && (
<>
<span className="text-gray-300 dark:text-gray-600 select-none">&middot;</span>
{isUsenet ? (
<span className="flex items-center gap-0.5 text-sky-600 dark:text-sky-400">
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M11.3 1.046A1 1 0 0112 2v5h4a1 1 0 01.82 1.573l-7 10A1 1 0 018 18v-5H4a1 1 0 01-.82-1.573l7-10a1 1 0 011.12-.38z" clipRule="evenodd" />
</svg>
NZB
</span>
) : (
<span className="flex items-center gap-0.5">
<svg className="w-3 h-3 text-emerald-500" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm3.293-7.707a1 1 0 011.414 0L9 10.586V3a1 1 0 112 0v7.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clipRule="evenodd" />
</svg>
<span className="text-emerald-600 dark:text-emerald-400">{result.seeders ?? 0}</span>
</span>
)}
</>
)}
{/* Age */}
{result.publishDate && (
<>
<span className="text-gray-300 dark:text-gray-600 select-none">&middot;</span>
<span>{formatAge(result.publishDate)}</span>
</>
)}
{/* Bonus Points */}
{result.bonusPoints > 0 && (
<>
<span className="text-gray-300 dark:text-gray-600 select-none">&middot;</span>
<span className="text-blue-600 dark:text-blue-400 font-medium">+{Math.round(result.bonusPoints)}</span>
</>
)}
</div>
</div>
{/* Action Button */}
<button
onClick={() => handleDownloadClick(result)}
disabled={isDownloading}
className="flex-shrink-0 px-4 py-1.5 text-[13px] font-semibold text-blue-600 dark:text-blue-400 bg-blue-500/10 hover:bg-blue-500/20 dark:bg-blue-400/10 dark:hover:bg-blue-400/20 rounded-full transition-all active:scale-95 disabled:opacity-40 disabled:pointer-events-none"
>
Get
</button>
</div>
);
})}
{results.map((result) => (
<ResultRow
key={result.guid}
result={result}
isEbookMode={isEbookMode}
isExpanded={expandedGuids.has(result.guid)}
isDownloading={isDownloading}
blockedReason={resolveBlockedReason(result, blockedLookup)}
onToggleExpand={() => {
setExpandedGuids((prev) => {
const next = new Set(prev);
if (next.has(result.guid)) next.delete(result.guid);
else next.add(result.guid);
return next;
});
}}
onDownload={() => handleDownloadClick(result)}
/>
))}
</div>
)}
</div>
@@ -615,3 +578,207 @@ 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;
}
function ResultRow({
result,
isEbookMode,
isExpanded,
isDownloading,
blockedReason,
onToggleExpand,
onDownload,
}: ResultRowProps) {
const score = Math.round(result.score);
const style = getScoreStyle(score);
const isUsenet = result.protocol === 'usenet';
const isAnnasArchive = isEbookMode && result.source === 'annas_archive';
const displayFormat = result.format || result.ebookFormat;
const { tags } = extractTitleTags(result.title);
const displayFormatLower = (displayFormat ?? '').toLowerCase();
const chipTags = tags.filter((t) => t.toLowerCase() !== displayFormatLower);
const titleRef = useRef<HTMLAnchorElement | null>(null);
const isTruncated = useIsTruncated(titleRef);
// Why: keep chevron rendered while expanded so users can collapse — once
// expanded, scrollWidth no longer exceeds clientWidth and isTruncated flips false.
const showChevron = isTruncated || isExpanded;
return (
<div className="flex items-center gap-3 px-3 py-3 rounded-xl hover:bg-gray-50/80 dark:hover:bg-white/[0.03] transition-colors group">
{/* Score Badge */}
<div
className={`flex-shrink-0 w-11 h-11 rounded-xl ${style.bg} flex flex-col items-center justify-center`}
title={`Score: ${score} (Match: ${Math.round(result.breakdown?.matchScore ?? 0)}, Format: ${Math.round(result.breakdown?.formatScore ?? 0)}, Size: ${Math.round(result.breakdown?.sizeScore ?? 0)}, Seeds: ${Math.round(result.breakdown?.seederScore ?? 0)})`}
>
<span className={`text-[15px] font-bold leading-none tabular-nums ${style.text}`}>
{score}
</span>
</div>
{/* 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 &mdash; {blockedReason}</span>
</div>
)}
{/* Title Row */}
<div className="flex items-center gap-1.5 min-w-0">
<a
ref={titleRef}
href={result.infoUrl || result.guid}
target="_blank"
rel="noopener noreferrer"
className={`text-sm font-medium text-gray-900 dark:text-white hover:text-blue-600 dark:hover:text-blue-400 transition-colors flex-1 min-w-0 ${isExpanded ? 'break-words whitespace-normal' : 'truncate'}`}
title={result.title}
aria-label={result.title}
>
{result.title}
</a>
{showChevron && (
<button
type="button"
onClick={onToggleExpand}
aria-expanded={isExpanded}
aria-label={isExpanded ? 'Hide full title' : 'Show full title'}
className="flex-shrink-0 p-2 -my-1.5 rounded-md text-gray-400 hover:text-gray-600 hover:bg-gray-100/70 dark:text-gray-500 dark:hover:text-gray-300 dark:hover:bg-white/[0.05] 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>
{/* Metadata Row */}
<div className="flex items-center gap-1 mt-0.5 text-xs text-gray-500 dark:text-gray-400 flex-wrap">
{/* Rank */}
<span className="text-gray-400 dark:text-gray-500 font-medium">#{result.rank}</span>
<span className="text-gray-300 dark:text-gray-600 select-none">&middot;</span>
{/* Indexer / Source */}
{isAnnasArchive ? (
<span className="text-orange-600 dark:text-orange-400 font-medium">Anna&apos;s Archive</span>
) : (
<span>{result.indexer}</span>
)}
{/* Size */}
{result.size > 0 && (
<>
<span className="text-gray-300 dark:text-gray-600 select-none">&middot;</span>
<span>{formatSize(result.size)}</span>
</>
)}
{/* Format */}
{displayFormat && (
<>
<span className="text-gray-300 dark:text-gray-600 select-none">&middot;</span>
<span className="px-1 py-px text-[10px] font-semibold uppercase tracking-wide rounded bg-purple-100 dark:bg-purple-500/15 text-purple-700 dark:text-purple-300">
{displayFormat}
</span>
</>
)}
{/* Title tag chips (language/edition/etc.) */}
{chipTags.map((tag) => (
<span
key={tag}
className="px-1 py-px text-[10px] font-semibold uppercase tracking-wide rounded bg-slate-100 dark:bg-slate-500/15 text-slate-700 dark:text-slate-300"
>
{tag}
</span>
))}
{/* Protocol (torrent vs usenet) - only show for non-Anna's Archive */}
{!isAnnasArchive && (
<>
<span className="text-gray-300 dark:text-gray-600 select-none">&middot;</span>
{isUsenet ? (
<span className="flex items-center gap-0.5 text-sky-600 dark:text-sky-400">
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M11.3 1.046A1 1 0 0112 2v5h4a1 1 0 01.82 1.573l-7 10A1 1 0 018 18v-5H4a1 1 0 01-.82-1.573l7-10a1 1 0 011.12-.38z" clipRule="evenodd" />
</svg>
NZB
</span>
) : (
<span className="flex items-center gap-0.5">
<svg className="w-3 h-3 text-emerald-500" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm3.293-7.707a1 1 0 011.414 0L9 10.586V3a1 1 0 112 0v7.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clipRule="evenodd" />
</svg>
<span className="text-emerald-600 dark:text-emerald-400">{result.seeders ?? 0}</span>
</span>
)}
</>
)}
{/* Age */}
{result.publishDate && (
<>
<span className="text-gray-300 dark:text-gray-600 select-none">&middot;</span>
<span>{formatAge(result.publishDate)}</span>
</>
)}
{/* Bonus Points */}
{result.bonusPoints > 0 && (
<>
<span className="text-gray-300 dark:text-gray-600 select-none">&middot;</span>
<span className="text-blue-600 dark:text-blue-400 font-medium">+{Math.round(result.bonusPoints)}</span>
</>
)}
</div>
</div>
{/* Action Button */}
<button
onClick={onDownload}
disabled={isDownloading}
className="flex-shrink-0 px-4 py-1.5 text-[13px] font-semibold text-blue-600 dark:text-blue-400 bg-blue-500/10 hover:bg-blue-500/20 dark:bg-blue-400/10 dark:hover:bg-blue-400/20 rounded-full transition-all active:scale-95 disabled:opacity-40 disabled:pointer-events-none"
>
Get
</button>
</div>
);
}
+18 -1
View File
@@ -27,6 +27,7 @@ interface RequestCardProps {
updatedAt: string;
completedAt?: string;
downloadAvailable?: boolean;
releaseDate?: string | Date | null;
audiobook: {
id: string;
audibleAsin?: string;
@@ -58,6 +59,18 @@ export function RequestCard({ request, showActions = true }: RequestCardProps) {
const isActive = ['searching', 'downloading', 'processing'].includes(request.status);
const isFailed = request.status === 'failed';
const releaseDateLabel = React.useMemo(() => {
if (request.status !== 'awaiting_release' || !request.releaseDate) return null;
const parsed = new Date(request.releaseDate);
if (Number.isNaN(parsed.getTime())) return null;
return parsed.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
timeZone: 'UTC',
});
}, [request.status, request.releaseDate]);
const handleConfirmCancel = async () => {
try {
await cancelRequest(request.id);
@@ -150,6 +163,11 @@ export function RequestCard({ request, showActions = true }: RequestCardProps) {
{/* Status Badge and Type Badge */}
<div className="flex items-center gap-2 flex-wrap">
<StatusBadge status={request.status} progress={request.progress} />
{releaseDateLabel && (
<span className="text-xs text-gray-500 dark:text-gray-400">
Releases {releaseDateLabel}
</span>
)}
{isEbook && (
<span
className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full"
@@ -255,7 +273,6 @@ export function RequestCard({ request, showActions = true }: RequestCardProps) {
onClose={() => setShowDetailsModal(false)}
requestStatus={request.status}
isAvailable={COMPLETED_STATUSES.includes(request.status as typeof COMPLETED_STATUSES[number])}
hideRequestActions
/>
)}
+4
View File
@@ -68,6 +68,10 @@ export function StatusBadge({ status, progress, className }: StatusBadgeProps) {
label: 'Pending Approval',
color: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200',
},
awaiting_release: {
label: 'Awaiting Release',
color: 'bg-teal-100 text-teal-800 dark:bg-teal-900 dark:text-teal-200',
},
denied: {
label: 'Request Denied',
color: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
+5 -2
View File
@@ -6,6 +6,7 @@
'use client';
import React, { useEffect, useRef, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { cn } from '@/lib/utils/cn';
interface ModalProps {
@@ -54,6 +55,7 @@ export function Modal({
}, [isOpen, handleClose]);
if (!isOpen) return null;
if (typeof document === 'undefined') return null;
const sizeClasses = {
sm: 'max-w-md',
@@ -63,7 +65,7 @@ export function Modal({
full: 'max-w-[95vw]',
};
return (
return createPortal(
<div className="fixed inset-0 z-50 overflow-y-auto">
{/* Backdrop */}
<div
@@ -114,6 +116,7 @@ export function Modal({
</div>
</div>
</div>
</div>
</div>,
document.body
);
}
+39 -16
View File
@@ -29,6 +29,11 @@ export interface PaginationSection {
interface UnifiedPaginationProps {
sections: PaginationSection[];
footerRef?: React.RefObject<HTMLElement | null>;
/** Controlled: which section's controls the pill displays. */
activeIndex: number;
/** Reports the observer's "dominant section" guess to the parent.
* The parent decides whether to honor it (e.g., ignores it while locked). */
onDominantSectionChange: (index: number) => void;
}
// ---------------------------------------------------------------------------
@@ -217,14 +222,21 @@ function SectionDots({ sections, activeIndex }: SectionDotsProps) {
// Main component
// ---------------------------------------------------------------------------
export function UnifiedPagination({ sections, footerRef }: UnifiedPaginationProps) {
const [activeIndex, setActiveIndex] = useState(0);
export function UnifiedPagination({
sections,
footerRef,
activeIndex,
onDominantSectionChange,
}: UnifiedPaginationProps) {
const [isTransitioning, setIsTransitioning] = useState(false);
const [footerVisible, setFooterVisible] = useState(false);
const ratiosRef = useRef<number[]>(sections.map(() => 0));
const [anySectionVisible, setAnySectionVisible] = useState(false);
const transitionTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const onDominantRef = useRef(onDominantSectionChange);
useEffect(() => {
onDominantRef.current = onDominantSectionChange;
}, [onDominantSectionChange]);
// Keep ratios array length in sync with sections
useEffect(() => {
@@ -232,13 +244,31 @@ export function UnifiedPagination({ sections, footerRef }: UnifiedPaginationProp
}, [sections.length]);
const activeSectionHasPages = sections[activeIndex]?.totalPages > 1;
const shouldShow = anySectionVisible && !footerVisible && activeSectionHasPages && sections.length > 0;
// Pill is visible anywhere on the homepage main content. Only the footer
// explicitly retreats it. Don't gate on a section being intersected — that
// hides the pill in the CTA-card gap between last section and footer.
const shouldShow = !footerVisible && activeSectionHasPages && sections.length > 0;
// Cross-fade whenever the controlled activeIndex changes (observer-driven via the
// parent OR a lock-driven explicit set). Skip on initial mount.
const prevActiveIndexRef = useRef(activeIndex);
useEffect(() => {
if (prevActiveIndexRef.current === activeIndex) return;
prevActiveIndexRef.current = activeIndex;
setIsTransitioning(true);
if (transitionTimerRef.current) clearTimeout(transitionTimerRef.current);
transitionTimerRef.current = setTimeout(() => setIsTransitioning(false), 320);
return () => {
if (transitionTimerRef.current) clearTimeout(transitionTimerRef.current);
};
}, [activeIndex]);
// ------------------------------------------------------------------
// Intersection observers for all sections
// ------------------------------------------------------------------
useEffect(() => {
const observers: IntersectionObserver[] = [];
let lastReportedDominant = -1;
sections.forEach((section, idx) => {
if (!section.sectionRef.current) return;
@@ -246,8 +276,6 @@ export function UnifiedPagination({ sections, footerRef }: UnifiedPaginationProp
const observer = new IntersectionObserver(
([entry]) => {
ratiosRef.current[idx] = entry.intersectionRatio;
const anyVisible = ratiosRef.current.some((r) => r > 0.05);
setAnySectionVisible(anyVisible);
// Find dominant section
let maxRatio = -1;
@@ -259,15 +287,11 @@ export function UnifiedPagination({ sections, footerRef }: UnifiedPaginationProp
}
}
setActiveIndex((current) => {
if (current !== dominant) {
setIsTransitioning(true);
if (transitionTimerRef.current) clearTimeout(transitionTimerRef.current);
transitionTimerRef.current = setTimeout(() => setIsTransitioning(false), 320);
return dominant;
}
return current;
});
// Report to parent. Parent decides whether to honor it (lock-aware).
if (dominant !== lastReportedDominant) {
lastReportedDominant = dominant;
onDominantRef.current(dominant);
}
},
{
threshold: Array.from({ length: 21 }, (_, i) => i / 20),
@@ -281,7 +305,6 @@ export function UnifiedPagination({ sections, footerRef }: UnifiedPaginationProp
return () => {
observers.forEach((o) => o.disconnect());
if (transitionTimerRef.current) clearTimeout(transitionTimerRef.current);
};
// Re-run when section refs change
// eslint-disable-next-line react-hooks/exhaustive-deps
+65 -5
View File
@@ -21,7 +21,11 @@ export const MAX_TOKENS_PER_USER = 25;
// Endpoint allowlist — restricts which routes API tokens may access
// ---------------------------------------------------------------------------
/** Shape of an allowed endpoint entry */
/**
* Shape of an allowed endpoint entry.
* `path` may be a literal (e.g. `/api/requests`) or contain `:name` placeholders
* that match a single path segment (e.g. `/api/requests/:id`).
*/
export interface AllowedEndpoint {
method: string;
path: string;
@@ -34,6 +38,8 @@ export interface EndpointDoc {
title: string;
description: string;
requiresAdmin: boolean;
/** True for endpoints that mutate state. Surfaced in the /api-docs UI. */
isWrite?: boolean;
}
/**
@@ -42,7 +48,10 @@ export interface EndpointDoc {
*/
export const API_TOKEN_ALLOWED_ENDPOINTS: readonly AllowedEndpoint[] = [
{ method: 'GET', path: '/api/auth/me' },
{ method: 'GET', path: '/api/audiobooks/search' },
{ method: 'GET', path: '/api/requests' },
{ method: 'POST', path: '/api/requests' },
{ method: 'GET', path: '/api/requests/:id' },
{ method: 'GET', path: '/api/admin/metrics' },
{ method: 'GET', path: '/api/admin/downloads/active' },
{ method: 'GET', path: '/api/admin/requests/recent' },
@@ -61,6 +70,14 @@ export const API_TOKEN_ENDPOINT_DOCS: readonly EndpointDoc[] = [
'Returns the authenticated user\'s profile information including username, role, and account details.',
requiresAdmin: false,
},
{
method: 'GET',
path: '/api/audiobooks/search',
title: 'Search audiobooks',
description:
'Search Audible for audiobooks by title or author. Query params: `q` (required), `page` (optional). Returns enriched results including per-user request and library availability status.',
requiresAdmin: false,
},
{
method: 'GET',
path: '/api/requests',
@@ -69,6 +86,23 @@ export const API_TOKEN_ENDPOINT_DOCS: readonly EndpointDoc[] = [
'Returns all audiobook requests visible to the authenticated user. Admins see all requests, users see their own.',
requiresAdmin: false,
},
{
method: 'POST',
path: '/api/requests',
title: 'Create request',
description:
'Create a new audiobook request on behalf of the token owner. Body: `{ "audiobook": { "asin", "title", "author", "narrator?", "description?", "coverArtUrl?" } }`. Follows the user\'s normal auto-approve rules; returns named error codes (`already_available`, `being_processed`, `duplicate`, `ignored`, `user_not_found`) on rejection.',
requiresAdmin: false,
isWrite: true,
},
{
method: 'GET',
path: '/api/requests/:id',
title: 'Get request by ID',
description:
'Returns a single audiobook request including audiobook details, download history, and recent job state. Users may only fetch requests they own; admins may fetch any.',
requiresAdmin: false,
},
{
method: 'GET',
path: '/api/admin/metrics',
@@ -95,13 +129,39 @@ export const API_TOKEN_ENDPOINT_DOCS: readonly EndpointDoc[] = [
},
] as const;
/**
* Compiled allowlist used by `isEndpointAllowed`. Patterns with `:name`
* placeholders are compiled to anchored regexes that match a single path
* segment (`[^/]+`); literal paths use string equality.
*/
interface CompiledEndpoint {
method: string;
literal: string | null;
pattern: RegExp | null;
}
function compileEndpoint(ep: AllowedEndpoint): CompiledEndpoint {
const method = ep.method.toUpperCase();
if (!ep.path.includes(':')) {
return { method, literal: ep.path, pattern: null };
}
const escaped = ep.path.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const regexSource = escaped.replace(/:[A-Za-z_][A-Za-z0-9_]*/g, '[^/]+');
return { method, literal: null, pattern: new RegExp(`^${regexSource}$`) };
}
const COMPILED_ENDPOINTS: readonly CompiledEndpoint[] = API_TOKEN_ALLOWED_ENDPOINTS.map(compileEndpoint);
/**
* Check whether a given method + path is on the API token allowlist.
* Method comparison is case-insensitive.
* Method comparison is case-insensitive. Supports dynamic single-segment
* placeholders (`:id`) compiled at module load.
*/
export function isEndpointAllowed(method: string, path: string): boolean {
const upperMethod = method.toUpperCase();
return API_TOKEN_ALLOWED_ENDPOINTS.some(
(ep) => ep.method === upperMethod && ep.path === path
);
return COMPILED_ENDPOINTS.some((ep) => {
if (ep.method !== upperMethod) return false;
if (ep.literal !== null) return ep.literal === path;
return ep.pattern!.test(path);
});
}
+36
View File
@@ -68,6 +68,42 @@ export type TorrentTitleFormat = (typeof TORRENT_TITLE_FORMATS)[number];
*/
export type AudioFormat = TorrentTitleFormat | 'OTHER';
/**
* Plex audiobook-library recognized extensions.
* Used by Plex format coercion to determine which formats are silently passed through.
* Research-grounded see issue #166 for context on Plex's silent-failure behavior.
* Note: includes formats not yet in `AUDIO_EXTENSIONS` (.aac/.wav/.alac) for future-proofing.
*/
export const PLEX_COMPATIBLE_EXTENSIONS = [
'.m4b',
'.m4a',
'.mp3',
'.flac',
'.aac',
'.wav',
'.alac',
] as const;
/**
* Unambiguous rename targets for Plex format coercion.
* `.mp4` `.m4b` always. `.m4a` `.m4b` is conditional (single-file only) and handled in coercion logic.
*/
export const COERCION_RENAME_MAP: Record<string, string> = {
'.mp4': '.m4b',
};
/**
* DRM-protected formats that cannot be decoded without keys.
* Plex format coercion logs a warning and skips these.
*/
export const DRM_EXTENSIONS = ['.aa', '.aax'] as const;
/**
* Formats that would require a full transcode to become Plex-compatible.
* Out of scope for v1 Plex format coercion logs a warning and skips.
*/
export const TRANSCODE_REQUIRED_EXTENSIONS = ['.ogg', '.opus', '.wma'] as const;
/**
* All supported ebook file extensions for ebook detection and file serving.
*/
+26
View File
@@ -0,0 +1,26 @@
/**
* Component: Job Type Display Labels
* Documentation: documentation/backend/services/scheduler.md
*/
// Short, human-readable labels for every job type that can appear in the
// admin Logs page or scheduled-jobs dropdown. Insertion order is the display
// order used by the Logs filter dropdown.
export const JOB_TYPE_LABELS: Record<string, string> = {
search_indexers: 'Search Indexers',
download_torrent: 'Download Torrent',
monitor_download: 'Monitor Download',
organize_files: 'Organize Files',
scan_plex: 'Library Scan',
match_plex: 'Library Match',
plex_library_scan: 'Library Scan (Scheduled)',
plex_recently_added_check: 'Recently Added Check',
audible_refresh: 'Audible Refresh',
retry_missing_torrents: 'Retry Missing Torrents',
retry_failed_imports: 'Retry Failed Imports',
cleanup_seeded_torrents: 'Cleanup Seeded Torrents',
monitor_rss_feeds: 'Monitor RSS Feeds',
find_missing_ebooks: 'Find Missing Ebooks',
sync_reading_shelves: 'Sync Reading Shelves',
check_watched_lists: 'Check Watched Lists',
};
+130
View File
@@ -0,0 +1,130 @@
/**
* Component: Admin Logs Filter Constants & Helpers
* Documentation: documentation/admin-dashboard.md
*
* Owns: date-range preset definitions + helpers, status dropdown labels.
* Does NOT own: VALID_LIMITS, VALID_STATUSES, DEFAULT_LIMIT those live in
* `src/app/admin/logs/types.ts` (the Stage-0 contract). This module imports
* `VALID_STATUSES` from there so status labels track the canonical value list.
*/
import { VALID_STATUSES, type LogStatus } from '@/app/admin/logs/types';
// ---------------------------------------------------------------------------
// Date-range presets — preset id encodes the meaning, durationMs the window.
// `custom` and `all_time` carry null durationMs (sentinels handled by helpers).
// Insertion order is the display order in the picker.
// ---------------------------------------------------------------------------
export type DatePresetId =
| 'last_hour'
| 'last_24h'
| 'last_7d'
| 'last_30d'
| 'custom'
| 'all_time';
export interface DatePreset {
id: DatePresetId;
label: string;
durationMs: number | null;
}
const HOUR_MS = 60 * 60 * 1000;
const DAY_MS = 24 * HOUR_MS;
export const DATE_PRESETS: readonly DatePreset[] = [
{ id: 'last_hour', label: 'Last hour', durationMs: HOUR_MS },
{ id: 'last_24h', label: 'Last 24h', durationMs: DAY_MS },
{ id: 'last_7d', label: 'Last 7 days', durationMs: 7 * DAY_MS },
{ id: 'last_30d', label: 'Last 30 days', durationMs: 30 * DAY_MS },
{ id: 'custom', label: 'Custom', durationMs: null },
{ id: 'all_time', label: 'All time', durationMs: null },
];
/** Hydrate-time default per Zach Resolution #1. Used by useLogsUrlState only on first mount. */
export const DEFAULT_DATE_PRESET_ID: DatePresetId = 'last_7d';
/** Tolerance for matching a stored `dateFrom` against a moving preset window. */
const PRESET_MATCH_TOLERANCE_MS = 60 * 1000;
/**
* Translate a preset id into a wire (dateFrom/dateTo) range.
* - For sliding-window presets, `to` stays null ("until now").
* - For `custom`, returns the current values unchanged callers should keep
* what the user typed rather than overwrite with nulls.
* - For `all_time`, both are null (no bound).
*/
export function presetToRange(
id: DatePresetId,
now: Date = new Date()
): { dateFrom: string | null; dateTo: string | null } {
if (id === 'all_time' || id === 'custom') {
return { dateFrom: null, dateTo: null };
}
const preset = DATE_PRESETS.find((p) => p.id === id);
if (!preset || preset.durationMs == null) {
return { dateFrom: null, dateTo: null };
}
return {
dateFrom: new Date(now.getTime() - preset.durationMs).toISOString(),
dateTo: null,
};
}
/**
* Identify which preset (if any) the current dateFrom/dateTo pair represents.
* - both null 'all_time'
* - dateFrom within tolerance of `now - presetDuration`, no dateTo that preset
* - anything else (e.g. dateTo set, or dateFrom outside tolerance) 'custom'
*/
export function getActivePresetId(
dateFrom: string | null,
dateTo: string | null,
now: Date = new Date()
): DatePresetId {
if (dateFrom == null && dateTo == null) return 'all_time';
if (dateTo != null) return 'custom';
if (dateFrom == null) return 'custom';
const fromMs = new Date(dateFrom).getTime();
if (!Number.isFinite(fromMs)) return 'custom';
const nowMs = now.getTime();
for (const preset of DATE_PRESETS) {
if (preset.durationMs == null) continue;
const expected = nowMs - preset.durationMs;
if (Math.abs(fromMs - expected) <= PRESET_MATCH_TOLERANCE_MS) {
return preset.id;
}
}
return 'custom';
}
// ---------------------------------------------------------------------------
// Status dropdown — pair labels with the canonical VALID_STATUSES value list.
// Adding a status only requires editing types.ts; the label here can be tuned
// independently for display copy.
// ---------------------------------------------------------------------------
const STATUS_LABEL_OVERRIDES: Partial<Record<LogStatus, string>> = {
all: 'All Statuses',
};
function capitalize(s: string): string {
return s.length === 0 ? s : s.charAt(0).toUpperCase() + s.slice(1);
}
export interface StatusOption {
value: LogStatus;
label: string;
}
export const STATUS_OPTIONS: readonly StatusOption[] = VALID_STATUSES.map((value) => ({
value,
label: STATUS_LABEL_OVERRIDES[value] ?? capitalize(value),
}));
/** Lookup a status's display label, falling back to capitalization. */
export function getStatusLabel(value: string): string {
const match = STATUS_OPTIONS.find((opt) => opt.value === value);
return match?.label ?? capitalize(value);
}
+1
View File
@@ -13,4 +13,5 @@ export const CANCELLABLE_STATUSES = [
'downloading',
'awaiting_search',
'awaiting_approval',
'awaiting_release',
] as const;
+34
View File
@@ -0,0 +1,34 @@
/**
* Component: useIsTruncated Hook
* Documentation: documentation/frontend/components.md
*
* Returns whether the referenced element's content overflows horizontally
* (i.e. is being clipped by `truncate` / `overflow: hidden`). Used by the
* Interactive Search modal to render an expand-disclosure chevron only when
* the title is actually being cut off keeping the row clean when there's
* nothing to disclose.
*/
import { useLayoutEffect, useState, type RefObject } from 'react';
export function useIsTruncated(ref: RefObject<HTMLElement | null>): boolean {
const [isTruncated, setIsTruncated] = useState(false);
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
const measure = () => {
setIsTruncated(el.scrollWidth > el.clientWidth);
};
measure();
if (typeof ResizeObserver === 'undefined') return;
const observer = new ResizeObserver(measure);
observer.observe(el);
return () => observer.disconnect();
}, [ref]);
return isTruncated;
}
+5 -4
View File
@@ -4,6 +4,7 @@
*/
import axios, { AxiosInstance } from 'axios';
import { RMAB_USER_AGENT } from '../utils/user-agent';
import * as cheerio from 'cheerio';
import { RMABLogger } from '../utils/logger';
import { getConfigService } from '../services/config.service';
@@ -273,7 +274,7 @@ export class AudibleService {
timeout: 10000,
headers: {
Accept: 'application/json',
'User-Agent': 'ReadMeABook/1.0',
'User-Agent': RMAB_USER_AGENT,
},
});
@@ -305,7 +306,7 @@ export class AudibleService {
timeout: 10000,
headers: {
Accept: 'application/json',
'User-Agent': 'ReadMeABook/1.0',
'User-Agent': RMAB_USER_AGENT,
},
});
@@ -676,7 +677,7 @@ export class AudibleService {
{
params: { region: audnexusRegion },
timeout: 10000,
headers: { 'User-Agent': 'ReadMeABook/1.0' },
headers: { 'User-Agent': RMAB_USER_AGENT },
},
);
@@ -767,7 +768,7 @@ export class AudibleService {
{
params: { region: audnexusRegion },
timeout: 5000,
headers: { 'User-Agent': 'ReadMeABook/1.0' },
headers: { 'User-Agent': RMAB_USER_AGENT },
},
);
+2 -1
View File
@@ -7,13 +7,14 @@
*/
import axios from 'axios';
import { RMAB_USER_AGENT } from '@/lib/utils/user-agent';
import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('Audnexus.Authors');
const AUDNEXUS_BASE = 'https://api.audnex.us';
const AUDNEXUS_TIMEOUT = 10000;
const AUDNEXUS_HEADERS = { 'User-Agent': 'ReadMeABook/1.0' };
const AUDNEXUS_HEADERS = { 'User-Agent': RMAB_USER_AGENT };
export interface AudnexusAuthorSearch {
asin: string;
+4 -2
View File
@@ -4,6 +4,7 @@
*/
import axios, { AxiosInstance } from 'axios';
import { RMAB_USER_AGENT } from '../utils/user-agent';
import https from 'https';
import path from 'path';
import { DOWNLOAD_CLIENT_TIMEOUT } from '../constants/download-timeouts';
@@ -50,7 +51,7 @@ export class DelugeService implements IDownloadClient {
? new https.Agent({ rejectUnauthorized: false }) : undefined;
if (httpsAgent) logger.info('[Deluge] SSL certificate verification disabled');
this.client = axios.create({ baseURL: this.baseUrl, timeout: DOWNLOAD_CLIENT_TIMEOUT, httpsAgent });
this.client = axios.create({ baseURL: this.baseUrl, timeout: DOWNLOAD_CLIENT_TIMEOUT, httpsAgent, headers: { 'User-Agent': RMAB_USER_AGENT } });
}
/** JSON-RPC call with automatic re-authentication on auth failure */
@@ -192,6 +193,7 @@ export class DelugeService implements IDownloadClient {
torrentResponse = await axios.get(torrentUrl, {
responseType: 'arraybuffer', maxRedirects: 0,
validateStatus: (s) => s >= 200 && s < 300, timeout: DOWNLOAD_CLIENT_TIMEOUT,
headers: { 'User-Agent': RMAB_USER_AGENT },
});
if (torrentResponse.data.length > 0) {
const magnetMatch = torrentResponse.data.toString().match(/^magnet:\?[^\s]+$/);
@@ -204,7 +206,7 @@ export class DelugeService implements IDownloadClient {
const loc = error.response.headers['location'];
if (loc?.startsWith('magnet:')) return this.addMagnetLink(loc, category, options);
if (loc?.startsWith('http://') || loc?.startsWith('https://')) {
try { torrentResponse = await axios.get(loc, { responseType: 'arraybuffer', timeout: DOWNLOAD_CLIENT_TIMEOUT, maxRedirects: 5 }); }
try { torrentResponse = await axios.get(loc, { responseType: 'arraybuffer', timeout: DOWNLOAD_CLIENT_TIMEOUT, maxRedirects: 5, headers: { 'User-Agent': RMAB_USER_AGENT } }); }
catch { throw new Error('Failed to download torrent file after redirect'); }
} else { throw new Error(`Invalid redirect location: ${loc}`); }
} else { throw new Error(`Failed to download torrent: HTTP ${status}`); }
+6 -1
View File
@@ -4,6 +4,7 @@
*/
import axios, { AxiosInstance } from 'axios';
import { RMAB_USER_AGENT } from '@/lib/utils/user-agent';
import https from 'https';
import zlib from 'zlib';
import { RMABLogger } from '@/lib/utils/logger';
@@ -140,6 +141,7 @@ export class NZBGetService implements IDownloadClient {
baseURL: this.baseUrl,
timeout: 30000,
httpsAgent: this.httpsAgent,
headers: { 'User-Agent': RMAB_USER_AGENT },
auth: {
username: this.username,
password: this.password,
@@ -226,7 +228,10 @@ export class NZBGetService implements IDownloadClient {
responseType: 'arraybuffer',
timeout: 30000,
maxRedirects: 5,
headers: options?.sourceHeaders,
headers: {
'User-Agent': RMAB_USER_AGENT,
...options?.sourceHeaders,
},
httpsAgent: url.startsWith('https') ? this.httpsAgent : undefined,
});
+2
View File
@@ -4,6 +4,7 @@
*/
import axios, { AxiosInstance } from 'axios';
import { RMAB_USER_AGENT } from '../utils/user-agent';
import { parseStringPromise } from 'xml2js';
import { RMABLogger } from '../utils/logger';
@@ -83,6 +84,7 @@ export class PlexService {
constructor() {
this.client = axios.create({
timeout: 10000,
headers: { 'User-Agent': RMAB_USER_AGENT },
});
}
+3 -1
View File
@@ -4,6 +4,7 @@
*/
import axios, { AxiosInstance } from 'axios';
import { RMAB_USER_AGENT } from '../utils/user-agent';
import { XMLParser } from 'fast-xml-parser';
import { DOWNLOAD_CLIENT_TIMEOUT } from '../constants/download-timeouts';
import { TorrentResult } from '../utils/ranking-algorithm';
@@ -86,6 +87,7 @@ export class ProwlarrService {
this.client = axios.create({
baseURL: `${this.baseUrl}/api/v1`,
headers: {
'User-Agent': RMAB_USER_AGENT,
'X-Api-Key': this.apiKey,
},
timeout: DOWNLOAD_CLIENT_TIMEOUT,
@@ -316,7 +318,7 @@ export class ProwlarrService {
extended: 1,
},
headers: {
'User-Agent': 'ReadMeABook',
'User-Agent': RMAB_USER_AGENT,
},
timeout: DOWNLOAD_CLIENT_TIMEOUT,
responseType: 'text', // Get XML as text

Some files were not shown because too many files have changed in this diff Show More