Compare commits

..

65 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
kikootwo 5f62ba7146 Bump version to 1.2.0 and update tests
Update package.json version to 1.2.0 and adjust tests to explicitly click the 'Cancel request' button. This adds an extra fireEvent.click for the 'Cancel request' role in RequestActionsDropdown.test.tsx and RequestCard.test.tsx to ensure the cancel handler is invoked reliably. Files changed: package.json, package-lock.json, tests/app/admin/components/RequestActionsDropdown.test.tsx, tests/components/requests/RequestCard.test.tsx.
2026-05-15 15:12:31 -04:00
kikootwo bc7fff9dd7 Add credential recovery script, docs, and Redis wait
Introduce an interactive credential recovery tool (scripts/recover-credentials.js) and accompanying documentation (documentation/admin-features/credential-recovery.md). Add npm script rmab:recover to package.json and wire the doc into TABLEOFCONTENTS.md. Improve docker/unified/app-start.sh to wait for local Redis to finish loading before initializing app services to avoid "LOADING" errors when queues start. The recovery script uses Prisma, runs entirely interactively via docker exec -it, performs DB changes in a single transaction, and persists a rotated CONFIG_ENCRYPTION_KEY to /app/config/.secrets and /etc/environment when needed.
2026-05-15 12:04:19 -04:00
kikootwo b775ccf473 Add cancel confirmation and cancellable statuses
Introduce a unified CANCELLABLE_STATUSES constant and add confirmation UI for cancelling requests. RequestActionsDropdown and RequestCard now show a ConfirmModal before cancelling and use the shared CANCELLABLE_STATUSES to gate cancel actions. The API route imports the constant to enforce server-side validation and uses Prisma.DbNull for selectedTorrent when withdrawing an awaiting-approval request. Tests updated to expect Prisma.DbNull. Improves UX and centralizes cancel logic to avoid duplicated status lists.
2026-05-15 09:49:42 -04:00
kikootwo 1a9aeb4713 Merge branch 'main' of https://github.com/kikootwo/ReadMeABook 2026-05-15 06:46:28 -04:00
kikootwo bb18feac5c Merge pull request #202 from xFlawless11x/feature/cancel-pending-approval
feat: allow cancellation of pending-approval requests
2026-05-15 06:46:33 -04:00
kikootwo 4b79b11987 Merge branch 'main' of https://github.com/kikootwo/ReadMeABook 2026-05-15 06:44:06 -04:00
kikootwo 86f7a6a354 Merge pull request #201 from xFlawless11x/fix/prowlarr-user-agent
Add User-Agent header to Prowlarr RSS queries
2026-05-15 06:43:03 -04:00
kikootwo 071c788ead Add series metadata tagging and tests
Include series and seriesPart metadata when tagging audio files. For m4b output the code uses show and episode_id; for mp3 and flac it writes SERIES and SERIES-PART. Adds unit tests verifying tag output for .m4b, .mp3, and .flac and that tags are omitted when fields are absent.
2026-05-15 06:42:17 -04:00
kikootwo f4fe6f936f Merge branch 'main' of https://github.com/kikootwo/ReadMeABook 2026-05-15 06:38:57 -04:00
kikootwo 741efa685c Merge pull request #198 from TylerNorris214/main
Add seriesPart metadata tag for Audiobookshelf series ordering
2026-05-15 06:38:50 -04:00
kikootwo df656b6178 Merge pull request #197 from cbusillo/fix/plex-home-profile-login-loop
Fix Plex Home profile selection login loop
2026-05-15 06:31:01 -04:00
kikootwo d2c90de07f Merge branch 'main' of https://github.com/kikootwo/ReadMeABook 2026-05-15 06:30:53 -04:00
kikootwo 07fbff1133 Add tests for BigInt duration overflow (Plex)
Add regression tests to verify durations exceeding INT4 max are persisted as BigInt for Plex flows. Tests added in plex-recently-added.processor.test.ts and scan-plex.processor.test.ts cover both create and update paths (regression #193), mock the observed overflow (~4,082,750s → 4,082,750,000ms) and assert prisma.create/prisma.update are called with BigInt duration values.
2026-05-15 06:27:42 -04:00
kikootwo de72180bdd Merge branch 'main' of https://github.com/kikootwo/ReadMeABook 2026-05-15 06:13:34 -04:00
kikootwo e9241d21af Merge pull request #194 from H0tChicken/fix/int4-duration-overflow
fix: use BigInt for PlexLibrary.duration to prevent INT4 overflow
2026-05-15 06:13:30 -04:00
kikootwo ad8d44bae0 Support auth-optional mode for qBittorrent
Add auth-optional support when both username and password are blank. Introduce authOptional flag and authHeaders() helper to omit Cookie when unauthenticated; make login() a no-op in auth-optional mode and avoid pointless re-login on 403. Adjust many API calls to respect auth-optional behavior and update testConnection/testConnectionWithCredentials to probe /app/version for connectivity in auth-optional scenarios and return clearer errors. Add unit tests covering the new auth-optional flows and header behavior.
2026-05-15 05:54:25 -04:00
kikootwo f56efa8b15 Improve ASIN/cleaning logic and add tests
Refactor bulk-import scanner to make ASIN extraction and search-string cleaning more robust, and add tests.

- Tighten and case-insensitize the ASIN regex, always return ASIN in uppercase.
- Export and use cleanSearchString (replaces inline folder-name sanitization in the scan route).
- When merging discoveries across folders, derive folderName/relativePath consistently and re-extract ASIN from the merged common parent if available.
- Add comprehensive unit/integration tests for extractAsinFromString, cleanSearchString, buildSearchTerm, and discoverAudiobooks (with an ffprobe mock).

These changes improve detection of ASINs in varied naming patterns, reduce duplicated cleanup logic, and ensure merged groups correctly inherit ASIN metadata.
2026-05-15 05:25:32 -04:00
xFlawless11x a7186096df Add User-Agent header to Prowlarr RSS queries
Set User-Agent to "ReadMeABook" on the Newznab proxy RSS endpoint
so RMAB is identifiable in Prowlarr stats instead of showing as
generic "axios". Sonarr/Radarr already do this with their own
User-Agent strings.

Only applies to the RSS feed endpoint (/{indexerId}/api) which
respects User-Agent for Source identification. The /api/v1/search
endpoint hardcodes Source as "Prowlarr" regardless of headers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-14 23:13:43 -04:00
xFlawless11x 1a25f544b1 feat: allow users and admins to cancel pending-approval requests
- Add cancel action to RequestActionsDropdown for admins
- Add cancel button to RequestCard for users
- Implement DELETE handler in /api/requests/[id] with:
  - Status gate: only cancellable if pending_approval or awaiting_approval
  - Clears selectedTorrent (Prisma.DbNull) on cancel
  - Fires on-grab notification job after cancel
- Tests: cancel flows for both statuses, rejection for non-cancellable status

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 21:19:46 -04:00
kikootwo 1711d256c2 Merge pull request #173 from MattiasC/feature/bulk-import-folder-fallback
Bulk import enhancement: group tagless files by folder and use folder name as search fallback
2026-05-14 16:15:41 -04:00
kikootwo 8376355233 Merge branch 'main' into feature/bulk-import-folder-fallback
Resolves conflicts in src/lib/integrations/audible.service.ts.

main switched the ASIN-detail fallback from HTML scraping to the JSON
catalog API (fetchAudibleDetailsFromApi), removing scrapeAudibleDetails.
The PR's lookupAsinFast was a fail-fast variant of the same pattern that
getAudiobookDetails now performs (Audnexus -> catalog API), so it's
redundant.

- Drop the lookupAsinFast method (delete entire HEAD-side conflict block)
- Take main's fetchAudibleDetailsFromApi verbatim (the scrapeAudibleDetails
  maxRetries parameterization is moot)
- In bulk-import scan route, swap lookupAsinFast for getAudiobookDetails

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 16:14:25 -04:00
kikootwo d1a980e210 Enhance download-torrent test mocks
Update tests/processors/download-torrent.processor.test.ts to better mock dependencies used by processDownloadTorrent. Add jobQueueMock.addNotificationJob.mockResolvedValue(undefined) to avoid unmocked job queue calls, and change prismaMock.request.update.mockResolvedValue from an empty object to include { type: 'audiobook', user: { plexUsername: 'testuser' } } in the affected test cases so the returned request shape matches code expectations.
2026-05-14 16:02:04 -04:00
kikootwo 5e4a38a340 Normalize notification events and update grab flow
Introduce a NotificationEventConfig interface and validate NOTIFICATION_EVENTS with `satisfies` for stronger typing and normalized metadata shape. Replace escaped emoji sequences with literal emoji, simplify helper functions (getEventMeta/getEventTitle) to use the typed registry, and clean up titleByRequestType typing.

In download-torrent.processor: include the requesting user when setting status to downloading to avoid an extra DB query, and use that returned user to enqueue a non-blocking `request_grabbed` notification.

Docs: note that `request_grabbed` notifications are opt-in for existing backends. Tests: add messageLabel rendering tests for Apprise and ntfy providers to validate emoji, label text, and type-specific titles.
2026-05-14 15:57:15 -04:00
kikootwo 4ded2cf219 Merge branch 'main' of https://github.com/kikootwo/ReadMeABook 2026-05-14 15:47:23 -04:00
kikootwo 21d811e2bf Merge pull request #162 from xFlawless11x/feature/on-grab-notification
feat: add On Grab notification event
2026-05-14 15:47:17 -04:00
kikootwo 247fe88b99 Refactor approval buttons into reusable component
Extract LoadingSpinner and ApprovalActionButtons components and replace duplicated approve/search/deny button blocks with the new ApprovalActionButtons to reduce duplication and centralize behavior/styles. Remove the inline LoadingSpinner in PendingApprovalSection, add an aria-label to the details button, and update the details modal's adminActions to use ApprovalActionButtons with callbacks that handle approval/denial/search and close modals as needed. Improves DRY, maintainability, and consistency of loading state handling.
2026-05-14 15:43:30 -04:00
kikootwo 3545ff6109 Merge pull request #158 from xFlawless11x/feature/admin-book-info-modal
feat: add book info modal to admin pending approval cards
2026-05-14 15:34:20 -04:00
kikootwo fb19c1a642 Merge branch 'main' of https://github.com/kikootwo/ReadMeABook 2026-05-14 15:34:19 -04:00
kikootwo 6c8ca9647d Support language/format/publisher for Audible
Expose language, formatType, and publisherName from the Audible catalog. Update audible.service to map format_type and publisher_name (and language) into the AudibleAudiobook model, update AudiobookDetailsModal to display language and format using the CSS "capitalize" class, and update documentation to list the new fields. Add unit tests to verify the mappings, details propagation, and behavior when fields are omitted.
2026-05-14 15:33:30 -04:00
kikootwo 18752dd02b Merge branch 'main' of https://github.com/kikootwo/ReadMeABook 2026-05-14 15:24:24 -04:00
kikootwo f8c70a6b9a Merge pull request #152 from Orvanix/feature/modal-view
feat(audiobook): add language, format and publisher to details modal
2026-05-14 15:24:22 -04:00
kikootwo fcae3bcf09 Audible: HTML refresh, multi-narrator & works dedup
Switch nightly discovery refresh to scrape Audible's curated HTML storefronts (popular, new releases, category pages) while keeping real-time user paths on the JSON catalog API. Add robust HTML resilience knobs (increased retries, capped jittered backoff, AdaptivePacer changes and per-batch cooldowns) to avoid failing nightly jobs during 503 storms. Implement multi-narrator capture via a new extractAllNarrators helper and update parsers to preserve all narrator anchors. Introduce two-pass dedup: in-memory deduplicateAndCollectGroups + collapseByExistingWorks that consults the works table, export metadataScore for consistent representative selection, and persist dedup groups (fire-and-forget). Wire collapseByExistingWorks into search/author/series routes and make defensive dedup in the refresh processor. Add HTML parsing helpers, runtime/lang-aware parsing, jitteredBackoff cap, and tests for the new behaviors.
2026-05-14 15:23:15 -04:00
TylerNorris214 edecda9e64 Add series and seriesPart to metadata tagging 2026-05-05 21:00:38 -05:00
TylerNorris214 6b76932a0a Add series and seriesPart to audiobook metadata 2026-05-05 20:59:12 -05:00
Chris Busillo 02b636e5b8 fix plex home profile login redirect 2026-05-04 13:41:53 -04:00
H0tChicken 37f063229c fix: use BigInt for PlexLibrary.duration to prevent INT4 overflow
The duration column (Int/int4, max ~2.15B) overflows when storing
millisecond values for items with large durations from Audiobookshelf
or Plex backends. Change to BigInt (int8) and wrap duration calculations
in BigInt() at the Prisma write boundary.

Changes:
- prisma/schema.prisma: PlexLibrary.duration Int? → BigInt?
- plex-recently-added.processor.ts: BigInt(Math.round(...)) wrapping
- scan-plex.processor.ts: same BigInt wrapping
- documentation/backend/database.md: updated duration type notation

Fixes #193

Co-Authored-By: Oz <oz-agent@warp.dev>
2026-05-04 00:32:09 +00:00
xFlawless11x ba1efa88f5 feat: add On Grab notification event
Adds request_grabbed event that fires when a torrent/NZB is successfully
handed off to the configured download client, filling the gap between
request_approved (pre-search) and request_available (fully imported).

- Add request_grabbed to NOTIFICATION_EVENTS with titleByRequestType
  (Audiobook Grabbed / Ebook Grabbed), info severity, Details messageLabel
- Add NotificationEventConfig interface and update getEventMeta() return
  type to expose messageLabel to all providers without TypeScript errors
- Add messageLabel: 'Reason' to issue_reported event
- Fix all 4 providers (Discord, ntfy, Pushover, Apprise) to derive message
  field label from meta.messageLabel ?? 'Error' instead of hardcoded
  isIssue ternary — prevents grab details showing as Error
- Trigger request_grabbed in download-torrent.processor.ts after
  client.addDownload() succeeds; message carries torrent title, indexer,
  and download client name; requestType sourced from request.type
- Update notifications.md documentation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 13:49:36 -04:00
Mattias Carlsson c9392c49c9 If ASIN lookup fails, use the folder name instead of the tag. 2026-04-19 22:09:46 +02:00
Mattias Carlsson 7b01cda955 Fix bulk import: merge untagged files into single tagged group per folder 2026-04-19 22:03:45 +02:00
Mattias Carlsson 9a6062d860 Decreased audible retries when doing manual imports. 2026-04-19 21:53:28 +02:00
Mattias Carlsson ad1ab3af05 Better searching when using ASIN from folder names. 2026-04-19 21:14:14 +02:00
Mattias Carlsson 35cb318389 Fix bulk import: group tagless files by folder, use folder name as search fallback 2026-04-10 10:22:01 +02:00
xFlawless11x e9d7a2359a feat: add book info modal to admin pending approval cards
Adds an info icon button (top-right of each card) in the Requests
Awaiting Approval section. Clicking it opens AudiobookDetailsModal
with full book details (cover, description, narrator, series, genres,
etc.) and embeds the Approve / Search / Deny action buttons so admins
can review and act without navigating away from the admin panel.

Implementation:
- AudiobookDetailsModal: adds optional `adminActions` prop rendered as
  a second row inside the existing sticky action bar
- admin/page.tsx: adds detailsAsin/detailsRequestId state, info button
  per card (conditional on audibleAsin presence), and AudiobookDetailsModal
  wired with admin action buttons matching the card button behaviour
- Documentation updated: request-approval.md, components.md, TABLEOFCONTENTS.md

Closes #157

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 11:29:26 -04:00
Orvanix 1abaff1677 feat(audiobook): add language, format and publisher to details modal 2026-03-14 17:45:31 +00:00
221 changed files with 17636 additions and 1444 deletions
+3
View File
@@ -57,3 +57,6 @@ next-env.d.ts
/test-data /test-data
/bookdrop /bookdrop
dockerfile.patch 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.** **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: **NEVER implement without approval.** When asked to assess, investigate, or fix a problem:
1. **Research & analyze** — Read code, trace the issue, identify root cause. 1. **Research & analyze** — Read code, trace the issue, identify root cause.
+23
View File
@@ -99,6 +99,29 @@ if [ "$READY" = "false" ]; then
echo "[App] The scheduler will not be initialized - scheduled jobs may be missing" echo "[App] The scheduler will not be initialized - scheduled jobs may be missing"
echo "[App] Check server logs above for errors (database connection, port conflict, etc.)" echo "[App] Check server logs above for errors (database connection, port conflict, etc.)"
else else
# =========================================================================
# WAIT FOR REDIS TO FINISH LOADING (internal Redis only)
# =========================================================================
# Redis returns "LOADING Redis is loading the dataset in memory" while it
# replays its AOF/RDB on startup. /api/health only checks Postgres, so it
# passes before Redis is actually ready to accept commands. Without this
# wait, /api/init kicks off Bull queues that flood the log with LOADING
# errors until the retry loop catches up.
if [ "$USE_EXTERNAL_REDIS" != "true" ]; then
REDIS_READY_TIMEOUT=${REDIS_READY_TIMEOUT:-60}
echo "[App] Waiting for Redis to finish loading (timeout: ${REDIS_READY_TIMEOUT}s)..."
for i in $(seq 1 "$REDIS_READY_TIMEOUT"); do
if redis-cli -h 127.0.0.1 -p 6379 ping 2>/dev/null | grep -q '^PONG$'; then
echo "[App] Redis is ready (took ${i}s)"
break
fi
if [ "$i" -eq "$REDIS_READY_TIMEOUT" ]; then
echo "[App] WARNING: Redis did not become ready within ${REDIS_READY_TIMEOUT}s - proceeding anyway"
fi
sleep 1
done
fi
# ========================================================================= # =========================================================================
# INITIALIZE APPLICATION SERVICES # INITIALIZE APPLICATION SERVICES
# ========================================================================= # =========================================================================
+11
View File
@@ -8,6 +8,7 @@
- **Admin-generated login token per user (URL-login)** → [backend/services/auth.md](backend/services/auth.md) - **Admin-generated login token per user (URL-login)** → [backend/services/auth.md](backend/services/auth.md)
- **Route protection, auth guards** → [frontend/routing-auth.md](frontend/routing-auth.md) - **Route protection, auth guards** → [frontend/routing-auth.md](frontend/routing-auth.md)
- **Login page UI/UX** → [frontend/pages/login.md](frontend/pages/login.md) - **Login page UI/UX** → [frontend/pages/login.md](frontend/pages/login.md)
- **Credential recovery (lost CONFIG_ENCRYPTION_KEY, locked-out admin)** → [admin-features/credential-recovery.md](admin-features/credential-recovery.md)
## Configuration & Setup ## Configuration & Setup
- **First-time setup wizard** → [setup-wizard.md](setup-wizard.md) - **First-time setup wizard** → [setup-wizard.md](setup-wizard.md)
@@ -45,6 +46,8 @@
- **Web scraping (popular, new releases)** → [integrations/audible.md](integrations/audible.md) - **Web scraping (popular, new releases)** → [integrations/audible.md](integrations/audible.md)
- **Database caching, real-time matching** → [integrations/audible.md](integrations/audible.md) - **Database caching, real-time matching** → [integrations/audible.md](integrations/audible.md)
- **Book covers API for login page** → [frontend/pages/login.md](frontend/pages/login.md) - **Book covers API for login page** → [frontend/pages/login.md](frontend/pages/login.md)
- **Dedup & works table (cross-ASIN identity)** → [integrations/audible.md](integrations/audible.md#dedup--works-table)
- **Multi-narrator capture in HTML scrapers** → [integrations/audible.md](integrations/audible.md#narrator-capture-in-html-scrapers)
## E-book Support (First-Class) ## E-book Support (First-Class)
- **First-class ebook requests, separate tracking** → [integrations/ebook-sidecar.md](integrations/ebook-sidecar.md) - **First-class ebook requests, separate tracking** → [integrations/ebook-sidecar.md](integrations/ebook-sidecar.md)
@@ -99,10 +102,12 @@
## Admin Features ## Admin Features
- **Dashboard (metrics, downloads, requests)** → [admin-dashboard.md](admin-dashboard.md) - **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) - **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) - **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 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) - **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 ## Fixes & Improvements
- **File hash-based library matching (ABS)** → [fixes/file-hash-matching.md](fixes/file-hash-matching.md) - **File hash-based library matching (ABS)** → [fixes/file-hash-matching.md](fixes/file-hash-matching.md)
@@ -141,9 +146,15 @@
**"What's the database schema?"** → [backend/database.md](backend/database.md) **"What's the database schema?"** → [backend/database.md](backend/database.md)
**"How does authentication work?"** → [backend/services/auth.md](backend/services/auth.md) **"How does authentication work?"** → [backend/services/auth.md](backend/services/auth.md)
**"How do I change my password?"** → [backend/services/auth.md](backend/services/auth.md) (local users only - accessed via user menu in header) **"How do I change my password?"** → [backend/services/auth.md](backend/services/auth.md) (local users only - accessed via user menu in header)
**"Local admin can't log in / 'Invalid username or password' with correct credentials"** → [admin-features/credential-recovery.md](admin-features/credential-recovery.md)
**"How do I recover from a lost CONFIG_ENCRYPTION_KEY?"** → [admin-features/credential-recovery.md](admin-features/credential-recovery.md)
**"How do I delete requests?"** → [admin-features/request-deletion.md](admin-features/request-deletion.md) **"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 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 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 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) **"How do I deploy?"** → [deployment/docker.md](deployment/docker.md) (multi-container), [deployment/unified.md](deployment/unified.md) (all-in-one)
**"How do I use the unified container?"** → [deployment/unified.md](deployment/unified.md) **"How do I use the unified container?"** → [deployment/unified.md](deployment/unified.md)
+26 -11
View File
@@ -57,9 +57,18 @@ Comprehensive overview of system metrics, active requests, download monitoring,
- Update global auto-approve setting (boolean) - Update global auto-approve setting (boolean)
**GET /api/admin/logs** **GET /api/admin/logs**
- Query params: page, limit, status, type - Query params: page, limit, status, type, search, dateFrom, dateTo, hasError, userId, audiobookQuery
- Returns: Job logs with request/audiobook/user details, pagination info - limit: one of 25/50/100 (default 50; invalid values clamp to 50)
- Filters: status (all/pending/active/completed/failed/delayed/stuck), type (all job types) - 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 ## Request Management Features
@@ -112,15 +121,21 @@ Comprehensive overview of system metrics, active requests, download monitoring,
## System Logs Features ## System Logs Features
- Real-time job monitoring (10s refresh) - Real-time job monitoring (10s SWR refresh; pauses on interact)
- Filter by status (pending/active/completed/failed/delayed/stuck) - **Filter row (5 pickers):** Status · Job Type · Date Range · User typeahead · Audiobook free-text
- Filter by job type (search_indexers/monitor_download/organize_files/scan_plex/match_plex) - 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 - Shows related audiobook/user for request jobs
- Expandable error messages - Expandable error messages, duration calc, attempt tracking, Bull job ID
- Duration calculation - Pagination: page-size selector (25 / 50 / 100), default 50
- Attempt tracking (current/max)
- Pagination (50 logs per page)
- Shows Bull job ID
## Tech Stack ## Tech Stack
@@ -0,0 +1,75 @@
# Credential Recovery Script
**Status:** ✅ Implemented | Interactive recovery for lost `CONFIG_ENCRYPTION_KEY` or forgotten local admin password
## Overview
Recovers from the "Invalid username or password" failure mode caused by a lost or rotated `CONFIG_ENCRYPTION_KEY`. Detects whether the key still works; either does a minimal password reset (preserves everything) or full recovery (rotates key + clears credentials that can no longer be decrypted).
## When to Use
- Local admin gets "Invalid username or password" with credentials known to be correct
- `/app/config/.secrets` was lost, truncated, or recreated
- After an unintended `CONFIG_ENCRYPTION_KEY` change
- See GitHub issue #200 for the symptom pattern
## How to Run
```
docker exec -it <container-name> npm run rmab:recover
```
- `-it` is required for the interactive prompts
- Or directly: `docker exec -it <container-name> node /app/scripts/recover-credentials.js`
## What It Does
1. Loads `DATABASE_URL` and `CONFIG_ENCRYPTION_KEY` from env (falls back to `/etc/environment`)
2. Diagnoses key health by attempting to decrypt an existing encrypted Configuration row
3. Lists local users (`authProvider='local'`, not soft-deleted); prompts for one
4. Prompts for new password twice (masked); validates length unless `ALLOW_WEAK_PASSWORD=true`
5. Prints the exact plan (mode + what will be cleared); requires typing `confirm` verbatim
6. Executes inside a single Prisma `$transaction`
7. If key was rotated: writes new key to `/app/config/.secrets` and `/etc/environment`
## Two Modes (auto-detected)
**Simple Password Reset (key works):**
- Only updates the chosen user's `authToken` (new bcrypt, re-encrypted)
- No other data touched
- No container restart needed
**Full Recovery (key broken):**
- Generates new `CONFIG_ENCRYPTION_KEY` (32 random bytes, base64)
- For each `Configuration` row with `encrypted=true`: re-encrypts with new key if old decrypt succeeds, deletes the row if not
- For `download_clients` JSON: re-encrypts each client password if possible, blanks it if not (URL/host/etc. preserved)
- For all `User.authToken` values: re-encrypts if possible, clears if not (Plex/OIDC users re-OAuth on next login)
- Overwrites target user's `authToken` with fresh bcrypt encrypted with new key
- Writes new key to `.secrets` + `/etc/environment`
- **Container restart required after this mode**
## What Survives (Full Recovery Mode)
- All requests + request history
- Library mappings, organization templates, schedules, user accounts
- Non-encrypted Configuration rows (paths, log level, backend mode, etc.)
- Plex/OIDC users whose tokens decrypted successfully (no re-OAuth needed)
## What User Re-enters After Full Recovery
- Plex auth token (or re-OAuth via login)
- Audiobookshelf API token (if used)
- OIDC client secret (if used)
- Prowlarr API key
- Download client passwords (per client)
- Any AI / Hardcover / Goodreads / notification provider secrets
## Security
- CLI only — no HTTP endpoint, no auto-run, no rescue-mode env flag
- Requires `docker exec` access (= host root equivalent)
- Refuses to accept any CLI arguments — all input via interactive prompts
- Does not echo or log password or key values
- Operation summary written to stdout; full audit info to app logger
- Idempotent within a single mode (re-runs are safe)
## Failure Modes
- DB transaction fails → no changes committed, safe to re-run
- DB transaction commits but `.secrets`/`/etc/environment` write fails → script prints the new key in plaintext with instructions for manual write (one-time exposure in operator's terminal)
## Related
- `backend/services/auth.md` — local auth flow + the decrypt-then-compare path
- `backend/services/config.md` — encryption format details
- `deployment/unified.md` — entrypoint behavior and `.secrets` persistence
@@ -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.
@@ -259,8 +259,11 @@ Update user (includes autoApproveRequests field)
- Title and author - Title and author
- User avatar and username - User avatar and username
- Request timestamp (relative: "2 hours ago") - Request timestamp (relative: "2 hours ago")
- Info button (ⓘ, top-right corner) — opens AudiobookDetailsModal for full book details
- Approve button (green, checkmark icon) - Approve button (green, checkmark icon)
- Search button (blue, magnifier icon) — opens InteractiveTorrentSearchModal
- Deny button (red, X icon) - Deny button (red, X icon)
- **Info modal:** `AudiobookDetailsModal` rendered with `adminActions` prop containing Approve/Search/Deny buttons, allowing admin to review full book details (cover, description, series, genres, narrator, etc.) without leaving the approval workflow
- Auto-refreshes every 10 seconds (SWR) - Auto-refreshes every 10 seconds (SWR)
- Loading states on buttons during approval/denial - Loading states on buttons during approval/denial
- Success/error toast notifications - Success/error toast notifications
+24 -2
View File
@@ -35,7 +35,7 @@ PostgreSQL database storing users, audiobooks, requests, downloads, configuratio
### Plex_Library (Library Cache) ### Plex_Library (Library Cache)
- `id` (UUID PK), `plex_guid` (unique, external ID from Plex or Audiobookshelf), `plex_rating_key` - `id` (UUID PK), `plex_guid` (unique, external ID from Plex or Audiobookshelf), `plex_rating_key`
- `title`, `author`, `narrator`, `summary`, `duration` (milliseconds), `year`, `user_rating` (0-10 scale) - `title`, `author`, `narrator`, `summary`, `duration` (BigInt, milliseconds), `year`, `user_rating` (0-10 scale)
- **Universal identifiers:** `asin` (Audible ASIN), `isbn` (ISBN-10 or ISBN-13) - **Universal identifiers:** `asin` (Audible ASIN), `isbn` (ISBN-10 or ISBN-13)
- `file_path`, `thumb_url`, `cached_library_cover_path` (local cached cover path), `plex_library_id`, `added_at` - `file_path`, `thumb_url`, `cached_library_cover_path` (local cached cover path), `plex_library_id`, `added_at`
- `last_scanned_at`, `created_at`, `updated_at` - `last_scanned_at`, `created_at`, `updated_at`
@@ -60,12 +60,14 @@ PostgreSQL database storing users, audiobooks, requests, downloads, configuratio
### Requests ### Requests
- `id` (UUID PK), `user_id` (FK), `audiobook_id` (FK) - `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 - **Approval flow:** awaiting_approval → (approve) → pending → searching → downloading → processing → downloaded → available
- **Denial flow:** awaiting_approval → (deny) → denied - **Denial flow:** awaiting_approval → (deny) → denied
- **awaiting_approval** - Request pending admin approval (only if auto-approve disabled) - **awaiting_approval** - Request pending admin approval (only if auto-approve disabled)
- **denied** - Request rejected by admin (terminal state) - **denied** - Request rejected by admin (terminal state)
- **pending** - Request approved and queued for processing - **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` - `progress` (0-100), `priority`, `error_message`
- `search_attempts`, `download_attempts`, `import_attempts`, `max_import_retries` (default 5) - `search_attempts`, `download_attempts`, `import_attempts`, `max_import_retries` (default 5)
- `last_search_at`, `last_import_at`, `created_at`, `updated_at`, `completed_at` - `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` - Indexes: `job_id`, `created_at`
- **Purpose:** Store detailed event logs for job operations (shown in admin logs UI) - **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 ## Relationships
- User → Requests (1:many) - User → Requests (1:many)
- Audiobook → Requests (1:many) - Audiobook → Requests (1:many)
- Request → Download History (1:many) - Request → Download History (1:many)
- Request → Jobs (1:many, nullable) - Request → Jobs (1:many, nullable)
- Request → Blocked Releases (1:many, CASCADE on hard delete)
- Job → Job Events (1:many, CASCADE delete) - Job → Job Events (1:many, CASCADE delete)
## Setup Strategy ## 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:** **search_indexers:**
- No torrents found → 'awaiting_search' status (not failed) - No torrents found → 'awaiting_search' status (not failed)
- Allows automatic retry via scheduled job - 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:** **organize_files:**
- No audiobook files found → 'awaiting_import' status - No audiobook files found → 'awaiting_import' status
@@ -7,7 +7,7 @@ Sends notifications for audiobook request events (pending approval, approved, av
## Key Details ## Key Details
- **Backends:** Apprise (API), Discord (webhooks), ntfy (API), Pushover (API) - **Backends:** Apprise (API), Discord (webhooks), ntfy (API), Pushover (API)
- **Events:** request_pending_approval, request_approved, request_available, request_error, issue_reported - **Events:** request_pending_approval, request_approved, request_grabbed, request_available, request_error, issue_reported
- **Encryption:** AES-256-GCM for sensitive config (webhook URLs, API keys, notification URLs) - **Encryption:** AES-256-GCM for sensitive config (webhook URLs, API keys, notification URLs)
- **Delivery:** Async via Bull job queue (priority 5) - **Delivery:** Async via Bull job queue (priority 5)
- **Failure Handling:** Non-blocking, Promise.allSettled (one backend fails, others succeed) - **Failure Handling:** Non-blocking, Promise.allSettled (one backend fails, others succeed)
@@ -33,11 +33,14 @@ model NotificationBackend {
|-------|---------|------------------------| |-------|---------|------------------------|
| request_pending_approval | User creates request | Request needs admin approval | | request_pending_approval | User creates request | Request needs admin approval |
| request_approved | Admin approves OR auto-approval | Request approved (manual or auto) | | request_approved | Admin approves OR auto-approval | Request approved (manual or auto) |
| request_grabbed | Torrent/NZB added to download client | Download handed off to configured download client (title resolves by type) — **opt-in: existing backends do not auto-subscribe; enable in Settings** |
| request_available | Plex/ABS scan or ebook download completes | Request available (title resolves by type) | | request_available | Plex/ABS scan or ebook download completes | Request available (title resolves by type) |
| request_error | Download/import fails | Request failed at any stage | | request_error | Download/import fails | Request failed at any stage |
| issue_reported | User reports issue | User reports problem with available audiobook | | issue_reported | User reports issue | User reports problem with available audiobook |
**Dynamic Titles:** Events can define `titleByRequestType` in `notification-events.ts` for type-specific titles. **Dynamic Titles:** Events can define `titleByRequestType` in `notification-events.ts` for type-specific titles.
- `request_grabbed` + `requestType: 'audiobook'` → "Audiobook Grabbed"
- `request_grabbed` + `requestType: 'ebook'` → "Ebook Grabbed"
- `request_available` + `requestType: 'audiobook'` → "Audiobook Available" - `request_available` + `requestType: 'audiobook'` → "Audiobook Available"
- `request_available` + `requestType: 'ebook'` → "Ebook Available" - `request_available` + `requestType: 'ebook'` → "Ebook Available"
- `request_available` + no requestType → "Request Available" (fallback) - `request_available` + no requestType → "Request Available" (fallback)
@@ -66,6 +69,11 @@ model NotificationBackend {
- Approve (with or without pre-selected torrent): After job triggered → request_approved - Approve (with or without pre-selected torrent): After job triggered → request_approved
- Deny: No notification - Deny: No notification
**Download Grabbed (processor: download-torrent)**
- After `client.addDownload()` succeeds and `DownloadHistory` record created → request_grabbed
- `message` field: `"${torrent.title} via ${indexer} (${clientType})"`
- `requestType`: from `request.type` (audiobook/ebook)
**Audiobook Available (processors: scan-plex, plex-recently-added)** **Audiobook Available (processors: scan-plex, plex-recently-added)**
- After `status: 'available'` update → request_available (requestType: 'audiobook') - After `status: 'available'` update → request_available (requestType: 'audiobook')
- Includes user info in query (plexUsername) - Includes user info in query (plexUsername)
+6 -3
View File
@@ -12,16 +12,18 @@ Manages recurring/scheduled jobs providing automated tasks (Plex scans, Audible
- Schedule editing UI with toast notifications - Schedule editing UI with toast notifications
- Human-friendly schedule descriptions and editor (preset/custom/advanced modes) - Human-friendly schedule descriptions and editor (preset/custom/advanced modes)
- Real-time cron expression preview - 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 ## Scheduled Jobs
1. **plex_library_scan** - Default: every 6 hours, full library scan, disabled by default (enable after setup) 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 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 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 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 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. **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 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 ## Architecture: Bull + Cron
@@ -155,6 +157,7 @@ interface ScheduledJob {
- ✅ Failed requests blocking re-requests → allow re-requesting failed/warn/cancelled - ✅ Failed requests blocking re-requests → allow re-requesting failed/warn/cancelled
- ✅ Files deleted immediately → kept until seeding requirements met - ✅ Files deleted immediately → kept until seeding requirements met
- ✅ No seeding time config → added `seeding_time_minutes` - ✅ 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 - ✅ 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 - ✅ 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 - ✅ Cron expressions not user-friendly → added human-readable descriptions and visual schedule builder
+9 -4
View File
@@ -13,9 +13,13 @@ Lets admins scan a server folder recursively, discover audiobook subfolders, mat
## Key Details ## Key Details
- **Access:** Admin-only, modal opened from admin dashboard Quick Actions - **Access:** Admin-only, modal opened from admin dashboard Quick Actions
- **Audio detection:** Uses `AUDIO_EXTENSIONS` from `src/lib/constants/audio-formats.ts` - **Audio detection:** Uses `AUDIO_EXTENSIONS` from `src/lib/constants/audio-formats.ts`
- **Audiobook boundary:** A folder containing audio files = one audiobook; subfolders not scanned further - **Audiobook boundary:** A folder containing audio files = one audiobook. Files with matching metadata tags are grouped by title+author+narrator. Files with no metadata title tag are all grouped together per folder (one entry, not one per file).
- **Metadata extraction:** ffprobe reads `album` (title), `album_artist` (author), `composer` (narrator) from first audio file - **Metadata extraction:** ffprobe reads `album` (title), `album_artist` (author), `composer` (narrator) from all audio files in folder
- **Fallback:** If metadata tags are empty, folder name used as search term; "Low Confidence" badge shown - **Search term fallback chain** (when no `album` tag):
1. **ASIN in folder name** — scans folder name for pattern `B[A-Z0-9]{9}` bounded by bracket/paren/space; if found, uses direct ASIN lookup instead of text search; no badge shown
2. **Folder name** — cleaned (strips bracketed ASIN/year, underscores→spaces); skipped if generic (CD1, Disc 2, Part 3, Vol 1, etc.); shows "Low Confidence" badge
3. **First file name** — last resort; shows "Low Confidence" badge
- **Generic folder detection:** `/^(cd|disc|disk|part|vol(ume)?)\s*\d+$/i` — these names are skipped as search terms
- **Author/narrator dedup:** Splits on `,;& ` delimiters, removes names appearing in both fields - **Author/narrator dedup:** Splits on `,;& ` delimiters, removes names appearing in both fields
- **Scan depth:** Max 10 levels recursion - **Scan depth:** Max 10 levels recursion
- **Rate limiting:** 1.5s delay between Audible searches (same as existing scraping rate limit) - **Rate limiting:** 1.5s delay between Audible searches (same as existing scraping rate limit)
@@ -56,7 +60,8 @@ Lets admins scan a server folder recursively, discover audiobook subfolders, mat
| Already in library | 40% opacity, green "In Library" badge, toggle disabled | | Already in library | 40% opacity, green "In Library" badge, toggle disabled |
| Active request exists | 40% opacity, purple "Requested" badge, toggle disabled | | Active request exists | 40% opacity, purple "Requested" badge, toggle disabled |
| No Audible match | Red "No Match" badge, folder name shown, pre-skipped | | No Audible match | Red "No Match" badge, folder name shown, pre-skipped |
| Low confidence (folder name fallback) | Amber "Low Confidence" badge | | ASIN extracted from folder name | No badge (high confidence — direct ASIN lookup) |
| Low confidence (folder name or file name fallback, no ASIN) | Amber "Low Confidence" badge |
## Files ## Files
+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 - **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 - **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 - **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 ## Key Decisions
- 10 section limit per user (total) - 10 section limit per user (total)
@@ -154,6 +154,12 @@ model Audiobook {
- Hash generated AFTER merging - Hash generated AFTER merging
- **Works correctly:** Hash reflects final organized state - **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) ### Multiple Downloads (Same Book)
- User re-downloads same audiobook (different edition/request) - User re-downloads same audiobook (different edition/request)
- Multiple records with same `filesHash` - Multiple records with same `filesHash`
+23 -6
View File
@@ -30,13 +30,13 @@ src/components/
**Audiobooks** **Audiobooks**
- **AudiobookCard** ✅ - Cover, title, author, narrator, duration, request button, clickable to open details modal. Shows "Requested by [username]" when someone else has requested the book, "Requested" when current user has requested it - **AudiobookCard** ✅ - Cover, title, author, narrator, duration, request button, clickable to open details modal. Shows "Requested by [username]" when someone else has requested the book, "Requested" when current user has requested it
- **AudiobookGrid** - Responsive grid (1/2/3/4 cols) - **AudiobookGrid** - Responsive grid (1/2/3/4 cols)
- **AudiobookDetailsModal** ✅ - Full-screen modal with comprehensive metadata (description, genres, rating, release date, narrator, request functionality). Shows requesting user's name when applicable - **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** **Requests**
- **RequestCard** ✅ - Cover, title, author, status badge, progress bar, timestamps, action buttons (cancel, manual search, interactive search) - **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, 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% - **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 - **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% - Active indicator: "Setting up..." with spinner when progress = 0%, "Active" with pulsing dot when progress > 0%
**Forms** **Forms**
@@ -71,8 +71,12 @@ src/components/
- Floating pagination pill at bottom center of viewport - Floating pagination pill at bottom center of viewport
- Minimal design: section label | ← | Page X of Y | → - Minimal design: section label | ← | Page X of Y | →
- Quick jump input (type page number + Enter) - Quick jump input (type page number + Enter)
- Auto-shows when scrolling through a section (IntersectionObserver) - Free-scroll tracking via IntersectionObserver (reports dominant section to parent)
- Auto-scrolls to section top on page change - 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 - Rounded-full design with backdrop blur and subtle shadow
- Responsive grid layouts (1/2/3/4 cols) - Responsive grid layouts (1/2/3/4 cols)
- Enhanced CTA section with gradient background (blue-to-indigo) - Enhanced CTA section with gradient background (blue-to-indigo)
@@ -109,10 +113,16 @@ interface AudiobookDetailsModalProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
onRequestSuccess?: () => void; onRequestSuccess?: () => void;
onStatusChange?: (newStatus: string) => void;
onIgnoreChange?: (isIgnored: boolean) => void;
isRequested?: boolean; isRequested?: boolean;
requestStatus?: string | null; requestStatus?: string | null;
isAvailable?: boolean; isAvailable?: boolean;
requestedByUsername?: string | null; 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
} }
interface RequestCardProps { interface RequestCardProps {
@@ -162,6 +172,13 @@ interface StickyPaginationProps {
sectionRef: React.RefObject<HTMLElement | null>; sectionRef: React.RefObject<HTMLElement | null>;
label: string; 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 ## Custom Hooks
+86 -21
View File
@@ -1,29 +1,40 @@
# Audible Integration # Audible Integration
**Status:** Implemented | Unauthenticated Audible JSON catalog API (primary) + Audnexus API (per-ASIN details) **Status:** Implemented | Hybrid — curated HTML for discovery refresh + Audible JSON catalog API for user-facing real-time + Audnexus for per-ASIN details
## Overview ## Overview
Audiobook metadata for discovery, search, and detail pages. All catalog operations (search, popular, new releases, categories, category books, author books, single-product details) now call Audible's unauthenticated public JSON catalog API (`api.audible.<tld>/1.0/catalog/*`). Per-ASIN detail lookups prefer Audnexus; the catalog API is used as fallback. Audiobook metadata for discovery, search, and detail pages. Split by access pattern:
- **Nightly discovery refresh** (popular / new releases / category lists) — scraped from Audible's **curated HTML storefronts** (`www.audible.<tld>/adblbestsellers`, `/newreleases`, `/search?node=<id>`). The HTML pages reflect Audible's own editorial picks.
- **User-facing real-time** (search, author books, categories listing, per-ASIN details) — Audible's unauthenticated public **JSON catalog API** (`api.audible.<tld>/1.0/catalog/*`).
- **Per-ASIN detail lookups** — Audnexus (`api.audnex.us/books/{asin}`) primary; catalog API used as fallback when Audnexus returns 404.
## Architecture ## Architecture
- **Primary data source:** Audible JSON catalog API, same endpoint used by the official Audible mobile apps. No authentication, no API key, no user credentials, no special headers. - **Curated HTML (refresh job only):** the three methods called solely by `audible-refresh.processor.ts` (`getPopularAudiobooks`, `getNewReleases`, `getCategoryBooks`) scrape Audible's storefront HTML to inherit editorial curation. Beefed-up retry/backoff knobs (12 retries, 3-min jittered cap) handle 503 storms patiently on the nightly job without slowing healthy users.
- **Per-ASIN details:** Audnexus (`api.audnex.us/books/{asin}`) remains primary; catalog API (`/1.0/catalog/products/{asin}`) is the fallback when Audnexus returns 404. - **JSON catalog API (real-time):** `search`, `searchByAuthorAsin`, `getCategories` (categories listing), and `fetchAudibleDetailsFromApi` (per-ASIN fallback). Same endpoint used by the official Audible mobile apps. No authentication, no API key, no user credentials, no special headers.
- **HTML scraping:** Removed from `audible.service.ts`. The only remaining HTML path is `audible-series.ts` (series-page scraping, out of scope). - **Audnexus (per-ASIN):** `getAudiobookDetails` and `getRuntime` prefer Audnexus, with catalog API fallback for `getAudiobookDetails`.
- **`www.audible.<tld>`:** Still used by `audible-series.ts` and by `getBaseUrl()` for "View on Audible" link generation. Not used for any catalog operation. - **`www.audible.<tld>`:** Used by HTML refresh scraping, by `audible-series.ts`, and by `getBaseUrl()` for "View on Audible" link generation.
## Data Sources ## Data Sources
All catalog operations are HTTP GET against `{apiBaseUrl}` (region-dependent, e.g. `https://api.audible.com`): ### Nightly refresh (HTML — `htmlClient`, baseURL `www.audible.<tld>`)
| Operation | Endpoint | Key params |
|---|---|---|
| Popular | `/adblbestsellers` | `pageSize=50`, `page=<n>` (omitted on first page) |
| New releases | `/newreleases` | `pageSize=50`, `page=<n>` (omitted on first page) |
| Category books | `/search` | `node=<categoryId>&pageSize=50&sort=popularity-rank&page=<n>` |
Parsed via cheerio. Selectors: `.productListItem` (popular/new releases), `.s-result-item, .productListItem` (categories).
### Real-time (JSON catalog API — `apiClient`, baseURL `api.audible.<tld>`)
| Operation | Endpoint | Key params | | Operation | Endpoint | Key params |
|---|---|---| |---|---|---|
| Search | `/1.0/catalog/products` | `keywords=<q>` | | Search | `/1.0/catalog/products` | `keywords=<q>` |
| Author books | `/1.0/catalog/products` | `author=<name>` (name, NOT ASIN) | | Author books | `/1.0/catalog/products` | `author=<name>` (name, NOT ASIN) |
| Popular | `/1.0/catalog/products` | `products_sort_by=BestSellers` |
| New releases | `/1.0/catalog/products` | `products_sort_by=-ReleaseDate` |
| Category books | `/1.0/catalog/products` | `category_id=<id>&products_sort_by=BestSellers` |
| Categories listing | `/1.0/catalog/categories` | (none) | | Categories listing | `/1.0/catalog/categories` | (none) |
| Single product | `/1.0/catalog/products/{asin}` | — | | Single product | `/1.0/catalog/products/{asin}` | — |
| Audnexus (per-ASIN) | `https://api.audnex.us/books/{asin}` | `region={audnexusParam}` | | Audnexus (per-ASIN) | `https://api.audnex.us/books/{asin}` | `region={audnexusParam}` |
@@ -48,20 +59,20 @@ Populates every `AudibleAudiobook` field. Covered:
## Gotchas ## Gotchas
- **Catalog API cannot filter preorders or surface curated bestsellers.** The API's `BestSellers` sort is a right-now velocity rank that spikes on launch-day promos and preorder windows; the `-ReleaseDate` sort returns 100% future preorders. There is no server-side `release_time`, `released-only`, `customer_rights`, or alternate sort (`Reviewed`, `MostListened`, etc.) — every plausible variant was tested and silently ignored. This is why the nightly refresh job uses the curated HTML storefront pages instead.
- **`author=` takes a name, not an ASIN.** The catalog API has no ASIN-based author param. `searchByAuthorAsin()` queries by name, then filters client-side: keeps only products where `products[].authors[].asin === authorAsin`. Preserves ASIN-authoritative author identity. Also filters by `product.language` via `isAcceptedLanguage()` for the configured region. - **`author=` takes a name, not an ASIN.** The catalog API has no ASIN-based author param. `searchByAuthorAsin()` queries by name, then filters client-side: keeps only products where `products[].authors[].asin === authorAsin`. Preserves ASIN-authoritative author identity. Also filters by `product.language` via `isAcceptedLanguage()` for the configured region.
- **Invalid ASIN returns HTTP 200 with stub body.** `/1.0/catalog/products/{asin}` responds 200 with `{product: {asin: INPUT}}` and no other fields. `fetchAudibleDetailsFromApi()` detects this via missing `product.title` and returns `null`. - **Invalid ASIN returns HTTP 200 with stub body.** `/1.0/catalog/products/{asin}` responds 200 with `{product: {asin: INPUT}}` and no other fields. `fetchAudibleDetailsFromApi()` detects this via missing `product.title` and returns `null`.
- **`publisher_summary` is HTML.** Service strips tags via inline `stripHtml()` helper (regex-based, no cheerio) before populating `description`. Falls back to `merchandising_summary` (plain text) if `publisher_summary` missing. - **`publisher_summary` is HTML.** Service strips tags via inline `stripHtml()` helper (regex-based, no cheerio) before populating `description`. Falls back to `merchandising_summary` (plain text) if `publisher_summary` missing.
- **Series is an array.** `products[].series[]` — a book may belong to multiple series. Service picks the first entry with non-empty `sequence`, else the first entry. `sequence` is cleaned by extracting first `/\d+(?:\.\d+)?/` match for numeric ordering. - **Series is an array.** `products[].series[]` — a book may belong to multiple series. Service picks the first entry with non-empty `sequence`, else the first entry. `sequence` is cleaned by extracting first `/\d+(?:\.\d+)?/` match for numeric ordering.
- **Stub `product_images`:** cover URL reads from `product_images['500']`; missing keys fall back to `undefined`. - **Stub `product_images`:** cover URL reads from `product_images['500']`; missing keys fall back to `undefined`.
- **`page` is 0-indexed.** Despite the default value appearing to be 1, the API returns items `(page * num_results)` through `((page + 1) * num_results - 1)`. So `page=1` fetches items 51100, not 150. All service methods accept a 1-indexed `page` and subtract 1 at the axios call. The symptom of getting this wrong is silent: queries whose `total_results ≤ num_results` return an empty `products` array while `total_results` is populated (e.g. author searches for small catalogues). - **`page` is 0-indexed (catalog API only).** Despite the default value appearing to be 1, the API returns items `(page * num_results)` through `((page + 1) * num_results - 1)`. So `page=1` fetches items 51100, not 150. All catalog-API service methods accept a 1-indexed `page` and subtract 1 at the axios call. The symptom of getting this wrong is silent: queries whose `total_results ≤ num_results` return an empty `products` array while `total_results` is populated (e.g. author searches for small catalogues). HTML paths use Audible's native 1-indexed `page` query param and omit it on the first page.
## Rate Limiting & Resilience ## Rate Limiting & Resilience
- 503s still possible but dramatically less frequent than the HTML surface. - **Real-time JSON API paths:** 503s are uncommon. `fetchWithRetry()` uses jittered exponential backoff, 5 retries, retries on 503/429/5xx. API responses include `Cache-Control: private, max-age=1800`.
- `fetchWithRetry()` — jittered exponential backoff, 5 retries, retries on 503/429/5xx. - **Nightly HTML refresh paths:** 503s are more likely (HTML storefront is more rate-sensitive). Same `fetchWithRetry()`, but with `HTML_MAX_RETRIES=12` and `HTML_MAX_BACKOFF_MS=180_000` (3-minute cap on jittered backoff). Healthy refreshes still complete fast (per-page success on attempt 0); users hit by sustained 503 storms grind through patiently rather than abandoning the refresh.
- `AdaptivePacer` circuit-breaker preserved. - **`AdaptivePacer`** — inter-page delay 24 s baseline, scales up multiplicatively under retry pressure, with a 4560 s circuit-breaker cooldown after 3 consecutive retry-pages.
- Inter-page base delay on API paths: **5001500ms** (down from 20004000ms for HTML). - **Per-batch cooldowns** in `audible-refresh.processor.ts` — 1530 s between popular/new-releases, 1020 s between categories.
- API responses include `Cache-Control: private, max-age=1800`.
## Region Configuration ## Region Configuration
@@ -101,8 +112,8 @@ Configurable Audible region for accurate metadata matching across international
- Automatic refresh: Region change triggers `audible_refresh` job. - Automatic refresh: Region change triggers `audible_refresh` job.
**Per-region HTTP clients (on init):** **Per-region HTTP clients (on init):**
- `apiClient``baseURL=apiBaseUrl`, `Accept: application/json`, `User-Agent: ReadMeABook/1.0`, no language/ipRedirect params. - `apiClient``baseURL=apiBaseUrl`, `Accept: application/json`, `User-Agent: ReadMeABook/1.0`, no language/ipRedirect params. Used for the real-time JSON catalog operations (search, author books, categories listing, per-ASIN details fallback).
- `htmlClient``baseURL=baseUrl`, browser headers, default params `ipRedirectOverride=true` + `language=<audibleLocaleParam>`. Used only by `audible-series.ts` and `getBaseUrl()`-based link generation. - `htmlClient``baseURL=baseUrl`, rotating browser headers (`pickUserAgent` + `getBrowserHeaders`), default params `ipRedirectOverride=true` + `language=<audibleLocaleParam>`. Used by the nightly discovery refresh (`/adblbestsellers`, `/newreleases`, `/search?node=...`), by `audible-series.ts`, and by `getBaseUrl()`-based link generation.
- Audnexus calls include `region=<audnexusParam>`. - Audnexus calls include `region=<audnexusParam>`.
**Files:** **Files:**
@@ -130,6 +141,44 @@ Single matching algorithm used everywhere (search, popular, new-releases, jobs).
**Note:** Fuzzy matching (70% threshold) is preserved in `ranking-algorithm.ts` for Prowlarr torrent ranking. Library availability checks require exact ASIN matches only. **Note:** Fuzzy matching (70% threshold) is preserved in `ranking-algorithm.ts` for Prowlarr torrent ranking. Library availability checks require exact ASIN matches only.
## Dedup & Works Table
**Status:** ✅ Implemented | Two-pass dedup on every discovery view + cross-batch identity via works table
Discovery views (search, author books, series detail) collapse duplicate Audible listings for the same recording (publisher re-listings, regional re-issues, full-cast vs single-narrator productions) into a single card. Two passes run in sequence:
1. **Local pass — `deduplicateAndCollectGroups()`** (`src/lib/utils/deduplicate-audiobooks.ts`)
- Stateless, in-memory. Keys books by normalized title + sorted narrator set + duration (±max(5%, 10 min) tolerance), with subtitle compatibility to keep distinct series entries separate.
- Picks a canonical representative per group by `metadataScore()` (cover + rating + duration + description + narrator + release date + genres).
- Emits `DedupGroup[]` describing every multi-ASIN collapse → handed to `persistDedupGroups()` for the works table.
2. **Works pass — `collapseByExistingWorks()`** (`src/lib/services/works.service.ts`)
- Async DB lookup. Reads `work_asins` for every ASIN in the local-passed list and collapses any books sharing a `workId` to one representative (same `metadataScore()` ranking).
- Catches duplicates the local pass misses: source-metadata divergence (e.g. HTML scraper captured different narrators), cross-page splits (paginated series), or non-matching field shapes.
- Degrades gracefully — returns the input unchanged on DB failure (view still renders).
### Works Table Schema
- `Work { id, title, author }` — one row per logical book
- `WorkAsin { id, workId, asin, narrator?, durationMinutes?, isCanonical, source, createdAt }` — many ASINs per Work
### Population Layers
- **Layer 1 (auto):** `persistDedupGroups()` writes whenever the local pass finds a duplicate. Merges across pre-existing works when a new group spans them.
- **Layer 2 (seed):** `seedAsin()` writes a single-ASIN work at request creation time, ensuring every requested ASIN has an entry to grow from.
### Read Paths
- **`collapseByExistingWorks()`** — view-level collapse (this section).
- **`getSiblingAsins()`** — library availability matching (`audiobook-matcher.ts`), request-creation duplicate prevention (`request-creator.service.ts`), ignored-audiobook expansion. Returns sibling ASINs grouped by input ASIN.
### Narrator Capture in HTML Scrapers
- HTML scrapers (`audible-series.ts`, the two `parse*Items` parsers in `audible.service.ts`) capture **all** narrator anchors via `extractAllNarrators()` (`src/lib/utils/extract-narrator.ts`). Multi-narrator productions render each name as its own `<a href="?searchNarrator=...">` link; capturing only the first (prior bug) made co-narrated audiobooks fail to dedup. Order is not significant — `normalizeNarrator()` sorts before comparison.
### Wired Routes
- `src/app/api/audiobooks/search/route.ts`
- `src/app/api/authors/[asin]/books/route.ts`
- `src/app/api/series/[asin]/route.ts`
Watched-list background jobs (`watched-lists.service.ts`) run the local pass only — they don't render a view, and the downstream `request-creator.service.ts` already does sibling-aware dedup at request creation time.
## Database-First Approach ## Database-First Approach
**Status:** Implemented **Status:** Implemented
@@ -137,12 +186,12 @@ Single matching algorithm used everywhere (search, popular, new-releases, jobs).
Discovery APIs serve cached data from DB with real-time matching. Discovery APIs serve cached data from DB with real-time matching.
**Flow:** **Flow:**
1. `audible_refresh` cron runs daily → fetches 200 popular + 200 new releases + user-configured categories via catalog API. 1. `audible_refresh` cron runs daily → fetches 200 popular + 200 new releases + user-configured categories by scraping Audible's curated HTML storefronts (`/adblbestsellers`, `/newreleases`, `/search?node=<id>&sort=popularity-rank`).
2. Downloads and caches cover thumbnails locally. 2. Downloads and caches cover thumbnails locally.
3. Stores metadata in `audible_cache`, ranked entries in `audible_cache_categories` with reserved IDs (`__popular__`, `__new_releases__`) and user category IDs. 3. Stores metadata in `audible_cache`, ranked entries in `audible_cache_categories` with reserved IDs (`__popular__`, `__new_releases__`) and user category IDs.
4. Cleans up unused thumbnails after sync. 4. Cleans up unused thumbnails after sync.
5. API routes query `AudibleCacheCategory` by categoryId → join with `AudibleCache` metadata → apply real-time matching → return enriched results. 5. API routes query `AudibleCacheCategory` by categoryId → join with `AudibleCache` metadata → apply real-time matching → return enriched results.
6. Homepage loads instantly (no Audible API hits). 6. Homepage loads instantly (no Audible HTTP hits at request time).
## Thumbnail Caching ## Thumbnail Caching
@@ -201,6 +250,9 @@ interface AudibleAudiobook {
series?: string; series?: string;
seriesPart?: string; seriesPart?: string;
seriesAsin?: string; seriesAsin?: string;
language?: string;
formatType?: string;
publisherName?: string;
} }
interface EnrichedAudibleAudiobook extends AudibleAudiobook { interface EnrichedAudibleAudiobook extends AudibleAudiobook {
@@ -228,12 +280,25 @@ interface AuthorBooksResult {
## Tech Stack ## Tech Stack
- `axios` (HTTP, two clients: `apiClient` for JSON catalog, `htmlClient` for series-page scraping only) - `axios` (HTTP, two clients: `apiClient` for JSON catalog API, `htmlClient` for HTML refresh + series scraping)
- `cheerio` (HTML parsing for refresh job and `audible-series.ts`)
- Audnexus API (per-ASIN details, primary) - Audnexus API (per-ASIN details, primary)
- PostgreSQL (`audible_cache`, `audible_cache_categories`) - PostgreSQL (`audible_cache`, `audible_cache_categories`)
## Fixed Issues ## Fixed Issues
**Series-page duplicates not collapsing across user views (2026-05-14)**
- **Problem:** Two re-listings of the same audiobook (same title, same narrator set, same duration, different ASINs) showed as two cards on series detail pages, even after the works table had already linked them via search-page dedup.
- **Root cause (two-part):** (1) HTML scrapers used `$el.find('a[href*="searchNarrator="]').first()` for multi-narrator productions, capturing only the first co-narrator. So two listings of the same recording landed in `deduplicateAndCollectGroups` with mismatched single-narrator strings and never merged. (2) `deduplicateAndCollectGroups` was stateless — it wrote to the works table but never read it back, so even when one path (e.g. search) successfully merged two ASINs and persisted the Work, every other path (series, author books) re-derived the dedup decision from scratch and split them again.
- **Fix:** (1) New `extractAllNarrators()` helper (`src/lib/utils/extract-narrator.ts`) captures every `searchNarrator=` anchor and joins them; all three HTML scrapers route through it. (2) New `collapseByExistingWorks()` consults the works table after the local pass and collapses any remaining books sharing a `workId`. Wired into the three user-facing discovery routes (search / author books / series detail). Skipped for watched-list background jobs — those feed `request-creator.service.ts` which already does sibling-aware dedup.
- **Location:** `src/lib/utils/extract-narrator.ts` (new); `src/lib/integrations/audible-series.ts` (parseSeriesBooks); `src/lib/integrations/audible.service.ts` (parseProductListItems + parseSearchResultItems); `src/lib/utils/deduplicate-audiobooks.ts` (`metadataScore` exported); `src/lib/services/works.service.ts` (`collapseByExistingWorks` added); three API routes updated.
**Discovery refresh reverted to curated HTML scraping (2026-05-14)**
- **Problem:** After switching all catalog ops to the JSON catalog API in `f564d0a`, the nightly discovery refresh (Popular / New Releases / user-configured Categories) started serving junk: New Releases became 100% preorders out to 2027, and Popular was dominated by launch-day no-name shovelware.
- **Root cause:** `products_sort_by=BestSellers` is a right-now sales velocity rank that spikes on launch promos and preorder windows; `-ReleaseDate` returns all catalog items in date order with no released-only filter. The catalog API exposes no server-side filter to exclude preorders or sort by established popularity (verified by exhaustively testing `release_time`, `availability_status`, `customer_rights`, `Reviewed`/`MostListened`/`SalesRank` sorts — all silently ignored or rejected). Doing the curation client-side would have made RMAB the editorial curator, which Audible's storefront pages already do well.
- **Fix:** Hybrid architecture — the three refresh-only methods (`getPopularAudiobooks`, `getNewReleases`, `getCategoryBooks`) went back to scraping Audible's curated HTML storefronts (`/adblbestsellers`, `/newreleases`, `/search?node=<id>&sort=popularity-rank`). All user-facing real-time paths (search, author books, categories listing, per-ASIN details) stayed on the JSON catalog API. To keep the higher-503-risk HTML traffic resilient on the unattended nightly job, `fetchWithRetry()` accepts an optional `maxBackoffMs` cap and HTML callers use `HTML_MAX_RETRIES=12` + `HTML_MAX_BACKOFF_MS=180_000` (3-min cap). Healthy users finish quickly; 503-blocked users grind through patiently.
- **Location:** `src/lib/integrations/audible.service.ts` (three methods + two private parsers `parseProductListItems` / `parseSearchResultItems`); `src/lib/utils/scrape-resilience.ts` (`jitteredBackoff` cap parameter).
**Audiobookshelf metadata matching not respecting configured region (2026-01-28)** **Audiobookshelf metadata matching not respecting configured region (2026-01-28)**
- **Problem:** `triggerABSItemMatch()` hardcoded `'audible'` provider (audible.com) instead of respecting user's configured Audible region. - **Problem:** `triggerABSItemMatch()` hardcoded `'audible'` provider (audible.com) instead of respecting user's configured Audible region.
- **Impact:** Users with non-US regions (CA, UK, AU, IN) had incorrect metadata matching in Audiobookshelf, causing wrong ASINs. - **Impact:** Users with non-US regions (CA, UK, AU, IN) had incorrect metadata matching in Audiobookshelf, causing wrong ASINs.
@@ -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.* - *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.* - *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 ### Kindle EPUB Fix
**Purpose:** Apply compatibility fixes to EPUB files before organizing, ensuring successful Kindle import. **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) 5. **Copy** files (not move - originals stay for seeding)
6. **Tag metadata** (if enabled) - writes correct title, author, narrator, ASIN to audio files 6. **Tag metadata** (if enabled) - writes correct title, author, narrator, ASIN to audio files
7. Copy cover art if found, else download from Audible 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)) 8. **Coerce file formats** (if enabled) - rename .mp4 → .m4b and single-file .m4a → .m4b for Plex compatibility (see: Plex Format Coercion below)
9. Update request status to `downloaded` and store file hash in `audiobooks.files_hash` 9. **Generate file hash** - SHA256 of sorted audio filenames for library matching (see: [fixes/file-hash-matching.md](../fixes/file-hash-matching.md))
10. **Trigger filesystem scan** (if enabled) - tells Plex/ABS to scan for new files 10. Update request status to `downloaded` and store file hash in `audiobooks.files_hash`
11. Originals remain until seeding requirements met 11. **Trigger filesystem scan** (if enabled) - tells Plex/ABS to scan for new files
12. Originals remain until seeding requirements met
## Filesystem Scan Triggering ## Filesystem Scan Triggering
@@ -150,6 +151,61 @@ exiftool "audiobook.m4b" | grep -i asin
- Multi-container: `docker exec readmeabook ffmpeg -version` - Multi-container: `docker exec readmeabook ffmpeg -version`
- Unified: `docker exec readmeabook-unified 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 ## Seeding Support
**Config:** `seeding_time_minutes` (0 = unlimited, never cleanup) **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}`) - **Path template:** Read from database config key `audiobook_path_template` (default: `{author}/{title} {asin}`)
- **Metadata tagging:** `metadata_tagging_enabled` (boolean, default: true) - **Metadata tagging:** `metadata_tagging_enabled` (boolean, default: true)
- **Chapter merging:** `chapter_merging_enabled` (boolean, default: false) - **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 - **Fallback:** `/media/audiobooks` if media_dir not configured
- **Temp directory:** `/tmp/readmeabook` (or `TEMP_DIR` env var) - **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 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** 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) 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 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 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 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 ## Audible Region
**Purpose:** Configure which Audible region to use for metadata and search to ensure accurate ASIN matching with your metadata engine. **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 - 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) - 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 ## Validation Flow
**Plex, Download Client, Paths:** **Plex, Download Client, Paths:**
@@ -279,6 +319,17 @@ src/app/admin/settings/
- No test required if URL/API key unchanged - No test required if URL/API key unchanged
- Saves only enabled indexers to database - 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** **PUT /api/admin/settings/download-client**
- Updates download client config - Updates download client config
- Requires prior successful test if credentials changed - Requires prior successful test if credentials changed
@@ -323,7 +374,7 @@ src/app/admin/settings/
## Validation ## Validation
**Plex:** Valid HTTP/HTTPS URL, non-empty token, library ID selected **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' **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}` **Paths:** Absolute paths, exist or creatable, writable, cannot be same directory, template must contain `{author}` or `{title}`
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "readmeabook", "name": "readmeabook",
"version": "1.0.15", "version": "1.2.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "readmeabook", "name": "readmeabook",
"version": "1.0.15", "version": "1.2.0",
"dependencies": { "dependencies": {
"@heroicons/react": "^2.2.0", "@heroicons/react": "^2.2.0",
"@prisma/client": "^6.19.0", "@prisma/client": "^6.19.0",
+3 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "readmeabook", "name": "readmeabook",
"version": "1.1.8", "version": "1.2.1",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
@@ -13,7 +13,8 @@
"prisma:generate": "prisma generate", "prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev", "prisma:migrate": "prisma migrate dev",
"prisma:studio": "prisma studio", "prisma:studio": "prisma studio",
"db:push": "prisma db push" "db:push": "prisma db push",
"rmab:recover": "node scripts/recover-credentials.js"
}, },
"dependencies": { "dependencies": {
"@heroicons/react": "^2.2.0", "@heroicons/react": "^2.2.0",
@@ -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;
+41 -2
View File
@@ -132,7 +132,7 @@ model PlexLibrary {
author String author String
narrator String? narrator String?
summary String? @db.Text summary String? @db.Text
duration Int? // Duration in milliseconds (Plex format) duration BigInt? // Duration in milliseconds (Plex format)
year Int? year Int?
userRating Decimal? @map("user_rating") @db.Decimal(3, 1) // User's rating (0-10 scale from Plex) userRating Decimal? @map("user_rating") @db.Decimal(3, 1) // User's rating (0-10 scale from Plex)
@@ -223,7 +223,7 @@ model Request {
userId String @map("user_id") userId String @map("user_id")
audiobookId String @map("audiobook_id") audiobookId String @map("audiobook_id")
status String @default("pending") 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 (audiobook): pending → searching → downloading → processing → downloaded → available (when matched in Plex)
// Flow (ebook): pending → searching → downloading → processing → downloaded (terminal - no available state) // Flow (ebook): pending → searching → downloading → processing → downloaded (terminal - no available state)
progress Int @default(0) // 0-100 progress Int @default(0) // 0-100
@@ -234,12 +234,14 @@ model Request {
downloadAttempts Int @default(0) @map("download_attempts") downloadAttempts Int @default(0) @map("download_attempts")
importAttempts Int @default(0) @map("import_attempts") importAttempts Int @default(0) @map("import_attempts")
maxImportRetries Int @default(5) @map("max_import_retries") maxImportRetries Int @default(5) @map("max_import_retries")
ebookAutoRetryCount Int? @map("ebook_auto_retry_count")
lastSearchAt DateTime? @map("last_search_at") lastSearchAt DateTime? @map("last_search_at")
customSearchTerms String? @map("custom_search_terms") @db.Text customSearchTerms String? @map("custom_search_terms") @db.Text
lastImportAt DateTime? @map("last_import_at") lastImportAt DateTime? @map("last_import_at")
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")
completedAt DateTime? @map("completed_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' // Request type: 'audiobook' (default) or 'ebook'
// Ebook requests are created automatically when an audiobook is organized (if ebook downloads enabled) // Ebook requests are created automatically when an audiobook is organized (if ebook downloads enabled)
@@ -257,6 +259,7 @@ model Request {
jobs Job[] jobs Job[]
parentRequest Request? @relation("EbookParent", fields: [parentRequestId], references: [id], onDelete: SetNull) parentRequest Request? @relation("EbookParent", fields: [parentRequestId], references: [id], onDelete: SetNull)
childRequests Request[] @relation("EbookParent") childRequests Request[] @relation("EbookParent")
blockedReleases BlockedRelease[]
@@index([userId]) @@index([userId])
@@index([audiobookId]) @@index([audiobookId])
@@ -268,6 +271,42 @@ model Request {
@@map("requests") @@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 { model DownloadHistory {
id String @id @default(uuid()) id String @id @default(uuid())
requestId String @map("request_id") requestId String @map("request_id")
+772
View File
@@ -0,0 +1,772 @@
/**
* Component: Credential Recovery Script
* Documentation: documentation/admin-features/credential-recovery.md
*
* Interactive recovery for lost CONFIG_ENCRYPTION_KEY or forgotten local admin password.
* Run inside the container with: docker exec -it <container> npm run rmab:recover
*
* Hard rules:
* - No CLI arguments accepted. All input via interactive prompts.
* - Never log password or key values.
* - All DB mutations inside a single transaction.
* - File writes happen only after DB commit succeeds.
*/
'use strict';
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const readline = require('readline');
const bcrypt = require('bcrypt');
const SECRETS_FILE = '/app/config/.secrets';
const ENVIRONMENT_FILE = '/etc/environment';
const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 16;
const KEY_LENGTH = 32;
const ENCRYPTED_CONFIG_KEYS_FOR_PROBE = [
'plex_token',
'prowlarr_api_key',
'audiobookshelf.api_token',
'oidc.client_secret',
];
// ---------------------------------------------------------------------------
// Env loading
// ---------------------------------------------------------------------------
// docker exec doesn't inherit runtime-generated env vars, and /etc/environment
// can drift from what the running app process is actually using (e.g. if
// .secrets was regenerated on a restart while the existing pg_user kept its
// original password). The source of truth is the live node process's
// /proc/<pid>/environ — read that first, then fall back to files.
// ---------------------------------------------------------------------------
const WANTED_ENV_KEYS = [
'DATABASE_URL',
'CONFIG_ENCRYPTION_KEY',
'POSTGRES_PASSWORD',
'POSTGRES_USER',
'POSTGRES_DB',
'ALLOW_WEAK_PASSWORD',
];
const envSource = {}; // key -> short label of where it came from
// The dockerfile bakes ENV DATABASE_URL=<this> at build time so prisma generate
// has a valid URL; the entrypoint overrides at runtime. But if the override
// didn't propagate to the child process inheriting via docker exec, we see
// this exact dummy value. Never trust it.
const DUMMY_DB_URL = 'postgresql://dummy:dummy@localhost:5432/dummy?schema=public';
function isUsableValue(key, value) {
if (value == null || value === '') return false;
if (key === 'DATABASE_URL' && value === DUMMY_DB_URL) return false;
if (key === 'DATABASE_URL' && /^postgresql:\/\/dummy:dummy@/.test(value)) return false;
return true;
}
function setIfMissing(key, value, sourceLabel) {
if (!isUsableValue(key, value)) return;
if (!isUsableValue(key, process.env[key])) {
process.env[key] = value;
envSource[key] = sourceLabel;
}
}
// Wipe inherited dummy URL up front so file/proc sources have a clean slate.
if (process.env.DATABASE_URL && !isUsableValue('DATABASE_URL', process.env.DATABASE_URL)) {
delete process.env.DATABASE_URL;
}
function loadEnvFromFile(filePath, sourceLabel) {
if (!fs.existsSync(filePath)) return;
let contents;
try {
contents = fs.readFileSync(filePath, 'utf8');
} catch (_err) {
return;
}
for (const rawLine of contents.split('\n')) {
const line = rawLine.trim();
if (!line || line.startsWith('#')) continue;
const eq = line.indexOf('=');
if (eq === -1) continue;
const key = line.slice(0, eq).trim();
let value = line.slice(eq + 1).trim();
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
setIfMissing(key, value, sourceLabel);
}
}
function loadEnvFromRunningProcess() {
// Walk every readable /proc/<pid>/environ. Pick the first process whose
// environ contains a non-empty DATABASE_URL. Do NOT filter by comm name —
// the app may run under gosu, npm, next-server, etc.
let procDir;
try {
procDir = fs.readdirSync('/proc');
} catch (_err) {
return null;
}
const ownPid = String(process.pid);
for (const entry of procDir) {
if (!/^\d+$/.test(entry)) continue;
if (entry === ownPid) continue;
let environBuf;
try {
environBuf = fs.readFileSync(`/proc/${entry}/environ`);
} catch (_err) {
// environ may be mode 400 owned by another user; skip silently.
continue;
}
if (!environBuf || environBuf.length === 0) continue;
const pairs = environBuf.toString('utf8').split('\u0000');
const collected = {};
for (const p of pairs) {
const eq = p.indexOf('=');
if (eq === -1) continue;
collected[p.slice(0, eq)] = p.slice(eq + 1);
}
if (!collected.DATABASE_URL) continue;
let comm = '';
try {
comm = fs.readFileSync(`/proc/${entry}/comm`, 'utf8').trim();
} catch (_e) {}
const label = `pid ${entry}${comm ? ` (${comm})` : ''}`;
for (const k of WANTED_ENV_KEYS) {
if (collected[k]) setIfMissing(k, collected[k], label);
}
return label;
}
return null;
}
// Priority order: /etc/environment (entrypoint's persisted authoritative state)
// > /app/config/.secrets (persisted keys) > /proc/<pid>/environ (running process).
// The inherited docker-exec env was already wiped of the dummy URL above.
loadEnvFromFile(ENVIRONMENT_FILE, '/etc/environment');
loadEnvFromFile(SECRETS_FILE, '/app/config/.secrets');
const liveProcPid = loadEnvFromRunningProcess();
// Last resort: construct DATABASE_URL from POSTGRES_PASSWORD + sensible defaults,
// mirroring what entrypoint.sh does. Works as long as POSTGRES_PASSWORD was
// recoverable from .secrets or another source.
function urlEncodePassword(s) {
// Match entrypoint.sh urlencode(): everything except [-_.~a-zA-Z0-9] is %xx.
return Array.from(s).map((c) => {
if (/[-_.~a-zA-Z0-9]/.test(c)) return c;
return '%' + c.charCodeAt(0).toString(16).padStart(2, '0');
}).join('');
}
if (!isUsableValue('DATABASE_URL', process.env.DATABASE_URL) && process.env.POSTGRES_PASSWORD) {
const user = process.env.POSTGRES_USER || 'readmeabook';
const db = process.env.POSTGRES_DB || 'readmeabook';
const host = '127.0.0.1';
const port = '5432';
const encoded = urlEncodePassword(process.env.POSTGRES_PASSWORD);
process.env.DATABASE_URL = `postgresql://${user}:${encoded}@${host}:${port}/${db}`;
envSource.DATABASE_URL = 'constructed from POSTGRES_PASSWORD + defaults';
}
// ---------------------------------------------------------------------------
// Encryption helpers (mirrors src/lib/services/encryption.service.ts)
// ---------------------------------------------------------------------------
function deriveKey(rawKey) {
if (!rawKey) {
throw new Error('CONFIG_ENCRYPTION_KEY is not set');
}
if (rawKey.length < KEY_LENGTH) {
const buf = Buffer.alloc(KEY_LENGTH);
Buffer.from(rawKey).copy(buf);
return buf;
}
if (rawKey.length > KEY_LENGTH) {
return Buffer.from(rawKey).subarray(0, KEY_LENGTH);
}
return Buffer.from(rawKey);
}
function decryptWithKey(encryptedData, keyBuffer) {
const parts = String(encryptedData || '').split(':');
if (parts.length !== 3) throw new Error('Invalid encrypted data format');
const iv = Buffer.from(parts[0], 'base64');
const authTag = Buffer.from(parts[1], 'base64');
const decipher = crypto.createDecipheriv(ALGORITHM, keyBuffer, iv);
decipher.setAuthTag(authTag);
let decrypted = decipher.update(parts[2], 'base64', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
}
function encryptWithKey(plaintext, keyBuffer) {
const iv = crypto.randomBytes(IV_LENGTH);
const cipher = crypto.createCipheriv(ALGORITHM, keyBuffer, iv);
let encrypted = cipher.update(plaintext, 'utf8', 'base64');
encrypted += cipher.final('base64');
const authTag = cipher.getAuthTag();
return `${iv.toString('base64')}:${authTag.toString('base64')}:${encrypted}`;
}
function tryDecrypt(encryptedData, keyBuffer) {
try {
return { ok: true, value: decryptWithKey(encryptedData, keyBuffer) };
} catch (err) {
return { ok: false, error: err };
}
}
function generateNewKey() {
return crypto.randomBytes(KEY_LENGTH).toString('base64');
}
// ---------------------------------------------------------------------------
// Prompt helpers
// ---------------------------------------------------------------------------
function ask(rl, question) {
return new Promise((resolve) => rl.question(question, (answer) => resolve(answer)));
}
function askHidden(question) {
return new Promise((resolve, reject) => {
if (!process.stdin.isTTY) {
reject(new Error('Interactive password input requires a TTY. Run with: docker exec -it ...'));
return;
}
process.stdout.write(question);
const stdin = process.stdin;
const wasRaw = stdin.isRaw;
stdin.setRawMode(true);
stdin.resume();
stdin.setEncoding('utf8');
let buffer = '';
const onData = (chunk) => {
for (const ch of chunk) {
if (ch === '\u0003') {
// Ctrl+C
stdin.setRawMode(wasRaw);
stdin.pause();
stdin.removeListener('data', onData);
process.stdout.write('\n');
reject(new Error('Cancelled by user'));
return;
}
if (ch === '\r' || ch === '\n') {
stdin.setRawMode(wasRaw);
stdin.pause();
stdin.removeListener('data', onData);
process.stdout.write('\n');
resolve(buffer);
return;
}
if (ch === '\u007f' || ch === '\b') {
if (buffer.length > 0) {
buffer = buffer.slice(0, -1);
process.stdout.write('\b \b');
}
continue;
}
if (ch < ' ') continue;
buffer += ch;
process.stdout.write('*');
}
};
stdin.on('data', onData);
});
}
// ---------------------------------------------------------------------------
// .secrets / /etc/environment file updates
// ---------------------------------------------------------------------------
function updateKeyInFile(filePath, keyName, newValue, quoted) {
if (!fs.existsSync(filePath)) {
fs.writeFileSync(
filePath,
`${keyName}=${quoted ? `"${newValue}"` : newValue}\n`,
{ mode: 0o600 }
);
return { created: true, replaced: false };
}
const original = fs.readFileSync(filePath, 'utf8');
const lines = original.split('\n');
let replaced = false;
const updated = lines.map((line) => {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) return line;
const eq = trimmed.indexOf('=');
if (eq === -1) return line;
const name = trimmed.slice(0, eq).trim();
if (name !== keyName) return line;
replaced = true;
return `${keyName}=${quoted ? `"${newValue}"` : newValue}`;
});
if (!replaced) {
if (updated[updated.length - 1] === '') {
updated[updated.length - 1] = `${keyName}=${quoted ? `"${newValue}"` : newValue}`;
updated.push('');
} else {
updated.push(`${keyName}=${quoted ? `"${newValue}"` : newValue}`);
}
}
fs.writeFileSync(filePath, updated.join('\n'));
return { created: false, replaced };
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main() {
// Reject any CLI args by design.
if (process.argv.length > 2) {
console.error('This script does not accept CLI arguments. All input is via interactive prompts.');
console.error('Run: docker exec -it <container> npm run rmab:recover');
process.exit(2);
}
console.log('');
console.log('================================================================');
console.log(' ReadMeABook — Credential Recovery');
console.log('================================================================');
console.log('');
console.log('Use when local login fails with "Invalid username or password"');
console.log('despite known-correct credentials. See:');
console.log(' documentation/admin-features/credential-recovery.md');
console.log('');
// Diagnostic: where did we resolve env vars from?
const dbSrc = envSource.DATABASE_URL || (process.env.DATABASE_URL ? 'inherited' : 'NOT FOUND');
const keySrc = envSource.CONFIG_ENCRYPTION_KEY || (process.env.CONFIG_ENCRYPTION_KEY ? 'inherited' : 'NOT FOUND');
console.log('Environment:');
console.log(` Live process w/ DATABASE_URL: ${liveProcPid || 'none found'}`);
console.log(` DATABASE_URL source: ${dbSrc}`);
console.log(` CONFIG_ENCRYPTION_KEY src: ${keySrc}`);
if (process.env.DATABASE_URL) {
const redacted = String(process.env.DATABASE_URL).replace(/(:\/\/[^:]+:)[^@]+(@)/, '$1***$2');
console.log(` DATABASE_URL (redacted): ${redacted}`);
}
console.log('');
if (!process.env.DATABASE_URL) {
console.error('ERROR: DATABASE_URL is not set and could not be loaded from any source.');
console.error(' Tried: /proc/<pid>/environ of running node process,');
console.error(' /etc/environment, /app/config/.secrets');
console.error(' Workaround: docker exec -it -e DATABASE_URL="<your url>" <container> npm run rmab:recover');
process.exit(1);
}
if (!process.env.CONFIG_ENCRYPTION_KEY) {
console.error('ERROR: CONFIG_ENCRYPTION_KEY is not set and could not be loaded from any source.');
console.error(' Tried: /proc/<pid>/environ of running node process,');
console.error(' /etc/environment, /app/config/.secrets');
process.exit(1);
}
const currentKey = deriveKey(process.env.CONFIG_ENCRYPTION_KEY);
// Load Prisma client (generated in container at src/generated/prisma)
let PrismaClient;
try {
({ PrismaClient } = require(path.join(__dirname, '..', 'src', 'generated', 'prisma', 'client')));
} catch (err) {
try {
({ PrismaClient } = require('@prisma/client'));
} catch (innerErr) {
console.error('ERROR: Could not load Prisma client. Tried generated path and @prisma/client.');
console.error(' Generated path error:', err.message);
console.error(' Package error: ', innerErr.message);
process.exit(1);
}
}
const prisma = new PrismaClient();
try {
// -------------------------------------------------------------------------
// Diagnose key health
// -------------------------------------------------------------------------
console.log('Step 1/5 — Diagnosing encryption key health...');
const encryptedRows = await prisma.configuration.findMany({
where: { encrypted: true },
});
let keyWorks = null; // null = unknown (no probe rows)
let probedKey = null;
for (const row of encryptedRows) {
if (!row.value) continue;
const result = tryDecrypt(row.value, currentKey);
if (result.ok) {
keyWorks = true;
probedKey = row.key;
break;
}
if (keyWorks === null) keyWorks = false;
}
if (keyWorks === true) {
console.log(` Key works (verified against Configuration row "${probedKey}").`);
} else if (keyWorks === false) {
console.log(` Key DOES NOT work — none of the ${encryptedRows.length} encrypted Configuration rows decrypt.`);
} else {
console.log(' No encrypted Configuration rows exist yet — defaulting to password-reset-only mode.');
}
// -------------------------------------------------------------------------
// List local users
// -------------------------------------------------------------------------
console.log('');
console.log('Step 2/5 — Selecting local user to reset...');
const localUsers = await prisma.user.findMany({
where: { authProvider: 'local', deletedAt: null },
select: {
id: true,
plexUsername: true,
plexId: true,
role: true,
isSetupAdmin: true,
authToken: true,
},
orderBy: [{ isSetupAdmin: 'desc' }, { plexUsername: 'asc' }],
});
if (localUsers.length === 0) {
console.error('');
console.error('ERROR: No local users exist in the database.');
console.error(' Use the setup wizard / registration page to create one instead.');
process.exit(1);
}
console.log('');
console.log(' Local users:');
for (const u of localUsers) {
const tag = [u.role];
if (u.isSetupAdmin) tag.push('setup-admin');
console.log(` - ${u.plexUsername} [${tag.join(', ')}]`);
}
console.log('');
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
let chosenUser = null;
while (!chosenUser) {
const typed = (await ask(rl, ' Username to reset: ')).trim().toLowerCase();
if (!typed) continue;
chosenUser = localUsers.find((u) => u.plexUsername === typed);
if (!chosenUser) {
console.log(` No local user named "${typed}". Try again, or Ctrl+C to abort.`);
}
}
// -------------------------------------------------------------------------
// New password
// -------------------------------------------------------------------------
console.log('');
console.log('Step 3/5 — New password...');
const allowWeak = process.env.ALLOW_WEAK_PASSWORD === 'true';
const minLen = allowWeak ? 1 : 8;
let newPassword = null;
while (!newPassword) {
rl.pause();
const a = await askHidden(' New password: ');
const b = await askHidden(' Confirm new password: ');
rl.resume();
if (a !== b) {
console.log(' Passwords did not match. Try again.');
continue;
}
if (a.length < minLen) {
console.log(` Password must be at least ${minLen} character(s). Try again.`);
continue;
}
newPassword = a;
}
// -------------------------------------------------------------------------
// Build the plan
// -------------------------------------------------------------------------
console.log('');
console.log('Step 4/5 — Plan...');
console.log('');
const fullRecovery = keyWorks === false;
if (fullRecovery) {
console.log(' MODE: FULL RECOVERY (encryption key is unrecoverable)');
console.log('');
console.log(' The following will happen, atomically:');
console.log(` 1. A new CONFIG_ENCRYPTION_KEY will be generated.`);
console.log(` 2. User "${chosenUser.plexUsername}" will get a new password (bcrypt + new key).`);
console.log(' 3. Every Configuration row with encrypted=true will be tried with the OLD key:');
console.log(' - If it decrypts: re-encrypted with the new key (preserved).');
console.log(' - If it cannot decrypt: DELETED (must be re-entered in Settings).');
console.log(' 4. download_clients JSON: each per-client password tried with OLD key:');
console.log(' - Decryptable: re-encrypted with new key.');
console.log(' - Not decryptable: blanked. URL, host, name, etc. preserved.');
console.log(' 5. User.authToken for every user tried with OLD key:');
console.log(' - Decryptable: re-encrypted with new key.');
console.log(' - Not decryptable: cleared. Plex/OIDC users re-OAuth on next login.');
console.log(' 6. /app/config/.secrets and /etc/environment updated with the new key.');
console.log('');
console.log(' Likely to need re-entering in Settings after this completes:');
console.log(' - Plex auth token (or just re-login with Plex)');
console.log(' - Audiobookshelf API token (if used)');
console.log(' - Prowlarr API key');
console.log(' - OIDC client secret (if used)');
console.log(' - Download client passwords (per client)');
console.log(' - Any AI / Hardcover / Goodreads / notification provider secrets');
console.log('');
console.log(' Survives untouched:');
console.log(' - All requests + request history');
console.log(' - Library mappings, organization templates, schedules');
console.log(' - User accounts (just credentials cleared)');
console.log(' - Non-encrypted config (paths, log level, backend mode, etc.)');
console.log('');
console.log(' Container restart REQUIRED after this completes.');
} else {
console.log(' MODE: PASSWORD RESET ONLY (encryption key is healthy)');
console.log('');
console.log(` Only one change: user "${chosenUser.plexUsername}" gets a new password.`);
console.log(' Everything else (all credentials, all settings) untouched.');
console.log(' No container restart needed.');
}
console.log('');
const confirm = (await ask(rl, " Type 'confirm' to proceed (anything else aborts): ")).trim();
if (confirm !== 'confirm') {
console.log(' Aborted. No changes made.');
rl.close();
await prisma.$disconnect();
process.exit(0);
}
rl.close();
// -------------------------------------------------------------------------
// Execute
// -------------------------------------------------------------------------
console.log('');
console.log('Step 5/5 — Applying changes...');
let summary;
let newKeyBase64 = null;
let newKeyBuffer = currentKey;
if (fullRecovery) {
newKeyBase64 = generateNewKey();
newKeyBuffer = deriveKey(newKeyBase64);
// Plan mutations in memory using OLD key for reads, NEW key for writes.
const configUpdates = [];
const configDeletes = [];
let downloadClientsUpdate = null;
const userUpdates = [];
// Configuration rows
for (const row of encryptedRows) {
if (!row.value) {
configDeletes.push(row.key);
continue;
}
const decrypted = tryDecrypt(row.value, currentKey);
if (decrypted.ok) {
configUpdates.push({ key: row.key, value: encryptWithKey(decrypted.value, newKeyBuffer) });
} else {
configDeletes.push(row.key);
}
}
// download_clients JSON (not marked encrypted=true at row level)
const dcRow = await prisma.configuration.findUnique({ where: { key: 'download_clients' } });
if (dcRow && dcRow.value) {
try {
const clients = JSON.parse(dcRow.value);
let touched = 0;
let cleared = 0;
if (Array.isArray(clients)) {
for (const client of clients) {
if (!client || !client.password) continue;
const decrypted = tryDecrypt(client.password, currentKey);
if (decrypted.ok) {
client.password = encryptWithKey(decrypted.value, newKeyBuffer);
touched++;
} else {
client.password = '';
cleared++;
}
}
downloadClientsUpdate = { value: JSON.stringify(clients), touched, cleared };
}
} catch (err) {
console.log(` WARNING: download_clients JSON unparseable, leaving as-is: ${err.message}`);
}
}
// User auth tokens (except the chosen user, whose token will be overwritten)
const allUsers = await prisma.user.findMany({
where: { deletedAt: null },
select: { id: true, authToken: true, authProvider: true },
});
for (const u of allUsers) {
if (u.id === chosenUser.id) continue;
if (!u.authToken) continue;
const decrypted = tryDecrypt(u.authToken, currentKey);
if (decrypted.ok) {
userUpdates.push({ id: u.id, authToken: encryptWithKey(decrypted.value, newKeyBuffer) });
} else {
userUpdates.push({ id: u.id, authToken: '' });
}
}
// Chosen user — fresh bcrypt encrypted with new key
const newHash = await bcrypt.hash(newPassword, 10);
const encryptedHash = encryptWithKey(newHash, newKeyBuffer);
// Apply atomically
summary = await prisma.$transaction(async (tx) => {
const result = {
configRotated: configUpdates.length,
configDeleted: configDeletes.length,
downloadClients: downloadClientsUpdate
? { touched: downloadClientsUpdate.touched, cleared: downloadClientsUpdate.cleared }
: null,
usersRotated: 0,
usersCleared: 0,
};
for (const u of configUpdates) {
await tx.configuration.update({ where: { key: u.key }, data: { value: u.value } });
}
for (const key of configDeletes) {
await tx.configuration.delete({ where: { key } });
}
if (downloadClientsUpdate) {
await tx.configuration.update({
where: { key: 'download_clients' },
data: { value: downloadClientsUpdate.value },
});
}
for (const u of userUpdates) {
await tx.user.update({ where: { id: u.id }, data: { authToken: u.authToken } });
if (u.authToken === '') result.usersCleared++;
else result.usersRotated++;
}
await tx.user.update({
where: { id: chosenUser.id },
data: { authToken: encryptedHash, lastLoginAt: null },
});
return result;
});
} else {
// Simple password reset, current key preserved
const newHash = await bcrypt.hash(newPassword, 10);
const encryptedHash = encryptWithKey(newHash, currentKey);
await prisma.$transaction(async (tx) => {
await tx.user.update({
where: { id: chosenUser.id },
data: { authToken: encryptedHash, lastLoginAt: null },
});
});
summary = null;
}
// -------------------------------------------------------------------------
// Post-commit: file writes (only on full recovery)
// -------------------------------------------------------------------------
let fileWriteFailed = false;
if (fullRecovery) {
try {
updateKeyInFile(SECRETS_FILE, 'CONFIG_ENCRYPTION_KEY', newKeyBase64, true);
} catch (err) {
fileWriteFailed = true;
console.error(` ERROR writing ${SECRETS_FILE}: ${err.message}`);
}
try {
updateKeyInFile(ENVIRONMENT_FILE, 'CONFIG_ENCRYPTION_KEY', newKeyBase64, false);
} catch (err) {
fileWriteFailed = true;
console.error(` ERROR writing ${ENVIRONMENT_FILE}: ${err.message}`);
}
}
// -------------------------------------------------------------------------
// Summary
// -------------------------------------------------------------------------
console.log('');
console.log('================================================================');
console.log(' Recovery complete.');
console.log('================================================================');
console.log('');
console.log(` User reset: ${chosenUser.plexUsername}`);
if (fullRecovery && summary) {
console.log(` Configuration rows re-encrypted: ${summary.configRotated}`);
console.log(` Configuration rows deleted: ${summary.configDeleted}`);
if (summary.downloadClients) {
console.log(` download_clients passwords re-encrypted: ${summary.downloadClients.touched}`);
console.log(` download_clients passwords cleared: ${summary.downloadClients.cleared}`);
}
console.log(` User tokens re-encrypted: ${summary.usersRotated}`);
console.log(` User tokens cleared: ${summary.usersCleared}`);
console.log('');
if (fileWriteFailed) {
console.log(' ⚠️ Could not persist the new key to .secrets / /etc/environment.');
console.log(' ⚠️ The new key is printed ONCE below. Write it into /app/config/.secrets:');
console.log('');
console.log(` CONFIG_ENCRYPTION_KEY="${newKeyBase64}"`);
console.log('');
console.log(' ⚠️ And into /etc/environment (without quotes):');
console.log('');
console.log(` CONFIG_ENCRYPTION_KEY=${newKeyBase64}`);
console.log('');
} else {
console.log(' New CONFIG_ENCRYPTION_KEY persisted to /app/config/.secrets and /etc/environment.');
}
console.log('');
console.log(' NEXT STEPS:');
console.log(' 1. Restart the container.');
console.log(` 2. Log in as "${chosenUser.plexUsername}" with the new password.`);
console.log(' 3. Re-enter cleared credentials in Settings (Plex, Prowlarr, etc.).');
} else {
console.log(' Encryption key was healthy — only the password was reset.');
console.log(` Log in as "${chosenUser.plexUsername}" with the new password. No restart needed.`);
}
console.log('');
} catch (err) {
console.error('');
console.error('ERROR: Recovery aborted.');
console.error(` ${err.message}`);
console.error('');
const msg = String(err && err.message ? err.message : '');
if (
msg.includes('was denied access') ||
msg.includes('P1010') ||
msg.includes('password authentication')
) {
console.error('Diagnosis: Postgres rejected the credentials in DATABASE_URL.');
console.error('This usually means /etc/environment or .secrets drifted from what the running');
console.error('app process is actually using (common after a container restart where .secrets');
console.error('was regenerated but the existing Postgres user kept its original password).');
console.error('');
console.error('Try one of:');
console.error(' 1. Restart the container so the entrypoint resyncs all env files, then re-run.');
console.error(' 2. Pass DATABASE_URL explicitly:');
console.error(' docker exec -it \\');
console.error(" -e DATABASE_URL=\"$(docker exec <container> cat /proc/1/environ \\");
console.error(" | tr '\\0' '\\n' | grep ^DATABASE_URL= | cut -d= -f2-)\" \\");
console.error(' <container> npm run rmab:recover');
}
console.error('');
console.error('No changes have been committed (or the DB transaction was rolled back).');
process.exitCode = 1;
} finally {
try {
await prisma.$disconnect();
} catch (_e) {
// ignore
}
}
}
main();
@@ -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 { authenticatedFetcher, fetchWithAuth } from '@/lib/utils/api';
import { useToast } from '@/components/ui/Toast'; import { useToast } from '@/components/ui/Toast';
import { AudiobookDetailsModal } from '@/components/audiobooks/AudiobookDetailsModal'; import { AudiobookDetailsModal } from '@/components/audiobooks/AudiobookDetailsModal';
import { BlockedReleasesChip } from './BlockedReleasesChip';
interface RecentRequest { interface RecentRequest {
requestId: string; requestId: string;
@@ -30,6 +31,7 @@ interface RecentRequest {
torrentUrl?: string | null; torrentUrl?: string | null;
downloadAttempts?: number; downloadAttempts?: number;
customSearchTerms?: string | null; customSearchTerms?: string | null;
blockedCount?: number;
} }
interface User { interface User {
@@ -55,6 +57,7 @@ const STATUS_OPTIONS = [
{ value: 'pending', label: 'Pending' }, { value: 'pending', label: 'Pending' },
{ value: 'awaiting_approval', label: 'Awaiting Approval' }, { value: 'awaiting_approval', label: 'Awaiting Approval' },
{ value: 'awaiting_search', label: 'Awaiting Search' }, { value: 'awaiting_search', label: 'Awaiting Search' },
{ value: 'awaiting_release', label: 'Awaiting Release' },
{ value: 'searching', label: 'Searching' }, { value: 'searching', label: 'Searching' },
{ value: 'downloading', label: 'Downloading' }, { value: 'downloading', label: 'Downloading' },
{ value: 'processing', label: 'Processing' }, { 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', 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_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_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', 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', 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', 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> = { const labels: Record<string, string> = {
awaiting_search: 'Awaiting Search', awaiting_search: 'Awaiting Search',
awaiting_release: 'Awaiting Release',
awaiting_import: 'Awaiting Import', awaiting_import: 'Awaiting Import',
awaiting_approval: 'Awaiting Approval', awaiting_approval: 'Awaiting Approval',
}; };
@@ -674,6 +679,13 @@ export function RecentRequestsTable({ ebookSidecarEnabled = false, annasArchiveB
Custom Search Custom Search
</span> </span>
)} )}
{(request.blockedCount ?? 0) > 0 && (
<BlockedReleasesChip
requestId={request.requestId}
blockedCount={request.blockedCount ?? 0}
onChange={() => mutate(apiUrl)}
/>
)}
</div> </div>
<div className="text-sm text-gray-500 dark:text-gray-400"> <div className="text-sm text-gray-500 dark:text-gray-400">
{request.author} {request.author}
@@ -12,6 +12,8 @@ import { createPortal } from 'react-dom';
import { InteractiveTorrentSearchModal } from '@/components/requests/InteractiveTorrentSearchModal'; import { InteractiveTorrentSearchModal } from '@/components/requests/InteractiveTorrentSearchModal';
import { AdjustSearchTermsModal } from './AdjustSearchTermsModal'; import { AdjustSearchTermsModal } from './AdjustSearchTermsModal';
import { useSmartDropdownPosition } from '@/hooks/useSmartDropdownPosition'; import { useSmartDropdownPosition } from '@/hooks/useSmartDropdownPosition';
import { ConfirmModal } from '@/components/ui/ConfirmModal';
import { CANCELLABLE_STATUSES } from '@/lib/constants/request-statuses';
export interface RequestActionsDropdownProps { export interface RequestActionsDropdownProps {
request: { request: {
@@ -54,8 +56,12 @@ export function RequestActionsDropdown({
const [showInteractiveSearch, setShowInteractiveSearch] = useState(false); const [showInteractiveSearch, setShowInteractiveSearch] = useState(false);
const [showInteractiveSearchEbook, setShowInteractiveSearchEbook] = useState(false); const [showInteractiveSearchEbook, setShowInteractiveSearchEbook] = useState(false);
const [showAdjustSearchTerms, setShowAdjustSearchTerms] = useState(false); const [showAdjustSearchTerms, setShowAdjustSearchTerms] = useState(false);
const [confirmCancelOpen, setConfirmCancelOpen] = useState(false);
const [isCancelling, setIsCancelling] = useState(false);
const { containerRef, dropdownRef, positionAbove, style } = useSmartDropdownPosition(isOpen); const { containerRef, dropdownRef, positionAbove, style } = useSmartDropdownPosition(isOpen);
const isAwaitingApproval = request.status === 'awaiting_approval';
// Determine request type // Determine request type
const isEbook = request.type === 'ebook'; const isEbook = request.type === 'ebook';
@@ -63,10 +69,10 @@ export function RequestActionsDropdown({
const canViewDetails = !isEbook && !!request.asin && !!onViewDetails; const canViewDetails = !isEbook && !!request.asin && !!onViewDetails;
// Determine available actions based on status // Determine available actions based on status
const canSearch = ['pending', 'failed', 'awaiting_search'].includes(request.status); const canSearch = ['pending', 'failed', 'awaiting_search', 'awaiting_release'].includes(request.status);
const canAdjustSearchTerms = ['pending', 'failed', 'awaiting_search', 'searching'].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 canRetryDownload = request.status === 'failed' && (request.downloadAttempts ?? 0) > 0 && !!onRetryDownload;
const canCancel = ['pending', 'searching', 'downloading', 'awaiting_search'].includes(request.status); const canCancel = (CANCELLABLE_STATUSES as readonly string[]).includes(request.status);
const canDelete = true; // Admins can always delete const canDelete = true; // Admins can always delete
// View Source: For ebooks, extract MD5 from slow download URL and link to Anna's Archive // View Source: For ebooks, extract MD5 from slow download URL and link to Anna's Archive
@@ -157,14 +163,21 @@ export function RequestActionsDropdown({
} }
}; };
const handleCancel = async () => { const handleCancel = () => {
setIsOpen(false); setIsOpen(false);
if (window.confirm(`Are you sure you want to cancel the request for "${request.title}"?`)) { setConfirmCancelOpen(true);
};
const handleConfirmCancel = async () => {
setIsCancelling(true);
try { try {
await onCancel(request.requestId); await onCancel(request.requestId);
setConfirmCancelOpen(false);
} catch (error) { } catch (error) {
console.error('Failed to cancel request:', error); console.error('Failed to cancel request:', error);
} setConfirmCancelOpen(false);
} finally {
setIsCancelling(false);
} }
}; };
@@ -529,6 +542,22 @@ export function RequestActionsDropdown({
currentSearchTerms={request.customSearchTerms} currentSearchTerms={request.customSearchTerms}
onSuccess={onSearchTermsUpdated} onSuccess={onSearchTermsUpdated}
/> />
<ConfirmModal
isOpen={confirmCancelOpen}
onClose={() => !isCancelling && setConfirmCancelOpen(false)}
onConfirm={handleConfirmCancel}
title={isAwaitingApproval ? 'Withdraw request' : 'Cancel request'}
message={
isAwaitingApproval
? `"${request.title}" is pending admin approval and will be withdrawn. The user can request it again later.`
: `"${request.title}" has already been approved and is actively being processed. Cancelling will stop the download.`
}
confirmText={isAwaitingApproval ? 'Withdraw request' : 'Cancel request'}
cancelText="Keep request"
variant="danger"
isLoading={isCancelling}
/>
</> </>
); );
} }
+18 -15
View File
@@ -28,6 +28,22 @@ interface ScheduledJob {
nextRun: string | null; 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() { function AdminJobsPageContent() {
const [jobs, setJobs] = useState<ScheduledJob[]>([]); const [jobs, setJobs] = useState<ScheduledJob[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -214,7 +230,7 @@ function AdminJobsPageContent() {
{job.name} {job.name}
</div> </div>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5"> <div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
{job.type} {JOB_DESCRIPTIONS[job.type] ?? ''}
</div> </div>
</div> </div>
<span <span
@@ -322,7 +338,7 @@ function AdminJobsPageContent() {
{job.name} {job.name}
</div> </div>
<div className="text-sm text-gray-500 dark:text-gray-400"> <div className="text-sm text-gray-500 dark:text-gray-400">
{job.type} {JOB_DESCRIPTIONS[job.type] ?? ''}
</div> </div>
</td> </td>
<td className="px-6 py-4"> <td className="px-6 py-4">
@@ -395,19 +411,6 @@ function AdminJobsPageContent() {
)} )}
</div> </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 */} {/* Confirmation Dialog */}
{confirmDialog.isOpen && ( {confirmDialog.isOpen && (
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center bg-black bg-opacity-50 p-4"> <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,
};
}
+180 -433
View File
@@ -1,361 +1,194 @@
/** /**
* Component: Admin System Logs Page * Component: Admin System Logs Page
* Documentation: documentation/admin-dashboard.md * 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'; 'use client';
import { useState } from 'react'; import { Suspense, useEffect, useRef, useState } from 'react';
import useSWR from 'swr'; import useSWR from 'swr';
import Link from 'next/link'; import { ToastProvider } from '@/components/ui/Toast';
import { authenticatedFetcher } from '@/lib/utils/api'; 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 { function EmptyState({
id: string; kind,
level: string; onClearFilters,
context: string; onClearSearch,
message: string; searchValue,
metadata: any; }: {
createdAt: string; kind: 'fresh' | 'filters-too-tight' | 'search-no-match';
} onClearFilters: () => void;
onClearSearch: () => void;
interface Log { searchValue: string;
id: string; }) {
bullJobId: string | null; if (kind === 'fresh') {
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' };
return ( 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}`}> <div className="text-center py-16">
<span className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${c.dot}`} /> <p className="text-gray-700 dark:text-gray-300 text-base font-medium">
{status.charAt(0).toUpperCase() + status.slice(1)} No background jobs have run yet.
</span> </p>
); <p className="text-gray-500 dark:text-gray-400 text-sm mt-1">
} New jobs will appear here as they start.
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>
);
}
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 formatType(type: string) {
return type.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase());
}
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' });
}
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 } = useSWR<LogsData>(
`/api/admin/logs?page=${page}&limit=50&status=${statusFilter}&type=${typeFilter}`,
authenticatedFetcher,
{ refreshInterval: 10000 }
);
const isLoading = !data && !error;
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>
);
}
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> </p>
</div> </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> </div>
);
}
return (
<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> </div>
); );
} }
const logs = data?.logs || []; function AdminLogsPageContent() {
const pagination = data?.pagination; const { filters, setFilters, clearAll, usingHydrateDateDefault } = useLogsUrlState();
const hasDetails = (log: Log) => log.events.length > 0 || !!log.errorMessage || !!log.bullJobId || (log.result && Object.keys(log.result).length > 0); const { effectiveInterval, setMutate, setLastUpdatedAt } = useAutoRefreshControl();
const key = buildLogsApiKey(filters);
// 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);
const { data, error, mutate } = useSWR<LogsData>(key, authenticatedFetcher, {
refreshInterval: effectiveInterval,
keepPreviousData: true,
});
// Wire SWR's mutate into the auto-refresh control so "Refresh now" works.
useEffect(() => {
setMutate(() => mutate());
return () => setMutate(null);
}, [mutate, setMutate]);
// Broadcast a "fresh data" timestamp when SWR data lands.
useEffect(() => {
if (data) setLastUpdatedAt(Date.now());
}, [data, setLastUpdatedAt]);
// 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]);
useEffect(() => {
if (keyChanging && data) setKeyChanging(false);
}, [data, keyChanging]);
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 ( return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900"> <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"> <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+ */} {/* Filter dropdowns + chip strip — owned by ben-filters, rendered here. */}
<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"> <LogsFilters />
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> <ActiveFilterChips />
<div>
<h1 className="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-gray-100"> {error && (
System Logs <div className="mb-6 p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
</h1> <h3 className="text-sm font-medium text-red-800 dark:text-red-200">
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1"> Error Loading Logs
View background jobs and system activity </h3>
<p className="text-sm text-red-700 dark:text-red-300 mt-1">
{error?.message || 'Failed to load system logs'}
</p> </p>
</div> </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>
{/* Filters — full-width stacked on mobile */} {showSkeleton ? (
<div className="mb-6 grid grid-cols-1 sm:grid-cols-2 gap-3 sm:gap-4"> <LogSkeleton />
<div> ) : emptyKind ? (
<label className="block text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-1.5"> <EmptyState
Status kind={emptyKind}
</label> onClearFilters={clearAll}
<select onClearSearch={() => setFilters({ search: '' })}
value={statusFilter} searchValue={filters.search}
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> {/* Mobile cards */}
<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"> <div className="space-y-3 sm:hidden">
{logs.map((log) => ( {logs.map((log) => (
<div <LogRow.Mobile key={log.id} log={log} />
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> </div>
{/* Desktop table — hidden on mobile */} {/* Desktop table */}
<div className="hidden sm:block bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden"> <div className="hidden sm:block bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700"> <table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
@@ -386,119 +219,33 @@ export default function AdminLogsPage() {
</thead> </thead>
<tbody className="bg-white dark:bg-gray-800 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">
{logs.map((log) => ( {logs.map((log) => (
<> <LogRow.Desktop key={log.id} log={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> </tbody>
</table> </table>
</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>
<LogsPagination
pagination={pagination}
onPageChange={(page) => setFilters({ page })}
onLimitChange={(limit: ValidLimit) => setFilters({ limit })}
/>
</>
)} )}
</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>
</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>
</div>
</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';
}
+143 -45
View File
@@ -14,8 +14,10 @@ import { RecentRequestsTable } from './components/RecentRequestsTable';
import { ToastProvider, useToast } from '@/components/ui/Toast'; import { ToastProvider, useToast } from '@/components/ui/Toast';
import { ReportedIssuesSection } from './components/ReportedIssuesSection'; import { ReportedIssuesSection } from './components/ReportedIssuesSection';
import { InteractiveTorrentSearchModal } from '@/components/requests/InteractiveTorrentSearchModal'; import { InteractiveTorrentSearchModal } from '@/components/requests/InteractiveTorrentSearchModal';
import { AudiobookDetailsModal } from '@/components/audiobooks/AudiobookDetailsModal';
import { BulkImportWizard } from '@/components/admin/BulkImportWizard'; import { BulkImportWizard } from '@/components/admin/BulkImportWizard';
import { TorrentResult } from '@/lib/utils/ranking-algorithm'; import { TorrentResult } from '@/lib/utils/ranking-algorithm';
import { InformationCircleIcon } from '@heroicons/react/24/outline';
import { formatDistanceToNow } from 'date-fns'; import { formatDistanceToNow } from 'date-fns';
import { useState } from 'react'; import { useState } from 'react';
@@ -56,15 +58,78 @@ function formatTorrentSize(bytes: number): string {
return gb >= 1 ? `${gb.toFixed(1)} GB` : `${mb.toFixed(0)} MB`; return gb >= 1 ? `${gb.toFixed(1)} GB` : `${mb.toFixed(0)} MB`;
} }
function LoadingSpinner() {
return (
<svg className="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
<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 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
);
}
interface ApprovalActionButtonsProps {
isLoading: boolean;
onApprove: () => void;
onSearch: () => void;
onDeny: () => void;
}
function ApprovalActionButtons({ isLoading, onApprove, onSearch, onDeny }: ApprovalActionButtonsProps) {
return (
<>
<button
onClick={onApprove}
disabled={isLoading}
className="flex-1 inline-flex items-center justify-center gap-1.5 px-3 py-2 bg-green-600 hover:bg-green-700 disabled:bg-green-400 disabled:cursor-not-allowed text-white text-sm font-medium rounded-lg transition-colors"
>
{isLoading ? <LoadingSpinner /> : (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
)}
<span>Approve</span>
</button>
<button
onClick={onSearch}
disabled={isLoading}
className="flex-1 inline-flex items-center justify-center gap-1.5 px-3 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 disabled:cursor-not-allowed text-white text-sm font-medium rounded-lg 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="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<span>Search</span>
</button>
<button
onClick={onDeny}
disabled={isLoading}
className="flex-1 inline-flex items-center justify-center gap-1.5 px-3 py-2 bg-red-600 hover:bg-red-700 disabled:bg-red-400 disabled:cursor-not-allowed text-white text-sm font-medium rounded-lg transition-colors"
>
{isLoading ? <LoadingSpinner /> : (
<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>
)}
<span>Deny</span>
</button>
</>
);
}
function PendingApprovalSection({ requests }: { requests: PendingApprovalRequest[] }) { function PendingApprovalSection({ requests }: { requests: PendingApprovalRequest[] }) {
const toast = useToast(); const toast = useToast();
const [loadingStates, setLoadingStates] = useState<Record<string, boolean>>({}); const [loadingStates, setLoadingStates] = useState<Record<string, boolean>>({});
const [searchModalRequestId, setSearchModalRequestId] = useState<string | null>(null); const [searchModalRequestId, setSearchModalRequestId] = useState<string | null>(null);
const [detailsAsin, setDetailsAsin] = useState<string | null>(null);
const [detailsRequestId, setDetailsRequestId] = useState<string | null>(null);
const searchModalRequest = searchModalRequestId const searchModalRequest = searchModalRequestId
? requests.find((r) => r.id === searchModalRequestId) ? requests.find((r) => r.id === searchModalRequestId)
: null; : null;
const detailsRequest = detailsRequestId
? requests.find((r) => r.id === detailsRequestId)
: null;
const handleApproveRequest = async (requestId: string) => { const handleApproveRequest = async (requestId: string) => {
setLoadingStates((prev) => ({ ...prev, [requestId]: true })); setLoadingStates((prev) => ({ ...prev, [requestId]: true }));
@@ -125,13 +190,6 @@ function PendingApprovalSection({ requests }: { requests: PendingApprovalRequest
await mutate('/api/admin/metrics'); await mutate('/api/admin/metrics');
}; };
const LoadingSpinner = () => (
<svg className="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
<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 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
);
return ( return (
<div className="mb-8"> <div className="mb-8">
{/* Section Header */} {/* Section Header */}
@@ -170,8 +228,23 @@ function PendingApprovalSection({ requests }: { requests: PendingApprovalRequest
return ( return (
<div <div
key={request.id} key={request.id}
className="bg-white dark:bg-gray-800 border-2 border-amber-200 dark:border-amber-800 rounded-lg shadow-sm hover:shadow-md transition-shadow overflow-hidden" className="relative bg-white dark:bg-gray-800 border-2 border-amber-200 dark:border-amber-800 rounded-lg shadow-sm hover:shadow-md transition-shadow overflow-hidden"
> >
{/* Info Button — opens AudiobookDetailsModal */}
{request.audiobook.audibleAsin && (
<button
onClick={() => {
setDetailsAsin(request.audiobook.audibleAsin);
setDetailsRequestId(request.id);
}}
className="absolute top-2 right-2 z-10 p-1 text-gray-400 hover:text-blue-500 dark:hover:text-blue-400 transition-colors rounded-full hover:bg-gray-100 dark:hover:bg-gray-700"
title="View book details"
aria-label="View book details"
>
<InformationCircleIcon className="w-5 h-5" />
</button>
)}
{/* Card Content */} {/* Card Content */}
<div className="p-4"> <div className="p-4">
<div className="flex gap-3"> <div className="flex gap-3">
@@ -314,42 +387,12 @@ function PendingApprovalSection({ requests }: { requests: PendingApprovalRequest
{/* Action Buttons */} {/* Action Buttons */}
<div className="border-t border-amber-200 dark:border-amber-800 bg-gray-50 dark:bg-gray-900/50 px-4 py-3 flex gap-2"> <div className="border-t border-amber-200 dark:border-amber-800 bg-gray-50 dark:bg-gray-900/50 px-4 py-3 flex gap-2">
<button <ApprovalActionButtons
onClick={() => handleApproveRequest(request.id)} isLoading={isLoading}
disabled={isLoading} onApprove={() => handleApproveRequest(request.id)}
className="flex-1 inline-flex items-center justify-center gap-1.5 px-3 py-2 bg-green-600 hover:bg-green-700 disabled:bg-green-400 disabled:cursor-not-allowed text-white text-sm font-medium rounded-lg transition-colors" onSearch={() => setSearchModalRequestId(request.id)}
> onDeny={() => handleDenyRequest(request.id)}
{isLoading ? <LoadingSpinner /> : ( />
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
)}
<span>Approve</span>
</button>
<button
onClick={() => setSearchModalRequestId(request.id)}
disabled={isLoading}
className="flex-1 inline-flex items-center justify-center gap-1.5 px-3 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 disabled:cursor-not-allowed text-white text-sm font-medium rounded-lg 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="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<span>Search</span>
</button>
<button
onClick={() => handleDenyRequest(request.id)}
disabled={isLoading}
className="flex-1 inline-flex items-center justify-center gap-1.5 px-3 py-2 bg-red-600 hover:bg-red-700 disabled:bg-red-400 disabled:cursor-not-allowed text-white text-sm font-medium rounded-lg transition-colors"
>
{isLoading ? <LoadingSpinner /> : (
<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>
)}
<span>Deny</span>
</button>
</div> </div>
</div> </div>
); );
@@ -375,6 +418,37 @@ function PendingApprovalSection({ requests }: { requests: PendingApprovalRequest
}} }}
/> />
)} )}
{/* Book Details Modal — opened via info button on each approval card */}
{detailsAsin && detailsRequestId && (
<AudiobookDetailsModal
asin={detailsAsin}
isOpen={true}
onClose={() => { setDetailsAsin(null); setDetailsRequestId(null); }}
requestStatus="awaiting_approval"
requestedByUsername={detailsRequest?.user.plexUsername ?? null}
adminActions={
<ApprovalActionButtons
isLoading={loadingStates[detailsRequestId] || false}
onApprove={async () => {
await handleApproveRequest(detailsRequestId);
setDetailsAsin(null);
setDetailsRequestId(null);
}}
onSearch={() => {
setSearchModalRequestId(detailsRequestId);
setDetailsAsin(null);
setDetailsRequestId(null);
}}
onDeny={async () => {
await handleDenyRequest(detailsRequestId);
setDetailsAsin(null);
setDetailsRequestId(null);
}}
/>
}
/>
)}
</div> </div>
); );
} }
@@ -575,7 +649,7 @@ function AdminDashboardContent() {
</div> </div>
{/* Quick Actions */} {/* 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 <Link
href="/admin/settings" 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" 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"
@@ -661,6 +735,30 @@ function AdminDashboardContent() {
</div> </div>
</Link> </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 <button
onClick={() => setIsBulkImportOpen(true)} 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" 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 => { }).then(res => {
if (!res.ok) throw new Error('Failed to save indexer configuration'); 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; break;
case 'download': case 'download':
+17
View File
@@ -16,6 +16,7 @@ export interface Settings {
oidc: OIDCSettings; oidc: OIDCSettings;
registration: RegistrationSettings; registration: RegistrationSettings;
prowlarr: ProwlarrSettings; prowlarr: ProwlarrSettings;
indexerOptions: IndexerOptionsSettings;
downloadClient: DownloadClientSettings; downloadClient: DownloadClientSettings;
paths: PathsSettings; paths: PathsSettings;
ebook: EbookSettings; ebook: EbookSettings;
@@ -76,6 +77,19 @@ export interface ProwlarrSettings {
apiKey: string; 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 * Download client (qBittorrent) configuration
*/ */
@@ -99,6 +113,7 @@ export interface PathsSettings {
audiobookPathTemplate?: string; audiobookPathTemplate?: string;
ebookPathTemplate?: string; ebookPathTemplate?: string;
metadataTaggingEnabled: boolean; metadataTaggingEnabled: boolean;
plexFormatCoercionEnabled: boolean;
chapterMergingEnabled: boolean; chapterMergingEnabled: boolean;
fileRenameEnabled: boolean; fileRenameEnabled: boolean;
fileRenameTemplate?: string; fileRenameTemplate?: string;
@@ -154,6 +169,7 @@ export interface IndexerConfig {
enabled: boolean; enabled: boolean;
priority: number; priority: number;
seedingTimeMinutes?: number; // Torrents only seedingTimeMinutes?: number; // Torrents only
ratioLimit?: number; // Torrents only (0 = no ratio requirement)
removeAfterProcessing?: boolean; // Usenet only removeAfterProcessing?: boolean; // Usenet only
rssEnabled: boolean; rssEnabled: boolean;
audiobookCategories?: number[]; // Category IDs for audiobook searches (default: [3030]) audiobookCategories?: number[]; // Category IDs for audiobook searches (default: [3030])
@@ -170,6 +186,7 @@ export interface SavedIndexerConfig {
protocol: string; protocol: string;
priority: number; priority: number;
seedingTimeMinutes?: number; // Torrents only seedingTimeMinutes?: number; // Torrents only
ratioLimit?: number; // Torrents only (0 = no ratio requirement)
removeAfterProcessing?: boolean; // Usenet only removeAfterProcessing?: boolean; // Usenet only
rssEnabled: boolean; rssEnabled: boolean;
audiobookCategories: number[]; // Category IDs for audiobook searches (default: [3030]) audiobookCategories: number[]; // Category IDs for audiobook searches (default: [3030])
@@ -136,6 +136,48 @@ export function IndexersTab({
)} )}
</div> </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"> <div className="border-t border-gray-200 dark:border-gray-700 pt-6">
<IndexerManagement <IndexerManagement
prowlarrUrl={settings.prowlarr.url} prowlarrUrl={settings.prowlarr.url}
@@ -414,6 +414,30 @@ export function PathsTab({ paths, onChange, onValidationChange }: PathsTabProps)
</div> </div>
</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 */} {/* 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="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"> <div className="flex items-start gap-4">
+3 -1
View File
@@ -131,7 +131,9 @@ export default function ApiDocsPage() {
{/* Footer note */} {/* Footer note */}
<div className="mt-10 text-center"> <div className="mt-10 text-center">
<p className="text-xs text-gray-400 dark:text-gray-500"> <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. JWT session authentication has access to all endpoints.
</p> </p>
</div> </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 }
);
}
});
});
}
+30 -2
View File
@@ -10,7 +10,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth'; import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db'; import { prisma } from '@/lib/db';
import { RMABLogger } from '@/lib/utils/logger'; import { RMABLogger } from '@/lib/utils/logger';
import { discoverAudiobooks } from '@/lib/utils/bulk-import-scanner'; import { discoverAudiobooks, cleanSearchString } from '@/lib/utils/bulk-import-scanner';
import { getAudibleService } from '@/lib/integrations/audible.service'; import { getAudibleService } from '@/lib/integrations/audible.service';
import { findPlexMatch } from '@/lib/utils/audiobook-matcher'; import { findPlexMatch } from '@/lib/utils/audiobook-matcher';
@@ -159,10 +159,37 @@ export async function POST(request: NextRequest) {
let hasActiveRequest = false; let hasActiveRequest = false;
try { try {
const searchResult = await audibleService.search(book.searchTerm); // If the scanner extracted an ASIN directly from the folder name,
// use a direct ASIN lookup (Audnexus API) — more reliable than a
// keyword text search. Fall back to text search if the lookup fails.
if (book.extractedAsin) {
try {
const asinResult = await audibleService.getAudiobookDetails(book.extractedAsin);
if (asinResult) {
match = asinResult;
}
} catch {
/* ASIN lookup failed — fall through to text search */
}
}
if (!match) {
// When an ASIN was extracted from the folder name but the direct
// lookup failed, prefer the folder name as the text search term
// over book.searchTerm. book.searchTerm may come from a single
// tagged file whose album tag is unreliable (e.g. a series name
// or intro track), whereas the folder name is the human-assigned
// title and is more likely to be accurate.
const textSearchTerm = book.extractedAsin
? cleanSearchString(book.folderName)
: book.searchTerm;
const searchResult = await audibleService.search(textSearchTerm);
if (searchResult.results.length > 0) { if (searchResult.results.length > 0) {
match = searchResult.results[0]; match = searchResult.results[0];
}
}
if (match) {
// Check library availability // Check library availability
const plexMatch = await findPlexMatch({ const plexMatch = await findPlexMatch({
@@ -208,6 +235,7 @@ export async function POST(request: NextRequest) {
audioFileCount: book.audioFileCount, audioFileCount: book.audioFileCount,
totalSizeBytes: book.totalSizeBytes, totalSizeBytes: book.totalSizeBytes,
metadataSource: book.metadataSource, metadataSource: book.metadataSource,
extractedAsin: book.extractedAsin,
searchTerm: book.searchTerm, searchTerm: book.searchTerm,
audioFiles: book.audioFiles, audioFiles: book.audioFiles,
match: match match: match
+133 -13
View File
@@ -10,27 +10,147 @@ import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('API.Admin.Logs'); 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) { export async function GET(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => { return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => { return requireAdmin(req, async () => {
try { try {
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const page = parseInt(searchParams.get('page') || '1'); const page = parsePage(searchParams.get('page'));
const limit = parseInt(searchParams.get('limit') || '100'); const limit = parseLimit(searchParams.get('limit'));
const status = searchParams.get('status') || 'all';
const type = searchParams.get('type') || 'all'; 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; 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([ const [logs, totalCount] = await Promise.all([
prisma.job.findMany({ prisma.job.findMany({
where, where,
+6
View File
@@ -119,6 +119,11 @@ export async function GET(request: NextRequest) {
}, },
take: 1, take: 1,
}, },
_count: {
select: {
blockedReleases: true,
},
},
}, },
orderBy, orderBy,
skip: (page - 1) * pageSize, skip: (page - 1) * pageSize,
@@ -141,6 +146,7 @@ export async function GET(request: NextRequest) {
torrentUrl: request.downloadHistory[0]?.torrentUrl || null, torrentUrl: request.downloadHistory[0]?.torrentUrl || null,
downloadAttempts: request.downloadAttempts, downloadAttempts: request.downloadAttempts,
customSearchTerms: request.customSearchTerms || null, customSearchTerms: request.customSearchTerms || null,
blockedCount: request._count?.blockedReleases ?? 0,
})); }));
return NextResponse.json({ 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 requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => { return requireAdmin(req, async () => {
try { 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) { if (!downloadDir || !mediaDir) {
return NextResponse.json( 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 // Update file rename setting
await prisma.configuration.upsert({ await prisma.configuration.upsert({
where: { key: 'file_rename_enabled' }, where: { key: 'file_rename_enabled' },
@@ -176,6 +189,7 @@ export async function PUT(request: NextRequest) {
configService.clearCache('ebook_path_template'); configService.clearCache('ebook_path_template');
configService.clearCache('metadata_tagging_enabled'); configService.clearCache('metadata_tagging_enabled');
configService.clearCache('chapter_merging_enabled'); configService.clearCache('chapter_merging_enabled');
configService.clearCache('plex_format_coercion_enabled');
configService.clearCache('file_rename_enabled'); configService.clearCache('file_rename_enabled');
configService.clearCache('file_rename_template'); configService.clearCache('file_rename_template');
configService.clearCache('file_chmod'); configService.clearCache('file_chmod');
@@ -17,6 +17,7 @@ interface SavedIndexerConfig {
protocol: string; protocol: string;
priority: number; priority: number;
seedingTimeMinutes?: number; // Torrents only seedingTimeMinutes?: number; // Torrents only
ratioLimit?: number; // Torrents only (0 = no ratio requirement)
removeAfterProcessing?: boolean; // Usenet only removeAfterProcessing?: boolean; // Usenet only
rssEnabled?: boolean; rssEnabled?: boolean;
audiobookCategories?: number[]; // Array of category IDs for audiobooks (default: [3030]) audiobookCategories?: number[]; // Array of category IDs for audiobooks (default: [3030])
@@ -79,6 +80,7 @@ export async function GET(request: NextRequest) {
// Add protocol-specific fields // Add protocol-specific fields
if (isTorrent) { if (isTorrent) {
config.seedingTimeMinutes = saved?.seedingTimeMinutes ?? 0; config.seedingTimeMinutes = saved?.seedingTimeMinutes ?? 0;
config.ratioLimit = saved?.ratioLimit ?? 0;
} else { } else {
config.removeAfterProcessing = saved?.removeAfterProcessing ?? true; config.removeAfterProcessing = saved?.removeAfterProcessing ?? true;
} }
@@ -134,6 +136,7 @@ export async function PUT(request: NextRequest) {
const isTorrent = indexer.protocol?.toLowerCase() === 'torrent'; const isTorrent = indexer.protocol?.toLowerCase() === 'torrent';
if (isTorrent) { if (isTorrent) {
config.seedingTimeMinutes = indexer.seedingTimeMinutes ?? 0; config.seedingTimeMinutes = indexer.seedingTimeMinutes ?? 0;
config.ratioLimit = indexer.ratioLimit ?? 0;
} else { } else {
config.removeAfterProcessing = indexer.removeAfterProcessing ?? true; config.removeAfterProcessing = indexer.removeAfterProcessing ?? true;
} }
+7
View File
@@ -81,6 +81,12 @@ export async function GET(request: NextRequest) {
url: configMap.get('prowlarr_url') || '', url: configMap.get('prowlarr_url') || '',
apiKey: maskValue('api_key', configMap.get('prowlarr_api_key')), 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 // downloadClient is populated from multi-client format for backward compatibility
// The DownloadTab component now uses DownloadClientManagement which reads from /api/admin/settings/download-clients // The DownloadTab component now uses DownloadClientManagement which reads from /api/admin/settings/download-clients
downloadClient: (() => { 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}', ebookPathTemplate: configMap.get('ebook_path_template') || configMap.get('audiobook_path_template') || '{author}/{title} {asin}',
metadataTaggingEnabled: configMap.get('metadata_tagging_enabled') === 'true', metadataTaggingEnabled: configMap.get('metadata_tagging_enabled') === 'true',
chapterMergingEnabled: configMap.get('chapter_merging_enabled') === 'true', chapterMergingEnabled: configMap.get('chapter_merging_enabled') === 'true',
plexFormatCoercionEnabled: configMap.get('plex_format_coercion_enabled') !== 'false',
fileRenameEnabled: configMap.get('file_rename_enabled') === 'true', fileRenameEnabled: configMap.get('file_rename_enabled') === 'true',
fileRenameTemplate: configMap.get('file_rename_template') || '{title}', fileRenameTemplate: configMap.get('file_rename_template') || '{title}',
fileChmod: configMap.get('file_chmod') || '664', fileChmod: configMap.get('file_chmod') || '664',
+10 -7
View File
@@ -7,8 +7,8 @@ import { NextRequest, NextResponse } from 'next/server';
import { getAudibleService } from '@/lib/integrations/audible.service'; import { getAudibleService } from '@/lib/integrations/audible.service';
import { enrichAudiobooksWithMatches } from '@/lib/utils/audiobook-matcher'; import { enrichAudiobooksWithMatches } from '@/lib/utils/audiobook-matcher';
import { deduplicateAndCollectGroups } from '@/lib/utils/deduplicate-audiobooks'; import { deduplicateAndCollectGroups } from '@/lib/utils/deduplicate-audiobooks';
import { persistDedupGroups } from '@/lib/services/works.service'; 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 { RMABLogger } from '@/lib/utils/logger';
import { annotateWithIgnoreStatus } from '@/lib/utils/ignored-audiobooks'; import { annotateWithIgnoreStatus } from '@/lib/utils/ignored-audiobooks';
@@ -37,20 +37,23 @@ export async function GET(request: NextRequest) {
const audibleService = getAudibleService(); const audibleService = getAudibleService();
const results = await audibleService.search(query, page); const results = await audibleService.search(query, page);
// Get current user (optional - for request status enrichment) // Get current user (optional — JWT or API token — for request-status enrichment)
const currentUser = getCurrentUser(request); const currentUser = await getCurrentUserAsync(request);
const userId = currentUser?.sub || undefined; const userId = currentUser?.sub || undefined;
// Deduplicate before enrichment to avoid wasted DB queries on duplicate entries // Two-pass dedup: local title/narrator/duration matching first, then collapse
// any remaining duplicates that the works table already knows are the same book
// (handles cases where source metadata diverges across paths or pages).
const { books: dedupedResults, groups } = deduplicateAndCollectGroups(results.results); const { books: dedupedResults, groups } = deduplicateAndCollectGroups(results.results);
// Fire-and-forget: persist dedup groups to works table for cross-ASIN matching
if (groups.length > 0) { if (groups.length > 0) {
persistDedupGroups(groups).catch(() => {}); persistDedupGroups(groups).catch(() => {});
} }
const collapsedResults = await collapseByExistingWorks(dedupedResults);
// Enrich search results with availability and request status information // Enrich search results with availability and request status information
const enrichedResults = await enrichAudiobooksWithMatches(dedupedResults, userId); const enrichedResults = await enrichAudiobooksWithMatches(collapsedResults, userId);
// Annotate with per-user ignore status // Annotate with per-user ignore status
const annotatedResults = await annotateWithIgnoreStatus(enrichedResults, userId); const annotatedResults = await annotateWithIgnoreStatus(enrichedResults, userId);
+7 -4
View File
@@ -7,7 +7,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { getAudibleService } from '@/lib/integrations/audible.service'; import { getAudibleService } from '@/lib/integrations/audible.service';
import { enrichAudiobooksWithMatches } from '@/lib/utils/audiobook-matcher'; import { enrichAudiobooksWithMatches } from '@/lib/utils/audiobook-matcher';
import { deduplicateAndCollectGroups } from '@/lib/utils/deduplicate-audiobooks'; import { deduplicateAndCollectGroups } from '@/lib/utils/deduplicate-audiobooks';
import { persistDedupGroups } from '@/lib/services/works.service'; import { persistDedupGroups, collapseByExistingWorks } from '@/lib/services/works.service';
import { getCurrentUser } from '@/lib/middleware/auth'; import { getCurrentUser } from '@/lib/middleware/auth';
import { RMABLogger } from '@/lib/utils/logger'; import { RMABLogger } from '@/lib/utils/logger';
import { annotateWithIgnoreStatus } from '@/lib/utils/ignored-audiobooks'; import { annotateWithIgnoreStatus } from '@/lib/utils/ignored-audiobooks';
@@ -56,17 +56,20 @@ export async function GET(
const audibleService = getAudibleService(); const audibleService = getAudibleService();
const result = await audibleService.searchByAuthorAsin(authorName.trim(), asin, page); const result = await audibleService.searchByAuthorAsin(authorName.trim(), asin, page);
// Deduplicate before enrichment to avoid wasted DB queries on duplicate entries // Two-pass dedup: local title/narrator/duration matching first, then collapse
// any remaining duplicates that the works table already knows are the same book
// (handles cases where source metadata diverges across paths or pages).
const { books: dedupedBooks, groups } = deduplicateAndCollectGroups(result.books); const { books: dedupedBooks, groups } = deduplicateAndCollectGroups(result.books);
// Fire-and-forget: persist dedup groups to works table for cross-ASIN matching
if (groups.length > 0) { if (groups.length > 0) {
persistDedupGroups(groups).catch(() => {}); persistDedupGroups(groups).catch(() => {});
} }
const collapsedBooks = await collapseByExistingWorks(dedupedBooks);
// Enrich with library availability and request status // Enrich with library availability and request status
const userId = currentUser.sub || undefined; const userId = currentUser.sub || undefined;
const enrichedBooks = await enrichAudiobooksWithMatches(dedupedBooks, userId); const enrichedBooks = await enrichAudiobooksWithMatches(collapsedBooks, userId);
// Annotate with per-user ignore status // Annotate with per-user ignore status
const annotatedBooks = await annotateWithIgnoreStatus(enrichedBooks, userId); const annotatedBooks = await annotateWithIgnoreStatus(enrichedBooks, userId);
+42 -3
View File
@@ -7,7 +7,9 @@ import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth'; import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db'; import { prisma } from '@/lib/db';
import { getAudibleService } from '@/lib/integrations/audible.service'; import { getAudibleService } from '@/lib/integrations/audible.service';
import { getConfigService } from '@/lib/services/config.service';
import { RMABLogger } from '@/lib/utils/logger'; import { RMABLogger } from '@/lib/utils/logger';
import { shouldSkipAutoSearch } from '@/lib/utils/release-date';
const logger = RMABLogger.create('API.BookDateSwipe'); const logger = RMABLogger.create('API.BookDateSwipe');
@@ -67,17 +69,22 @@ async function handler(req: AuthenticatedRequest) {
let year: number | undefined; let year: number | undefined;
let series: string | undefined; let series: string | undefined;
let seriesPart: string | undefined; let seriesPart: string | undefined;
let releaseDate: Date | null = null;
try { try {
const audibleService = getAudibleService(); const audibleService = getAudibleService();
const audnexusData = await audibleService.getAudiobookDetails(recommendation.audnexusAsin); const audnexusData = await audibleService.getAudiobookDetails(recommendation.audnexusAsin);
if (audnexusData?.releaseDate) { if (audnexusData?.releaseDate) {
try { try {
const releaseYear = new Date(audnexusData.releaseDate).getFullYear(); const parsed = new Date(audnexusData.releaseDate);
if (!isNaN(parsed.getTime())) {
releaseDate = parsed;
const releaseYear = parsed.getFullYear();
if (!isNaN(releaseYear)) { if (!isNaN(releaseYear)) {
year = releaseYear; year = releaseYear;
logger.debug(`Extracted year ${year} from Audnexus releaseDate: ${audnexusData.releaseDate}`); logger.debug(`Extracted year ${year} from Audnexus releaseDate: ${audnexusData.releaseDate}`);
} }
}
} catch (error) { } catch (error) {
logger.warn(`Failed to parse Audnexus releaseDate "${audnexusData.releaseDate}": ${error instanceof Error ? error.message : 'Unknown 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 // 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({ const newRequest = await prisma.request.create({
data: { data: {
@@ -191,11 +218,21 @@ async function handler(req: AuthenticatedRequest) {
status: initialStatus, status: initialStatus,
type: 'audiobook', // Explicit type for user-created requests type: 'audiobook', // Explicit type for user-created requests
priority: 0, priority: 0,
releaseDate,
}, },
}); });
logger.info(`Created request for "${recommendation.title}" with status: ${initialStatus}`); 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 // Import job queue service
const { getJobQueueService } = await import('@/lib/services/job-queue.service'); const { getJobQueueService } = await import('@/lib/services/job-queue.service');
const jobQueue = getJobQueueService(); const jobQueue = getJobQueueService();
@@ -224,7 +261,8 @@ async function handler(req: AuthenticatedRequest) {
logger.error('Failed to queue notification', { error: error instanceof Error ? error.message : String(error) }); logger.error('Failed to queue notification', { error: error instanceof Error ? error.message : String(error) });
}); });
// Trigger search job only if auto-approved // Trigger search job only if auto-approved AND not gated by release date
if (!releaseGateSkip) {
await jobQueue.addSearchJob(newRequest.id, { await jobQueue.addSearchJob(newRequest.id, {
id: audiobook.id, id: audiobook.id,
title: audiobook.title, title: audiobook.title,
@@ -235,6 +273,7 @@ async function handler(req: AuthenticatedRequest) {
logger.info(`Triggered search job for request ${newRequest.id}`); logger.info(`Triggered search job for request ${newRequest.id}`);
} }
} }
}
} catch (error) { } catch (error) {
logger.error('Error creating request', { error: error instanceof Error ? error.message : String(error) }); logger.error('Error creating request', { error: error instanceof Error ? error.message : String(error) });
@@ -52,8 +52,8 @@ export async function POST(
); );
} }
// Only allow manual search for pending, failed, awaiting_search statuses // Only allow manual search for pending, failed, awaiting_search, awaiting_release statuses
const searchableStatuses = ['pending', 'failed', 'awaiting_search']; const searchableStatuses = ['pending', 'failed', 'awaiting_search', 'awaiting_release'];
if (!searchableStatuses.includes(requestRecord.status)) { if (!searchableStatuses.includes(requestRecord.status)) {
return NextResponse.json( return NextResponse.json(
{ {
+34 -2
View File
@@ -4,10 +4,12 @@
*/ */
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { Prisma } from '@/generated/prisma/client';
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth'; import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db'; import { prisma } from '@/lib/db';
import { RMABLogger } from '@/lib/utils/logger'; import { RMABLogger } from '@/lib/utils/logger';
import { CLIENT_PROTOCOL_MAP, DownloadClientType } from '@/lib/interfaces/download-client.interface'; import { CLIENT_PROTOCOL_MAP, DownloadClientType } from '@/lib/interfaces/download-client.interface';
import { CANCELLABLE_STATUSES } from '@/lib/constants/request-statuses';
const logger = RMABLogger.create('API.RequestById'); const logger = RMABLogger.create('API.RequestById');
@@ -112,6 +114,10 @@ export async function PATCH(
id, id,
deletedAt: null, // Only allow updates to active requests deletedAt: null, // Only allow updates to active requests
}, },
include: {
audiobook: true,
user: { select: { plexUsername: true } },
},
}); });
if (!requestRecord) { if (!requestRecord) {
@@ -130,18 +136,44 @@ export async function PATCH(
} }
if (action === 'cancel') { if (action === 'cancel') {
// Cancel the request if (!(CANCELLABLE_STATUSES as readonly string[]).includes(requestRecord.status)) {
return NextResponse.json(
{
error: 'ValidationError',
message: `Cannot cancel request with status: ${requestRecord.status}`,
},
{ status: 400 }
);
}
const isAwaitingApproval = requestRecord.status === 'awaiting_approval';
const updated = await prisma.request.update({ const updated = await prisma.request.update({
where: { id }, where: { id },
data: { data: {
status: 'cancelled', status: 'cancelled',
updatedAt: new Date(), updatedAt: new Date(),
...(isAwaitingApproval && { selectedTorrent: Prisma.DbNull }),
}, },
include: { include: {
audiobook: true, audiobook: true,
}, },
}); });
try {
const { getJobQueueService } = await import('@/lib/services/job-queue.service');
const jobQueue = getJobQueueService();
await jobQueue.addNotificationJob(
'request_cancelled',
updated.id,
updated.audiobook.title,
updated.audiobook.author,
requestRecord.user.plexUsername || 'Unknown User'
);
} catch (error) {
logger.error('Failed to queue cancellation notification', { error });
}
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
request: updated, request: updated,
@@ -150,7 +182,7 @@ export async function PATCH(
} else if (action === 'retry') { } else if (action === 'retry') {
// Retry failed request - allow users to retry their own warn/failed requests // Retry failed request - allow users to retry their own warn/failed requests
// Only allow retry for failed, warn, or awaiting_* statuses // 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)) { if (!retryableStatuses.includes(requestRecord.status)) {
return NextResponse.json( 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 // Status groups for server-side filtering and count aggregation
const STATUS_GROUPS: Record<string, string[]> = { const STATUS_GROUPS: Record<string, string[]> = {
active: ['pending', 'searching', 'downloading', 'processing'], active: ['pending', 'searching', 'downloading', 'processing'],
waiting: ['awaiting_search', 'awaiting_import', 'awaiting_approval'], waiting: ['awaiting_search', 'awaiting_import', 'awaiting_approval', 'awaiting_release'],
completed: ['available', 'downloaded'], completed: ['available', 'downloaded'],
failed: ['failed'], failed: ['failed'],
cancelled: ['cancelled', 'denied'], cancelled: ['cancelled', 'denied'],
+7 -4
View File
@@ -9,7 +9,7 @@ import { RMABLogger } from '@/lib/utils/logger';
import { scrapeSeriesPage } from '@/lib/integrations/audible-series'; import { scrapeSeriesPage } from '@/lib/integrations/audible-series';
import { enrichAudiobooksWithMatches } from '@/lib/utils/audiobook-matcher'; import { enrichAudiobooksWithMatches } from '@/lib/utils/audiobook-matcher';
import { deduplicateAndCollectGroups } from '@/lib/utils/deduplicate-audiobooks'; import { deduplicateAndCollectGroups } from '@/lib/utils/deduplicate-audiobooks';
import { persistDedupGroups } from '@/lib/services/works.service'; import { persistDedupGroups, collapseByExistingWorks } from '@/lib/services/works.service';
import { annotateWithIgnoreStatus } from '@/lib/utils/ignored-audiobooks'; import { annotateWithIgnoreStatus } from '@/lib/utils/ignored-audiobooks';
const logger = RMABLogger.create('API.Series.Detail'); const logger = RMABLogger.create('API.Series.Detail');
@@ -52,17 +52,20 @@ export async function GET(
); );
} }
// Deduplicate before enrichment to avoid wasted DB queries on duplicate entries // Two-pass dedup: local title/narrator/duration matching first, then collapse
// any remaining duplicates that the works table already knows are the same book
// (handles cases where source metadata diverges across paths or pages).
const { books: dedupedBooks, groups } = deduplicateAndCollectGroups(detail.books); const { books: dedupedBooks, groups } = deduplicateAndCollectGroups(detail.books);
// Fire-and-forget: persist dedup groups to works table for cross-ASIN matching
if (groups.length > 0) { if (groups.length > 0) {
persistDedupGroups(groups).catch(() => {}); persistDedupGroups(groups).catch(() => {});
} }
const collapsedBooks = await collapseByExistingWorks(dedupedBooks);
// Enrich books with library availability and request status // Enrich books with library availability and request status
const userId = currentUser.sub || undefined; const userId = currentUser.sub || undefined;
const enrichedBooks = await enrichAudiobooksWithMatches(dedupedBooks, userId); const enrichedBooks = await enrichAudiobooksWithMatches(collapsedBooks, userId);
// Annotate with per-user ignore status // Annotate with per-user ignore status
const annotatedBooks = await annotateWithIgnoreStatus(enrichedBooks, userId); const annotatedBooks = await annotateWithIgnoreStatus(enrichedBooks, userId);
+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) // BookDate configuration (optional, global for all users)
// Note: libraryScope and customPrompt are now per-user settings, not required here // Note: libraryScope and customPrompt are now per-user settings, not required here
if (bookdate && bookdate.provider && bookdate.apiKey && bookdate.model) { if (bookdate && bookdate.provider && bookdate.apiKey && bookdate.model) {
+5 -1
View File
@@ -265,11 +265,15 @@ function LoginContent() {
} }
// Poll for authorization // Poll for authorization
await login(pinId); const loginResult = await login(pinId);
// Close popup // Close popup
authWindow.close(); authWindow.close();
if (loginResult === 'profile-selection-required') {
return;
}
// Redirect to intended page or homepage // Redirect to intended page or homepage
const redirect = searchParams.get('redirect') || '/'; const redirect = searchParams.get('redirect') || '/';
router.push(redirect); router.push(redirect);
+124 -10
View File
@@ -14,6 +14,18 @@ import { HomeSectionConfigModal } from '@/components/home/HomeSectionConfigModal
import { useHomeSections } from '@/lib/hooks/useHomeSections'; import { useHomeSections } from '@/lib/hooks/useHomeSections';
import { usePreferences } from '@/contexts/PreferencesContext'; import { usePreferences } from '@/contexts/PreferencesContext';
import { Cog6ToothIcon } from '@heroicons/react/24/outline'; 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 { function getSectionTitle(sectionType: string, categoryName?: string | null): string {
if (sectionType === 'popular') return 'Popular Audiobooks'; if (sectionType === 'popular') return 'Popular Audiobooks';
@@ -21,6 +33,14 @@ function getSectionTitle(sectionType: string, categoryName?: string | null): str
return categoryName || 'Category'; 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() { export default function HomePage() {
const { sections, nextRefresh, isLoading: sectionsLoading, saveSections } = useHomeSections(); const { sections, nextRefresh, isLoading: sectionsLoading, saveSections } = useHomeSections();
const { cardSize, setCardSize, squareCovers, setSquareCovers, hideAvailable, setHideAvailable } = usePreferences(); const { cardSize, setCardSize, squareCovers, setSquareCovers, hideAvailable, setHideAvailable } = usePreferences();
@@ -30,6 +50,12 @@ export default function HomePage() {
const [totalPagesMap, setTotalPagesMap] = useState<Record<string, number>>({}); const [totalPagesMap, setTotalPagesMap] = useState<Record<string, number>>({});
const [configOpen, setConfigOpen] = useState(false); 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); const footerRef = useRef<HTMLElement>(null);
// Create stable refs for each section // Create stable refs for each section
@@ -52,6 +78,38 @@ export default function HomePage() {
setTotalPagesMap({}); setTotalPagesMap({});
}, [hideAvailable]); }, [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 getPage = (key: string) => pages[key] || 1;
const setPage = useCallback((key: string, page: number) => { const setPage = useCallback((key: string, page: number) => {
setPages((prev) => ({ ...prev, [key]: page })); 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 // Build pagination sections for the floating pill
const paginationSections: PaginationSection[] = sections.map((s, i) => { const paginationSections: PaginationSection[] = sections.map((s, i) => {
const key = getSectionKey(s); const key = getSectionKey(s);
@@ -72,13 +192,9 @@ export default function HomePage() {
accentColor: SECTION_DOT_COLORS[i % SECTION_DOT_COLORS.length], accentColor: SECTION_DOT_COLORS[i % SECTION_DOT_COLORS.length],
currentPage: getPage(key), currentPage: getPage(key),
totalPages: totalPagesMap[key] || 1, totalPages: totalPagesMap[key] || 1,
onPageChange: (page: number) => { onPageChange: (page: number) => handlePageChange(i, key, page, ref),
setPage(key, page);
ref.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
},
sectionRef: ref, sectionRef: ref,
onScrollToSection: () => onScrollToSection: () => handleScrollToSection(i, ref),
ref.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }),
}; };
}); });
@@ -125,10 +241,6 @@ export default function HomePage() {
categoryName={section.categoryName} categoryName={section.categoryName}
colorIndex={index} colorIndex={index}
page={getPage(key)} page={getPage(key)}
onPageChange={(page) => {
setPage(key, page);
ref.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}}
sectionRef={ref} sectionRef={ref}
cardSize={cardSize} cardSize={cardSize}
squareCovers={squareCovers} squareCovers={squareCovers}
@@ -174,6 +286,8 @@ export default function HomePage() {
<UnifiedPagination <UnifiedPagination
footerRef={footerRef} footerRef={footerRef}
sections={paginationSections} sections={paginationSections}
activeIndex={Math.min(activeIndex, paginationSections.length - 1)}
onDominantSectionChange={handleDominantSectionChange}
/> />
)} )}
+4
View File
@@ -87,6 +87,7 @@ interface SetupState {
downloadDir: string; downloadDir: string;
mediaDir: string; mediaDir: string;
metadataTaggingEnabled: boolean; metadataTaggingEnabled: boolean;
plexFormatCoercionEnabled: boolean;
chapterMergingEnabled: boolean; chapterMergingEnabled: boolean;
bookdateProvider: string; bookdateProvider: string;
bookdateApiKey: string; bookdateApiKey: string;
@@ -161,6 +162,7 @@ export default function SetupWizard() {
downloadDir: '/downloads', downloadDir: '/downloads',
mediaDir: '/media/audiobooks', mediaDir: '/media/audiobooks',
metadataTaggingEnabled: true, metadataTaggingEnabled: true,
plexFormatCoercionEnabled: true,
chapterMergingEnabled: false, chapterMergingEnabled: false,
bookdateProvider: 'openai', bookdateProvider: 'openai',
bookdateApiKey: '', bookdateApiKey: '',
@@ -237,6 +239,7 @@ export default function SetupWizard() {
download_dir: state.downloadDir, download_dir: state.downloadDir,
media_dir: state.mediaDir, media_dir: state.mediaDir,
metadata_tagging_enabled: state.metadataTaggingEnabled, metadata_tagging_enabled: state.metadataTaggingEnabled,
plex_format_coercion_enabled: state.plexFormatCoercionEnabled,
chapter_merging_enabled: state.chapterMergingEnabled, chapter_merging_enabled: state.chapterMergingEnabled,
}, },
bookdate: state.bookdateConfigured ? { bookdate: state.bookdateConfigured ? {
@@ -537,6 +540,7 @@ export default function SetupWizard() {
downloadDir={state.downloadDir} downloadDir={state.downloadDir}
mediaDir={state.mediaDir} mediaDir={state.mediaDir}
metadataTaggingEnabled={state.metadataTaggingEnabled} metadataTaggingEnabled={state.metadataTaggingEnabled}
plexFormatCoercionEnabled={state.plexFormatCoercionEnabled}
chapterMergingEnabled={state.chapterMergingEnabled} chapterMergingEnabled={state.chapterMergingEnabled}
pathsTested={state.pathsTested} pathsTested={state.pathsTested}
onUpdate={updateField} onUpdate={updateField}
+26
View File
@@ -13,6 +13,7 @@ interface PathsStepProps {
downloadDir: string; downloadDir: string;
mediaDir: string; mediaDir: string;
metadataTaggingEnabled: boolean; metadataTaggingEnabled: boolean;
plexFormatCoercionEnabled: boolean;
chapterMergingEnabled: boolean; chapterMergingEnabled: boolean;
pathsTested: boolean; pathsTested: boolean;
onUpdate: (field: string, value: any) => void; onUpdate: (field: string, value: any) => void;
@@ -24,6 +25,7 @@ export function PathsStep({
downloadDir, downloadDir,
mediaDir, mediaDir,
metadataTaggingEnabled, metadataTaggingEnabled,
plexFormatCoercionEnabled,
chapterMergingEnabled, chapterMergingEnabled,
pathsTested, pathsTested,
onUpdate, onUpdate,
@@ -246,6 +248,30 @@ export function PathsStep({
</div> </div>
</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 */} {/* 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="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"> <div className="flex items-start gap-4">
+1
View File
@@ -25,6 +25,7 @@ interface SelectedIndexer {
protocol: string; protocol: string;
priority: number; priority: number;
seedingTimeMinutes?: number; // Torrents only seedingTimeMinutes?: number; // Torrents only
ratioLimit?: number; // Torrents only (0 = no ratio requirement)
removeAfterProcessing?: boolean; // Usenet only removeAfterProcessing?: boolean; // Usenet only
rssEnabled: boolean; rssEnabled: boolean;
audiobookCategories: number[]; // Categories for audiobook searches audiobookCategories: number[]; // Categories for audiobook searches
@@ -39,7 +39,12 @@ function BookRow({
const isDisabled = book.inLibrary || book.hasActiveRequest; const isDisabled = book.inLibrary || book.hasActiveRequest;
const isSkipped = book.skipped; const isSkipped = book.skipped;
const hasMatch = book.match !== null; const hasMatch = book.match !== null;
const isLowConfidence = book.metadataSource === 'file_name'; // Low confidence when search term came from a filename or folder name fallback,
// BUT not when an ASIN was extracted directly from the folder name (that's a
// direct lookup and is as reliable as embedded metadata tags).
const isLowConfidence =
(book.metadataSource === 'file_name' || book.metadataSource === 'folder_name') &&
!book.extractedAsin;
return ( return (
<div <div
+3 -1
View File
@@ -34,7 +34,9 @@ export interface ScannedBook {
relativePath: string; relativePath: string;
audioFileCount: number; audioFileCount: number;
totalSizeBytes: number; totalSizeBytes: number;
metadataSource: 'tags' | 'file_name'; metadataSource: 'tags' | 'folder_name' | 'file_name';
/** ASIN extracted directly from the folder name, if present. */
extractedAsin?: string;
searchTerm: string; searchTerm: string;
audioFiles: string[]; audioFiles: string[];
match: AudibleMatch | null; match: AudibleMatch | null;
@@ -1,9 +1,6 @@
/** /**
* Component: Indexer Configuration Modal * Component: Indexer Configuration Modal
* Documentation: documentation/frontend/components.md * Documentation: documentation/frontend/components.md
*
* Supports separate category configurations for AudioBook and EBook searches
* via tabbed interface in the Categories section.
*/ */
'use client'; 'use client';
@@ -13,6 +10,7 @@ import { Modal } from '@/components/ui/Modal';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input'; import { Input } from '@/components/ui/Input';
import { CategoryTreeView } from './CategoryTreeView'; import { CategoryTreeView } from './CategoryTreeView';
import { TorrentSeedingFields } from './IndexerConfigModalTorrentFields';
import { DEFAULT_AUDIOBOOK_CATEGORIES, DEFAULT_EBOOK_CATEGORIES } from '@/lib/utils/torrent-categories'; import { DEFAULT_AUDIOBOOK_CATEGORIES, DEFAULT_EBOOK_CATEGORIES } from '@/lib/utils/torrent-categories';
type CategoryTab = 'audiobook' | 'ebook'; type CategoryTab = 'audiobook' | 'ebook';
@@ -30,6 +28,7 @@ interface IndexerConfigModalProps {
initialConfig?: { initialConfig?: {
priority: number; priority: number;
seedingTimeMinutes?: number; seedingTimeMinutes?: number;
ratioLimit?: number;
removeAfterProcessing?: boolean; removeAfterProcessing?: boolean;
rssEnabled: boolean; rssEnabled: boolean;
audiobookCategories: number[]; audiobookCategories: number[];
@@ -41,6 +40,7 @@ interface IndexerConfigModalProps {
protocol: string; protocol: string;
priority: number; priority: number;
seedingTimeMinutes?: number; seedingTimeMinutes?: number;
ratioLimit?: number;
removeAfterProcessing?: boolean; removeAfterProcessing?: boolean;
rssEnabled: boolean; rssEnabled: boolean;
audiobookCategories: number[]; audiobookCategories: number[];
@@ -61,6 +61,7 @@ export function IndexerConfigModal({
const defaults = { const defaults = {
priority: 10, priority: 10,
seedingTimeMinutes: 0, seedingTimeMinutes: 0,
ratioLimit: 0,
removeAfterProcessing: true, // Default to true for Usenet removeAfterProcessing: true, // Default to true for Usenet
rssEnabled: indexer.supportsRss, rssEnabled: indexer.supportsRss,
audiobookCategories: DEFAULT_AUDIOBOOK_CATEGORIES, audiobookCategories: DEFAULT_AUDIOBOOK_CATEGORIES,
@@ -74,6 +75,9 @@ export function IndexerConfigModal({
const [seedingTimeMinutes, setSeedingTimeMinutes] = useState( const [seedingTimeMinutes, setSeedingTimeMinutes] = useState(
initialConfig?.seedingTimeMinutes ?? defaults.seedingTimeMinutes initialConfig?.seedingTimeMinutes ?? defaults.seedingTimeMinutes
); );
const [ratioLimit, setRatioLimit] = useState(
initialConfig?.ratioLimit ?? defaults.ratioLimit
);
const [removeAfterProcessing, setRemoveAfterProcessing] = useState( const [removeAfterProcessing, setRemoveAfterProcessing] = useState(
initialConfig?.removeAfterProcessing ?? defaults.removeAfterProcessing initialConfig?.removeAfterProcessing ?? defaults.removeAfterProcessing
); );
@@ -89,21 +93,20 @@ export function IndexerConfigModal({
initialConfig?.ebookCategories ?? defaults.ebookCategories initialConfig?.ebookCategories ?? defaults.ebookCategories
); );
// Tab state for categories
const [activeTab, setActiveTab] = useState<CategoryTab>('audiobook'); const [activeTab, setActiveTab] = useState<CategoryTab>('audiobook');
// Validation errors
const [errors, setErrors] = useState<{ const [errors, setErrors] = useState<{
priority?: string; priority?: string;
seedingTimeMinutes?: string; seedingTimeMinutes?: string;
ratioLimit?: string;
}>({}); }>({});
// Reset form when modal opens or indexer changes
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
if (mode === 'add') { if (mode === 'add') {
setPriority(defaults.priority); setPriority(defaults.priority);
setSeedingTimeMinutes(defaults.seedingTimeMinutes); setSeedingTimeMinutes(defaults.seedingTimeMinutes);
setRatioLimit(defaults.ratioLimit);
setRemoveAfterProcessing(defaults.removeAfterProcessing); setRemoveAfterProcessing(defaults.removeAfterProcessing);
setRssEnabled(defaults.rssEnabled); setRssEnabled(defaults.rssEnabled);
setAudiobookCategories(defaults.audiobookCategories); setAudiobookCategories(defaults.audiobookCategories);
@@ -111,6 +114,7 @@ export function IndexerConfigModal({
} else { } else {
setPriority(initialConfig?.priority ?? defaults.priority); setPriority(initialConfig?.priority ?? defaults.priority);
setSeedingTimeMinutes(initialConfig?.seedingTimeMinutes ?? defaults.seedingTimeMinutes); setSeedingTimeMinutes(initialConfig?.seedingTimeMinutes ?? defaults.seedingTimeMinutes);
setRatioLimit(initialConfig?.ratioLimit ?? defaults.ratioLimit);
setRemoveAfterProcessing(initialConfig?.removeAfterProcessing ?? defaults.removeAfterProcessing); setRemoveAfterProcessing(initialConfig?.removeAfterProcessing ?? defaults.removeAfterProcessing);
setRssEnabled(initialConfig?.rssEnabled ?? defaults.rssEnabled); setRssEnabled(initialConfig?.rssEnabled ?? defaults.rssEnabled);
setAudiobookCategories(initialConfig?.audiobookCategories ?? defaults.audiobookCategories); setAudiobookCategories(initialConfig?.audiobookCategories ?? defaults.audiobookCategories);
@@ -132,6 +136,10 @@ export function IndexerConfigModal({
newErrors.seedingTimeMinutes = 'Seeding time cannot be negative'; newErrors.seedingTimeMinutes = 'Seeding time cannot be negative';
} }
if (isTorrent && ratioLimit < 0) {
newErrors.ratioLimit = 'Ratio limit cannot be negative';
}
setErrors(newErrors); setErrors(newErrors);
return Object.keys(newErrors).length === 0; return Object.keys(newErrors).length === 0;
}; };
@@ -154,6 +162,7 @@ export function IndexerConfigModal({
// Add protocol-specific fields // Add protocol-specific fields
if (isTorrent) { if (isTorrent) {
config.seedingTimeMinutes = seedingTimeMinutes; config.seedingTimeMinutes = seedingTimeMinutes;
config.ratioLimit = ratioLimit;
} else { } else {
config.removeAfterProcessing = removeAfterProcessing; 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 // Get the current categories based on active tab
const currentCategories = activeTab === 'audiobook' ? audiobookCategories : ebookCategories; const currentCategories = activeTab === 'audiobook' ? audiobookCategories : ebookCategories;
const setCurrentCategories = activeTab === 'audiobook' ? setAudiobookCategories : setEbookCategories; const setCurrentCategories = activeTab === 'audiobook' ? setAudiobookCategories : setEbookCategories;
@@ -238,30 +258,15 @@ export function IndexerConfigModal({
)} )}
</div> </div>
{/* Seeding Time (Torrents only) */} {/* Seeding Time + Ratio Limit (Torrents only) */}
{isTorrent && ( {isTorrent && (
<div> <TorrentSeedingFields
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"> seedingTimeMinutes={seedingTimeMinutes}
Seeding Time (minutes) ratioLimit={ratioLimit}
</label> errors={errors}
<Input onSeedingTimeChange={handleSeedingTimeChange}
type="number" onRatioLimitChange={handleRatioLimitChange}
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>
)} )}
{/* Remove After Processing (Usenet only) */} {/* 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; protocol: string;
priority: number; priority: number;
seedingTimeMinutes?: number; // Torrents only seedingTimeMinutes?: number; // Torrents only
ratioLimit?: number; // Torrents only (0 = no ratio requirement)
removeAfterProcessing?: boolean; // Usenet only removeAfterProcessing?: boolean; // Usenet only
rssEnabled: boolean; rssEnabled: boolean;
audiobookCategories: number[]; // Categories for audiobook searches audiobookCategories: number[]; // Categories for audiobook searches
+15 -2
View File
@@ -90,6 +90,11 @@ export function EndpointCard({ endpoint, token, useSession }: EndpointCardProps)
Admin Admin
</span> </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> </div>
<h3 className="text-base font-semibold text-gray-900 dark:text-white mb-1"> <h3 className="text-base font-semibold text-gray-900 dark:text-white mb-1">
{endpoint.title} {endpoint.title}
@@ -99,10 +104,12 @@ export function EndpointCard({ endpoint, token, useSession }: EndpointCardProps)
</p> </p>
</div> </div>
<div className="flex-shrink-0 flex flex-col items-end gap-1">
<button <button
onClick={handleTryIt} onClick={handleTryIt}
disabled={loading} disabled={loading || endpoint.isWrite}
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]" 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 ? ( {loading ? (
<> <>
@@ -118,6 +125,12 @@ export function EndpointCard({ endpoint, token, useSession }: EndpointCardProps)
</> </>
)} )}
</button> </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>
)}
</div>
</div> </div>
{/* Expandable response area */} {/* Expandable response area */}
+1 -1
View File
@@ -34,7 +34,7 @@ const getStatusConfig = (audiobook: Audiobook) => {
return { type: 'processing', label: 'Processing', color: 'amber' }; 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)) { if (audiobook.requestStatus && pendingStatuses.includes(audiobook.requestStatus)) {
return { type: 'pending', label: 'Requested', color: 'blue' }; return { type: 'pending', label: 'Requested', color: 'blue' };
} }
@@ -38,6 +38,8 @@ interface AudiobookDetailsModalProps {
hideRequestActions?: boolean; hideRequestActions?: boolean;
hasReportedIssue?: boolean; hasReportedIssue?: boolean;
aiReason?: string | null; aiReason?: string | null;
/** Optional admin action buttons (Approve / Search / Deny) rendered as a second row in the action bar */
adminActions?: React.ReactNode;
} }
// Status helper // Status helper
@@ -51,7 +53,7 @@ const getStatusInfo = (isAvailable: boolean, requestStatus: string | null, reque
return { type: 'processing', label: 'Processing', canRequest: false }; 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)) { if (requestStatus && pendingStatuses.includes(requestStatus)) {
const label = requestStatus === 'awaiting_approval' const label = requestStatus === 'awaiting_approval'
? requestedByUsername ? `Pending Approval (${requestedByUsername})` : 'Pending Approval' ? requestedByUsername ? `Pending Approval (${requestedByUsername})` : 'Pending Approval'
@@ -80,6 +82,7 @@ export function AudiobookDetailsModal({
hideRequestActions = false, hideRequestActions = false,
hasReportedIssue = false, hasReportedIssue = false,
aiReason = null, aiReason = null,
adminActions,
}: AudiobookDetailsModalProps) { }: AudiobookDetailsModalProps) {
const { user } = useAuth(); const { user } = useAuth();
const { squareCovers } = usePreferences(); const { squareCovers } = usePreferences();
@@ -548,6 +551,30 @@ export function AudiobookDetailsModal({
</a> </a>
</div> </div>
{/* Language */}
{audiobook.language && (
<div>
<p className="text-gray-500 dark:text-gray-400">Language</p>
<p className="text-gray-900 dark:text-gray-100 capitalize">{audiobook.language}</p>
</div>
)}
{/* Format */}
{audiobook.formatType && (
<div>
<p className="text-gray-500 dark:text-gray-400">Format</p>
<p className="text-gray-900 dark:text-gray-100 capitalize">{audiobook.formatType}</p>
</div>
)}
{/* Publisher */}
{audiobook.publisherName && (
<div>
<p className="text-gray-500 dark:text-gray-400">Publisher</p>
<p className="text-gray-900 dark:text-gray-100">{audiobook.publisherName}</p>
</div>
)}
{/* Download Link - subtle utility, visible from any context */} {/* Download Link - subtle utility, visible from any context */}
{isAvailable && downloadAvailable && requestId && user?.permissions?.download !== false && ( {isAvailable && downloadAvailable && requestId && user?.permissions?.download !== false && (
<div> <div>
@@ -739,6 +766,13 @@ export function AudiobookDetailsModal({
)} )}
</div> </div>
{/* Admin Actions Row (Approve / Search / Deny) — injected by admin pages */}
{adminActions && (
<div className="flex items-center gap-2 mt-3 pt-3 border-t border-amber-200 dark:border-amber-700/50">
{adminActions}
</div>
)}
</div> </div>
)} )}
-2
View File
@@ -89,7 +89,6 @@ interface HomeSectionProps {
categoryName: string | null; categoryName: string | null;
colorIndex: number; colorIndex: number;
page: number; page: number;
onPageChange: (page: number) => void;
sectionRef: React.RefObject<HTMLElement | null>; sectionRef: React.RefObject<HTMLElement | null>;
cardSize: number; cardSize: number;
squareCovers: boolean; squareCovers: boolean;
@@ -226,7 +225,6 @@ export function HomeSection({
categoryName, categoryName,
colorIndex, colorIndex,
page, page,
onPageChange,
sectionRef, sectionRef,
cardSize, cardSize,
squareCovers, squareCovers,
@@ -27,6 +27,10 @@ export function ApiTokensSection() {
View API documentation View API documentation
</Link> </Link>
</p> </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>
</div> </div>
@@ -12,6 +12,8 @@
import React, { useState, useEffect, useRef, useCallback } from 'react'; import React, { useState, useEffect, useRef, useCallback } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { TorrentResult, RankedTorrent } from '@/lib/utils/ranking-algorithm'; import { TorrentResult, RankedTorrent } from '@/lib/utils/ranking-algorithm';
import { extractTitleTags } from '@/lib/utils/title-tags';
import { useIsTruncated } from '@/lib/hooks/useIsTruncated';
import { import {
useInteractiveSearch, useInteractiveSearch,
useSelectTorrent, useSelectTorrent,
@@ -24,6 +26,20 @@ import {
} from '@/lib/hooks/useRequests'; } from '@/lib/hooks/useRequests';
import { useReplaceWithTorrent } from '@/lib/hooks/useReportedIssues'; import { useReplaceWithTorrent } from '@/lib/hooks/useReportedIssues';
import { Audiobook } from '@/lib/hooks/useAudiobooks'; 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 { interface InteractiveTorrentSearchModalProps {
isOpen: boolean; isOpen: boolean;
@@ -116,9 +132,18 @@ export function InteractiveTorrentSearchModal({
const [results, setResults] = useState<(RankedTorrent & { qualityScore?: number; source?: string; ebookFormat?: string })[]>([]); const [results, setResults] = useState<(RankedTorrent & { qualityScore?: number; source?: string; ebookFormat?: string })[]>([]);
const [confirmTorrent, setConfirmTorrent] = useState<TorrentResult | null>(null); 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 [searchTitle, setSearchTitle] = useState(customSearchTerms || audiobook.title);
const [isCustomConfirming, setIsCustomConfirming] = useState(false); const [isCustomConfirming, setIsCustomConfirming] = useState(false);
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
const [expandedGuids, setExpandedGuids] = useState<Set<string>>(() => new Set());
// Stable close handler via ref // Stable close handler via ref
const onCloseRef = useRef(onClose); const onCloseRef = useRef(onClose);
@@ -157,8 +182,45 @@ export function InteractiveTorrentSearchModal({
useEffect(() => { useEffect(() => {
setSearchTitle(customSearchTerms || audiobook.title); setSearchTitle(customSearchTerms || audiobook.title);
setResults([]); setResults([]);
setExpandedGuids(new Set());
}, [isOpen, audiobook.title, customSearchTerms]); }, [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 // Perform search when modal opens
useEffect(() => { useEffect(() => {
if (isOpen && results.length === 0) { if (isOpen && results.length === 0) {
@@ -189,6 +251,7 @@ export function InteractiveTorrentSearchModal({
const performSearch = async () => { const performSearch = async () => {
setResults([]); setResults([]);
setExpandedGuids(new Set());
try { try {
let data; let data;
if (isEbookMode) { if (isEbookMode) {
@@ -380,125 +443,25 @@ export function InteractiveTorrentSearchModal({
{/* Results List */} {/* Results List */}
{!isSearching && results.length > 0 && ( {!isSearching && results.length > 0 && (
<div className="space-y-0.5"> <div className="space-y-0.5">
{results.map((result) => { {results.map((result) => (
const score = Math.round(result.score); <ResultRow
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} 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" result={result}
> isEbookMode={isEbookMode}
{/* Score Badge */} isExpanded={expandedGuids.has(result.guid)}
<div isDownloading={isDownloading}
className={`flex-shrink-0 w-11 h-11 rounded-xl ${style.bg} flex flex-col items-center justify-center`} blockedReason={resolveBlockedReason(result, blockedLookup)}
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)})`} onToggleExpand={() => {
> setExpandedGuids((prev) => {
<span className={`text-[15px] font-bold leading-none tabular-nums ${style.text}`}> const next = new Set(prev);
{score} if (next.has(result.guid)) next.delete(result.guid);
</span> else next.add(result.guid);
</div> return next;
});
{/* Content */} }}
<div className="flex-1 min-w-0"> onDownload={() => handleDownloadClick(result)}
{/* 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>
);
})}
</div> </div>
)} )}
</div> </div>
@@ -615,3 +578,207 @@ export function InteractiveTorrentSearchModal({
return createPortal(modalContent, document.body); 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>
);
}
+45 -8
View File
@@ -13,7 +13,8 @@ import { useCancelRequest } from '@/lib/hooks/useRequests';
import { cn } from '@/lib/utils/cn'; import { cn } from '@/lib/utils/cn';
import { usePreferences } from '@/contexts/PreferencesContext'; import { usePreferences } from '@/contexts/PreferencesContext';
import { AudiobookDetailsModal } from '@/components/audiobooks/AudiobookDetailsModal'; import { AudiobookDetailsModal } from '@/components/audiobooks/AudiobookDetailsModal';
import { COMPLETED_STATUSES } from '@/lib/constants/request-statuses'; import { ConfirmModal } from '@/components/ui/ConfirmModal';
import { COMPLETED_STATUSES, CANCELLABLE_STATUSES } from '@/lib/constants/request-statuses';
interface RequestCardProps { interface RequestCardProps {
request: { request: {
@@ -26,6 +27,7 @@ interface RequestCardProps {
updatedAt: string; updatedAt: string;
completedAt?: string; completedAt?: string;
downloadAvailable?: boolean; downloadAvailable?: boolean;
releaseDate?: string | Date | null;
audiobook: { audiobook: {
id: string; id: string;
audibleAsin?: string; audibleAsin?: string;
@@ -45,22 +47,37 @@ export function RequestCard({ request, showActions = true }: RequestCardProps) {
const [showError, setShowError] = React.useState(false); const [showError, setShowError] = React.useState(false);
const [showDetailsModal, setShowDetailsModal] = React.useState(false); const [showDetailsModal, setShowDetailsModal] = React.useState(false);
const [coverError, setCoverError] = React.useState(false); const [coverError, setCoverError] = React.useState(false);
const [confirmCancelOpen, setConfirmCancelOpen] = React.useState(false);
const isAwaitingApproval = request.status === 'awaiting_approval';
const requestType = request.type || 'audiobook'; const requestType = request.type || 'audiobook';
const isEbook = requestType === 'ebook'; const isEbook = requestType === 'ebook';
const isCompleted = COMPLETED_STATUSES.includes(request.status as typeof COMPLETED_STATUSES[number]); const isCompleted = COMPLETED_STATUSES.includes(request.status as typeof COMPLETED_STATUSES[number]);
const canCancel = ['pending', 'searching', 'downloading', 'awaiting_search'].includes(request.status); const canCancel = (CANCELLABLE_STATUSES as readonly string[]).includes(request.status);
const isActive = ['searching', 'downloading', 'processing'].includes(request.status); const isActive = ['searching', 'downloading', 'processing'].includes(request.status);
const isFailed = request.status === 'failed'; const isFailed = request.status === 'failed';
const handleCancel = async () => { const releaseDateLabel = React.useMemo(() => {
if (window.confirm('Are you sure you want to cancel this request?')) { 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 { try {
await cancelRequest(request.id); await cancelRequest(request.id);
setConfirmCancelOpen(false);
} catch (error) { } catch (error) {
console.error('Failed to cancel request:', error); console.error('Failed to cancel request:', error);
} setConfirmCancelOpen(false);
} }
}; };
@@ -146,6 +163,11 @@ export function RequestCard({ request, showActions = true }: RequestCardProps) {
{/* Status Badge and Type Badge */} {/* Status Badge and Type Badge */}
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<StatusBadge status={request.status} progress={request.progress} /> <StatusBadge status={request.status} progress={request.progress} />
{releaseDateLabel && (
<span className="text-xs text-gray-500 dark:text-gray-400">
Releases {releaseDateLabel}
</span>
)}
{isEbook && ( {isEbook && (
<span <span
className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full" className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full"
@@ -228,13 +250,13 @@ export function RequestCard({ request, showActions = true }: RequestCardProps) {
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{canCancel && ( {canCancel && (
<Button <Button
onClick={handleCancel} onClick={() => setConfirmCancelOpen(true)}
loading={isLoading} loading={isLoading}
variant="outline" variant="outline"
size="sm" size="sm"
className="text-xs sm:text-sm text-red-600 border-red-600 hover:bg-red-50 dark:hover:bg-red-900/20" className="text-xs sm:text-sm text-red-600 border-red-600 hover:bg-red-50 dark:hover:bg-red-900/20"
> >
Cancel {isAwaitingApproval ? 'Withdraw' : 'Cancel'}
</Button> </Button>
)} )}
</div> </div>
@@ -251,9 +273,24 @@ export function RequestCard({ request, showActions = true }: RequestCardProps) {
onClose={() => setShowDetailsModal(false)} onClose={() => setShowDetailsModal(false)}
requestStatus={request.status} requestStatus={request.status}
isAvailable={COMPLETED_STATUSES.includes(request.status as typeof COMPLETED_STATUSES[number])} isAvailable={COMPLETED_STATUSES.includes(request.status as typeof COMPLETED_STATUSES[number])}
hideRequestActions
/> />
)} )}
<ConfirmModal
isOpen={confirmCancelOpen}
onClose={() => !isLoading && setConfirmCancelOpen(false)}
onConfirm={handleConfirmCancel}
title={isAwaitingApproval ? 'Withdraw request' : 'Cancel request'}
message={
isAwaitingApproval
? 'This request is pending admin approval and will be withdrawn. You can request it again later.'
: 'This request has already been approved and is actively being processed. Cancelling will stop the download.'
}
confirmText={isAwaitingApproval ? 'Withdraw request' : 'Cancel request'}
cancelText="Keep request"
variant="danger"
isLoading={isLoading}
/>
</div> </div>
); );
} }
+4
View File
@@ -68,6 +68,10 @@ export function StatusBadge({ status, progress, className }: StatusBadgeProps) {
label: 'Pending Approval', label: 'Pending Approval',
color: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200', 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: { denied: {
label: 'Request Denied', label: 'Request Denied',
color: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200', color: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',

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