mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 12:50:09 +00:00
Compare commits
106 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 96aa01a3ec | |||
| 411b5f88a4 | |||
| 01e61f3368 | |||
| 5d9a764151 | |||
| b1492fc32e | |||
| fb0445d95f | |||
| a2d25e8a39 | |||
| cea98f67ef | |||
| dd5a5962b4 | |||
| eef6ae3462 | |||
| 06195e6570 | |||
| 6ec53ff7e3 | |||
| e39e44ee44 | |||
| 8bcfadc877 | |||
| 1065577a04 | |||
| 31d30bdfa0 | |||
| e74787ffc0 | |||
| 0561459782 | |||
| e65e737bee | |||
| f23afc1ba2 | |||
| 6f8ac86a43 | |||
| 5f62ba7146 | |||
| bc7fff9dd7 | |||
| b775ccf473 | |||
| 1a9aeb4713 | |||
| bb18feac5c | |||
| 4b79b11987 | |||
| 86f7a6a354 | |||
| 071c788ead | |||
| f4fe6f936f | |||
| 741efa685c | |||
| df656b6178 | |||
| d2c90de07f | |||
| 07fbff1133 | |||
| de72180bdd | |||
| e9241d21af | |||
| ad8d44bae0 | |||
| f56efa8b15 | |||
| a7186096df | |||
| 1a25f544b1 | |||
| 1711d256c2 | |||
| 8376355233 | |||
| d1a980e210 | |||
| 5e4a38a340 | |||
| 4ded2cf219 | |||
| 21d811e2bf | |||
| 247fe88b99 | |||
| 3545ff6109 | |||
| fb19c1a642 | |||
| 6c8ca9647d | |||
| 18752dd02b | |||
| f8c70a6b9a | |||
| fcae3bcf09 | |||
| edecda9e64 | |||
| 6b76932a0a | |||
| 02b636e5b8 | |||
| 37f063229c | |||
| ba1efa88f5 | |||
| 5f0855b2f8 | |||
| 44524667a2 | |||
| f564d0a574 | |||
| ade12cb82d | |||
| c9392c49c9 | |||
| 7b01cda955 | |||
| 9a6062d860 | |||
| ad1ab3af05 | |||
| 35cb318389 | |||
| e9d7a2359a | |||
| 54b54d343a | |||
| 8a757f5b67 | |||
| 1abaff1677 | |||
| 850e777a81 | |||
| 4322c3af90 | |||
| c8bfcdb611 | |||
| 6fc622c4e7 | |||
| dbf13c39d5 | |||
| f8c6ff3882 | |||
| 4d3af02dc8 | |||
| 5ae58a36b4 | |||
| d73d13aa26 | |||
| 81712ad3ce | |||
| b20673e7ea | |||
| 6af15b9622 | |||
| e98ac8a4e5 | |||
| c373ffffbc | |||
| 2749902564 | |||
| 6a668cc62f | |||
| 06447fed71 | |||
| 0ae8f66a2d | |||
| 09cff5b68d | |||
| da7ad7cac1 | |||
| 8aac63715a | |||
| 0a405f2313 | |||
| 98c89db0a7 | |||
| 309a7960a8 | |||
| 06e77b8eba | |||
| dfc34df3d1 | |||
| 5d2e33e369 | |||
| 789a2e50ef | |||
| 9cb9d06144 | |||
| a81549768c | |||
| c0cff56b47 | |||
| e2ae4c7eef | |||
| a564fefd7c | |||
| 01b59fae9d | |||
| 137e2b5607 |
@@ -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/
|
||||||
|
|||||||
@@ -2,7 +2,19 @@
|
|||||||
|
|
||||||
**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:
|
||||||
|
1. **Research & analyze** — Read code, trace the issue, identify root cause.
|
||||||
|
2. **Present a solution plan** — Explain the root cause, list the specific files and changes needed, and describe the approach clearly.
|
||||||
|
3. **Wait for explicit approval** — Do NOT write any code until the user confirms the plan.
|
||||||
|
4. Only after approval: implement, build, and report results.
|
||||||
|
|
||||||
|
This applies to bug fixes, feature requests, and any code changes. Investigation and analysis are always fine — writing code is not until approved.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,15 @@ services:
|
|||||||
PUID: 1000
|
PUID: 1000
|
||||||
PGID: 1000
|
PGID: 1000
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
|
# OPTIONAL: File Permission Mask
|
||||||
|
# ========================================================================
|
||||||
|
# Set a umask to control default file permissions for all files created
|
||||||
|
# by the application. Common values:
|
||||||
|
# - 002: Group-writable (files: 664, dirs: 775) - recommended for shared access
|
||||||
|
# - 022: Group-readable only (files: 644, dirs: 755) - more restrictive
|
||||||
|
# UMASK: "002"
|
||||||
|
|
||||||
# ========================================================================
|
# ========================================================================
|
||||||
# OPTIONAL: Secrets (auto-generated on first run if not provided)
|
# OPTIONAL: Secrets (auto-generated on first run if not provided)
|
||||||
# ========================================================================
|
# ========================================================================
|
||||||
|
|||||||
@@ -22,6 +22,12 @@ PGID=${PGID:-$(id -g node)}
|
|||||||
echo "[App] Starting Next.js server..."
|
echo "[App] Starting Next.js server..."
|
||||||
echo "[App] Process will run as UID:GID = $PUID:$PGID"
|
echo "[App] Process will run as UID:GID = $PUID:$PGID"
|
||||||
|
|
||||||
|
# Apply UMASK if set (controls default file permissions)
|
||||||
|
if [ -n "$UMASK" ]; then
|
||||||
|
echo "[App] Applying umask: $UMASK"
|
||||||
|
umask "$UMASK"
|
||||||
|
fi
|
||||||
|
|
||||||
cd /app
|
cd /app
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -93,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
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|||||||
@@ -387,6 +387,7 @@ PORT=$PORT
|
|||||||
HOSTNAME=$HOSTNAME
|
HOSTNAME=$HOSTNAME
|
||||||
PUID=${PUID:-}
|
PUID=${PUID:-}
|
||||||
PGID=${PGID:-}
|
PGID=${PGID:-}
|
||||||
|
UMASK=${UMASK:-}
|
||||||
ROOTLESS_CONTAINER=${ROOTLESS_CONTAINER:-}
|
ROOTLESS_CONTAINER=${ROOTLESS_CONTAINER:-}
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
|
|||||||
+13
-1
@@ -24,14 +24,26 @@ RUN apt-get update && apt-get install -y \
|
|||||||
supervisor \
|
supervisor \
|
||||||
curl \
|
curl \
|
||||||
openssl \
|
openssl \
|
||||||
ffmpeg \
|
|
||||||
locales \
|
locales \
|
||||||
gosu \
|
gosu \
|
||||||
|
xz-utils \
|
||||||
&& sed -i 's/^# \(en_US.UTF-8 UTF-8\)/\1/' /etc/locale.gen \
|
&& sed -i 's/^# \(en_US.UTF-8 UTF-8\)/\1/' /etc/locale.gen \
|
||||||
&& locale-gen en_US.UTF-8 \
|
&& locale-gen en_US.UTF-8 \
|
||||||
&& update-locale LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8 \
|
&& update-locale LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8 \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Install static ffmpeg (no transitive dependencies like imagemagick)
|
||||||
|
ADD https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz /tmp/ffmpeg.tar.xz
|
||||||
|
RUN cd /tmp && tar xf ffmpeg.tar.xz && \
|
||||||
|
cp ffmpeg-*-static/ffmpeg ffmpeg-*-static/ffprobe /usr/local/bin/ && \
|
||||||
|
rm -rf /tmp/ffmpeg*
|
||||||
|
|
||||||
|
# Remove imagemagick (pre-installed in node:20-bookworm base image, not needed)
|
||||||
|
RUN apt-get purge -y imagemagick imagemagick-6-common 'imagemagick-6*' \
|
||||||
|
'libmagickcore*' 'libmagickwand*' && \
|
||||||
|
apt-get autoremove -y --purge && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
ENV LANG=en_US.UTF-8
|
ENV LANG=en_US.UTF-8
|
||||||
ENV LC_ALL=en_US.UTF-8
|
ENV LC_ALL=en_US.UTF-8
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,10 @@
|
|||||||
## Authentication & Users
|
## Authentication & Users
|
||||||
- **Plex OAuth, JWT sessions, RBAC** → [backend/services/auth.md](backend/services/auth.md)
|
- **Plex OAuth, JWT sessions, RBAC** → [backend/services/auth.md](backend/services/auth.md)
|
||||||
- **Local admin authentication, password change** → [backend/services/auth.md](backend/services/auth.md)
|
- **Local admin authentication, password change** → [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)
|
||||||
@@ -44,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)
|
||||||
@@ -98,9 +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)
|
||||||
- **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)
|
||||||
@@ -139,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)
|
||||||
@@ -166,3 +179,6 @@
|
|||||||
**"How do Hardcover shelves work?"** → [backend/services/hardcover-sync.md](backend/services/hardcover-sync.md)
|
**"How do Hardcover shelves work?"** → [backend/services/hardcover-sync.md](backend/services/hardcover-sync.md)
|
||||||
**"How do I add a new shelf provider?"** → [backend/services/goodreads-sync.md](backend/services/goodreads-sync.md#adding-a-new-provider)
|
**"How do I add a new shelf provider?"** → [backend/services/goodreads-sync.md](backend/services/goodreads-sync.md#adding-a-new-provider)
|
||||||
**"How does the shelf sync core work?"** → [backend/services/goodreads-sync.md](backend/services/goodreads-sync.md#shared-sync-core)
|
**"How does the shelf sync core work?"** → [backend/services/goodreads-sync.md](backend/services/goodreads-sync.md#shared-sync-core)
|
||||||
|
**"How does bulk import work?"** → [features/bulk-import.md](features/bulk-import.md)
|
||||||
|
**"How do I import multiple audiobooks at once?"** → [features/bulk-import.md](features/bulk-import.md)
|
||||||
|
**"How does the bulk import scanner detect audiobooks?"** → [features/bulk-import.md](features/bulk-import.md)
|
||||||
|
|||||||
@@ -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 — <reason>"** chip inline. Interactive search results are **not filtered** — admin sees the full picture.
|
||||||
|
|
||||||
|
## Test Coverage
|
||||||
|
- `tests/utils/release-key.test.ts` — normalization rules.
|
||||||
|
- `tests/services/blocklist.service.test.ts` — upsert idempotency, lookup match, JobEvent emission.
|
||||||
|
- `tests/processors/*` — auto-block triggers + filter coverage on each search path.
|
||||||
|
- `tests/api/admin-blocklist.routes.test.ts` — auth gate, where composition, single + bulk DELETE, by-request GET, sort/pagination/limit clamp.
|
||||||
|
|
||||||
|
## UX Rules Honored
|
||||||
|
- **Intentional affordances** — every tappable element is a real `<button>`/`<a>` with hover/focus treatment; expand-rows show an explicit chevron.
|
||||||
|
- **Source data stays true** — release names render verbatim. Chips/badges add context (source, reason, "blocked"), they never replace the original string.
|
||||||
|
|
||||||
|
## Out of Scope (v2)
|
||||||
|
- Global (cross-request) blocklist + per-block toggle UI.
|
||||||
|
- Manual proactive admin block.
|
||||||
|
- Requester-facing UI surface.
|
||||||
|
- Auto-expiration / TTL.
|
||||||
|
- Zero-seeder torrents as a block trigger.
|
||||||
|
- Indexer-side push (Prowlarr blocklist API).
|
||||||
|
|
||||||
|
## Related
|
||||||
|
- [Database schema](../backend/database.md)
|
||||||
|
- [Search processors](../phase3/prowlarr.md)
|
||||||
|
- [Admin dashboard](../admin-dashboard.md)
|
||||||
|
- [Request deletion](request-deletion.md) — interaction with hard/soft delete cascade.
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -249,6 +249,14 @@ oidc.admin_claim_value = 'readmeabook-admin'
|
|||||||
- **Admin Settings:** OIDC section in `/admin/settings` (auth tab)
|
- **Admin Settings:** OIDC section in `/admin/settings` (auth tab)
|
||||||
- **Library:** `openid-client` (OIDC discovery, token exchange, PKCE)
|
- **Library:** `openid-client` (OIDC discovery, token exchange, PKCE)
|
||||||
|
|
||||||
|
## Admin-Generated Login Token
|
||||||
|
|
||||||
|
- Login token stored as SHA-256 hash in `User.loginTokenHash`
|
||||||
|
- Admin generates/revokes via user permissions modal
|
||||||
|
- User navigates to `/auth/token/login?token=rmab_...` → page POSTs token to API in request body
|
||||||
|
- API: `POST /api/auth/token/login` with `{ token }` in JSON body
|
||||||
|
- Invalid token redirects to `/login`
|
||||||
|
|
||||||
## Security
|
## Security
|
||||||
|
|
||||||
- Never log tokens
|
- Never log tokens
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
# Bulk Import Feature
|
||||||
|
|
||||||
|
**Status:** ✅ Implemented | Admin-only | Multi-step wizard modal
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
Lets admins scan a server folder recursively, discover audiobook subfolders, match against Audible, review matches, and import selected books via the existing manual import pipeline.
|
||||||
|
|
||||||
|
## Flow
|
||||||
|
1. **Select Folder** — Browse base folders (Downloads, Media Library, Book Drop), pick scan root
|
||||||
|
2. **Scan & Match** — Recursively discover audiobook folders (max 10 levels), read metadata via ffprobe, search Audible per book (1.5s rate limit)
|
||||||
|
3. **Review & Import** — Scrollable list with skip toggles, library status, confidence badges; Start Import queues organize_files jobs
|
||||||
|
|
||||||
|
## Key Details
|
||||||
|
- **Access:** Admin-only, modal opened from admin dashboard Quick Actions
|
||||||
|
- **Audio detection:** Uses `AUDIO_EXTENSIONS` from `src/lib/constants/audio-formats.ts`
|
||||||
|
- **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 all audio files in folder
|
||||||
|
- **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
|
||||||
|
- **Scan depth:** Max 10 levels recursion
|
||||||
|
- **Rate limiting:** 1.5s delay between Audible searches (same as existing scraping rate limit)
|
||||||
|
- **Library check:** Uses `findPlexMatch()` for ASIN-based availability detection
|
||||||
|
- **Import:** Reuses existing `organize_files` job queue (same as manual import)
|
||||||
|
- **No new database tables** — all state is ephemeral during wizard session
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
**POST /api/admin/bulk-import/scan** (SSE stream)
|
||||||
|
- Body: `{ rootPath: string }`
|
||||||
|
- Path validation: must be within download_dir, media_dir, or /bookdrop
|
||||||
|
- Streams events: `progress`, `discovery_complete`, `matching`, `book_matched`, `complete`, `error`
|
||||||
|
- Each `book_matched` event includes: folderPath, match (Audible data), inLibrary, hasActiveRequest, metadataSource
|
||||||
|
|
||||||
|
**POST /api/admin/bulk-import/execute**
|
||||||
|
- Body: `{ imports: Array<{ folderPath: string, asin: string }> }`
|
||||||
|
- Creates audiobook records + requests, queues organize_files jobs
|
||||||
|
- Returns: `{ success, results[], summary: { total, succeeded, failed } }`
|
||||||
|
|
||||||
|
## SSE Event Types
|
||||||
|
|
||||||
|
| Event | Data | When |
|
||||||
|
|---|---|---|
|
||||||
|
| `progress` | `{ phase, foldersScanned, audiobooksFound, currentFolder }` | During folder discovery |
|
||||||
|
| `discovery_complete` | `{ totalFound, message }` | All folders scanned |
|
||||||
|
| `matching` | `{ current, total, folderName, searchTerm }` | Before each Audible search |
|
||||||
|
| `book_matched` | Full book result with match data | After each Audible search |
|
||||||
|
| `complete` | `{ audiobooks[], totalFound, matched, inLibrary }` | All matching done |
|
||||||
|
| `error` | `{ message }` | On failure |
|
||||||
|
|
||||||
|
## UI States
|
||||||
|
|
||||||
|
| State | Visual |
|
||||||
|
|---|---|
|
||||||
|
| Normal (will import) | Full opacity, blue toggle ON |
|
||||||
|
| Skipped by user | 40% opacity, gray toggle OFF |
|
||||||
|
| Already in library | 40% opacity, green "In Library" 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 |
|
||||||
|
| 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
|
||||||
|
|
||||||
|
**Backend:**
|
||||||
|
- `src/lib/utils/bulk-import-scanner.ts` — Folder discovery + ffprobe metadata
|
||||||
|
- `src/app/api/admin/bulk-import/scan/route.ts` — SSE scan endpoint
|
||||||
|
- `src/app/api/admin/bulk-import/execute/route.ts` — Batch import endpoint
|
||||||
|
|
||||||
|
**Frontend:**
|
||||||
|
- `src/components/admin/BulkImportWizard.tsx` — Modal orchestrator
|
||||||
|
- `src/components/admin/bulk-import/types.ts` — Shared types
|
||||||
|
- `src/components/admin/bulk-import/ScanFolderStep.tsx` — Folder browser
|
||||||
|
- `src/components/admin/bulk-import/ScanProgressStep.tsx` — Progress display
|
||||||
|
- `src/components/admin/bulk-import/MatchReviewStep.tsx` — Review list + import
|
||||||
|
|
||||||
|
**Modified:**
|
||||||
|
- `src/app/admin/page.tsx` — Added Bulk Import quick action + modal
|
||||||
|
|
||||||
|
## Related
|
||||||
|
- [Manual Import](manual-import.md) — Single-book import (reused pipeline)
|
||||||
|
- [File Organization](../phase3/file-organization.md) — organize_files job
|
||||||
|
- [Audible Integration](../integrations/audible.md) — Search/scraping
|
||||||
|
- [Background Jobs](../backend/services/jobs.md) — Job queue system
|
||||||
@@ -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`
|
||||||
|
|||||||
@@ -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 <Mon DD, YYYY>" 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
|
||||||
|
|||||||
@@ -1,104 +1,131 @@
|
|||||||
# Audible Integration
|
# Audible Integration
|
||||||
|
|
||||||
**Status:** ✅ Implemented (Audnexus API + Web Scraping)
|
**Status:** Implemented | Hybrid — curated HTML for discovery refresh + Audible JSON catalog API for user-facing real-time + Audnexus for per-ASIN details
|
||||||
|
|
||||||
Audiobook metadata from Audnexus API (primary) and Audible.com scraping (fallback) for discovery, search, and detail pages.
|
## Overview
|
||||||
|
|
||||||
## Detail Page Strategy
|
Audiobook metadata for discovery, search, and detail pages. Split by access pattern:
|
||||||
|
|
||||||
**Primary: Audnexus API**
|
- **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.
|
||||||
- Endpoint: `https://api.audnex.us/books/{asin}`
|
- **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/*`).
|
||||||
- Structured JSON response (no parsing needed)
|
- **Per-ASIN detail lookups** — Audnexus (`api.audnex.us/books/{asin}`) primary; catalog API used as fallback when Audnexus returns 404.
|
||||||
- Provides: title, authors, narrators, description, duration, rating, genres, cover art
|
|
||||||
- Free, no API key required
|
|
||||||
- ~95% success rate for popular audiobooks
|
|
||||||
|
|
||||||
**Fallback: Audible Scraping**
|
## Architecture
|
||||||
- Used when Audnexus returns 404
|
|
||||||
- Parse Audible HTML with Cheerio
|
- **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.
|
||||||
- Multiple selector strategies with promotional text filtering
|
- **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.
|
||||||
- Extract JSON-LD structured data when available
|
- **Audnexus (per-ASIN):** `getAudiobookDetails` and `getRuntime` prefer Audnexus, with catalog API fallback for `getAudiobookDetails`.
|
||||||
|
- **`www.audible.<tld>`:** Used by HTML refresh scraping, by `audible-series.ts`, and by `getBaseUrl()` for "View on Audible" link generation.
|
||||||
|
|
||||||
|
## Data Sources
|
||||||
|
|
||||||
|
### 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 |
|
||||||
|
|---|---|---|
|
||||||
|
| Search | `/1.0/catalog/products` | `keywords=<q>` |
|
||||||
|
| Author books | `/1.0/catalog/products` | `author=<name>` (name, NOT ASIN) |
|
||||||
|
| Categories listing | `/1.0/catalog/categories` | (none) |
|
||||||
|
| Single product | `/1.0/catalog/products/{asin}` | — |
|
||||||
|
| Audnexus (per-ASIN) | `https://api.audnex.us/books/{asin}` | `region={audnexusParam}` |
|
||||||
|
|
||||||
|
All `products` endpoints share:
|
||||||
|
- `num_results` — max **50** (service constant `AUDIBLE_PAGE_SIZE = 50`)
|
||||||
|
- `page` — **0-indexed at the API** (service public interface is 1-indexed; the service subtracts 1 at the call site). See Gotchas.
|
||||||
|
- `response_groups=<CATALOG_RESPONSE_GROUPS>`
|
||||||
|
|
||||||
|
## `response_groups` Constant
|
||||||
|
|
||||||
|
`CATALOG_RESPONSE_GROUPS = 'contributors,product_desc,product_attrs,product_extended_attrs,media,rating,series,category_ladders,product_details'`
|
||||||
|
|
||||||
|
Populates every `AudibleAudiobook` field. Covered:
|
||||||
|
- `contributors` → authors (with ASINs), narrators
|
||||||
|
- `product_desc` → `publisher_summary`, `merchandising_summary`
|
||||||
|
- `product_attrs` / `product_extended_attrs` / `product_details` → title, release_date, language, runtime_length_min
|
||||||
|
- `media` → `product_images` (cover URLs, uses `500` variant)
|
||||||
|
- `rating` → `overall_distribution.display_stars`
|
||||||
|
- `series` → array of `{asin, title, sequence}`
|
||||||
|
- `category_ladders` → genre names (deduped, capped at 5)
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
- **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.
|
||||||
|
- **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`.
|
||||||
|
- **`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 51–100, not 1–50. 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
|
||||||
|
|
||||||
|
- **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`.
|
||||||
|
- **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`** — inter-page delay 2–4 s baseline, scales up multiplicatively under retry pressure, with a 45–60 s circuit-breaker cooldown after 3 consecutive retry-pages.
|
||||||
|
- **Per-batch cooldowns** in `audible-refresh.processor.ts` — 15–30 s between popular/new-releases, 10–20 s between categories.
|
||||||
|
|
||||||
## Region Configuration
|
## Region Configuration
|
||||||
|
|
||||||
**Status:** ✅ Implemented
|
**Status:** Implemented
|
||||||
|
|
||||||
Configurable Audible region for accurate metadata matching across different international Audible stores.
|
Configurable Audible region for accurate metadata matching across international stores.
|
||||||
|
|
||||||
**Supported Regions:**
|
**Supported Regions:**
|
||||||
- United States (`us`) - `audible.com` (default, English)
|
|
||||||
- Canada (`ca`) - `audible.ca` (English)
|
|
||||||
- United Kingdom (`uk`) - `audible.co.uk` (English)
|
|
||||||
- Australia (`au`) - `audible.com.au` (English)
|
|
||||||
- India (`in`) - `audible.in` (English)
|
|
||||||
- Germany (`de`) - `audible.de` (non-English)
|
|
||||||
- Spain (`es`) - `audible.es` (non-English)
|
|
||||||
- French (`fr`) - `audible.fr` (non-English)
|
|
||||||
|
|
||||||
**`isEnglish` Flag:**
|
| Code | Name | HTML baseUrl | apiBaseUrl | isEnglish |
|
||||||
- Each region has `isEnglish: boolean` in `AudibleRegionConfig`
|
|---|---|---|---|---|
|
||||||
- Non-English regions (`isEnglish: false`) display an amber warning in all region dropdowns (setup wizard + admin settings)
|
| `us` | United States | `https://www.audible.com` | `https://api.audible.com` | true (default) |
|
||||||
- Warning text: "Many features such as search, discovery, and metadata matching are not yet fully supported for non-English regions."
|
| `ca` | Canada | `https://www.audible.ca` | `https://api.audible.ca` | true |
|
||||||
- Dropdown options for non-English regions show `*` suffix (e.g., "Germany *")
|
| `uk` | United Kingdom | `https://www.audible.co.uk` | `https://api.audible.co.uk` | true |
|
||||||
|
| `au` | Australia | `https://www.audible.com.au` | `https://api.audible.com.au` | true |
|
||||||
|
| `in` | India | `https://www.audible.in` | `https://api.audible.in` | true |
|
||||||
|
| `de` | Germany | `https://www.audible.de` | `https://api.audible.de` | false |
|
||||||
|
| `es` | Spain | `https://www.audible.es` | `https://api.audible.es` | false |
|
||||||
|
| `fr` | France | `https://www.audible.fr` | `https://api.audible.fr` | false |
|
||||||
|
|
||||||
**Why Regions Matter:**
|
**`AudibleRegionConfig` fields:** `code`, `name`, `baseUrl`, `apiBaseUrl`, `audnexusParam`, `language`.
|
||||||
- Each Audible region uses different ASINs for the same audiobook
|
|
||||||
- Metadata engines (Audnexus/Audible Agent) in Plex/Audiobookshelf must match RMAB's region
|
**`isEnglish` flag:**
|
||||||
- Mismatched regions cause poor search results and failed metadata matching
|
- Non-English regions show amber warning in region dropdowns (setup wizard + admin settings): "Many features such as search, discovery, and metadata matching are not yet fully supported for non-English regions."
|
||||||
|
- Dropdown options for non-English regions show `*` suffix.
|
||||||
|
|
||||||
|
**Why regions matter:**
|
||||||
|
- Each Audible region uses different ASINs for the same audiobook.
|
||||||
|
- Metadata engines (Audnexus / Audible Agent) in Plex / Audiobookshelf must match RMAB's region.
|
||||||
|
|
||||||
**Configuration:**
|
**Configuration:**
|
||||||
- Key: `audible.region` (stored in database)
|
- Key: `audible.region` (stored in database)
|
||||||
- Default: `us`
|
- Default: `us`
|
||||||
- Set during: Setup wizard (Backend Selection step) or Admin Settings (Library tab)
|
- Set during: Setup wizard (Backend Selection step) or Admin Settings (Library tab)
|
||||||
- Help text instructs users to match their metadata engine region
|
- Auto-detection: Service checks config before each request and re-initializes if region changed.
|
||||||
|
- Cache clearing: Region change clears ConfigService cache and AudibleService state.
|
||||||
|
- Automatic refresh: Region change triggers `audible_refresh` job.
|
||||||
|
|
||||||
**Implementation:**
|
**Per-region HTTP clients (on init):**
|
||||||
- `AudibleService` loads region from config on initialization
|
- `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).
|
||||||
- Dynamically builds base URL: `AUDIBLE_REGIONS[region].baseUrl`
|
- `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 API calls include region parameter: `?region={code}`
|
- Audnexus calls include `region=<audnexusParam>`.
|
||||||
- IP redirect prevention: `?ipRedirectOverride=true` on all Audible requests (region only)
|
|
||||||
- **Locale enforcement:** `?language=english` query parameter on all Audible requests (forces English content regardless of server IP geolocation)
|
|
||||||
- Configuration service helper: `getAudibleRegion()` returns configured region
|
|
||||||
- **Auto-detection of region changes**: Service checks config before each request and re-initializes if region changed
|
|
||||||
- **Cache clearing**: When region changes, ConfigService cache and AudibleService initialization are cleared
|
|
||||||
- **Automatic refresh**: Changing region automatically triggers `audible_refresh` job to fetch new data
|
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
- Types: `src/lib/types/audible.ts`
|
- Types: `src/lib/types/audible.ts`
|
||||||
- Service: `src/lib/integrations/audible.service.ts`
|
- Service: `src/lib/integrations/audible.service.ts`
|
||||||
|
- Series (HTML): `src/lib/integrations/audible-series.ts`
|
||||||
- Config: `src/lib/services/config.service.ts`
|
- Config: `src/lib/services/config.service.ts`
|
||||||
- API: `src/app/api/admin/settings/audible/route.ts`
|
- API: `src/app/api/admin/settings/audible/route.ts`
|
||||||
|
|
||||||
## Discovery Strategy (Popular/New/Search)
|
|
||||||
|
|
||||||
- Parse Audible HTML with Cheerio
|
|
||||||
- Multi-page scraping (20 items/page)
|
|
||||||
- Rate limit: max 10 req/min, 1.5s delay between pages
|
|
||||||
- Cache results in database (24hr TTL)
|
|
||||||
|
|
||||||
## Data Sources
|
|
||||||
|
|
||||||
URLs dynamically built based on configured region:
|
|
||||||
|
|
||||||
1. **Best Sellers:** `{baseUrl}/adblbestsellers`
|
|
||||||
2. **New Releases:** `{baseUrl}/newreleases`
|
|
||||||
3. **Search:** `{baseUrl}/search?keywords={query}&ipRedirectOverride=true`
|
|
||||||
4. **Detail Page:** `{baseUrl}/pd/{asin}?ipRedirectOverride=true`
|
|
||||||
5. **Audnexus API:** `https://api.audnex.us/books/{asin}?region={code}`
|
|
||||||
|
|
||||||
Where `{baseUrl}` is determined by configured region (e.g., `https://www.audible.co.uk` for UK).
|
|
||||||
|
|
||||||
## Metadata Extracted
|
|
||||||
|
|
||||||
- ASIN (Audible ID)
|
|
||||||
- Title, author, narrator
|
|
||||||
- Duration (minutes), release date, rating
|
|
||||||
- Description, cover art URL
|
|
||||||
- Genres/categories
|
|
||||||
|
|
||||||
## Unified Matching (`audiobook-matcher.ts`)
|
## Unified Matching (`audiobook-matcher.ts`)
|
||||||
|
|
||||||
**Status:** ✅ Production Ready (ASIN-Only Matching)
|
**Status:** Production Ready (ASIN-Only Matching)
|
||||||
|
|
||||||
Single matching algorithm used everywhere (search, popular, new-releases, jobs).
|
Single matching algorithm used everywhere (search, popular, new-releases, jobs).
|
||||||
|
|
||||||
@@ -112,50 +139,80 @@ Single matching algorithm used everywhere (search, popular, new-releases, jobs).
|
|||||||
- `findPlexMatch()`: ASIN (field) → ASIN (GUID) → null
|
- `findPlexMatch()`: ASIN (field) → ASIN (GUID) → null
|
||||||
- `matchAudiobook()`: ASIN → ISBN → null
|
- `matchAudiobook()`: ASIN → ISBN → null
|
||||||
|
|
||||||
**Benefits:**
|
**Note:** Fuzzy matching (70% threshold) is preserved in `ranking-algorithm.ts` for Prowlarr torrent ranking. Library availability checks require exact ASIN matches only.
|
||||||
- Real-time matching at query time (not pre-matched)
|
|
||||||
- 100% confidence matches only (eliminates false positives)
|
|
||||||
- O(1) indexed lookups (faster than fuzzy matching)
|
|
||||||
- Solves race condition with Audiobookshelf ASIN population
|
|
||||||
- Used by all APIs for consistency
|
|
||||||
|
|
||||||
**Note:** Fuzzy matching (70% threshold) is preserved in `ranking-algorithm.ts` for Prowlarr torrent ranking, where it's needed to score multiple release candidates. 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
|
||||||
|
|
||||||
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` job runs daily → fetches 200 popular + 200 new releases + user-configured categories
|
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 (reduces Audible load)
|
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
|
||||||
|
|
||||||
**Status:** ✅ Implemented
|
**Status:** Implemented
|
||||||
|
|
||||||
Cover images cached locally to reduce external requests and improve performance.
|
Cover images cached locally to reduce external requests.
|
||||||
|
|
||||||
**Features:**
|
- Downloads covers during `audible_refresh` job.
|
||||||
- Downloads covers during `audible_refresh` job
|
- Stores in `/app/cache/thumbnails` (Docker volume).
|
||||||
- Stores in `/app/cache/thumbnails` (Docker volume)
|
- Serves via `/api/cache/thumbnails/[filename]`.
|
||||||
- Serves via `/api/cache/thumbnails/[filename]`
|
- Auto-cleanup of unused thumbnails.
|
||||||
- Auto-cleanup of unused thumbnails
|
- Falls back to original URL if cache fails.
|
||||||
- Falls back to original URL if cache fails
|
- 24-hour browser cache headers.
|
||||||
- 24-hour browser cache headers
|
- Filename: `{asin}.{ext}` (e.g. `B08G9PRS1K.jpg`).
|
||||||
|
|
||||||
**Implementation:**
|
**Files:**
|
||||||
- Service: `src/lib/services/thumbnail-cache.service.ts`
|
- Service: `src/lib/services/thumbnail-cache.service.ts`
|
||||||
- API Route: `src/app/api/cache/thumbnails/[filename]/route.ts`
|
- API Route: `src/app/api/cache/thumbnails/[filename]/route.ts`
|
||||||
- Storage: Docker volume `cache` mounted at `/app/cache`
|
- Storage: Docker volume `cache` mounted at `/app/cache`
|
||||||
- Filename: `{asin}.{ext}` (e.g., `B08G9PRS1K.jpg`)
|
|
||||||
|
|
||||||
**API Endpoints:**
|
## App-Level API Endpoints
|
||||||
|
|
||||||
**GET /api/audiobooks/popular?page=1&limit=20**
|
**GET /api/audiobooks/popular?page=1&limit=20**
|
||||||
**GET /api/audiobooks/new-releases?page=1&limit=20**
|
**GET /api/audiobooks/new-releases?page=1&limit=20**
|
||||||
@@ -182,6 +239,7 @@ interface AudibleAudiobook {
|
|||||||
asin: string;
|
asin: string;
|
||||||
title: string;
|
title: string;
|
||||||
author: string;
|
author: string;
|
||||||
|
authorAsin?: string;
|
||||||
narrator?: string;
|
narrator?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
coverArtUrl?: string;
|
coverArtUrl?: string;
|
||||||
@@ -189,6 +247,12 @@ interface AudibleAudiobook {
|
|||||||
releaseDate?: string;
|
releaseDate?: string;
|
||||||
rating?: number;
|
rating?: number;
|
||||||
genres?: string[];
|
genres?: string[];
|
||||||
|
series?: string;
|
||||||
|
seriesPart?: string;
|
||||||
|
seriesAsin?: string;
|
||||||
|
language?: string;
|
||||||
|
formatType?: string;
|
||||||
|
publisherName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface EnrichedAudibleAudiobook extends AudibleAudiobook {
|
interface EnrichedAudibleAudiobook extends AudibleAudiobook {
|
||||||
@@ -197,48 +261,58 @@ interface EnrichedAudibleAudiobook extends AudibleAudiobook {
|
|||||||
plexGuid: string | null;
|
plexGuid: string | null;
|
||||||
dbId: string;
|
dbId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface AudibleSearchResult {
|
||||||
|
query: string;
|
||||||
|
results: AudibleAudiobook[];
|
||||||
|
totalResults: number;
|
||||||
|
page: number;
|
||||||
|
hasMore: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthorBooksResult {
|
||||||
|
books: AudibleAudiobook[];
|
||||||
|
hasMore: boolean;
|
||||||
|
page: number;
|
||||||
|
totalResults: number;
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
- axios (HTTP)
|
- `axios` (HTTP, two clients: `apiClient` for JSON catalog API, `htmlClient` for HTML refresh + series scraping)
|
||||||
- cheerio (HTML parsing)
|
- `cheerio` (HTML parsing for refresh job and `audible-series.ts`)
|
||||||
- Redis (caching, optional)
|
- Audnexus API (per-ASIN details, primary)
|
||||||
- Database (PostgreSQL)
|
- PostgreSQL (`audible_cache`, `audible_cache_categories`)
|
||||||
- string-similarity (matching)
|
|
||||||
|
|
||||||
## Fixed Issues
|
## Fixed Issues
|
||||||
|
|
||||||
**Search returning empty results (2026-01-07)**
|
**Series-page duplicates not collapsing across user views (2026-05-14)**
|
||||||
- **Problem:** Audible changed HTML structure for search results from `.productListItem` to `.s-result-item`
|
- **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.
|
||||||
- **Impact:** All search queries returned 0 results
|
- **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:** Updated `search()` method to support both `.s-result-item` (current) and `.productListItem` (legacy)
|
- **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.
|
||||||
- **Selectors updated:**
|
- **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.
|
||||||
- Main: `.s-result-item, .productListItem`
|
|
||||||
- Title: `h2` (new) or `h3 a` (legacy)
|
|
||||||
- Author: `a[href*="/author/"]` (new) or `.authorLabel` (legacy)
|
|
||||||
- Narrator: `a[href*="searchNarrator="]` (new) or `.narratorLabel` (legacy)
|
|
||||||
- Runtime: `span:contains("Length:")` (new) or `.runtimeLabel` (legacy)
|
|
||||||
- Rating: `.a-icon-star span` (new) or `.ratingsLabel` (legacy)
|
|
||||||
- **Location:** `src/lib/integrations/audible.service.ts:235`
|
|
||||||
|
|
||||||
**Some audiobooks missing from search results (2026-01-07)**
|
**Discovery refresh reverted to curated HTML scraping (2026-05-14)**
|
||||||
- **Problem:** ASIN extraction only matched `/pd/` URLs but some audiobooks use `/ac/` URLs
|
- **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.
|
||||||
- **Impact:** Books like "Beatitude" by DJ Krimmer (ASIN: B0DVH7XL36) were skipped
|
- **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:** Updated ASIN regex to match both `/pd/` and `/ac/` URL patterns: `/\/(?:pd|ac)\/[^\/]+\/([A-Z0-9]{10})/`
|
- **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:75, 161, 240`
|
- **Location:** `src/lib/integrations/audible.service.ts` (three methods + two private parsers `parseProductListItems` / `parseSearchResultItems`); `src/lib/utils/scrape-resilience.ts` (`jitteredBackoff` cap parameter).
|
||||||
- **Affects:** `getPopularAudiobooks()`, `getNewReleases()`, `search()` methods
|
|
||||||
|
|
||||||
**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 and poor search results
|
- **Impact:** Users with non-US regions (CA, UK, AU, IN) had incorrect metadata matching in Audiobookshelf, causing wrong ASINs.
|
||||||
- **Fix:** Added `mapRegionToABSProvider()` to convert RMAB region codes to AudiobookShelf provider values. US → `'audible'`, others → `'audible.{region}'` (e.g., `'audible.ca'`, `'audible.uk'`)
|
- **Fix:** Added `mapRegionToABSProvider()` to convert RMAB region codes to Audiobookshelf provider values. US → `'audible'`, others → `'audible.{region}'` (e.g. `'audible.ca'`, `'audible.uk'`).
|
||||||
- **Location:** `src/lib/services/audiobookshelf/api.ts:14, 147`
|
- **Location:** `src/lib/services/audiobookshelf/api.ts:14, 147`
|
||||||
- **Affects:** All Audiobookshelf metadata matching operations
|
|
||||||
|
|
||||||
**Non-English locale pages served to users outside US (2026-02-05)**
|
**Non-English locale pages served to users outside US (2026-02-05)**
|
||||||
- **Problem:** Audible uses IP geolocation to serve locale-specific pages (e.g., Spanish content for Dominican Republic IPs). `ipRedirectOverride=true` only prevents region redirects (audible.com → audible.co.uk), NOT language/locale changes.
|
- **Problem:** Audible uses IP geolocation to serve locale-specific pages. `ipRedirectOverride=true` only prevents region redirects, NOT language/locale changes.
|
||||||
- **Impact:** Users self-hosting from non-English-speaking countries got non-English bestsellers/new releases on their homepage.
|
- **Impact:** Users self-hosting from non-English-speaking countries got non-English content on HTML-scraped surfaces.
|
||||||
- **Fix:** Added `language=english` query parameter to all Audible requests via axios default params. Audible respects this parameter and serves English content regardless of IP geolocation. Fails gracefully for regions where English isn't available.
|
- **Fix:** Added `language=<audibleLocaleParam>` default param on `htmlClient` (axios default params). Still in effect for the remaining HTML path (`audible-series.ts`). **Not applied to `apiClient`** — the catalog JSON API is region-bound via `apiBaseUrl` and does not require the language param.
|
||||||
- **Location:** `src/lib/integrations/audible.service.ts` — `initialize()` (axios default params)
|
- **Location:** `src/lib/integrations/audible.service.ts` — `initialize()` (htmlClient params)
|
||||||
- **Affects:** All Audible scraping: popular, new releases, search, detail pages
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- [Audiobookshelf Integration](./audiobookshelf.md)
|
||||||
|
- [Plex Integration](./plex.md)
|
||||||
|
- [Ranking Algorithm](../phase3/ranking-algorithm.md)
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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}`
|
||||||
|
|
||||||
|
|||||||
Generated
+2
-2
@@ -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
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "readmeabook",
|
"name": "readmeabook",
|
||||||
"version": "1.1.2",
|
"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,24 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "ignored_audiobooks" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"user_id" TEXT NOT NULL,
|
||||||
|
"asin" TEXT NOT NULL,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"author" TEXT NOT NULL,
|
||||||
|
"cover_art_url" TEXT,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "ignored_audiobooks_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "ignored_audiobooks_user_id_idx" ON "ignored_audiobooks"("user_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "ignored_audiobooks_asin_idx" ON "ignored_audiobooks"("asin");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "ignored_audiobooks_user_id_asin_key" ON "ignored_audiobooks"("user_id", "asin");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ignored_audiobooks" ADD CONSTRAINT "ignored_audiobooks_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable - Add login_token_hash column for admin-generated login tokens
|
||||||
|
ALTER TABLE "users" ADD COLUMN "login_token_hash" TEXT;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable - Add sessions_invalidated_at column for immediate session revocation
|
||||||
|
ALTER TABLE "users" ADD COLUMN "sessions_invalidated_at" TIMESTAMPTZ;
|
||||||
@@ -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;
|
||||||
+76
-2
@@ -57,6 +57,12 @@ model User {
|
|||||||
interactiveSearchAccess Boolean? @map("interactive_search_access") // null = use global setting, true = allow, false = deny
|
interactiveSearchAccess Boolean? @map("interactive_search_access") // null = use global setting, true = allow, false = deny
|
||||||
downloadAccess Boolean? @map("download_access") // null = use global setting, true = allow, false = deny
|
downloadAccess Boolean? @map("download_access") // null = use global setting, true = allow, false = deny
|
||||||
|
|
||||||
|
// Login token (admin-generated, for direct URL login)
|
||||||
|
loginTokenHash String? @map("login_token_hash") // SHA-256 hash of the login token (never store plaintext)
|
||||||
|
|
||||||
|
// Session invalidation (set when login token is revoked to force-logout active sessions)
|
||||||
|
sessionsInvalidatedAt DateTime? @map("sessions_invalidated_at")
|
||||||
|
|
||||||
// Soft delete support
|
// Soft delete support
|
||||||
deletedAt DateTime? @map("deleted_at")
|
deletedAt DateTime? @map("deleted_at")
|
||||||
deletedBy String? @map("deleted_by") // Admin user ID who deleted this user
|
deletedBy String? @map("deleted_by") // Admin user ID who deleted this user
|
||||||
@@ -74,6 +80,7 @@ model User {
|
|||||||
watchedSeries WatchedSeries[]
|
watchedSeries WatchedSeries[]
|
||||||
watchedAuthors WatchedAuthor[]
|
watchedAuthors WatchedAuthor[]
|
||||||
homeSections UserHomeSection[]
|
homeSections UserHomeSection[]
|
||||||
|
ignoredAudiobooks IgnoredAudiobook[]
|
||||||
|
|
||||||
@@index([plexId])
|
@@index([plexId])
|
||||||
@@index([role])
|
@@index([role])
|
||||||
@@ -125,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)
|
||||||
|
|
||||||
@@ -216,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
|
||||||
@@ -227,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)
|
||||||
@@ -250,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])
|
||||||
@@ -261,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")
|
||||||
@@ -528,6 +574,7 @@ model GoodreadsShelf {
|
|||||||
lastSyncAt DateTime? @map("last_sync_at")
|
lastSyncAt DateTime? @map("last_sync_at")
|
||||||
bookCount Int? @map("book_count")
|
bookCount Int? @map("book_count")
|
||||||
coverUrls String? @map("cover_urls") @db.Text // JSON array of cover image URLs
|
coverUrls String? @map("cover_urls") @db.Text // JSON array of cover image URLs
|
||||||
|
autoRequest Boolean @default(true) @map("auto_request") // Whether to auto-create requests for books on this shelf
|
||||||
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")
|
||||||
|
|
||||||
@@ -578,6 +625,7 @@ model HardcoverShelf {
|
|||||||
lastSyncAt DateTime? @map("last_sync_at")
|
lastSyncAt DateTime? @map("last_sync_at")
|
||||||
bookCount Int? @map("book_count")
|
bookCount Int? @map("book_count")
|
||||||
coverUrls String? @map("cover_urls") @db.Text // JSON array of cover image URLs
|
coverUrls String? @map("cover_urls") @db.Text // JSON array of cover image URLs
|
||||||
|
autoRequest Boolean @default(true) @map("auto_request") // Whether to auto-create requests for books on this shelf
|
||||||
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")
|
||||||
|
|
||||||
@@ -673,6 +721,32 @@ model WatchedAuthor {
|
|||||||
@@map("watched_authors")
|
@@map("watched_authors")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// IGNORED AUDIOBOOK TABLE
|
||||||
|
// Per-user ignore list for auto-request suppression.
|
||||||
|
// Stores the ASIN the user clicked ignore on; works-system expansion
|
||||||
|
// happens at check-time in request-creator.service.ts.
|
||||||
|
// Documentation: documentation/features/ignored-audiobooks.md
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
model IgnoredAudiobook {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
userId String @map("user_id")
|
||||||
|
asin String // Audible ASIN that was explicitly ignored
|
||||||
|
title String // Display only — snapshot at ignore time
|
||||||
|
author String // Display only — snapshot at ignore time
|
||||||
|
coverArtUrl String? @map("cover_art_url") @db.Text
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([userId, asin])
|
||||||
|
@@index([userId])
|
||||||
|
@@index([asin])
|
||||||
|
@@map("ignored_audiobooks")
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// USER HOME SECTION TABLE
|
// USER HOME SECTION TABLE
|
||||||
// Per-user configurable home page sections (popular, new_releases, category)
|
// Per-user configurable home page sections (popular, new_releases, category)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
/**
|
||||||
|
* Component: Admin Blocklist Page
|
||||||
|
* Documentation: documentation/admin-features/release-blocklist.md
|
||||||
|
*
|
||||||
|
* Thin orchestrator: reads URL via useBlocklistUrlState, owns SWR + optimistic
|
||||||
|
* row state, composes sub-components. Mirrors /admin/logs/page.tsx patterns.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Suspense, useState, useEffect, useMemo } from 'react';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
import { ToastProvider } from '@/components/ui/Toast';
|
||||||
|
import { authenticatedFetcher } from '@/lib/utils/api';
|
||||||
|
import { useBlocklistUrlState } from './hooks/useBlocklistUrlState';
|
||||||
|
import {
|
||||||
|
BlockedReleaseRow,
|
||||||
|
BlocklistData,
|
||||||
|
buildBlocklistApiKey,
|
||||||
|
computeEmptyState,
|
||||||
|
hasActiveFilters,
|
||||||
|
hasActiveSearch,
|
||||||
|
ValidLimit,
|
||||||
|
} from './types';
|
||||||
|
import { BlocklistToolbar } from './components/BlocklistToolbar';
|
||||||
|
import BlocklistFilters from './components/BlocklistFilters';
|
||||||
|
import BlocklistActiveFilterChips from './components/BlocklistActiveFilterChips';
|
||||||
|
import { BlocklistTable } from './components/BlocklistTable';
|
||||||
|
import { BlocklistPagination } from './components/BlocklistPagination';
|
||||||
|
import { BlocklistSkeleton } from './components/BlocklistSkeleton';
|
||||||
|
|
||||||
|
function EmptyState({
|
||||||
|
kind,
|
||||||
|
onClearFilters,
|
||||||
|
onClearSearch,
|
||||||
|
searchValue,
|
||||||
|
}: {
|
||||||
|
kind: 'fresh' | 'filters-too-tight' | 'search-no-match';
|
||||||
|
onClearFilters: () => void;
|
||||||
|
onClearSearch: () => void;
|
||||||
|
searchValue: string;
|
||||||
|
}) {
|
||||||
|
if (kind === 'fresh') {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-16">
|
||||||
|
<p className="text-gray-700 dark:text-gray-300 text-base font-medium">
|
||||||
|
No blocked releases.
|
||||||
|
</p>
|
||||||
|
<p className="text-gray-500 dark:text-gray-400 text-sm mt-1">
|
||||||
|
RMAB will add releases here automatically when downloads or imports fail.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (kind === 'search-no-match') {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-16">
|
||||||
|
<p className="text-gray-700 dark:text-gray-300 text-base font-medium">
|
||||||
|
No matches for “{searchValue}”.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClearSearch}
|
||||||
|
className="mt-3 inline-flex items-center gap-1.5 min-h-[44px] px-4 py-2 text-sm font-medium text-blue-600 dark:text-blue-400 hover:underline"
|
||||||
|
>
|
||||||
|
Clear search
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="text-center py-16">
|
||||||
|
<p className="text-gray-700 dark:text-gray-300 text-base font-medium">
|
||||||
|
No entries match your current filters.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClearFilters}
|
||||||
|
className="mt-3 inline-flex items-center gap-1.5 min-h-[44px] px-4 py-2 text-sm font-medium text-blue-600 dark:text-blue-400 hover:underline"
|
||||||
|
>
|
||||||
|
Clear filters
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AdminBlocklistContent() {
|
||||||
|
const { filters, setFilters, clearAll } = useBlocklistUrlState();
|
||||||
|
const key = buildBlocklistApiKey(filters);
|
||||||
|
|
||||||
|
const { data, error, mutate } = useSWR<BlocklistData>(key, authenticatedFetcher, {
|
||||||
|
keepPreviousData: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Optimistic-removal overlay: ids removed by the current session's Unblock
|
||||||
|
// clicks. Once SWR returns fresh data, the next-render derivation drops any
|
||||||
|
// ids that are no longer present anyway.
|
||||||
|
const [optimisticRemoved, setOptimisticRemoved] = useState<Set<string>>(() => new Set());
|
||||||
|
|
||||||
|
// Reconcile optimistic state with server data: any id we removed that is
|
||||||
|
// also absent from the new data can be forgotten.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!data) return;
|
||||||
|
setOptimisticRemoved((prev) => {
|
||||||
|
if (prev.size === 0) return prev;
|
||||||
|
const serverIds = new Set(data.entries.map((e) => e.id));
|
||||||
|
const next = new Set<string>();
|
||||||
|
for (const id of prev) {
|
||||||
|
if (serverIds.has(id)) next.add(id);
|
||||||
|
}
|
||||||
|
return next.size === prev.size ? prev : next;
|
||||||
|
});
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
const visibleEntries = useMemo<BlockedReleaseRow[]>(() => {
|
||||||
|
if (!data) return [];
|
||||||
|
if (optimisticRemoved.size === 0) return data.entries;
|
||||||
|
return data.entries.filter((e) => !optimisticRemoved.has(e.id));
|
||||||
|
}, [data, optimisticRemoved]);
|
||||||
|
|
||||||
|
const handleUnblocked = (id: string) => {
|
||||||
|
setOptimisticRemoved((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.add(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUnblockFailed = (entry: BlockedReleaseRow) => {
|
||||||
|
// Roll back the optimistic removal. The next SWR cycle will re-fetch.
|
||||||
|
setOptimisticRemoved((prev) => {
|
||||||
|
if (!prev.has(entry.id)) return prev;
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(entry.id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBulkCleared = () => {
|
||||||
|
// Drop optimistic state and refresh — bulk delete invalidates row mapping.
|
||||||
|
setOptimisticRemoved(new Set());
|
||||||
|
mutate();
|
||||||
|
};
|
||||||
|
|
||||||
|
const showSkeleton = !data;
|
||||||
|
const total = data?.pagination.total ?? 0;
|
||||||
|
const pagination = data?.pagination ?? {
|
||||||
|
page: filters.page,
|
||||||
|
limit: filters.limit,
|
||||||
|
total: 0,
|
||||||
|
totalPages: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
const emptyKind = computeEmptyState({
|
||||||
|
total: visibleEntries.length,
|
||||||
|
hasFilters: hasActiveFilters(filters),
|
||||||
|
hasSearch: hasActiveSearch(filters),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 sm:py-8">
|
||||||
|
<BlocklistToolbar total={total} onCleared={handleBulkCleared} />
|
||||||
|
<BlocklistFilters />
|
||||||
|
<BlocklistActiveFilterChips />
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-6 p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||||
|
<h3 className="text-sm font-medium text-red-800 dark:text-red-200">
|
||||||
|
Error Loading Blocklist
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-red-700 dark:text-red-300 mt-1">
|
||||||
|
{error?.message || 'Failed to load blocklist'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showSkeleton ? (
|
||||||
|
<BlocklistSkeleton />
|
||||||
|
) : emptyKind ? (
|
||||||
|
<EmptyState
|
||||||
|
kind={emptyKind}
|
||||||
|
onClearFilters={clearAll}
|
||||||
|
onClearSearch={() => setFilters({ search: '' })}
|
||||||
|
searchValue={filters.search}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<BlocklistTable
|
||||||
|
entries={visibleEntries}
|
||||||
|
onUnblocked={handleUnblocked}
|
||||||
|
onUnblockFailed={handleUnblockFailed}
|
||||||
|
/>
|
||||||
|
<BlocklistPagination
|
||||||
|
pagination={pagination}
|
||||||
|
onPageChange={(page) => setFilters({ page })}
|
||||||
|
onLimitChange={(limit: ValidLimit) => setFilters({ limit })}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminBlocklistPage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<ToastProvider>
|
||||||
|
<AdminBlocklistContent />
|
||||||
|
</ToastProvider>
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
/**
|
||||||
|
* Component: Admin Blocklist — Shared Types & Filter Contract
|
||||||
|
* Documentation: documentation/admin-features/release-blocklist.md
|
||||||
|
*
|
||||||
|
* URL ↔ API param contract for the /admin/blocklist page. URL param names ===
|
||||||
|
* API query param names — no translation layer.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const BLOCKLIST_PARAMS = {
|
||||||
|
search: 'search',
|
||||||
|
source: 'source',
|
||||||
|
requestId: 'requestId',
|
||||||
|
dateFrom: 'dateFrom',
|
||||||
|
dateTo: 'dateTo',
|
||||||
|
sortBy: 'sortBy',
|
||||||
|
sortOrder: 'sortOrder',
|
||||||
|
page: 'page',
|
||||||
|
limit: 'limit',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const VALID_LIMITS = [25, 50, 100] as const;
|
||||||
|
export type ValidLimit = (typeof VALID_LIMITS)[number];
|
||||||
|
|
||||||
|
export const VALID_SOURCES = ['all', 'organize_fail', 'download_fail', 'manual'] as const;
|
||||||
|
export type BlockSourceFilter = (typeof VALID_SOURCES)[number];
|
||||||
|
|
||||||
|
export const VALID_SORT_FIELDS = ['createdAt', 'releaseName', 'reason'] as const;
|
||||||
|
export type SortField = (typeof VALID_SORT_FIELDS)[number];
|
||||||
|
|
||||||
|
export const VALID_SORT_ORDERS = ['asc', 'desc'] as const;
|
||||||
|
export type SortOrder = (typeof VALID_SORT_ORDERS)[number];
|
||||||
|
|
||||||
|
export const DEFAULT_LIMIT: ValidLimit = 50;
|
||||||
|
export const DEFAULT_PAGE = 1;
|
||||||
|
export const DEFAULT_SORT_BY: SortField = 'createdAt';
|
||||||
|
export const DEFAULT_SORT_ORDER: SortOrder = 'desc';
|
||||||
|
|
||||||
|
export interface BlocklistFilterState {
|
||||||
|
search: string;
|
||||||
|
source: BlockSourceFilter;
|
||||||
|
requestId: string | null;
|
||||||
|
dateFrom: string | null;
|
||||||
|
dateTo: string | null;
|
||||||
|
sortBy: SortField;
|
||||||
|
sortOrder: SortOrder;
|
||||||
|
page: number;
|
||||||
|
limit: ValidLimit;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_FILTER_STATE: BlocklistFilterState = {
|
||||||
|
search: '',
|
||||||
|
source: 'all',
|
||||||
|
requestId: null,
|
||||||
|
dateFrom: null,
|
||||||
|
dateTo: null,
|
||||||
|
sortBy: DEFAULT_SORT_BY,
|
||||||
|
sortOrder: DEFAULT_SORT_ORDER,
|
||||||
|
page: DEFAULT_PAGE,
|
||||||
|
limit: DEFAULT_LIMIT,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SOURCE_LABELS: Record<BlockSourceFilter, string> = {
|
||||||
|
all: 'All sources',
|
||||||
|
organize_fail: 'Organize failure',
|
||||||
|
download_fail: 'Download failure',
|
||||||
|
manual: 'Manual',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SOURCE_BADGE_LABEL: Record<string, string> = {
|
||||||
|
organize_fail: 'Organize',
|
||||||
|
download_fail: 'Download',
|
||||||
|
manual: 'Manual',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// API response shape — mirrors the route's `select` projection.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
export interface BlockedReleaseRequestRelation {
|
||||||
|
id: string;
|
||||||
|
deletedAt: string | null;
|
||||||
|
audiobook: { title: string; author: string } | null;
|
||||||
|
user: { plexUsername: string } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BlockedReleaseRow {
|
||||||
|
id: string;
|
||||||
|
requestId: string;
|
||||||
|
releaseName: string;
|
||||||
|
releaseHash: string | null;
|
||||||
|
indexerName: string | null;
|
||||||
|
indexerId: number | null;
|
||||||
|
source: string;
|
||||||
|
reason: string;
|
||||||
|
reasonDetail: string | null;
|
||||||
|
downloadHistoryId: string | null;
|
||||||
|
jobId: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
request: BlockedReleaseRequestRelation | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BlocklistPagination {
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
total: number;
|
||||||
|
totalPages: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BlocklistData {
|
||||||
|
entries: BlockedReleaseRow[];
|
||||||
|
pagination: BlocklistPagination;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// SWR / URL builders — single source of truth for the API query string.
|
||||||
|
// `buildBlocklistQueryString` is reused by the bulk-clear DELETE call so the
|
||||||
|
// clear-scope matches what the user sees.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
export function buildBlocklistQueryString(state: BlocklistFilterState): string {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set(BLOCKLIST_PARAMS.page, String(state.page));
|
||||||
|
params.set(BLOCKLIST_PARAMS.limit, String(state.limit));
|
||||||
|
|
||||||
|
if (state.source && state.source !== 'all') {
|
||||||
|
params.set(BLOCKLIST_PARAMS.source, state.source);
|
||||||
|
}
|
||||||
|
if (state.requestId) params.set(BLOCKLIST_PARAMS.requestId, state.requestId);
|
||||||
|
if (state.search) params.set(BLOCKLIST_PARAMS.search, state.search);
|
||||||
|
if (state.dateFrom) params.set(BLOCKLIST_PARAMS.dateFrom, state.dateFrom);
|
||||||
|
if (state.dateTo) params.set(BLOCKLIST_PARAMS.dateTo, state.dateTo);
|
||||||
|
if (state.sortBy !== DEFAULT_SORT_BY) params.set(BLOCKLIST_PARAMS.sortBy, state.sortBy);
|
||||||
|
if (state.sortOrder !== DEFAULT_SORT_ORDER) {
|
||||||
|
params.set(BLOCKLIST_PARAMS.sortOrder, state.sortOrder);
|
||||||
|
}
|
||||||
|
|
||||||
|
return params.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildBlocklistApiKey(state: BlocklistFilterState): string {
|
||||||
|
return `/api/admin/blocklist?${buildBlocklistQueryString(state)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the query string the bulk-clear DELETE call should use. Strips
|
||||||
|
* page/limit/sort (irrelevant for delete scope) — only filter axes survive.
|
||||||
|
*/
|
||||||
|
export function buildBulkClearQueryString(state: BlocklistFilterState): string {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (state.source && state.source !== 'all') {
|
||||||
|
params.set(BLOCKLIST_PARAMS.source, state.source);
|
||||||
|
}
|
||||||
|
if (state.requestId) params.set(BLOCKLIST_PARAMS.requestId, state.requestId);
|
||||||
|
if (state.search) params.set(BLOCKLIST_PARAMS.search, state.search);
|
||||||
|
if (state.dateFrom) params.set(BLOCKLIST_PARAMS.dateFrom, state.dateFrom);
|
||||||
|
if (state.dateTo) params.set(BLOCKLIST_PARAMS.dateTo, state.dateTo);
|
||||||
|
return params.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Filter-state predicates — drive empty-state copy + chip strip + Clear button
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
export function hasActiveFilters(state: BlocklistFilterState): boolean {
|
||||||
|
return (
|
||||||
|
state.source !== 'all' ||
|
||||||
|
state.requestId !== null ||
|
||||||
|
state.dateFrom !== null ||
|
||||||
|
state.dateTo !== null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasActiveSearch(state: BlocklistFilterState): boolean {
|
||||||
|
return state.search !== '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EmptyStateKind = 'fresh' | 'filters-too-tight' | 'search-no-match';
|
||||||
|
|
||||||
|
export function computeEmptyState(args: {
|
||||||
|
total: number;
|
||||||
|
hasFilters: boolean;
|
||||||
|
hasSearch: boolean;
|
||||||
|
}): EmptyStateKind | null {
|
||||||
|
if (args.total > 0) return null;
|
||||||
|
if (args.hasSearch) return 'search-no-match';
|
||||||
|
if (args.hasFilters) return 'filters-too-tight';
|
||||||
|
return 'fresh';
|
||||||
|
}
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
/**
|
||||||
|
* Component: Blocked Releases Chip (request-detail surface)
|
||||||
|
* Documentation: documentation/admin-features/release-blocklist.md
|
||||||
|
*
|
||||||
|
* Visible chip on a request row showing "N releases blocked" — click to expand
|
||||||
|
* a popover listing names + reasons. Real <button> with explicit chevron, no
|
||||||
|
* surprise expansion (per zach.md UX rule on intentional affordances).
|
||||||
|
*
|
||||||
|
* Fetches the per-request blocklist on first expand only (lazy) — closing
|
||||||
|
* collapses the panel without re-fetch. Each "Unblock" inside the panel hits
|
||||||
|
* the same DELETE endpoint as the admin blocklist page.
|
||||||
|
*
|
||||||
|
* Displayed release names are rendered verbatim — chips/badges add context,
|
||||||
|
* they don't replace (per zach.md "displayed source data stays true to source").
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { useToast } from '@/components/ui/Toast';
|
||||||
|
import { fetchWithAuth, authenticatedFetcher } from '@/lib/utils/api';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
import { SOURCE_BADGE_LABEL } from '@/app/admin/blocklist/types';
|
||||||
|
import type { BlockedReleaseRow } from '@/app/admin/blocklist/types';
|
||||||
|
|
||||||
|
interface BlockedReleasesChipProps {
|
||||||
|
requestId: string;
|
||||||
|
blockedCount: number;
|
||||||
|
/** Called after a successful unblock so the parent table can refresh. */
|
||||||
|
onChange: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ByRequestResponse {
|
||||||
|
entries: BlockedReleaseRow[];
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BlockedReleasesChip({ requestId, blockedCount, onChange }: BlockedReleasesChipProps) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const buttonRef = useRef<HTMLButtonElement | null>(null);
|
||||||
|
const popoverRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const [position, setPosition] = useState<{ top: number; left: number } | null>(null);
|
||||||
|
|
||||||
|
const swrKey = isOpen ? `/api/admin/blocklist/by-request/${requestId}` : null;
|
||||||
|
const { data, error, mutate, isLoading } = useSWR<ByRequestResponse>(swrKey, authenticatedFetcher);
|
||||||
|
|
||||||
|
// Recompute popover anchor when opening or on window resize/scroll.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
const recompute = () => {
|
||||||
|
const el = buttonRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
setPosition({
|
||||||
|
top: rect.bottom + 6,
|
||||||
|
left: rect.left,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
recompute();
|
||||||
|
window.addEventListener('resize', recompute);
|
||||||
|
window.addEventListener('scroll', recompute, true);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('resize', recompute);
|
||||||
|
window.removeEventListener('scroll', recompute, true);
|
||||||
|
};
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
// Close on outside click or Escape.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
const handleClick = (e: MouseEvent) => {
|
||||||
|
const target = e.target as Node;
|
||||||
|
if (
|
||||||
|
popoverRef.current?.contains(target) ||
|
||||||
|
buttonRef.current?.contains(target)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsOpen(false);
|
||||||
|
};
|
||||||
|
const handleKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') setIsOpen(false);
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', handleClick);
|
||||||
|
document.addEventListener('keydown', handleKey);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleClick);
|
||||||
|
document.removeEventListener('keydown', handleKey);
|
||||||
|
};
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
if (blockedCount <= 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
ref={buttonRef}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsOpen((v) => !v)}
|
||||||
|
aria-expanded={isOpen}
|
||||||
|
aria-label={`${blockedCount} ${blockedCount === 1 ? 'release' : 'releases'} blocked — show details`}
|
||||||
|
title={`${blockedCount} ${blockedCount === 1 ? 'release' : 'releases'} blocked for this request`}
|
||||||
|
className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-200 hover:bg-amber-200 dark:hover:bg-amber-900/60 transition-colors min-h-[24px]"
|
||||||
|
>
|
||||||
|
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
|
||||||
|
</svg>
|
||||||
|
<span>{blockedCount} {blockedCount === 1 ? 'release' : 'releases'} blocked</span>
|
||||||
|
<svg
|
||||||
|
className={`w-3 h-3 transition-transform duration-200 ${isOpen ? 'rotate-180' : ''}`}
|
||||||
|
fill="currentColor"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path fillRule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isOpen && position && typeof window !== 'undefined' && createPortal(
|
||||||
|
<div
|
||||||
|
ref={popoverRef}
|
||||||
|
role="dialog"
|
||||||
|
aria-label="Blocked releases"
|
||||||
|
style={{ top: position.top, left: position.left }}
|
||||||
|
className="fixed z-50 w-80 max-w-[calc(100vw-2rem)] max-h-[60vh] overflow-y-auto bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-xl"
|
||||||
|
>
|
||||||
|
<div className="px-3 py-2 border-b border-gray-200 dark:border-gray-700 sticky top-0 bg-white dark:bg-gray-800 flex items-center justify-between">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||||
|
Blocked for this request
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsOpen(false)}
|
||||||
|
aria-label="Close"
|
||||||
|
className="p-1 -mr-1 rounded text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||||
|
>
|
||||||
|
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-3">
|
||||||
|
{isLoading && (
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">Loading…</p>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm text-red-600 dark:text-red-400">Failed to load blocked releases.</p>
|
||||||
|
)}
|
||||||
|
{data && data.entries.length === 0 && (
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">No blocked releases.</p>
|
||||||
|
)}
|
||||||
|
{data && data.entries.length > 0 && (
|
||||||
|
<ul className="space-y-3">
|
||||||
|
{data.entries.map((entry) => (
|
||||||
|
<BlockedEntryItem
|
||||||
|
key={entry.id}
|
||||||
|
entry={entry}
|
||||||
|
onRemoved={() => {
|
||||||
|
mutate();
|
||||||
|
onChange();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BlockedEntryItem({
|
||||||
|
entry,
|
||||||
|
onRemoved,
|
||||||
|
}: {
|
||||||
|
entry: BlockedReleaseRow;
|
||||||
|
onRemoved: () => void;
|
||||||
|
}) {
|
||||||
|
const toast = useToast();
|
||||||
|
const [isUnblocking, setIsUnblocking] = useState(false);
|
||||||
|
|
||||||
|
const handleUnblock = async () => {
|
||||||
|
setIsUnblocking(true);
|
||||||
|
try {
|
||||||
|
const response = await fetchWithAuth(`/api/admin/blocklist/${entry.id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const body = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(body.error || body.message || 'Failed to unblock');
|
||||||
|
}
|
||||||
|
toast.success(`Unblocked: ${entry.releaseName}`);
|
||||||
|
onRemoved();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : 'Failed to unblock');
|
||||||
|
} finally {
|
||||||
|
setIsUnblocking(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const sourceLabel = SOURCE_BADGE_LABEL[entry.source] ?? entry.source;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li className="border border-gray-100 dark:border-gray-700/60 rounded-md p-2.5">
|
||||||
|
<p
|
||||||
|
className="text-sm text-gray-900 dark:text-gray-100 break-words"
|
||||||
|
title={entry.releaseName}
|
||||||
|
>
|
||||||
|
{entry.releaseName}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-1.5 mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-semibold uppercase tracking-wide bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-200">
|
||||||
|
{sourceLabel}
|
||||||
|
</span>
|
||||||
|
<span className="truncate" title={entry.reason}>{entry.reason}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleUnblock}
|
||||||
|
disabled={isUnblocking}
|
||||||
|
className="inline-flex items-center gap-1 px-2.5 py-1 text-xs font-medium text-blue-700 dark:text-blue-300 bg-blue-50 dark:bg-blue-900/30 hover:bg-blue-100 dark:hover:bg-blue-900/50 rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{isUnblocking ? 'Unblocking…' : 'Unblock'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import { mutate } from 'swr';
|
|||||||
import { authenticatedFetcher, fetchWithAuth } from '@/lib/utils/api';
|
import { 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'].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
@@ -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 "Trigger Now" 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 "{level}".
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 “{query}”
|
||||||
|
</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]);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
+181
-434
@@ -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>
|
||||||
</div>
|
);
|
||||||
|
}
|
||||||
|
if (kind === 'search-no-match') {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-16">
|
||||||
|
<p className="text-gray-700 dark:text-gray-300 text-base font-medium">
|
||||||
|
No matches for “{searchValue}”.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClearSearch}
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
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} · {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 "Show Details" 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
}
|
||||||
+176
-45
@@ -14,7 +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 { 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';
|
||||||
|
|
||||||
@@ -55,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 }));
|
||||||
|
|
||||||
@@ -124,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 */}
|
||||||
@@ -169,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">
|
||||||
@@ -313,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>
|
||||||
);
|
);
|
||||||
@@ -374,11 +418,44 @@ 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AdminDashboardContent() {
|
function AdminDashboardContent() {
|
||||||
|
const [isBulkImportOpen, setIsBulkImportOpen] = useState(false);
|
||||||
|
|
||||||
// Fetch data with auto-refresh every 10 seconds
|
// Fetch data with auto-refresh every 10 seconds
|
||||||
const { data: metrics, error: metricsError } = useSWR(
|
const { data: metrics, error: metricsError } = useSWR(
|
||||||
'/api/admin/metrics',
|
'/api/admin/metrics',
|
||||||
@@ -572,7 +649,7 @@ function AdminDashboardContent() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Quick Actions */}
|
{/* Quick Actions */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 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"
|
||||||
@@ -657,7 +734,61 @@ function AdminDashboardContent() {
|
|||||||
</span>
|
</span>
|
||||||
</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>
|
</div>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setIsBulkImportOpen(true)}
|
||||||
|
className="block p-6 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg hover:shadow-md transition-all text-left"
|
||||||
|
>
|
||||||
|
<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="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span className="font-medium text-gray-900 dark:text-gray-100">
|
||||||
|
Bulk Import
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bulk Import Wizard Modal */}
|
||||||
|
<BulkImportWizard
|
||||||
|
isOpen={isBulkImportOpen}
|
||||||
|
onClose={() => setIsBulkImportOpen(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Requests Awaiting Approval */}
|
{/* Requests Awaiting Approval */}
|
||||||
{pendingApprovalData?.requests && pendingApprovalData.requests.length > 0 && (
|
{pendingApprovalData?.requests && pendingApprovalData.requests.length > 0 && (
|
||||||
|
|||||||
@@ -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':
|
||||||
|
|||||||
@@ -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,9 +113,12 @@ 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;
|
||||||
|
fileChmod?: string;
|
||||||
|
dirChmod?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -152,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])
|
||||||
@@ -168,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">
|
||||||
@@ -439,6 +463,54 @@ export function PathsTab({ paths, onChange, onValidationChange }: PathsTabProps)
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* File Permissions */}
|
||||||
|
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-3">
|
||||||
|
File Permissions
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||||
|
Octal permissions applied when organizing files into the media library. These may be further restricted by the container's UMASK setting.
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
File Permissions
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={paths.fileChmod || '664'}
|
||||||
|
onChange={(e) => updatePath('fileChmod', e.target.value)}
|
||||||
|
placeholder="664"
|
||||||
|
className={`font-mono max-w-32 ${paths.fileChmod && !/^[0-7]{3,4}$/.test(paths.fileChmod) ? 'border-red-500 dark:border-red-500' : ''}`}
|
||||||
|
/>
|
||||||
|
{paths.fileChmod && !/^[0-7]{3,4}$/.test(paths.fileChmod) && (
|
||||||
|
<p className="text-xs text-red-600 dark:text-red-400 mt-1">Must be 3-4 octal digits (0-7)</p>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
e.g. 664 = owner/group read-write, others read
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
Directory Permissions
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={paths.dirChmod || '775'}
|
||||||
|
onChange={(e) => updatePath('dirChmod', e.target.value)}
|
||||||
|
placeholder="775"
|
||||||
|
className={`font-mono max-w-32 ${paths.dirChmod && !/^[0-7]{3,4}$/.test(paths.dirChmod) ? 'border-red-500 dark:border-red-500' : ''}`}
|
||||||
|
/>
|
||||||
|
{paths.dirChmod && !/^[0-7]{3,4}$/.test(paths.dirChmod) && (
|
||||||
|
<p className="text-xs text-red-600 dark:text-red-400 mt-1">Must be 3-4 octal digits (0-7)</p>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
e.g. 775 = owner/group full access, others read-execute
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Test Paths Button */}
|
{/* Test Paths Button */}
|
||||||
<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">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ interface User {
|
|||||||
autoApproveRequests: boolean | null;
|
autoApproveRequests: boolean | null;
|
||||||
interactiveSearchAccess: boolean | null;
|
interactiveSearchAccess: boolean | null;
|
||||||
downloadAccess: boolean | null;
|
downloadAccess: boolean | null;
|
||||||
|
hasLoginToken: boolean;
|
||||||
_count: {
|
_count: {
|
||||||
requests: number;
|
requests: number;
|
||||||
};
|
};
|
||||||
@@ -220,6 +221,7 @@ function AdminUsersPageContent() {
|
|||||||
const [globalDownloadAccess, setGlobalDownloadAccess] = useState<boolean>(true);
|
const [globalDownloadAccess, setGlobalDownloadAccess] = useState<boolean>(true);
|
||||||
const [globalSettingsOpen, setGlobalSettingsOpen] = useState(false);
|
const [globalSettingsOpen, setGlobalSettingsOpen] = useState(false);
|
||||||
const [permissionsUserId, setPermissionsUserId] = useState<string | null>(null);
|
const [permissionsUserId, setPermissionsUserId] = useState<string | null>(null);
|
||||||
|
const [generatedToken, setGeneratedToken] = useState<string | null>(null);
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
|
||||||
const isLoading = !data && !error;
|
const isLoading = !data && !error;
|
||||||
@@ -363,6 +365,24 @@ function AdminUsersPageContent() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleToggleToken = async (user: { id: string; plexUsername: string }, newValue: boolean) => {
|
||||||
|
try {
|
||||||
|
if (newValue) {
|
||||||
|
const result = await fetchJSON(`/api/admin/users/${user.id}/login-token`, { method: 'POST' });
|
||||||
|
setGeneratedToken(result.fullToken);
|
||||||
|
toast.success(`Login token generated for ${user.plexUsername}`);
|
||||||
|
} else {
|
||||||
|
await fetchJSON(`/api/admin/users/${user.id}/login-token`, { method: 'DELETE' });
|
||||||
|
setGeneratedToken(null);
|
||||||
|
toast.success(`Login token revoked for ${user.plexUsername}`);
|
||||||
|
}
|
||||||
|
mutate();
|
||||||
|
} catch (err) {
|
||||||
|
const errorMsg = err instanceof Error ? err.message : 'Failed to update login token';
|
||||||
|
toast.error(errorMsg);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const showEditDialog = (user: User) => {
|
const showEditDialog = (user: User) => {
|
||||||
setEditRole(user.role);
|
setEditRole(user.role);
|
||||||
setEditDialog({ isOpen: true, user });
|
setEditDialog({ isOpen: true, user });
|
||||||
@@ -968,11 +988,15 @@ function AdminUsersPageContent() {
|
|||||||
{/* User Permissions Modal */}
|
{/* User Permissions Modal */}
|
||||||
<UserPermissionsModal
|
<UserPermissionsModal
|
||||||
isOpen={permissionsUser !== null}
|
isOpen={permissionsUser !== null}
|
||||||
onClose={() => setPermissionsUserId(null)}
|
onClose={() => {
|
||||||
|
setPermissionsUserId(null);
|
||||||
|
setGeneratedToken(null);
|
||||||
|
}}
|
||||||
user={permissionsUser}
|
user={permissionsUser}
|
||||||
globalAutoApprove={globalAutoApprove}
|
globalAutoApprove={globalAutoApprove}
|
||||||
globalInteractiveSearch={globalInteractiveSearch}
|
globalInteractiveSearch={globalInteractiveSearch}
|
||||||
globalDownloadAccess={globalDownloadAccess}
|
globalDownloadAccess={globalDownloadAccess}
|
||||||
|
generatedToken={generatedToken}
|
||||||
onToggleAutoApprove={(user, newValue) => {
|
onToggleAutoApprove={(user, newValue) => {
|
||||||
handleUserAutoApproveToggle(user as User, newValue);
|
handleUserAutoApproveToggle(user as User, newValue);
|
||||||
}}
|
}}
|
||||||
@@ -982,6 +1006,9 @@ function AdminUsersPageContent() {
|
|||||||
onToggleDownloadAccess={(user, newValue) => {
|
onToggleDownloadAccess={(user, newValue) => {
|
||||||
handleUserDownloadAccessToggle(user as User, newValue);
|
handleUserDownloadAccessToggle(user as User, newValue);
|
||||||
}}
|
}}
|
||||||
|
onToggleToken={(user, newValue) => {
|
||||||
|
handleToggleToken(user, newValue);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -7,7 +7,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 { checkApiTokenRevokeRateLimit } from '@/lib/utils/apiTokenRateLimit';
|
import { checkApiTokenRevokeRateLimit } from '@/lib/utils/rateLimit';
|
||||||
|
|
||||||
const logger = RMABLogger.create('API.Admin.ApiTokens');
|
const logger = RMABLogger.create('API.Admin.ApiTokens');
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,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 { checkApiTokenCreateRateLimit } from '@/lib/utils/apiTokenRateLimit';
|
import { checkApiTokenCreateRateLimit } from '@/lib/utils/rateLimit';
|
||||||
import { MAX_TOKENS_PER_USER } from '@/lib/constants/api-tokens';
|
import { MAX_TOKENS_PER_USER } from '@/lib/constants/api-tokens';
|
||||||
import { generateApiToken } from '@/lib/utils/api-token';
|
import { generateApiToken } from '@/lib/utils/api-token';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
/**
|
||||||
|
* Component: Admin Blocklist — Single Unblock
|
||||||
|
* Documentation: documentation/admin-features/release-blocklist.md
|
||||||
|
*
|
||||||
|
* DELETE /api/admin/blocklist/[id] → removes a single blocklist entry.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||||
|
import { Prisma } from '@/generated/prisma';
|
||||||
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
|
import { removeBlock } from '@/lib/services/blocklist.service';
|
||||||
|
|
||||||
|
const logger = RMABLogger.create('API.Admin.Blocklist.Unblock');
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||||
|
return requireAdmin(req, async () => {
|
||||||
|
const { id } = await params;
|
||||||
|
if (!id || typeof id !== 'string' || id.trim().length === 0) {
|
||||||
|
return NextResponse.json({ error: 'Invalid id' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await removeBlock(id);
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
if (
|
||||||
|
error instanceof Prisma.PrismaClientKnownRequestError &&
|
||||||
|
error.code === 'P2025'
|
||||||
|
) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'NotFound', message: 'Blocklist entry not found' },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
logger.error('Failed to remove blocklist entry', {
|
||||||
|
id,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to remove blocklist entry' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
/**
|
||||||
|
* Component: Admin Blocklist — Per-Request Lookup
|
||||||
|
* Documentation: documentation/admin-features/release-blocklist.md
|
||||||
|
*
|
||||||
|
* GET /api/admin/blocklist/by-request/[requestId]
|
||||||
|
* → { entries: BlockedRelease[], count: number }
|
||||||
|
*
|
||||||
|
* Lightweight, unpaginated lookup used by:
|
||||||
|
* - The "N releases blocked" chip on the admin recent-requests table.
|
||||||
|
* - The InteractiveTorrentSearchModal "already blocked" badge.
|
||||||
|
*
|
||||||
|
* Per-request blocklists are bounded by indexer candidate count (~tens),
|
||||||
|
* so no pagination is needed.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||||
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
|
import { getBlocklistForRequest } from '@/lib/services/blocklist.service';
|
||||||
|
|
||||||
|
const logger = RMABLogger.create('API.Admin.Blocklist.ByRequest');
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ requestId: string }> }
|
||||||
|
) {
|
||||||
|
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||||
|
return requireAdmin(req, async () => {
|
||||||
|
const { requestId } = await params;
|
||||||
|
if (!requestId || typeof requestId !== 'string' || requestId.trim().length === 0) {
|
||||||
|
return NextResponse.json({ error: 'Invalid requestId' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const entries = await getBlocklistForRequest(requestId);
|
||||||
|
return NextResponse.json({ entries, count: entries.length });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to fetch blocklist for request', {
|
||||||
|
requestId,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to fetch blocklist for request' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
/**
|
||||||
|
* Component: Admin Blocklist API (list + filter-scoped bulk clear)
|
||||||
|
* Documentation: documentation/admin-features/release-blocklist.md
|
||||||
|
*
|
||||||
|
* GET /api/admin/blocklist → paginated, filtered, sorted list
|
||||||
|
* DELETE /api/admin/blocklist?…filters → filter-scoped bulk clear ("Clear filtered (N)")
|
||||||
|
*
|
||||||
|
* `buildBlocklistWhere` is exported as a pure function for the route tests AND
|
||||||
|
* for the DELETE handler to share with GET — the bulk clear MUST scope to the
|
||||||
|
* exact same rows the user is currently viewing.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||||
|
import { prisma } from '@/lib/db';
|
||||||
|
import { Prisma } from '@/generated/prisma';
|
||||||
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
|
import { clearBlocklist } from '@/lib/services/blocklist.service';
|
||||||
|
|
||||||
|
const logger = RMABLogger.create('API.Admin.Blocklist');
|
||||||
|
|
||||||
|
const VALID_LIMITS = [25, 50, 100] as const;
|
||||||
|
const DEFAULT_LIMIT = 50;
|
||||||
|
const VALID_SOURCES = ['organize_fail', 'download_fail', 'manual'] as const;
|
||||||
|
const VALID_SORT_FIELDS = ['createdAt', 'releaseName', 'reason'] as const;
|
||||||
|
const VALID_SORT_ORDERS = ['asc', 'desc'] as const;
|
||||||
|
|
||||||
|
export interface BlocklistWhereParams {
|
||||||
|
requestId?: string | null;
|
||||||
|
source?: string | null;
|
||||||
|
search?: string | null;
|
||||||
|
dateFrom?: string | null;
|
||||||
|
dateTo?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseLimit(raw: string | null): number {
|
||||||
|
const n = Number(raw);
|
||||||
|
return (VALID_LIMITS as readonly number[]).includes(n) ? n : DEFAULT_LIMIT;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePage(raw: string | null): number {
|
||||||
|
const n = parseInt(raw ?? '1', 10);
|
||||||
|
return Number.isFinite(n) && n >= 1 ? n : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDate(raw: string | null | undefined): Date | null {
|
||||||
|
if (!raw) return null;
|
||||||
|
const d = new Date(raw);
|
||||||
|
return Number.isNaN(d.getTime()) ? null : d;
|
||||||
|
}
|
||||||
|
|
||||||
|
function trim(raw: string | null | undefined): string | null {
|
||||||
|
if (!raw) return null;
|
||||||
|
const t = raw.trim();
|
||||||
|
return t.length > 0 ? t : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the Prisma where clause for blocklist queries.
|
||||||
|
* Pure function — same input always yields same output. Exported for tests AND
|
||||||
|
* for the DELETE handler so bulk-clear filter scope matches GET exactly.
|
||||||
|
*/
|
||||||
|
export function buildBlocklistWhere(
|
||||||
|
params: BlocklistWhereParams
|
||||||
|
): Prisma.BlockedReleaseWhereInput {
|
||||||
|
const where: Prisma.BlockedReleaseWhereInput = {};
|
||||||
|
|
||||||
|
const requestId = trim(params.requestId);
|
||||||
|
if (requestId) {
|
||||||
|
where.requestId = requestId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const source = trim(params.source);
|
||||||
|
if (source && source !== 'all' && (VALID_SOURCES as readonly string[]).includes(source)) {
|
||||||
|
where.source = source;
|
||||||
|
}
|
||||||
|
|
||||||
|
const from = parseDate(params.dateFrom);
|
||||||
|
const to = parseDate(params.dateTo);
|
||||||
|
if (from || to) {
|
||||||
|
where.createdAt = {
|
||||||
|
...(from ? { gte: from } : {}),
|
||||||
|
...(to ? { lte: to } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const search = trim(params.search);
|
||||||
|
if (search) {
|
||||||
|
where.OR = [
|
||||||
|
{ releaseName: { contains: search, mode: 'insensitive' } },
|
||||||
|
{ reason: { contains: search, mode: 'insensitive' } },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return where;
|
||||||
|
}
|
||||||
|
|
||||||
|
function whereFromSearchParams(searchParams: URLSearchParams): Prisma.BlockedReleaseWhereInput {
|
||||||
|
return buildBlocklistWhere({
|
||||||
|
requestId: searchParams.get('requestId'),
|
||||||
|
source: searchParams.get('source'),
|
||||||
|
search: searchParams.get('search'),
|
||||||
|
dateFrom: searchParams.get('dateFrom'),
|
||||||
|
dateTo: searchParams.get('dateTo'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||||
|
return requireAdmin(req, async () => {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const page = parsePage(searchParams.get('page'));
|
||||||
|
const limit = parseLimit(searchParams.get('limit'));
|
||||||
|
|
||||||
|
const sortByRaw = searchParams.get('sortBy') ?? 'createdAt';
|
||||||
|
const sortBy = (VALID_SORT_FIELDS as readonly string[]).includes(sortByRaw)
|
||||||
|
? (sortByRaw as (typeof VALID_SORT_FIELDS)[number])
|
||||||
|
: 'createdAt';
|
||||||
|
const sortOrderRaw = searchParams.get('sortOrder') ?? 'desc';
|
||||||
|
const sortOrder = (VALID_SORT_ORDERS as readonly string[]).includes(sortOrderRaw)
|
||||||
|
? (sortOrderRaw as (typeof VALID_SORT_ORDERS)[number])
|
||||||
|
: 'desc';
|
||||||
|
|
||||||
|
const where = whereFromSearchParams(searchParams);
|
||||||
|
|
||||||
|
const orderBy: Prisma.BlockedReleaseOrderByWithRelationInput = { [sortBy]: sortOrder };
|
||||||
|
|
||||||
|
const skip = (page - 1) * limit;
|
||||||
|
|
||||||
|
const [entries, totalCount] = await Promise.all([
|
||||||
|
prisma.blockedRelease.findMany({
|
||||||
|
where,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
requestId: true,
|
||||||
|
releaseName: true,
|
||||||
|
releaseHash: true,
|
||||||
|
indexerName: true,
|
||||||
|
indexerId: true,
|
||||||
|
source: true,
|
||||||
|
reason: true,
|
||||||
|
reasonDetail: true,
|
||||||
|
downloadHistoryId: true,
|
||||||
|
jobId: true,
|
||||||
|
createdAt: true,
|
||||||
|
request: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
deletedAt: true,
|
||||||
|
audiobook: { select: { title: true, author: true } },
|
||||||
|
user: { select: { plexUsername: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy,
|
||||||
|
skip,
|
||||||
|
take: limit,
|
||||||
|
}),
|
||||||
|
prisma.blockedRelease.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
entries,
|
||||||
|
pagination: {
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
total: totalCount,
|
||||||
|
totalPages: Math.max(1, Math.ceil(totalCount / limit)),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to fetch blocklist', {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to fetch blocklist' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/admin/blocklist?<same filter params as GET>
|
||||||
|
*
|
||||||
|
* Filter-scoped bulk clear. The "Clear filtered (N)" admin UI hits this with
|
||||||
|
* the exact same query string used for the current GET. Returns the count of
|
||||||
|
* rows actually deleted. Empty filters intentionally allowed — the UI gates
|
||||||
|
* with a typed-token confirmation modal; the server's job is enforcing the
|
||||||
|
* auth + admin boundary.
|
||||||
|
*/
|
||||||
|
export async function DELETE(request: NextRequest) {
|
||||||
|
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||||
|
return requireAdmin(req, async () => {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const where = whereFromSearchParams(searchParams);
|
||||||
|
const result = await clearBlocklist(where);
|
||||||
|
return NextResponse.json({ count: result.count });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to clear blocklist', {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to clear blocklist' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,304 @@
|
|||||||
|
/**
|
||||||
|
* Component: Bulk Import Execute API
|
||||||
|
* Documentation: documentation/features/bulk-import.md
|
||||||
|
*
|
||||||
|
* Queues manual imports for multiple audiobooks at once.
|
||||||
|
* Reuses the same logic as the single manual import endpoint.
|
||||||
|
* Admin-only.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||||
|
import { prisma } from '@/lib/db';
|
||||||
|
import { getJobQueueService } from '@/lib/services/job-queue.service';
|
||||||
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
|
import { AUDIO_EXTENSIONS } from '@/lib/constants/audio-formats';
|
||||||
|
import { getAudibleService } from '@/lib/integrations/audible.service';
|
||||||
|
|
||||||
|
const logger = RMABLogger.create('API.Admin.BulkImport.Execute');
|
||||||
|
|
||||||
|
const BOOKDROP_PATH = '/bookdrop';
|
||||||
|
|
||||||
|
/** Statuses that indicate the request is actively being worked on. */
|
||||||
|
const ACTIVE_STATUSES = ['searching', 'downloading', 'processing', 'awaiting_import'];
|
||||||
|
|
||||||
|
/** Statuses that can be recycled for a new manual import. */
|
||||||
|
const RECYCLABLE_STATUSES = [
|
||||||
|
'failed', 'warn', 'cancelled', 'denied', 'pending',
|
||||||
|
'awaiting_search', 'awaiting_approval',
|
||||||
|
];
|
||||||
|
|
||||||
|
interface ImportItem {
|
||||||
|
folderPath: string;
|
||||||
|
asin: string;
|
||||||
|
audioFiles?: string[]; // Specific files to import (from scanner grouping)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportResult {
|
||||||
|
folderPath: string;
|
||||||
|
asin: string;
|
||||||
|
success: boolean;
|
||||||
|
requestId?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check if a directory contains audio files. */
|
||||||
|
async function hasAudioFiles(dirPath: string): Promise<boolean> {
|
||||||
|
const fs = await import('fs/promises');
|
||||||
|
const pathModule = await import('path');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const children = await fs.readdir(dirPath, { withFileTypes: true });
|
||||||
|
return children.some(
|
||||||
|
(child) =>
|
||||||
|
child.isFile() &&
|
||||||
|
(AUDIO_EXTENSIONS as readonly string[]).includes(
|
||||||
|
pathModule.extname(child.name).toLowerCase()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||||
|
return requireAdmin(req, async () => {
|
||||||
|
try {
|
||||||
|
const pathModule = await import('path');
|
||||||
|
const fs = await import('fs/promises');
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const { imports } = body as { imports: ImportItem[] };
|
||||||
|
|
||||||
|
if (!imports || !Array.isArray(imports) || imports.length === 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'imports array is required and must not be empty' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load allowed roots
|
||||||
|
const [downloadDirConfig, mediaDirConfig] = await Promise.all([
|
||||||
|
prisma.configuration.findUnique({ where: { key: 'download_dir' } }),
|
||||||
|
prisma.configuration.findUnique({ where: { key: 'media_dir' } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const allowedRoots: string[] = [];
|
||||||
|
if (downloadDirConfig?.value) {
|
||||||
|
allowedRoots.push(pathModule.resolve(downloadDirConfig.value).replace(/\\/g, '/'));
|
||||||
|
}
|
||||||
|
if (mediaDirConfig?.value) {
|
||||||
|
allowedRoots.push(pathModule.resolve(mediaDirConfig.value).replace(/\\/g, '/'));
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const bookdropStat = await fs.stat(BOOKDROP_PATH);
|
||||||
|
if (bookdropStat.isDirectory()) {
|
||||||
|
allowedRoots.push(pathModule.resolve(BOOKDROP_PATH).replace(/\\/g, '/'));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* not mounted */
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = req.user!.id;
|
||||||
|
const audibleService = getAudibleService();
|
||||||
|
const jobQueue = getJobQueueService();
|
||||||
|
const results: ImportResult[] = [];
|
||||||
|
|
||||||
|
for (const item of imports) {
|
||||||
|
const { folderPath, asin, audioFiles: itemAudioFiles } = item;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Validate path
|
||||||
|
const normalizedPath = pathModule.resolve(folderPath).replace(/\\/g, '/');
|
||||||
|
const isAllowed = allowedRoots.some(
|
||||||
|
(root) => normalizedPath === root || normalizedPath.startsWith(root + '/')
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isAllowed) {
|
||||||
|
results.push({ folderPath, asin, success: false, error: 'Path outside allowed directories' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify directory exists
|
||||||
|
try {
|
||||||
|
const stat = await fs.stat(normalizedPath);
|
||||||
|
if (!stat.isDirectory()) {
|
||||||
|
results.push({ folderPath, asin, success: false, error: 'Not a directory' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
results.push({ folderPath, asin, success: false, error: 'Directory not found' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify audio files: if specific files provided, trust the scanner;
|
||||||
|
// otherwise fall back to folder-level check
|
||||||
|
if (!itemAudioFiles || itemAudioFiles.length === 0) {
|
||||||
|
const hasAudio = await hasAudioFiles(normalizedPath);
|
||||||
|
if (!hasAudio) {
|
||||||
|
results.push({ folderPath, asin, success: false, error: 'No audio files' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve or create audiobook record
|
||||||
|
let audiobookId: string;
|
||||||
|
let existingBook = await prisma.audiobook.findFirst({
|
||||||
|
where: { audibleAsin: asin },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingBook) {
|
||||||
|
audiobookId = existingBook.id;
|
||||||
|
} else {
|
||||||
|
// Try Audible cache, then Audnexus
|
||||||
|
const cached = await prisma.audibleCache.findUnique({ where: { asin } });
|
||||||
|
if (cached) {
|
||||||
|
const newBook = await prisma.audiobook.create({
|
||||||
|
data: {
|
||||||
|
audibleAsin: asin,
|
||||||
|
title: cached.title,
|
||||||
|
author: cached.author,
|
||||||
|
coverArtUrl: cached.coverArtUrl,
|
||||||
|
narrator: cached.narrator,
|
||||||
|
status: 'pending',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
audiobookId = newBook.id;
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const liveData = await audibleService.getAudiobookDetails(asin);
|
||||||
|
if (!liveData) {
|
||||||
|
results.push({ folderPath, asin, success: false, error: 'Audiobook not found' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const newBook = await prisma.audiobook.create({
|
||||||
|
data: {
|
||||||
|
audibleAsin: asin,
|
||||||
|
title: liveData.title,
|
||||||
|
author: liveData.author,
|
||||||
|
coverArtUrl: liveData.coverArtUrl,
|
||||||
|
narrator: liveData.narrator,
|
||||||
|
series: liveData.series,
|
||||||
|
seriesPart: liveData.seriesPart,
|
||||||
|
seriesAsin: liveData.seriesAsin,
|
||||||
|
year: liveData.releaseDate
|
||||||
|
? new Date(liveData.releaseDate).getFullYear() || undefined
|
||||||
|
: undefined,
|
||||||
|
status: 'pending',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
audiobookId = newBook.id;
|
||||||
|
} catch {
|
||||||
|
results.push({ folderPath, asin, success: false, error: 'Failed to fetch audiobook details' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for existing request and recycle or create
|
||||||
|
const existingRequest = await prisma.request.findFirst({
|
||||||
|
where: {
|
||||||
|
audiobookId,
|
||||||
|
type: 'audiobook',
|
||||||
|
deletedAt: null,
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
let requestId: string;
|
||||||
|
|
||||||
|
if (existingRequest) {
|
||||||
|
if (ACTIVE_STATUSES.includes(existingRequest.status)) {
|
||||||
|
results.push({ folderPath, asin, success: false, error: 'Already being processed' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
RECYCLABLE_STATUSES.includes(existingRequest.status) ||
|
||||||
|
existingRequest.status === 'downloaded' ||
|
||||||
|
existingRequest.status === 'available'
|
||||||
|
) {
|
||||||
|
await prisma.request.update({
|
||||||
|
where: { id: existingRequest.id },
|
||||||
|
data: {
|
||||||
|
status: 'processing',
|
||||||
|
progress: 100,
|
||||||
|
errorMessage: null,
|
||||||
|
importAttempts: 0,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
requestId = existingRequest.id;
|
||||||
|
} else {
|
||||||
|
const newReq = await prisma.request.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
audiobookId,
|
||||||
|
type: 'audiobook',
|
||||||
|
status: 'processing',
|
||||||
|
progress: 100,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
requestId = newReq.id;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const newReq = await prisma.request.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
audiobookId,
|
||||||
|
type: 'audiobook',
|
||||||
|
status: 'processing',
|
||||||
|
progress: 100,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
requestId = newReq.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Queue organize_files job (pass specific files if scanner provided them)
|
||||||
|
await jobQueue.addOrganizeJob(
|
||||||
|
requestId,
|
||||||
|
audiobookId,
|
||||||
|
normalizedPath,
|
||||||
|
undefined,
|
||||||
|
false,
|
||||||
|
itemAudioFiles && itemAudioFiles.length > 0 ? itemAudioFiles : undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
results.push({ folderPath, asin, success: true, requestId });
|
||||||
|
logger.info(`Bulk import queued: asin=${asin}, path=${normalizedPath}, request=${requestId}`);
|
||||||
|
} catch (itemError) {
|
||||||
|
logger.error(`Bulk import item failed: asin=${asin}, path=${folderPath}`, {
|
||||||
|
error: itemError instanceof Error ? itemError.message : String(itemError),
|
||||||
|
});
|
||||||
|
results.push({
|
||||||
|
folderPath,
|
||||||
|
asin,
|
||||||
|
success: false,
|
||||||
|
error: itemError instanceof Error ? itemError.message : 'Import failed',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const succeeded = results.filter((r) => r.success).length;
|
||||||
|
const failed = results.filter((r) => !r.success).length;
|
||||||
|
|
||||||
|
logger.info(`Bulk import execute complete: ${succeeded} queued, ${failed} failed`);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
results,
|
||||||
|
summary: { total: results.length, succeeded, failed },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Bulk import execute failed', {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: error instanceof Error ? error.message : 'Bulk import failed' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
/**
|
||||||
|
* Component: Bulk Import Scan API (SSE)
|
||||||
|
* Documentation: documentation/features/bulk-import.md
|
||||||
|
*
|
||||||
|
* Streams audiobook discovery and Audible matching results via Server-Sent Events.
|
||||||
|
* Admin-only. Validates path is within allowed roots.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||||
|
import { prisma } from '@/lib/db';
|
||||||
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
|
import { discoverAudiobooks, cleanSearchString } from '@/lib/utils/bulk-import-scanner';
|
||||||
|
import { getAudibleService } from '@/lib/integrations/audible.service';
|
||||||
|
import { findPlexMatch } from '@/lib/utils/audiobook-matcher';
|
||||||
|
|
||||||
|
const logger = RMABLogger.create('API.Admin.BulkImport.Scan');
|
||||||
|
|
||||||
|
const BOOKDROP_PATH = '/bookdrop';
|
||||||
|
const AUDIBLE_SEARCH_DELAY_MS = 1500;
|
||||||
|
|
||||||
|
/** Load allowed root directories from configuration. */
|
||||||
|
async function getAllowedRoots(): Promise<string[]> {
|
||||||
|
const pathModule = await import('path');
|
||||||
|
const fs = await import('fs/promises');
|
||||||
|
|
||||||
|
const [downloadDirConfig, mediaDirConfig] = await Promise.all([
|
||||||
|
prisma.configuration.findUnique({ where: { key: 'download_dir' } }),
|
||||||
|
prisma.configuration.findUnique({ where: { key: 'media_dir' } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const roots: string[] = [];
|
||||||
|
if (downloadDirConfig?.value) {
|
||||||
|
roots.push(pathModule.resolve(downloadDirConfig.value).replace(/\\/g, '/'));
|
||||||
|
}
|
||||||
|
if (mediaDirConfig?.value) {
|
||||||
|
roots.push(pathModule.resolve(mediaDirConfig.value).replace(/\\/g, '/'));
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const stat = await fs.stat(BOOKDROP_PATH);
|
||||||
|
if (stat.isDirectory()) {
|
||||||
|
roots.push(pathModule.resolve(BOOKDROP_PATH).replace(/\\/g, '/'));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* not mounted */
|
||||||
|
}
|
||||||
|
|
||||||
|
return roots;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check if a path is within allowed roots. */
|
||||||
|
function isPathAllowed(normalizedPath: string, roots: string[]): boolean {
|
||||||
|
return roots.some(
|
||||||
|
(root) => normalizedPath === root || normalizedPath.startsWith(root + '/')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Delay helper for rate limiting. */
|
||||||
|
function delay(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||||
|
return requireAdmin(req, async () => {
|
||||||
|
const pathModule = await import('path');
|
||||||
|
const fs = await import('fs/promises');
|
||||||
|
|
||||||
|
let body: any;
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { rootPath } = body;
|
||||||
|
if (!rootPath) {
|
||||||
|
return NextResponse.json({ error: 'rootPath is required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate path
|
||||||
|
const allowedRoots = await getAllowedRoots();
|
||||||
|
const normalizedPath = pathModule.resolve(rootPath).replace(/\\/g, '/');
|
||||||
|
|
||||||
|
if (!isPathAllowed(normalizedPath, allowedRoots)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Access denied: path outside allowed directories' },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify directory exists
|
||||||
|
try {
|
||||||
|
const stat = await fs.stat(normalizedPath);
|
||||||
|
if (!stat.isDirectory()) {
|
||||||
|
return NextResponse.json({ error: 'Path is not a directory' }, { status: 400 });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Directory not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Bulk import scan started: ${normalizedPath}`);
|
||||||
|
|
||||||
|
// Create SSE stream
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const abortController = new AbortController();
|
||||||
|
|
||||||
|
const stream = new ReadableStream({
|
||||||
|
async start(controller) {
|
||||||
|
const send = (event: string, data: any) => {
|
||||||
|
try {
|
||||||
|
controller.enqueue(
|
||||||
|
encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`)
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
/* stream closed */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Phase 1: Discover audiobook folders
|
||||||
|
const audiobooks = await discoverAudiobooks(
|
||||||
|
normalizedPath,
|
||||||
|
(progress) => {
|
||||||
|
send('progress', progress);
|
||||||
|
},
|
||||||
|
abortController.signal
|
||||||
|
);
|
||||||
|
|
||||||
|
if (audiobooks.length === 0) {
|
||||||
|
send('complete', { audiobooks: [], message: 'No audiobooks found' });
|
||||||
|
controller.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
send('discovery_complete', {
|
||||||
|
totalFound: audiobooks.length,
|
||||||
|
message: `Found ${audiobooks.length} audiobook folders`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Phase 2: Match each audiobook against Audible
|
||||||
|
const audibleService = getAudibleService();
|
||||||
|
const results: any[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < audiobooks.length; i++) {
|
||||||
|
if (abortController.signal.aborted) break;
|
||||||
|
|
||||||
|
const book = audiobooks[i];
|
||||||
|
|
||||||
|
send('matching', {
|
||||||
|
current: i + 1,
|
||||||
|
total: audiobooks.length,
|
||||||
|
folderName: book.folderName,
|
||||||
|
searchTerm: book.searchTerm,
|
||||||
|
});
|
||||||
|
|
||||||
|
let match: any = null;
|
||||||
|
let inLibrary = false;
|
||||||
|
let hasActiveRequest = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 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) {
|
||||||
|
match = searchResult.results[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (match) {
|
||||||
|
|
||||||
|
// Check library availability
|
||||||
|
const plexMatch = await findPlexMatch({
|
||||||
|
asin: match.asin,
|
||||||
|
title: match.title,
|
||||||
|
author: match.author,
|
||||||
|
narrator: match.narrator,
|
||||||
|
});
|
||||||
|
inLibrary = plexMatch !== null;
|
||||||
|
|
||||||
|
// Check for active requests
|
||||||
|
if (!inLibrary) {
|
||||||
|
const activeRequest = await prisma.request.findFirst({
|
||||||
|
where: {
|
||||||
|
audiobook: { audibleAsin: match.asin },
|
||||||
|
type: 'audiobook',
|
||||||
|
status: {
|
||||||
|
in: [
|
||||||
|
'pending', 'searching', 'downloading', 'processing',
|
||||||
|
'awaiting_search', 'awaiting_import', 'awaiting_approval',
|
||||||
|
'downloaded', 'available',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
deletedAt: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
hasActiveRequest = activeRequest !== null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (searchError) {
|
||||||
|
logger.warn(
|
||||||
|
`Audible search failed for "${book.searchTerm}": ${
|
||||||
|
searchError instanceof Error ? searchError.message : String(searchError)
|
||||||
|
}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
index: i,
|
||||||
|
folderPath: book.folderPath,
|
||||||
|
folderName: book.folderName,
|
||||||
|
relativePath: book.relativePath,
|
||||||
|
audioFileCount: book.audioFileCount,
|
||||||
|
totalSizeBytes: book.totalSizeBytes,
|
||||||
|
metadataSource: book.metadataSource,
|
||||||
|
extractedAsin: book.extractedAsin,
|
||||||
|
searchTerm: book.searchTerm,
|
||||||
|
audioFiles: book.audioFiles,
|
||||||
|
match: match
|
||||||
|
? {
|
||||||
|
asin: match.asin,
|
||||||
|
title: match.title,
|
||||||
|
author: match.author,
|
||||||
|
narrator: match.narrator,
|
||||||
|
coverArtUrl: match.coverArtUrl,
|
||||||
|
durationMinutes: match.durationMinutes,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
inLibrary,
|
||||||
|
hasActiveRequest,
|
||||||
|
};
|
||||||
|
|
||||||
|
results.push(result);
|
||||||
|
send('book_matched', result);
|
||||||
|
|
||||||
|
// Rate limit: wait between Audible searches (except after last)
|
||||||
|
if (i < audiobooks.length - 1) {
|
||||||
|
await delay(AUDIBLE_SEARCH_DELAY_MS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
send('complete', {
|
||||||
|
totalFound: results.length,
|
||||||
|
matched: results.filter((r) => r.match !== null).length,
|
||||||
|
inLibrary: results.filter((r) => r.inLibrary).length,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Bulk import scan failed', {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
send('error', {
|
||||||
|
message: error instanceof Error ? error.message : 'Scan failed',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
controller.close();
|
||||||
|
} catch {
|
||||||
|
/* already closed */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
cancel() {
|
||||||
|
abortController.abort();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cast to NextResponse: SSE streams require raw Response constructor,
|
||||||
|
// but requireAdmin types expect NextResponse. The Response is valid at runtime.
|
||||||
|
return new Response(stream, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/event-stream',
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
Connection: 'keep-alive',
|
||||||
|
},
|
||||||
|
}) as unknown as NextResponse;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -17,47 +17,6 @@ const logger = RMABLogger.create('API.Admin.Filesystem.Browse');
|
|||||||
interface DirectoryEntry {
|
interface DirectoryEntry {
|
||||||
name: string;
|
name: string;
|
||||||
type: 'directory';
|
type: 'directory';
|
||||||
audioFileCount: number;
|
|
||||||
subfolderCount: number;
|
|
||||||
totalSize: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Scan immediate children of a directory to gather audio file and subfolder stats.
|
|
||||||
*/
|
|
||||||
async function getDirectoryStats(
|
|
||||||
dirPath: string
|
|
||||||
): Promise<{ audioFileCount: number; subfolderCount: number; totalSize: number }> {
|
|
||||||
const fs = await import('fs/promises');
|
|
||||||
const pathModule = await import('path');
|
|
||||||
|
|
||||||
let audioFileCount = 0;
|
|
||||||
let subfolderCount = 0;
|
|
||||||
let totalSize = 0;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const children = await fs.readdir(dirPath, { withFileTypes: true });
|
|
||||||
for (const child of children) {
|
|
||||||
if (child.isDirectory()) {
|
|
||||||
subfolderCount++;
|
|
||||||
} else if (child.isFile()) {
|
|
||||||
const ext = pathModule.extname(child.name).toLowerCase();
|
|
||||||
if ((AUDIO_EXTENSIONS as readonly string[]).includes(ext)) {
|
|
||||||
audioFileCount++;
|
|
||||||
try {
|
|
||||||
const stat = await fs.stat(pathModule.join(dirPath, child.name));
|
|
||||||
totalSize += stat.size;
|
|
||||||
} catch {
|
|
||||||
/* skip unreadable files */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
/* directory not readable */
|
|
||||||
}
|
|
||||||
|
|
||||||
return { audioFileCount, subfolderCount, totalSize };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -152,20 +111,11 @@ export async function GET(request: NextRequest) {
|
|||||||
// Read directory entries
|
// Read directory entries
|
||||||
const dirEntries = await fs.readdir(normalizedPath, { withFileTypes: true });
|
const dirEntries = await fs.readdir(normalizedPath, { withFileTypes: true });
|
||||||
|
|
||||||
// Gather stats for each subdirectory (parallel for performance)
|
// List subdirectories (no nested stat calls — keeps browsing fast)
|
||||||
const directoryEntries = dirEntries.filter((e) => e.isDirectory());
|
const entries: DirectoryEntry[] = dirEntries
|
||||||
const statsPromises = directoryEntries.map(async (entry): Promise<DirectoryEntry> => {
|
.filter((e) => e.isDirectory())
|
||||||
const fullPath = pathModule.join(normalizedPath, entry.name);
|
.map((entry) => ({ name: entry.name, type: 'directory' as const }))
|
||||||
const stats = await getDirectoryStats(fullPath);
|
.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
return {
|
|
||||||
name: entry.name,
|
|
||||||
type: 'directory',
|
|
||||||
...stats,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const entries = await Promise.all(statsPromises);
|
|
||||||
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
||||||
|
|
||||||
// Gather audio files in the current directory
|
// Gather audio files in the current directory
|
||||||
const audioFiles: Array<{ name: string; size: number }> = [];
|
const audioFiles: Array<{ name: string; size: number }> = [];
|
||||||
|
|||||||
+133
-13
@@ -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,
|
||||||
|
|||||||
@@ -55,9 +55,25 @@ export async function POST(request: NextRequest) {
|
|||||||
const fs = await import('fs/promises');
|
const fs = await import('fs/promises');
|
||||||
|
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { folderPath, asin, cleanupSource } = body;
|
const { folderPath, asin, cleanupSource, selectedFiles } = body;
|
||||||
let { audiobookId } = body;
|
let { audiobookId } = body;
|
||||||
|
|
||||||
|
// Validate selectedFiles if provided
|
||||||
|
if (selectedFiles !== undefined) {
|
||||||
|
if (!Array.isArray(selectedFiles) || selectedFiles.length === 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'selectedFiles must be a non-empty array of file names' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!selectedFiles.every((f: unknown) => typeof f === 'string')) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'selectedFiles must contain only strings' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Validate required fields
|
// Validate required fields
|
||||||
if ((!audiobookId && !asin) || !folderPath) {
|
if ((!audiobookId && !asin) || !folderPath) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -120,7 +136,44 @@ export async function POST(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify folder contains audio files
|
// Verify selected files exist and are audio files, or fall back to folder scan
|
||||||
|
let audioFileCount: number;
|
||||||
|
const validatedFiles: string[] = [];
|
||||||
|
|
||||||
|
if (selectedFiles && selectedFiles.length > 0) {
|
||||||
|
for (const fileName of selectedFiles as string[]) {
|
||||||
|
// Prevent path traversal
|
||||||
|
if (fileName.includes('/') || fileName.includes('\\') || fileName === '..' || fileName === '.') {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Invalid file name: ${fileName}` },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const ext = pathModule.extname(fileName).toLowerCase();
|
||||||
|
if (!(AUDIO_EXTENSIONS as readonly string[]).includes(ext)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Not an audio file: ${fileName}` },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const fileStat = await fs.stat(pathModule.join(normalizedPath, fileName));
|
||||||
|
if (!fileStat.isFile()) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Not a file: ${fileName}` },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
validatedFiles.push(fileName);
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `File not found: ${fileName}` },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
audioFileCount = validatedFiles.length;
|
||||||
|
} else {
|
||||||
const audioCheck = await hasAudioFiles(normalizedPath);
|
const audioCheck = await hasAudioFiles(normalizedPath);
|
||||||
if (!audioCheck.found) {
|
if (!audioCheck.found) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -128,6 +181,8 @@ export async function POST(request: NextRequest) {
|
|||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
audioFileCount = audioCheck.count;
|
||||||
|
}
|
||||||
|
|
||||||
// Resolve audiobook by ASIN if audiobookId not provided
|
// Resolve audiobook by ASIN if audiobookId not provided
|
||||||
if (!audiobookId && asin) {
|
if (!audiobookId && asin) {
|
||||||
@@ -317,9 +372,16 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
// Queue organize_files job
|
// Queue organize_files job
|
||||||
const jobQueue = getJobQueueService();
|
const jobQueue = getJobQueueService();
|
||||||
await jobQueue.addOrganizeJob(requestId, audiobookId, normalizedPath, undefined, cleanupSource === true);
|
await jobQueue.addOrganizeJob(
|
||||||
|
requestId,
|
||||||
|
audiobookId,
|
||||||
|
normalizedPath,
|
||||||
|
undefined,
|
||||||
|
cleanupSource === true,
|
||||||
|
validatedFiles.length > 0 ? validatedFiles : undefined
|
||||||
|
);
|
||||||
|
|
||||||
logger.info(`Manual import queued: request=${requestId}, path=${normalizedPath}, audioFiles=${audioCheck.count}`);
|
logger.info(`Manual import queued: request=${requestId}, path=${normalizedPath}, audioFiles=${audioFileCount}`);
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -100,15 +100,21 @@ export async function PATCH(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Queue search job
|
// Queue search job based on request type
|
||||||
const { getJobQueueService } = await import('@/lib/services/job-queue.service');
|
const { getJobQueueService } = await import('@/lib/services/job-queue.service');
|
||||||
const jobQueue = getJobQueueService();
|
const jobQueue = getJobQueueService();
|
||||||
await jobQueue.addSearchJob(id, {
|
const audiobookData = {
|
||||||
id: existingRequest.audiobook.id,
|
id: existingRequest.audiobook.id,
|
||||||
title: existingRequest.audiobook.title,
|
title: existingRequest.audiobook.title,
|
||||||
author: existingRequest.audiobook.author,
|
author: existingRequest.audiobook.author,
|
||||||
asin: existingRequest.audiobook.audibleAsin || undefined,
|
asin: existingRequest.audiobook.audibleAsin || undefined,
|
||||||
});
|
};
|
||||||
|
|
||||||
|
if (existingRequest.type === 'ebook') {
|
||||||
|
await jobQueue.addSearchEbookJob(id, audiobookData);
|
||||||
|
} else {
|
||||||
|
await jobQueue.addSearchJob(id, audiobookData);
|
||||||
|
}
|
||||||
|
|
||||||
searchTriggered = true;
|
searchTriggered = true;
|
||||||
logger.info(`Search triggered for request ${id} with custom terms`, { requestId: id });
|
logger.info(`Search triggered for request ${id} with custom terms`, { requestId: id });
|
||||||
|
|||||||
@@ -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,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 } = 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(
|
||||||
@@ -32,6 +32,21 @@ export async function PUT(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate octal permission strings (3-4 digits, each 0-7)
|
||||||
|
const octalRegex = /^[0-7]{3,4}$/;
|
||||||
|
if (fileChmod !== undefined && !octalRegex.test(fileChmod)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'File permissions must be 3-4 octal digits (0-7), e.g. 664' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (dirChmod !== undefined && !octalRegex.test(dirChmod)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Directory permissions must be 3-4 octal digits (0-7), e.g. 775' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Update configuration
|
// Update configuration
|
||||||
await prisma.configuration.upsert({
|
await prisma.configuration.upsert({
|
||||||
where: { key: 'download_dir' },
|
where: { key: 'download_dir' },
|
||||||
@@ -97,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' },
|
||||||
@@ -123,6 +151,34 @@ export async function PUT(request: NextRequest) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update file permissions (octal chmod)
|
||||||
|
if (fileChmod !== undefined) {
|
||||||
|
await prisma.configuration.upsert({
|
||||||
|
where: { key: 'file_chmod' },
|
||||||
|
update: { value: fileChmod },
|
||||||
|
create: {
|
||||||
|
key: 'file_chmod',
|
||||||
|
value: fileChmod,
|
||||||
|
category: 'automation',
|
||||||
|
description: 'Octal permissions applied to organized files',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update directory permissions (octal chmod)
|
||||||
|
if (dirChmod !== undefined) {
|
||||||
|
await prisma.configuration.upsert({
|
||||||
|
where: { key: 'dir_chmod' },
|
||||||
|
update: { value: dirChmod },
|
||||||
|
create: {
|
||||||
|
key: 'dir_chmod',
|
||||||
|
value: dirChmod,
|
||||||
|
category: 'automation',
|
||||||
|
description: 'Octal permissions applied to created directories',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
logger.info('Paths settings updated');
|
logger.info('Paths settings updated');
|
||||||
|
|
||||||
// Clear config cache for all updated keys so services get fresh values
|
// Clear config cache for all updated keys so services get fresh values
|
||||||
@@ -133,8 +189,11 @@ 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('dir_chmod');
|
||||||
|
|
||||||
// Invalidate all download client singletons to force reload of download_dir
|
// Invalidate all download client singletons to force reload of download_dir
|
||||||
const { invalidateDownloadClientManager } = await import('@/lib/services/download-client-manager.service');
|
const { invalidateDownloadClientManager } = await import('@/lib/services/download-client-manager.service');
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,8 +134,11 @@ 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',
|
||||||
|
dirChmod: configMap.get('dir_chmod') || '775',
|
||||||
},
|
},
|
||||||
ebook: {
|
ebook: {
|
||||||
// New granular source toggles (with migration from legacy ebook_sidecar_enabled)
|
// New granular source toggles (with migration from legacy ebook_sidecar_enabled)
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
/**
|
||||||
|
* Component: Admin User Login Token
|
||||||
|
* Documentation: documentation/backend/services/auth.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||||
|
import { prisma } from '@/lib/db';
|
||||||
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
|
import { generateApiToken } from '@/lib/utils/api-token';
|
||||||
|
|
||||||
|
const logger = RMABLogger.create('API.Admin.Users.LoginToken');
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||||
|
return requireAdmin(req, async () => {
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
const targetUser = await prisma.user.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: { plexUsername: true, deletedAt: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!targetUser) {
|
||||||
|
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetUser.deletedAt) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Cannot generate token for deleted user' },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { fullToken, tokenHash } = generateApiToken();
|
||||||
|
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id },
|
||||||
|
data: { loginTokenHash: tokenHash },
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info('Admin generated login token for user', {
|
||||||
|
targetUser: targetUser.plexUsername,
|
||||||
|
createdBy: req.user!.username,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ fullToken }, { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to generate login token', {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
return NextResponse.json({ error: 'Failed to generate login token' }, { status: 500 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||||
|
return requireAdmin(req, async () => {
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
const targetUser = await prisma.user.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: { plexUsername: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!targetUser) {
|
||||||
|
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id },
|
||||||
|
data: { loginTokenHash: null, sessionsInvalidatedAt: new Date() },
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info('Admin revoked login token for user', {
|
||||||
|
targetUser: targetUser.plexUsername,
|
||||||
|
revokedBy: req.user!.username,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to revoke login token', {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
return NextResponse.json({ error: 'Failed to revoke login token' }, { status: 500 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -33,6 +33,7 @@ export async function GET(request: NextRequest) {
|
|||||||
autoApproveRequests: true,
|
autoApproveRequests: true,
|
||||||
interactiveSearchAccess: true,
|
interactiveSearchAccess: true,
|
||||||
downloadAccess: true,
|
downloadAccess: true,
|
||||||
|
loginTokenHash: true,
|
||||||
_count: {
|
_count: {
|
||||||
select: {
|
select: {
|
||||||
requests: true,
|
requests: true,
|
||||||
@@ -44,7 +45,12 @@ export async function GET(request: NextRequest) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json({ users });
|
return NextResponse.json({
|
||||||
|
users: users.map(({ loginTokenHash, ...u }) => ({
|
||||||
|
...u,
|
||||||
|
hasLoginToken: loginTokenHash !== null,
|
||||||
|
})),
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to fetch users', { error: error instanceof Error ? error.message : String(error) });
|
logger.error('Failed to fetch users', { error: error instanceof Error ? error.message : String(error) });
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
@@ -260,6 +260,7 @@ export async function POST(
|
|||||||
parentRequestId: availableRequest?.id || null, // Link to parent if exists
|
parentRequestId: availableRequest?.id || null, // Link to parent if exists
|
||||||
status: 'awaiting_approval',
|
status: 'awaiting_approval',
|
||||||
progress: 0,
|
progress: 0,
|
||||||
|
customSearchTerms: availableRequest?.customSearchTerms || null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -292,6 +293,7 @@ export async function POST(
|
|||||||
parentRequestId: availableRequest?.id || null,
|
parentRequestId: availableRequest?.id || null,
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
progress: 0,
|
progress: 0,
|
||||||
|
customSearchTerms: availableRequest?.customSearchTerms || null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -252,6 +252,7 @@ export async function POST(
|
|||||||
status: 'awaiting_approval',
|
status: 'awaiting_approval',
|
||||||
progress: 0,
|
progress: 0,
|
||||||
selectedTorrent: selectedEbook as any,
|
selectedTorrent: selectedEbook as any,
|
||||||
|
customSearchTerms: availableRequest?.customSearchTerms || null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
logger.info(`Created ebook request ${ebookRequest.id}, awaiting approval`);
|
logger.info(`Created ebook request ${ebookRequest.id}, awaiting approval`);
|
||||||
@@ -296,6 +297,7 @@ export async function POST(
|
|||||||
parentRequestId: availableRequest?.id || null,
|
parentRequestId: availableRequest?.id || null,
|
||||||
status: 'searching',
|
status: 'searching',
|
||||||
progress: 0,
|
progress: 0,
|
||||||
|
customSearchTerms: availableRequest?.customSearchTerms || null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
logger.info(`Created new ebook request ${ebookRequest.id}`);
|
logger.info(`Created new ebook request ${ebookRequest.id}`);
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { prisma } from '@/lib/db';
|
|||||||
import { enrichAudiobooksWithMatches, getAvailableAsins } from '@/lib/utils/audiobook-matcher';
|
import { enrichAudiobooksWithMatches, getAvailableAsins } from '@/lib/utils/audiobook-matcher';
|
||||||
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';
|
||||||
|
|
||||||
const logger = RMABLogger.create('API.Audiobooks.Category');
|
const logger = RMABLogger.create('API.Audiobooks.Category');
|
||||||
|
|
||||||
@@ -129,12 +130,15 @@ export async function GET(
|
|||||||
const userId = currentUser?.sub || undefined;
|
const userId = currentUser?.sub || undefined;
|
||||||
const enrichedAudiobooks = await enrichAudiobooksWithMatches(audibleBooks, userId);
|
const enrichedAudiobooks = await enrichAudiobooksWithMatches(audibleBooks, userId);
|
||||||
|
|
||||||
|
// Annotate with per-user ignore status
|
||||||
|
const annotatedAudiobooks = await annotateWithIgnoreStatus(enrichedAudiobooks, userId);
|
||||||
|
|
||||||
const totalPages = Math.ceil(totalCount / limit);
|
const totalPages = Math.ceil(totalCount / limit);
|
||||||
const hasMore = page < totalPages;
|
const hasMore = page < totalPages;
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
audiobooks: enrichedAudiobooks,
|
audiobooks: annotatedAudiobooks,
|
||||||
count: enrichedAudiobooks.length,
|
count: enrichedAudiobooks.length,
|
||||||
totalCount,
|
totalCount,
|
||||||
page,
|
page,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { prisma } from '@/lib/db';
|
|||||||
import { enrichAudiobooksWithMatches, getAvailableAsins } from '@/lib/utils/audiobook-matcher';
|
import { enrichAudiobooksWithMatches, getAvailableAsins } from '@/lib/utils/audiobook-matcher';
|
||||||
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 { NEW_RELEASES_CATEGORY_ID } from '@/lib/processors/audible-refresh.processor';
|
import { NEW_RELEASES_CATEGORY_ID } from '@/lib/processors/audible-refresh.processor';
|
||||||
|
|
||||||
const logger = RMABLogger.create('API.Audiobooks.NewReleases');
|
const logger = RMABLogger.create('API.Audiobooks.NewReleases');
|
||||||
@@ -136,12 +137,15 @@ export async function GET(request: NextRequest) {
|
|||||||
// Enrich with real-time Plex library matching and request status
|
// Enrich with real-time Plex library matching and request status
|
||||||
const enrichedAudiobooks = await enrichAudiobooksWithMatches(audibleBooks, userId);
|
const enrichedAudiobooks = await enrichAudiobooksWithMatches(audibleBooks, userId);
|
||||||
|
|
||||||
|
// Annotate with per-user ignore status
|
||||||
|
const annotatedAudiobooks = await annotateWithIgnoreStatus(enrichedAudiobooks, userId);
|
||||||
|
|
||||||
const totalPages = Math.ceil(totalCount / limit);
|
const totalPages = Math.ceil(totalCount / limit);
|
||||||
const hasMore = page < totalPages;
|
const hasMore = page < totalPages;
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
audiobooks: enrichedAudiobooks,
|
audiobooks: annotatedAudiobooks,
|
||||||
count: enrichedAudiobooks.length,
|
count: enrichedAudiobooks.length,
|
||||||
totalCount,
|
totalCount,
|
||||||
page,
|
page,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { prisma } from '@/lib/db';
|
|||||||
import { enrichAudiobooksWithMatches, getAvailableAsins } from '@/lib/utils/audiobook-matcher';
|
import { enrichAudiobooksWithMatches, getAvailableAsins } from '@/lib/utils/audiobook-matcher';
|
||||||
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 { POPULAR_CATEGORY_ID } from '@/lib/processors/audible-refresh.processor';
|
import { POPULAR_CATEGORY_ID } from '@/lib/processors/audible-refresh.processor';
|
||||||
|
|
||||||
const logger = RMABLogger.create('API.Audiobooks.Popular');
|
const logger = RMABLogger.create('API.Audiobooks.Popular');
|
||||||
@@ -136,12 +137,15 @@ export async function GET(request: NextRequest) {
|
|||||||
// Enrich with real-time Plex library matching and request status
|
// Enrich with real-time Plex library matching and request status
|
||||||
const enrichedAudiobooks = await enrichAudiobooksWithMatches(audibleBooks, userId);
|
const enrichedAudiobooks = await enrichAudiobooksWithMatches(audibleBooks, userId);
|
||||||
|
|
||||||
|
// Annotate with per-user ignore status
|
||||||
|
const annotatedAudiobooks = await annotateWithIgnoreStatus(enrichedAudiobooks, userId);
|
||||||
|
|
||||||
const totalPages = Math.ceil(totalCount / limit);
|
const totalPages = Math.ceil(totalCount / limit);
|
||||||
const hasMore = page < totalPages;
|
const hasMore = page < totalPages;
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
audiobooks: enrichedAudiobooks,
|
audiobooks: annotatedAudiobooks,
|
||||||
count: enrichedAudiobooks.length,
|
count: enrichedAudiobooks.length,
|
||||||
totalCount,
|
totalCount,
|
||||||
page,
|
page,
|
||||||
|
|||||||
@@ -7,9 +7,10 @@ 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';
|
||||||
|
|
||||||
const logger = RMABLogger.create('API.Audiobooks.Search');
|
const logger = RMABLogger.create('API.Audiobooks.Search');
|
||||||
|
|
||||||
@@ -36,25 +37,31 @@ 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
|
||||||
|
const annotatedResults = await annotateWithIgnoreStatus(enrichedResults, userId);
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
query: results.query,
|
query: results.query,
|
||||||
results: enrichedResults,
|
results: annotatedResults,
|
||||||
totalResults: enrichedResults.length,
|
totalResults: enrichedResults.length,
|
||||||
page: results.page,
|
page: results.page,
|
||||||
hasMore: results.hasMore,
|
hasMore: results.hasMore,
|
||||||
|
|||||||
@@ -45,9 +45,17 @@ export async function POST(request: NextRequest) {
|
|||||||
// Get user from database
|
// Get user from database
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.user.findUnique({
|
||||||
where: { id: payload.sub },
|
where: { id: payload.sub },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
plexId: true,
|
||||||
|
plexUsername: true,
|
||||||
|
role: true,
|
||||||
|
deletedAt: true,
|
||||||
|
sessionsInvalidatedAt: true,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!user) {
|
if (!user || user.deletedAt) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
error: 'Unauthorized',
|
error: 'Unauthorized',
|
||||||
@@ -57,6 +65,19 @@ export async function POST(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if session was invalidated after this refresh token was issued
|
||||||
|
if (user.sessionsInvalidatedAt && payload.iat &&
|
||||||
|
payload.iat < Math.floor(user.sessionsInvalidatedAt.getTime() / 1000)) {
|
||||||
|
logger.warn('Refresh token issued before session invalidation', { userId: payload.sub });
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: 'Unauthorized',
|
||||||
|
message: 'Session has been revoked',
|
||||||
|
},
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Generate new access token
|
// Generate new access token
|
||||||
const accessToken = generateAccessToken({
|
const accessToken = generateAccessToken({
|
||||||
sub: user.id,
|
sub: user.id,
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
/**
|
||||||
|
* Component: Token Login Route
|
||||||
|
* Documentation: documentation/backend/services/auth.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { prisma } from '@/lib/db';
|
||||||
|
import { generateAccessToken, generateRefreshToken } from '@/lib/utils/jwt';
|
||||||
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
|
import { checkTokenLoginRateLimit } from '@/lib/utils/rateLimit';
|
||||||
|
import crypto from 'crypto';
|
||||||
|
|
||||||
|
const logger = RMABLogger.create('API.Auth.TokenLogin');
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const ip = request.headers.get('x-forwarded-for') ?? 'unknown';
|
||||||
|
const rateLimit = checkTokenLoginRateLimit(ip);
|
||||||
|
if (!rateLimit.allowed) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Too many login attempts. Please try again later.' },
|
||||||
|
{
|
||||||
|
status: 429,
|
||||||
|
headers: { 'Retry-After': String(rateLimit.retryAfterSeconds) },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { token } = await request.json();
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return NextResponse.json({ error: 'Missing token parameter' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
|
||||||
|
|
||||||
|
const user = await prisma.user.findFirst({
|
||||||
|
where: {
|
||||||
|
loginTokenHash: tokenHash,
|
||||||
|
deletedAt: null,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
plexId: true,
|
||||||
|
plexUsername: true,
|
||||||
|
plexEmail: true,
|
||||||
|
avatarUrl: true,
|
||||||
|
role: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
logger.warn('Token login failed - not found or user deleted');
|
||||||
|
return NextResponse.json({ error: 'Invalid token' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: { lastLoginAt: new Date() },
|
||||||
|
});
|
||||||
|
|
||||||
|
const accessToken = generateAccessToken({
|
||||||
|
sub: user.id,
|
||||||
|
plexId: user.plexId,
|
||||||
|
username: user.plexUsername,
|
||||||
|
role: user.role,
|
||||||
|
});
|
||||||
|
|
||||||
|
const refreshToken = generateRefreshToken(user.id);
|
||||||
|
|
||||||
|
logger.info('Token login successful', { username: user.plexUsername });
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
accessToken,
|
||||||
|
refreshToken,
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
username: user.plexUsername,
|
||||||
|
email: user.plexEmail,
|
||||||
|
avatarUrl: user.avatarUrl,
|
||||||
|
role: user.role,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Token login error', {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
return NextResponse.json({ error: 'Authentication failed' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,9 +7,10 @@ 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';
|
||||||
|
|
||||||
const logger = RMABLogger.create('API.Authors.Books');
|
const logger = RMABLogger.create('API.Authors.Books');
|
||||||
|
|
||||||
@@ -55,23 +56,29 @@ 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);
|
||||||
|
|
||||||
logger.info(`Author books complete: "${authorName}" → ${enrichedBooks.length} books (page ${page})`);
|
// Annotate with per-user ignore status
|
||||||
|
const annotatedBooks = await annotateWithIgnoreStatus(enrichedBooks, userId);
|
||||||
|
|
||||||
|
logger.info(`Author books complete: "${authorName}" → ${annotatedBooks.length} books (page ${page})`);
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
books: enrichedBooks,
|
books: annotatedBooks,
|
||||||
authorName: authorName.trim(),
|
authorName: authorName.trim(),
|
||||||
authorAsin: asin,
|
authorAsin: asin,
|
||||||
totalBooks: enrichedBooks.length,
|
totalBooks: enrichedBooks.length,
|
||||||
|
|||||||
@@ -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) });
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user