mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-02 20:30:10 +00:00
Compare commits
141 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 | |||
| f09931f352 | |||
| 5b4aa3fa15 | |||
| 3e2221ad5b | |||
| 859a331012 | |||
| c35bec9f89 | |||
| 09e1a0db3a | |||
| 832a8ad00b | |||
| cc8e106a2b | |||
| 079a337f1c | |||
| 6025ac200a | |||
| 248bd5359c | |||
| f65cb59a9c | |||
| d1ea65a41a | |||
| ca02b8b6e7 | |||
| 85aa80938a | |||
| efb4f64014 | |||
| c29cfa3a07 | |||
| 7f706e806f | |||
| 338331d006 | |||
| 6ca2e964e8 | |||
| 1d1aaa7ff3 | |||
| 6da2c4ce95 | |||
| ce8f4d642b | |||
| ae4a73144d | |||
| c57d0c1492 | |||
| 8f8387abff | |||
| 4ae68d01de | |||
| 225ef8c919 | |||
| e4e127880b | |||
| b940ad39f9 | |||
| f45f31b49c | |||
| 978e177715 | |||
| 3861d07cf4 | |||
| 41d45d1210 | |||
| cfe780c6f0 |
+5
-1
@@ -1,5 +1,6 @@
|
||||
# IDE
|
||||
.idea
|
||||
.vscode
|
||||
|
||||
# Dependencies
|
||||
/node_modules
|
||||
@@ -55,4 +56,7 @@ next-env.d.ts
|
||||
/test-media
|
||||
/test-data
|
||||
/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.**
|
||||
|
||||
**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
|
||||
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)
|
||||
# ========================================================================
|
||||
|
||||
@@ -22,6 +22,12 @@ PGID=${PGID:-$(id -g node)}
|
||||
echo "[App] Starting Next.js server..."
|
||||
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
|
||||
|
||||
# =============================================================================
|
||||
@@ -93,6 +99,29 @@ if [ "$READY" = "false" ]; then
|
||||
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.)"
|
||||
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
|
||||
# =========================================================================
|
||||
|
||||
@@ -387,6 +387,7 @@ PORT=$PORT
|
||||
HOSTNAME=$HOSTNAME
|
||||
PUID=${PUID:-}
|
||||
PGID=${PGID:-}
|
||||
UMASK=${UMASK:-}
|
||||
ROOTLESS_CONTAINER=${ROOTLESS_CONTAINER:-}
|
||||
EOF
|
||||
|
||||
@@ -403,6 +404,29 @@ echo "🔄 Running Prisma migrations..."
|
||||
cd /app
|
||||
su - node -c "cd /app && DATABASE_URL='$DATABASE_URL' npx prisma db push --skip-generate --accept-data-loss" || echo "⚠️ Migrations may have failed, continuing..."
|
||||
|
||||
# Run data migrations (run-once SQL scripts tracked in _data_migrations table)
|
||||
echo "🔄 Running data migrations..."
|
||||
|
||||
for sql_file in /app/prisma/data-migrations/*.sql; do
|
||||
if [ -f "$sql_file" ]; then
|
||||
migration_name=$(basename "$sql_file")
|
||||
|
||||
already_run=$(psql "$DATABASE_URL" -tA -c "SELECT 1 FROM _data_migrations WHERE name = '$migration_name' LIMIT 1;")
|
||||
if [ "$already_run" = "1" ]; then
|
||||
echo " Skipping $migration_name (already executed)"
|
||||
continue
|
||||
fi
|
||||
|
||||
echo " Running $migration_name..."
|
||||
if su - node -c "cd /app && DATABASE_URL='$DATABASE_URL' npx prisma db execute --schema prisma/schema.prisma --file '$sql_file'"; then
|
||||
psql "$DATABASE_URL" -c "INSERT INTO _data_migrations (name) VALUES ('$migration_name');"
|
||||
echo " ✅ $migration_name completed"
|
||||
else
|
||||
echo "⚠️ Data migration $migration_name failed, will retry on next start"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
# Stop internal PostgreSQL (supervisord will restart it via wrapper)
|
||||
if [ "$USE_EXTERNAL_POSTGRES" = "false" ]; then
|
||||
echo "🔧 Stopping temporary PostgreSQL instance..."
|
||||
|
||||
+13
-1
@@ -24,14 +24,26 @@ RUN apt-get update && apt-get install -y \
|
||||
supervisor \
|
||||
curl \
|
||||
openssl \
|
||||
ffmpeg \
|
||||
locales \
|
||||
gosu \
|
||||
xz-utils \
|
||||
&& sed -i 's/^# \(en_US.UTF-8 UTF-8\)/\1/' /etc/locale.gen \
|
||||
&& locale-gen en_US.UTF-8 \
|
||||
&& update-locale LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8 \
|
||||
&& 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 LC_ALL=en_US.UTF-8
|
||||
|
||||
|
||||
@@ -5,8 +5,10 @@
|
||||
## Authentication & Users
|
||||
- **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)
|
||||
- **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)
|
||||
- **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
|
||||
- **First-time setup wizard** → [setup-wizard.md](setup-wizard.md)
|
||||
@@ -32,10 +34,20 @@
|
||||
- **File hash matching for accurate ASIN** → [fixes/file-hash-matching.md](fixes/file-hash-matching.md)
|
||||
- **OIDC authentication** → [backend/services/auth.md](backend/services/auth.md)
|
||||
|
||||
## Reading Shelves (Goodreads, Hardcover)
|
||||
- **Goodreads shelf sync (RSS feeds)** → [backend/services/goodreads-sync.md](backend/services/goodreads-sync.md)
|
||||
- **Hardcover shelf sync (GraphQL API)** → [backend/services/hardcover-sync.md](backend/services/hardcover-sync.md)
|
||||
- **Shared sync core (Audible lookup, request creation)** → [backend/services/goodreads-sync.md](backend/services/goodreads-sync.md#shared-sync-core)
|
||||
- **Combined shelves API, GenericShelf** → [backend/services/goodreads-sync.md](backend/services/goodreads-sync.md)
|
||||
- **Hook factory (createShelfHooks)** → [backend/services/goodreads-sync.md](backend/services/goodreads-sync.md#hook-factory)
|
||||
- **Adding a new shelf provider** → [backend/services/goodreads-sync.md](backend/services/goodreads-sync.md#adding-a-new-provider)
|
||||
|
||||
## Audible Integration
|
||||
- **Web scraping (popular, new releases)** → [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)
|
||||
- **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)
|
||||
- **First-class ebook requests, separate tracking** → [integrations/ebook-sidecar.md](integrations/ebook-sidecar.md)
|
||||
@@ -77,6 +89,7 @@
|
||||
- **Component catalog (cards, badges, forms)** → [frontend/components.md](frontend/components.md)
|
||||
- **RequestCard, StatusBadge, ProgressBar** → [frontend/components.md](frontend/components.md)
|
||||
- **Pages: home, search, requests, profile** → [frontend/components.md](frontend/components.md)
|
||||
- **Home page sections (per-user, configurable)** → [features/home-sections.md](features/home-sections.md)
|
||||
|
||||
## BookDate (AI Recommendations)
|
||||
- **AI-powered recommendations, swipe interface** → [features/bookdate.md](features/bookdate.md)
|
||||
@@ -89,9 +102,12 @@
|
||||
|
||||
## Admin Features
|
||||
- **Dashboard (metrics, downloads, requests)** → [admin-dashboard.md](admin-dashboard.md)
|
||||
- **System logs (filters, search, pagination, /api/admin/logs)** → [admin-dashboard.md](admin-dashboard.md)
|
||||
- **Bulk import (scan folders, match Audible, batch import)** → [features/bulk-import.md](features/bulk-import.md)
|
||||
- **Jobs management UI** → [backend/services/scheduler.md](backend/services/scheduler.md)
|
||||
- **Request deletion (soft delete, seeding awareness)** → [admin-features/request-deletion.md](admin-features/request-deletion.md)
|
||||
- **Request approval system, auto-approve settings** → [admin-features/request-approval.md](admin-features/request-approval.md)
|
||||
- **Release blocklist (auto-block failed releases, /admin/blocklist)** → [admin-features/release-blocklist.md](admin-features/release-blocklist.md)
|
||||
|
||||
## Fixes & Improvements
|
||||
- **File hash-based library matching (ABS)** → [fixes/file-hash-matching.md](fixes/file-hash-matching.md)
|
||||
@@ -130,9 +146,15 @@
|
||||
**"What's the database schema?"** → [backend/database.md](backend/database.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)
|
||||
**"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 approve/deny user requests?"** → [admin-features/request-approval.md](admin-features/request-approval.md)
|
||||
**"How do I enable auto-approve for requests?"** → [admin-features/request-approval.md](admin-features/request-approval.md)
|
||||
**"How does the release blocklist work?"** → [admin-features/release-blocklist.md](admin-features/release-blocklist.md)
|
||||
**"Why does the same bad release keep getting re-downloaded?"** → [admin-features/release-blocklist.md](admin-features/release-blocklist.md) (it shouldn't anymore — auto-blocked on permanent failure)
|
||||
**"How do I unblock a release?"** → [admin-features/release-blocklist.md](admin-features/release-blocklist.md) (admin → /admin/blocklist → Unblock, or chip on the request row)
|
||||
**"How does the admin book info modal work?"** → [admin-features/request-approval.md](admin-features/request-approval.md#ui-features), [frontend/components.md](frontend/components.md#component-apis)
|
||||
**"How do I customize audiobook folder organization?"** → [settings-pages.md](settings-pages.md#audiobook-organization-template), [phase3/file-organization.md](phase3/file-organization.md#target-structure)
|
||||
**"How do I deploy?"** → [deployment/docker.md](deployment/docker.md) (multi-container), [deployment/unified.md](deployment/unified.md) (all-in-one)
|
||||
**"How do I use the unified container?"** → [deployment/unified.md](deployment/unified.md)
|
||||
@@ -150,3 +172,13 @@
|
||||
**"Why do BookDate library books show placeholders?"** → [features/library-thumbnail-cache.md](features/library-thumbnail-cache.md)
|
||||
**"How does file hash matching work?"** → [fixes/file-hash-matching.md](fixes/file-hash-matching.md)
|
||||
**"Why is ABS matching the wrong book?"** → [fixes/file-hash-matching.md](fixes/file-hash-matching.md) (file hash prevents false positives)
|
||||
**"How do I customize my home page?"** → [features/home-sections.md](features/home-sections.md)
|
||||
**"How do Audible categories work?"** → [features/home-sections.md](features/home-sections.md)
|
||||
**"How do I add category sections to the home page?"** → [features/home-sections.md](features/home-sections.md)
|
||||
**"How do Goodreads shelves work?"** → [backend/services/goodreads-sync.md](backend/services/goodreads-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 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)
|
||||
|
||||
**GET /api/admin/logs**
|
||||
- Query params: page, limit, status, type
|
||||
- Returns: Job logs with request/audiobook/user details, pagination info
|
||||
- Filters: status (all/pending/active/completed/failed/delayed/stuck), type (all job types)
|
||||
- Query params: page, limit, status, type, search, dateFrom, dateTo, hasError, userId, audiobookQuery
|
||||
- limit: one of 25/50/100 (default 50; invalid values clamp to 50)
|
||||
- status: 'all' or one of pending/active/completed/failed/delayed/stuck
|
||||
- type: 'all' or any job type key
|
||||
- dateFrom / dateTo: ISO UTC strings; invalid dates silently dropped
|
||||
- hasError: 'true' or '1' → `status in (failed, stuck) OR errorMessage IS NOT NULL`
|
||||
- userId: uuid → filters via `request.userId`
|
||||
- audiobookQuery: free text → OR-contains (case-insensitive) on `request.audiobook.{title,author}`
|
||||
- search: free text → 6-column OR: bullJobId (startsWith, case-sensitive), errorMessage (contains-i), events.some.message (contains-i), request.audiobook.title/author (contains-i), request.user.plexUsername (contains-i)
|
||||
- hasError + search combine under top-level `AND`; other filters compose via AND on `where`
|
||||
- Where-builder: exported `buildLogsWhere(params)` in route file (pure, testable)
|
||||
- Returns: `{ logs, pagination: { page, limit, total, totalPages } }`
|
||||
|
||||
## Request Management Features
|
||||
|
||||
@@ -112,15 +121,21 @@ Comprehensive overview of system metrics, active requests, download monitoring,
|
||||
|
||||
## System Logs Features
|
||||
|
||||
- Real-time job monitoring (10s refresh)
|
||||
- Filter by status (pending/active/completed/failed/delayed/stuck)
|
||||
- Filter by job type (search_indexers/monitor_download/organize_files/scan_plex/match_plex)
|
||||
- Real-time job monitoring (10s SWR refresh; pauses on interact)
|
||||
- **Filter row (5 pickers):** Status · Job Type · Date Range · User typeahead · Audiobook free-text
|
||||
- Status: dropdown over VALID_STATUSES (from `src/app/admin/logs/types.ts`); labels via `STATUS_OPTIONS` in `src/lib/constants/log-filters.ts`
|
||||
- Job Type: dropdown over `JOB_TYPE_LABELS` insertion order (`src/lib/constants/job-labels.ts`)
|
||||
- Date Range: presets (Last hour / 24h / 7d / 30d / Custom / All time) — default = Last 7 days (Zach #1); Custom uses `<input type="datetime-local">` rendered as local time, wired as UTC ISO
|
||||
- User: typeahead via `useUserSearch` (fetch-once from `/api/admin/users`, SWR-cached, in-memory filter, max 10 suggestions); selection sets `userId = User.id`
|
||||
- Audiobook: free-text → server-side OR-contains on title/author (Zach #4 — no picker)
|
||||
- **Active filter chips:** dismissable `<button aria-label="Remove filter: X">` strip; NOT sticky (Zach #6 — scrolls with content). Errors-only renders as a chip when active.
|
||||
- **Clear all filters:** visible only when ≥1 filter or the search input is non-default
|
||||
- **Pause-on-interact reasons (registered to `useAutoRefreshControl`):**
|
||||
- `logs-status-dropdown`, `logs-type-dropdown`, `logs-date-picker`, `logs-user-typeahead`, `logs-book-input`
|
||||
- **URL = source of truth** via `useLogsUrlState` (`src/app/admin/logs/hooks/`); param names exported as `LOG_PARAMS`; same names used by `/api/admin/logs`
|
||||
- Shows related audiobook/user for request jobs
|
||||
- Expandable error messages
|
||||
- Duration calculation
|
||||
- Attempt tracking (current/max)
|
||||
- Pagination (50 logs per page)
|
||||
- Shows Bull job ID
|
||||
- Expandable error messages, duration calc, attempt tracking, Bull job ID
|
||||
- Pagination: page-size selector (25 / 50 / 100), default 50
|
||||
|
||||
## Tech Stack
|
||||
|
||||
|
||||
@@ -0,0 +1,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
|
||||
- User avatar and username
|
||||
- Request timestamp (relative: "2 hours ago")
|
||||
- Info button (ⓘ, top-right corner) — opens AudiobookDetailsModal for full book details
|
||||
- Approve button (green, checkmark icon)
|
||||
- Search button (blue, magnifier icon) — opens InteractiveTorrentSearchModal
|
||||
- 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)
|
||||
- Loading states on buttons during approval/denial
|
||||
- Success/error toast notifications
|
||||
|
||||
@@ -35,7 +35,7 @@ PostgreSQL database storing users, audiobooks, requests, downloads, configuratio
|
||||
|
||||
### Plex_Library (Library Cache)
|
||||
- `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)
|
||||
- `file_path`, `thumb_url`, `cached_library_cover_path` (local cached cover path), `plex_library_id`, `added_at`
|
||||
- `last_scanned_at`, `created_at`, `updated_at`
|
||||
@@ -60,12 +60,14 @@ PostgreSQL database storing users, audiobooks, requests, downloads, configuratio
|
||||
|
||||
### Requests
|
||||
- `id` (UUID PK), `user_id` (FK), `audiobook_id` (FK)
|
||||
- `status` ('pending'|'searching'|'downloading'|'processing'|'downloaded'|'available'|'failed'|'cancelled'|'awaiting_search'|'awaiting_import'|'warn'|'awaiting_approval'|'denied')
|
||||
- `status` ('pending'|'searching'|'downloading'|'processing'|'downloaded'|'available'|'failed'|'cancelled'|'awaiting_search'|'awaiting_import'|'awaiting_release'|'warn'|'awaiting_approval'|'denied')
|
||||
- **Approval flow:** awaiting_approval → (approve) → pending → searching → downloading → processing → downloaded → available
|
||||
- **Denial flow:** awaiting_approval → (deny) → denied
|
||||
- **awaiting_approval** - Request pending admin approval (only if auto-approve disabled)
|
||||
- **denied** - Request rejected by admin (terminal state)
|
||||
- **pending** - Request approved and queued for processing
|
||||
- **awaiting_release** - Book has a future release date; auto-search skipped until release (admin toggle controls behavior)
|
||||
- `release_date` (Date, nullable) - Book release date snapshot from Audnexus at request creation; used by skip-unreleased-auto-search gate
|
||||
- `progress` (0-100), `priority`, `error_message`
|
||||
- `search_attempts`, `download_attempts`, `import_attempts`, `max_import_retries` (default 5)
|
||||
- `last_search_at`, `last_import_at`, `created_at`, `updated_at`, `completed_at`
|
||||
@@ -109,12 +111,32 @@ PostgreSQL database storing users, audiobooks, requests, downloads, configuratio
|
||||
- Indexes: `job_id`, `created_at`
|
||||
- **Purpose:** Store detailed event logs for job operations (shown in admin logs UI)
|
||||
|
||||
### Blocked_Releases
|
||||
- `id` (UUID PK), `request_id` (FK → Requests, CASCADE on hard delete)
|
||||
- `release_name` (text) - original release title as the indexer returned it
|
||||
- `release_key` (text) - normalized lookup key: `trim().toLowerCase()` of release_name
|
||||
- `release_hash` (nullable) - `torrentHash` (qBit) OR `nzbId` (SAB/NZBGet); mutually exclusive in source
|
||||
- `indexer_name` (nullable), `indexer_id` (int, nullable)
|
||||
- `source` ('organize_fail'|'download_fail'|'manual'; 'manual' reserved for v2)
|
||||
- `reason` (text) - short, e.g. "No audiobook files found", "Download failed (par2)"
|
||||
- `reason_detail` (text, nullable) - raw client error string (SAB failMessage, NZBGet Par/Unpack code)
|
||||
- `download_history_id` (nullable) - traceability to the DownloadHistory row that drove the block
|
||||
- `job_id` (nullable) - origin job; also drives JobEvent emission via RMABLogger.forJob
|
||||
- `created_at` (timestamp)
|
||||
- Unique: `(request_id, release_key)` - idempotency for concurrent auto-block writes
|
||||
- Indexes: `request_id`, `release_key`, `release_hash`, `created_at DESC`
|
||||
- **Purpose:** Per-request blocklist. Search processors filter their candidate set against this table so future searches skip releases that have already failed for the same request.
|
||||
- **Soft/hard delete:** Soft-delete (sets `requests.deleted_at`) does NOT cascade - blocklist entries survive. Hard-delete cascades and wipes entries.
|
||||
- **Match rules:** Case-insensitive exact match on `release_key` OR exact match on `release_hash`.
|
||||
- **Service:** Single writer is `src/lib/services/blocklist.service.ts` (`addAutoBlock` is idempotent via upsert; never throws).
|
||||
|
||||
## Relationships
|
||||
|
||||
- User → Requests (1:many)
|
||||
- Audiobook → Requests (1:many)
|
||||
- Request → Download History (1:many)
|
||||
- Request → Jobs (1:many, nullable)
|
||||
- Request → Blocked Releases (1:many, CASCADE on hard delete)
|
||||
- Job → Job Events (1:many, CASCADE delete)
|
||||
|
||||
## Setup Strategy
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
# API Tokens
|
||||
|
||||
**Status:** ✅ Implemented | Personal long-lived tokens, allowlisted endpoints, write capability per issue #169
|
||||
|
||||
## Overview
|
||||
Static `rmab_`-prefixed tokens act with the owner's full user-level permissions on a fixed allowlist of endpoints. JWT sessions are NOT restricted by the allowlist.
|
||||
|
||||
## Key Details
|
||||
- **Prefix:** `rmab_` (12-char stored display prefix: `rmab_` + 7 hex chars)
|
||||
- **Storage:** SHA-256 hash in `apiToken.tokenHash`; full token shown ONCE on create
|
||||
- **Role binding:** Token `role` matches token owner's role at creation time; admin tokens require admin-created
|
||||
- **Per-user cap:** 25 active (non-expired) tokens (`MAX_TOKENS_PER_USER`)
|
||||
- **Expiry:** Optional (`never`, `30d`, `90d`, `1y`)
|
||||
- **Soft-deleted users:** Tokens reject if `tokenUser.deletedAt` is set
|
||||
- **Identity attribution:** `req.user.id` resolves to `apiToken.userId` (target user), NOT `apiToken.createdById`
|
||||
- **Header:** `Authorization: Bearer rmab_<token>`
|
||||
|
||||
## Allowed Endpoints
|
||||
| Method | Path | Title | Write | Admin |
|
||||
|---|---|---|---|---|
|
||||
| GET | `/api/auth/me` | Current user | | |
|
||||
| GET | `/api/audiobooks/search` | Search audiobooks | | |
|
||||
| GET | `/api/requests` | List requests | | |
|
||||
| POST | `/api/requests` | Create request | ✓ | |
|
||||
| GET | `/api/requests/:id` | Get request by ID | | |
|
||||
| GET | `/api/admin/metrics` | System metrics | | ✓ |
|
||||
| GET | `/api/admin/downloads/active` | Active downloads | | ✓ |
|
||||
| GET | `/api/admin/requests/recent` | Recent requests | | ✓ |
|
||||
|
||||
Source of truth: `src/lib/constants/api-tokens.ts` (`API_TOKEN_ALLOWED_ENDPOINTS`, `API_TOKEN_ENDPOINT_DOCS`).
|
||||
|
||||
## Matcher (`isEndpointAllowed`)
|
||||
- Compiled once at module load.
|
||||
- `path` entries containing `:name` are converted to anchored regexes where each placeholder matches `[^/]+` (a single segment).
|
||||
- Sibling sub-routes (e.g. `/api/requests/:id/select-torrent`) are NOT matched by the `/api/requests/:id` entry — they require their own allowlist entry.
|
||||
- Method comparison is case-insensitive.
|
||||
|
||||
## POST `/api/requests` (Write)
|
||||
- Body: `{ "audiobook": { "asin", "title", "author", "narrator?", "description?", "coverArtUrl?" } }`
|
||||
- Internally calls `createRequestForUser(req.user.id, audiobook, { bypassIgnore: true })` — token requests bypass the ignore list, matching UI behavior.
|
||||
- Optional query param: `?skipAutoSearch=true` defers search-job creation.
|
||||
- Side effects (identical to UI): duplicate detection, library check, Audnexus enrichment, audiobook upsert, ignore-list check (bypassed), per-user dedup, auto-approve gating, release-date gate, notification queue, search-job queue.
|
||||
- Auto-approve: follows the token owner's per-user `autoApproveRequests` setting, then global. No bypass.
|
||||
- Response: `201 { success: true, request }` or named error: `{ error: "AlreadyAvailable" | "BeingProcessed" | "DuplicateRequest" | "Ignored" | "UserNotFound" | "ValidationError", message }`
|
||||
|
||||
## GET `/api/requests/:id`
|
||||
- Returns full request including `audiobook`, `downloadHistory` (selected), and recent `jobs`.
|
||||
- Ownership enforced: `requestRecord.userId === req.user.id || role === 'admin'` → otherwise 403.
|
||||
- Soft-deleted requests (`deletedAt != null`) return 404.
|
||||
|
||||
## GET `/api/audiobooks/search`
|
||||
- Auth is optional, NOT gated by allowlist (route never calls `requireAuth`).
|
||||
- Uses `getCurrentUserAsync` to recognize both JWT sessions AND API tokens for per-user enrichment (request status, ignore status).
|
||||
- Without auth: returns generic results with no user-context annotations.
|
||||
- With JWT or `rmab_` token: returns results enriched with `isRequested`, `requestStatus`, `requestId`, `isIgnored`, etc.
|
||||
|
||||
## Auth flow
|
||||
1. Request hits route; `requireAuth` extracts `Authorization: Bearer ...` token.
|
||||
2. If token starts with `rmab_` → `authenticateApiToken` (SHA-256 lookup, expiry + soft-delete check, fire-and-forget `lastUsedAt` update).
|
||||
3. If on the allowlist → handler runs with `req.user = { sub, id, plexId, username, role }`.
|
||||
4. If not on the allowlist → 403 "This endpoint is not available via API token authentication".
|
||||
5. JWT tokens skip the allowlist entirely.
|
||||
|
||||
## UI surfaces
|
||||
- `/api-docs` page (`src/app/api-docs/page.tsx`) — auto-renders `API_TOKEN_ENDPOINT_DOCS`. Endpoints with `isWrite: true` show an amber **Write** badge; the "Try it" button is disabled with a "use curl" hint to avoid sending mutating requests from a UI that cannot construct request bodies.
|
||||
- Profile → API Tokens (`src/components/profile/ApiTokensSection.tsx`) — create/revoke UI. Includes a one-line warning that tokens act with the owner's full permissions.
|
||||
- Admin → Users → API Tokens — admin can create tokens on behalf of any user.
|
||||
|
||||
## Files
|
||||
- Constants + matcher: `src/lib/constants/api-tokens.ts`
|
||||
- Middleware: `src/lib/middleware/auth.ts` (`requireAuth`, `getCurrentUser`, `getCurrentUserAsync`)
|
||||
- Routes:
|
||||
- `src/app/api/user/api-tokens/route.ts` (user create/list/revoke)
|
||||
- `src/app/api/admin/api-tokens/route.ts` (admin)
|
||||
- UI: `src/app/api-docs/page.tsx`, `src/components/api-docs/EndpointCard.tsx`, `src/components/api-docs/TokenInput.tsx`, `src/components/profile/ApiTokensSection.tsx`
|
||||
|
||||
## Tests
|
||||
- `tests/constants/api-tokens.test.ts` — matcher: positive matches, negative matches, sub-route exclusion, method case-insensitivity, allowlist/docs parity.
|
||||
- `tests/middleware/auth.middleware.test.ts` — middleware token auth path, allowlist enforcement (incl. dynamic ID match), sibling-route blocking, `getCurrentUserAsync`.
|
||||
- `tests/api/requests-id.route.test.ts` — owner GET 200, cross-user GET 403.
|
||||
|
||||
## Related
|
||||
- [backend/services/auth.md](auth.md) — JWT sessions, role-based access control
|
||||
- [backend/services/notifications.md](notifications.md) — request notification triggers
|
||||
@@ -249,6 +249,14 @@ oidc.admin_claim_value = 'readmeabook-admin'
|
||||
- **Admin Settings:** OIDC section in `/admin/settings` (auth tab)
|
||||
- **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
|
||||
|
||||
- Never log tokens
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
# Goodreads & Shelf Sync
|
||||
|
||||
**Status:** ✅ Implemented | RSS feed parsing, shared sync core, extensible provider architecture
|
||||
|
||||
## Overview
|
||||
Syncs user-subscribed Goodreads shelves via RSS feeds, resolves books to Audible ASINs, and creates requests. Also documents the shared shelf sync core used by all providers.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Files
|
||||
- `src/lib/services/goodreads-sync.service.ts` — RSS fetch/parse, delegates to shared core
|
||||
- `src/lib/services/shelf-sync-core.service.ts` — Shared sync logic (Audible lookup, cover enrichment, request creation)
|
||||
- `src/lib/utils/shelf-helpers.ts` — Shared `processBooks()` utility for cover URL parsing
|
||||
- `src/lib/hooks/createShelfHooks.ts` — Generic hook factory for shelf CRUD operations
|
||||
- `src/app/api/user/goodreads-shelves/route.ts` — GET (list) + POST (add) routes
|
||||
- `src/app/api/user/goodreads-shelves/[id]/route.ts` — DELETE + PATCH routes
|
||||
- `src/app/api/user/shelves/route.ts` — Combined GET for all providers (GenericShelf shape)
|
||||
- `src/lib/hooks/useGoodreadsShelves.ts` — Frontend hooks (via `createShelfHooks` factory)
|
||||
|
||||
### Database Models
|
||||
- **GoodreadsShelf** — Per-user shelf subscription (`userId`, `rssUrl`, `name`, `lastSyncAt`, `bookCount`, `coverUrls`)
|
||||
- **BookMapping** — Shared table for all providers. Keyed by `provider` + `externalBookId`. Caches Audible ASIN lookups.
|
||||
|
||||
## Goodreads RSS Feed
|
||||
- **Format:** `https://www.goodreads.com/review/list_rss/{userId}?shelf={shelfName}`
|
||||
- **Auth:** None required (public RSS)
|
||||
- **Parsing:** `fast-xml-parser` extracts `item` entries with `book_id`, `title`, `author_name`, `book_image_url`
|
||||
|
||||
## Shared Sync Core
|
||||
|
||||
`shelf-sync-core.service.ts` contains all provider-agnostic sync logic:
|
||||
|
||||
### Interface: `ShelfBook`
|
||||
```typescript
|
||||
{ bookId: string; title: string; author: string; coverUrl?: string }
|
||||
```
|
||||
|
||||
### Function: `processShelfBooks()`
|
||||
Accepts provider-agnostic book list + context, performs:
|
||||
1. **BookMapping lookup** — Check if book already resolved (`provider` + `externalBookId`)
|
||||
2. **Audible search** — Full query (`title author`), fallback with cleaned title (strips parenthetical series info)
|
||||
3. **noMatch retry** — Re-searches after `NO_MATCH_RETRY_DAYS` (7 days)
|
||||
4. **Request creation** — Calls `createRequestForUser()` for matched ASINs
|
||||
5. **Cover enrichment** — Queries `audibleCache` for cached covers, builds `/api/cache/thumbnails/` URLs
|
||||
6. **Shelf metadata update** — Writes `lastSyncAt`, `bookCount`, top 8 books as JSON to `coverUrls`
|
||||
|
||||
### Constants
|
||||
- `DEFAULT_MAX_LOOKUPS_PER_SHELF` = 10 (per scheduled cycle; 0 = unlimited for manual triggers)
|
||||
- `NO_MATCH_RETRY_DAYS` = 7
|
||||
|
||||
### Hook Factory: `createShelfHooks(endpoint)`
|
||||
Returns `{ useList, useAdd, useDelete, useUpdate }` — all with SWR caching, optimistic updates, and automatic revalidation of the combined `/api/user/shelves` endpoint.
|
||||
|
||||
## API Endpoints
|
||||
|
||||
| Method | Path | Purpose |
|
||||
|---|---|---|
|
||||
| GET | `/api/user/goodreads-shelves` | List user's Goodreads shelves |
|
||||
| POST | `/api/user/goodreads-shelves` | Add shelf (validates RSS feed, triggers sync) |
|
||||
| DELETE | `/api/user/goodreads-shelves/[id]` | Remove shelf (ownership check) |
|
||||
| PATCH | `/api/user/goodreads-shelves/[id]` | Update RSS URL (triggers re-sync) |
|
||||
| GET | `/api/user/shelves` | Combined endpoint — merges all providers into `GenericShelf` |
|
||||
|
||||
## Adding a New Provider
|
||||
1. Create Prisma shelf model + migration (BookMapping table is already shared)
|
||||
2. Create API client service for the external data source
|
||||
3. Create thin sync service (~50-80 lines) that fetches books and calls `processShelfBooks()`
|
||||
4. Create API routes (or use a generic route handler)
|
||||
5. Create hook file (~40 lines) using `createShelfHooks(endpoint)`
|
||||
6. Add tab in `AddShelfModal` with provider-specific form fields
|
||||
|
||||
## Related
|
||||
- [Hardcover sync](hardcover-sync.md)
|
||||
- [Background jobs](jobs.md)
|
||||
- [Scheduler](scheduler.md)
|
||||
@@ -0,0 +1,66 @@
|
||||
# Hardcover Shelf Sync
|
||||
|
||||
**Status:** ✅ Implemented | GraphQL API integration, Audible ASIN resolution, automated request creation
|
||||
|
||||
## Overview
|
||||
Syncs user-subscribed Hardcover lists via their GraphQL API, resolves books to Audible ASINs, and creates audiobook requests automatically.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Files
|
||||
- `src/lib/services/hardcover-api.service.ts` — GraphQL queries, `fetchHardcoverList()`
|
||||
- `src/lib/services/hardcover-sync.service.ts` — Provider-specific orchestration, delegates to shared core
|
||||
- `src/lib/services/shelf-sync-core.service.ts` — Shared sync logic (Audible lookup, cover enrichment, request creation)
|
||||
- `src/app/api/user/hardcover-shelves/route.ts` — GET (list) + POST (add) routes
|
||||
- `src/app/api/user/hardcover-shelves/[id]/route.ts` — DELETE + PATCH routes
|
||||
- `src/lib/hooks/useHardcoverShelves.ts` — Frontend hooks (via `createShelfHooks` factory)
|
||||
|
||||
### Database Models
|
||||
- **HardcoverShelf** — Per-user list subscription (`userId`, `listId`, encrypted `apiToken`, `name`, `lastSyncAt`, `bookCount`, `coverUrls`)
|
||||
- **BookMapping** — Shared across all providers. Keyed by `provider` + `externalBookId`. Caches Audible ASIN resolution (`audibleAsin`, `noMatch`, `lastSearchAt`)
|
||||
|
||||
## Hardcover API
|
||||
|
||||
- **Endpoint:** `https://api.hardcover.app/v1/graphql` (Hasura-based)
|
||||
- **Auth:** Bearer token in Authorization header
|
||||
- **Username type:** `citext` (case-insensitive text) — use `$username: citext!` in GraphQL variables
|
||||
|
||||
### Query Strategies (custom lists)
|
||||
| Input | Strategy | Query root |
|
||||
|---|---|---|
|
||||
| URL with `@username` | Scoped to that user | `users(where: {username: {_eq: $username}}) { lists(...) }` |
|
||||
| Bare slug (no username) | Authenticated user's own list | `me { lists(where: {slug: {_eq: $slug}}) }` |
|
||||
| Numeric ID | Global lookup (IDs are unique) | `lists(where: {id: {_eq: $listId}})` |
|
||||
|
||||
### Status Lists
|
||||
- Prefix: `status-{id}` (e.g., `status-1`)
|
||||
- Query: `me { user_books(where: {status_id: {_eq: $statusId}}) }`
|
||||
- Status IDs: 1=Want to Read, 2=Currently Reading, 3=Read, 4=Did Not Finish
|
||||
|
||||
## Sync Flow
|
||||
1. Fetch shelves from DB (all or specific `shelfId`)
|
||||
2. Decrypt API token (encryption service)
|
||||
3. Fetch books from Hardcover GraphQL API
|
||||
4. Delegate to `processShelfBooks()` in shelf-sync-core (Audible lookup, request creation, cover enrichment)
|
||||
5. Update shelf metadata (`lastSyncAt`, `bookCount`, `coverUrls`)
|
||||
|
||||
## API Endpoints
|
||||
|
||||
| Method | Path | Purpose |
|
||||
|---|---|---|
|
||||
| GET | `/api/user/hardcover-shelves` | List user's shelves with book counts/covers |
|
||||
| POST | `/api/user/hardcover-shelves` | Add new shelf (validates via API fetch, encrypts token, triggers sync) |
|
||||
| DELETE | `/api/user/hardcover-shelves/[id]` | Remove shelf (ownership check) |
|
||||
| PATCH | `/api/user/hardcover-shelves/[id]` | Update listId/apiToken (triggers re-sync on change) |
|
||||
|
||||
## Key Details
|
||||
- **Token cleanup:** Strips `Bearer ` prefix if user pastes it
|
||||
- **Duplicate check:** Unique constraint on `(userId, listId)`
|
||||
- **Immediate sync:** POST and PATCH trigger `addSyncShelvesJob()` with unlimited lookups
|
||||
- **Scheduled sync:** Runs via `sync_reading_shelves` job (default: max 10 lookups/shelf/cycle)
|
||||
- **Cover data:** Stores top 8 books as JSON in `coverUrls` field for shelf card display
|
||||
|
||||
## Related
|
||||
- [Shelf sync core (shared logic)](goodreads-sync.md#shared-sync-core)
|
||||
- [Background jobs](jobs.md)
|
||||
- [Scheduler](scheduler.md)
|
||||
@@ -48,6 +48,7 @@ Manages background job queue using Bull (Redis-backed) for async tasks: searchin
|
||||
**search_indexers:**
|
||||
- No torrents found → 'awaiting_search' status (not failed)
|
||||
- Allows automatic retry via scheduled job
|
||||
- Upstream release-date gate: 4 enqueue sites (`request-creator.service`, `retry-missing-torrents.processor`, `monitor-rss-feeds.processor`, `bookdate/swipe/route`) check `shouldSkipAutoSearch` against `indexer.skip_unreleased`; gated requests are created/kept in `awaiting_release` and `addSearchJob` is not called. Manual search bypasses the gate.
|
||||
|
||||
**organize_files:**
|
||||
- No audiobook files found → 'awaiting_import' status
|
||||
|
||||
@@ -7,7 +7,7 @@ Sends notifications for audiobook request events (pending approval, approved, av
|
||||
|
||||
## Key Details
|
||||
- **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)
|
||||
- **Delivery:** Async via Bull job queue (priority 5)
|
||||
- **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_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_error | Download/import fails | Request failed at any stage |
|
||||
| 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.
|
||||
- `request_grabbed` + `requestType: 'audiobook'` → "Audiobook Grabbed"
|
||||
- `request_grabbed` + `requestType: 'ebook'` → "Ebook Grabbed"
|
||||
- `request_available` + `requestType: 'audiobook'` → "Audiobook Available"
|
||||
- `request_available` + `requestType: 'ebook'` → "Ebook Available"
|
||||
- `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
|
||||
- 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)**
|
||||
- After `status: 'available'` update → request_available (requestType: 'audiobook')
|
||||
- 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
|
||||
- Human-friendly schedule descriptions and editor (preset/custom/advanced modes)
|
||||
- Real-time cron expression preview
|
||||
- Admin Jobs page shows per-job descriptions inline; startup auto-renames legacy "Plex *" job names to neutral defaults (type-gated, exact-literal match only)
|
||||
|
||||
## Scheduled Jobs
|
||||
|
||||
1. **plex_library_scan** - Default: every 6 hours, full library scan, disabled by default (enable after setup)
|
||||
2. **plex_recently_added_check** - Default: every 5 minutes, lightweight polling of top 10 recently added items, enabled by default
|
||||
3. **audible_refresh** - Default: daily midnight, fetches 200 popular + 200 new releases, stores with rankings, disabled by default
|
||||
4. **retry_missing_torrents** - Default: daily midnight, re-searches 'awaiting_search' status (limit 50), handles both audiobook and ebook requests, enabled by default
|
||||
4. **retry_missing_torrents** - Default: daily midnight, processes union of `awaiting_search` ∪ `awaiting_release` (limit 50), handles both audiobook and ebook requests. Bidirectional transitions: `awaiting_search` → `awaiting_release` when release date is future + `indexer.skip_unreleased` ON; `awaiting_release` → `awaiting_search` + run search when release date has passed or setting OFF. Sole owner of these transitions. Enabled by default.
|
||||
5. **retry_failed_imports** - Default: every 6 hours, re-attempts 'awaiting_import' status (limit 50), enabled by default
|
||||
6. **cleanup_seeded_torrents** - Default: every 30 mins, deletes torrents after seeding requirements met, respects `seeding_time_minutes` config (0 = never), enabled by default
|
||||
7. **monitor_rss_feeds** - Default: every 15 mins, checks RSS feeds from enabled indexers, matches against 'awaiting_search' requests (audiobook and ebook, limit 100), triggers appropriate search jobs for matches, enabled by default
|
||||
6. **find_missing_ebooks** - Default: daily midnight, scans `downloaded` ∪ `available` audiobook requests (limit 50) for missing ebook companions and triggers the existing ebook fetch flow (`addSearchEbookJob`). Gated by `ebook_auto_grab_enabled` AND at least one ebook source enabled (`ebook_annas_archive_enabled` or `ebook_indexer_search_enabled`; legacy `ebook_sidecar_enabled` accepted as Anna's fallback). Skips ebook children in-flight (`pending`, `awaiting_approval`, `searching`, `downloading`, `processing`, `awaiting_search`, `awaiting_release`) or `cancelled`. Retries `failed`/`warn` children up to **5 lifetime auto-retries** per audiobook, tracked in `Request.ebookAutoRetryCount` (nullable; processor-private — manual "Fetch Ebook" never reads/writes it). Per-candidate writes are wrapped in `prisma.$transaction` for race-safety with concurrent auto-grab; counter rolls back if `addSearchEbookJob` throws. Enabled by default. Returns `{ scanned, gapsFound, triggered, created, retried, skippedInFlight, skippedCancelled, skippedCapHit }`.
|
||||
7. **cleanup_seeded_torrents** - Default: every 30 mins, deletes torrents after seeding requirements met. Respects per-indexer `seedingTimeMinutes` AND `ratioLimit` (BOTH required when set; `0` disables that criterion; both `0` = never cleaned up). Undefined ratio with `ratioLimit > 0` = not met (safe-deny). Enabled by default.
|
||||
8. **monitor_rss_feeds** - Default: every 15 mins, checks RSS feeds from enabled indexers, matches against `awaiting_search` requests (audiobook and ebook, limit 100). Query is unchanged — release-date gate is applied AFTER a match is found: if matched book is unreleased + `indexer.skip_unreleased` ON, the match is skipped and request status is NOT mutated (retry job owns transitions). Enabled by default.
|
||||
|
||||
## Architecture: Bull + Cron
|
||||
|
||||
@@ -129,10 +131,10 @@ interface ScheduledJob {
|
||||
## Audible Refresh Processor
|
||||
|
||||
**Implementation:**
|
||||
1. Clear previous `isPopular`/`isNewRelease` flags
|
||||
2. Fetch 200 popular + 200 new releases (multi-page scraping)
|
||||
3. Download and cache cover thumbnails locally (stored in `/app/cache/thumbnails`)
|
||||
4. Store/update in DB with category flags, rankings (`popularRank`, `newReleaseRank`), and cached cover paths
|
||||
1. Fetch 200 popular + 200 new releases (multi-page scraping)
|
||||
2. Download and cache cover thumbnails locally (stored in `/app/cache/thumbnails`)
|
||||
3. Wipe and re-populate `AudibleCacheCategory` entries with reserved IDs (`__popular__`, `__new_releases__`) and user-configured category IDs
|
||||
4. Upsert book metadata in `AudibleCache`, ranked entries in `AudibleCacheCategory`
|
||||
5. Record sync timestamp (`lastAudibleSync`)
|
||||
6. Clean up unused thumbnails (removes covers for audiobooks no longer in cache)
|
||||
7. Perform fuzzy matching (70% threshold) against Plex library
|
||||
@@ -155,6 +157,7 @@ interface ScheduledJob {
|
||||
- ✅ Failed requests blocking re-requests → allow re-requesting failed/warn/cancelled
|
||||
- ✅ Files deleted immediately → kept until seeding requirements met
|
||||
- ✅ No seeding time config → added `seeding_time_minutes`
|
||||
- ✅ No ratio-based seeding policy → added per-indexer `ratioLimit` (AND-semantics with `seedingTimeMinutes`; `0` disables; undefined client ratio = safe-deny)
|
||||
- ✅ Scheduled jobs not running on schedule → implemented Bull repeatable jobs with cron scheduling
|
||||
- ✅ MaxListenersExceededWarning → increased maxListeners to 20 on both Redis client and Bull queue
|
||||
- ✅ Cron expressions not user-friendly → added human-readable descriptions and visual schedule builder
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,64 @@
|
||||
# Home Page Sections (Per-User Configurable)
|
||||
|
||||
**Status:** Implemented | Per-user home page with configurable sections (popular, new releases, Audible categories)
|
||||
|
||||
## Overview
|
||||
Users customize their home page by adding/removing/reordering sections. Each section displays audiobooks from a specific source: built-in Popular, New Releases, or scraped Audible categories.
|
||||
|
||||
## Data Models
|
||||
|
||||
**UserHomeSection** (`user_home_sections`):
|
||||
- `id`, `userId` (FK User), `sectionType` ('popular'|'new_releases'|'category'), `categoryId` (nullable), `categoryName` (nullable), `sortOrder` (int)
|
||||
- Unique: `(userId, sectionType, categoryId)`
|
||||
- Default: Popular (0) + New Releases (1) created on first access
|
||||
|
||||
**AudibleCacheCategory** (`audible_cache_categories`):
|
||||
- `id`, `asin`, `categoryId`, `rank`, `lastSyncedAt`
|
||||
- Unique: `(asin, categoryId)`, Indexes: `categoryId`, `(categoryId, rank)`
|
||||
|
||||
## API Endpoints
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | `/api/user/home-sections` | user | Returns sections + nextRefresh |
|
||||
| PUT | `/api/user/home-sections` | user | Save full config (delete-recreate), max 10 |
|
||||
| GET | `/api/audible/categories` | user | Live scrape top-level categories |
|
||||
| GET | `/api/audiobooks/category/[categoryId]` | public | Paginated category books from cache |
|
||||
|
||||
## Refresh Processor (Unified Storage)
|
||||
- All section data stored in `AudibleCacheCategory` with reserved IDs: `__popular__` and `__new_releases__` for built-in sections
|
||||
- Popular/new-releases use same wipe-and-populate pattern as user categories
|
||||
- After built-in sections, queries DISTINCT categoryIds from `UserHomeSection`
|
||||
- Per section: wipe `AudibleCacheCategory` rows, scrape, upsert `AudibleCache` metadata, insert ranked category entries
|
||||
- Batch cooldown between sections (10-20s random)
|
||||
- Constants exported from `audible-refresh.processor.ts`: `POPULAR_CATEGORY_ID`, `NEW_RELEASES_CATEGORY_ID`
|
||||
|
||||
## AudibleService Methods
|
||||
- `getCategories()`: Scrapes `{baseUrl}/categories`, returns `{id, name}[]`
|
||||
- `getCategoryBooks(categoryId, limit)`: Scrapes `/search?node={id}&pageSize=50&sort=popularity-rank`, up to 200 results
|
||||
|
||||
## Frontend
|
||||
- **Hooks:** `useHomeSections()`, `useCategoryAudiobooks()`, `useAudibleCategories()` in `src/lib/hooks/useHomeSections.ts`
|
||||
- **Config Modal:** `src/components/home/HomeSectionConfigModal.tsx` — drag-and-drop (desktop), up/down arrows (mobile), auto-save with debounce
|
||||
- **Section Component:** `src/components/home/HomeSection.tsx` — renders individual section with color-coded header
|
||||
- **Home Page:** `src/app/page.tsx` — dynamic sections from user config, gear icon for customize
|
||||
- **Pagination:** `src/components/ui/UnifiedPagination.tsx` — controlled by `HomePage` for `activeIndex`; observer reports dominant section but parent gates updates via `lockedTo` state. Lock set on Prev/Next/jump; released on user scroll input (`wheel` / `touchstart` / Arrow / Page / Home / End keys) or any dot click. Fit-aware scroll via `src/lib/utils/paginationScroll.ts` — no scroll when section fits viewport, otherwise snaps top under sticky header with clamps that structurally prevent scrolling the section out of view. Pill is shown anywhere on main content; only the footer hides it.
|
||||
|
||||
## Key Decisions
|
||||
- 10 section limit per user (total)
|
||||
- Category picker scraped live (no categories table)
|
||||
- Top-level categories only (v1)
|
||||
- Wipe-and-re-scrape per category during refresh
|
||||
- Deduplication of categories across users before scraping
|
||||
- If category disappears, user sees empty section
|
||||
- 10-color palette assigned by sort order
|
||||
|
||||
## Files
|
||||
- Schema: `prisma/schema.prisma` (UserHomeSection, AudibleCacheCategory)
|
||||
- Migration: `prisma/migrations/20260306000000_add_home_sections/migration.sql`
|
||||
- Service: `src/lib/integrations/audible.service.ts` (getCategories, getCategoryBooks)
|
||||
- Processor: `src/lib/processors/audible-refresh.processor.ts`
|
||||
- API Routes: `src/app/api/user/home-sections/route.ts`, `src/app/api/audible/categories/route.ts`, `src/app/api/audiobooks/category/[categoryId]/route.ts`
|
||||
- Hooks: `src/lib/hooks/useHomeSections.ts`
|
||||
- Components: `src/components/home/HomeSectionConfigModal.tsx`, `src/components/home/HomeSection.tsx`
|
||||
- Tests: `tests/api/home-sections.routes.test.ts`, `tests/processors/audible-refresh.processor.test.ts`
|
||||
@@ -154,6 +154,12 @@ model Audiobook {
|
||||
- Hash generated AFTER merging
|
||||
- **Works correctly:** Hash reflects final organized state
|
||||
|
||||
### Coerced Files (Plex Format Coercion)
|
||||
- Files renamed from `.mp4` → `.m4b` (or single-file `.m4a` → `.m4b`) by Plex format coercion
|
||||
- Hash generated AFTER coercion → reflects post-coercion filenames
|
||||
- **Works correctly going forward:** ABS sees post-coercion names, hash matches
|
||||
- **Pre-existing library entries** hashed before coercion was enabled will NOT match post-coercion files — retroactive library sweep is out of scope (see issue #166)
|
||||
|
||||
### Multiple Downloads (Same Book)
|
||||
- User re-downloads same audiobook (different edition/request)
|
||||
- Multiple records with same `filesHash`
|
||||
|
||||
@@ -30,13 +30,13 @@ src/components/
|
||||
**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
|
||||
- **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**
|
||||
- **RequestCard** ✅ - Cover, title, author, status badge, progress bar, timestamps, action buttons (cancel, manual search, interactive search)
|
||||
- **StatusBadge** - Color-coded status (pending=yellow, searching=blue, downloading=purple, downloaded=green, processing=orange, available=green, completed=green, failed=red, warn=orange, cancelled=gray). Shows "Initializing..." when downloading with 0% progress (fetching torrent info), "Downloading" when progress > 0%
|
||||
- **RequestCard** ✅ - Cover, title, author, status badge, progress bar, timestamps, action buttons (cancel, manual search, interactive search). When status=`awaiting_release` and `releaseDate` is set, shows "Releases <Mon DD, YYYY>" next to the status badge (UTC-formatted)
|
||||
- **StatusBadge** - Color-coded status (pending=yellow, awaiting_search=yellow, searching=blue, downloading=purple, downloaded=green, processing=orange, awaiting_import=orange, available=green, completed=green, failed=red, warn=orange, cancelled=gray, awaiting_approval=yellow, awaiting_release=teal "Awaiting Release", denied=red). Shows "Initializing..." when downloading with 0% progress (fetching torrent info), "Downloading" when progress > 0%
|
||||
- **ProgressBar** - Animated fill with percentage
|
||||
- **InteractiveTorrentSearchModal** ✅ - Responsive table of ranked torrent results, uses ConfirmModal for downloads, hides columns on smaller screens (size on mobile, seeds on tablet, indexer on desktop)
|
||||
- **InteractiveTorrentSearchModal** ✅ - Responsive table of ranked torrent results, uses ConfirmModal for downloads, hides columns on smaller screens (size on mobile, seeds on tablet, indexer on desktop). Titles render verbatim; bracketed tags (e.g. `[German]`, `[Unabridged]`) parsed via `extractTitleTags` render as slate chips in the metadata row (de-duped vs `displayFormat`); an explicit chevron-disclosure button toggles per-`guid` expand only when the title is truncated (via `useIsTruncated`), state resets on close
|
||||
- Active indicator: "Setting up..." with spinner when progress = 0%, "Active" with pulsing dot when progress > 0%
|
||||
|
||||
**Forms**
|
||||
@@ -71,8 +71,12 @@ src/components/
|
||||
- Floating pagination pill at bottom center of viewport
|
||||
- Minimal design: section label | ← | Page X of Y | →
|
||||
- Quick jump input (type page number + Enter)
|
||||
- Auto-shows when scrolling through a section (IntersectionObserver)
|
||||
- Auto-scrolls to section top on page change
|
||||
- Free-scroll tracking via IntersectionObserver (reports dominant section to parent)
|
||||
- Controlled `activeIndex` lives on the home page; pill is observer-aware but parent-decided
|
||||
- **Lock-to-section on Prev/Next/jump:** pill stays anchored to the paged section until the user generates a scroll input (`wheel`, `touchstart`, `ArrowUp/Down`, `PageUp/Down`, `Home`, `End`) or clicks another section's dot. 30s safety auto-release.
|
||||
- **Fit-aware scroll:** if the section already fits below the sticky header, paging swaps cards in place (no scroll). Otherwise snaps the section top under the header with breathing room (8px top, 24px bottom). Target Y is clamped to `[0, maxScrollY]` so paging can never scroll the section out of the viewport.
|
||||
- Dot click on a different section always scrolls (intentional navigation) and releases any active lock.
|
||||
- Visibility: pill is shown anywhere on homepage main content; hidden only when the footer enters view. Stays visible over the CTA card gap between the last section and the footer.
|
||||
- Rounded-full design with backdrop blur and subtle shadow
|
||||
- Responsive grid layouts (1/2/3/4 cols)
|
||||
- Enhanced CTA section with gradient background (blue-to-indigo)
|
||||
@@ -109,10 +113,16 @@ interface AudiobookDetailsModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onRequestSuccess?: () => void;
|
||||
onStatusChange?: (newStatus: string) => void;
|
||||
onIgnoreChange?: (isIgnored: boolean) => void;
|
||||
isRequested?: boolean;
|
||||
requestStatus?: string | null;
|
||||
isAvailable?: boolean;
|
||||
requestedByUsername?: string | null;
|
||||
hideRequestActions?: boolean; // Hides sticky action bar for read-only contexts (BookDate, ShelvesSection)
|
||||
hasReportedIssue?: boolean;
|
||||
aiReason?: string | null;
|
||||
adminActions?: React.ReactNode; // Optional admin buttons (Approve/Search/Deny) rendered as second row in action bar
|
||||
}
|
||||
|
||||
interface RequestCardProps {
|
||||
@@ -162,6 +172,13 @@ interface StickyPaginationProps {
|
||||
sectionRef: React.RefObject<HTMLElement | null>;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface UnifiedPaginationProps {
|
||||
sections: PaginationSection[];
|
||||
footerRef?: React.RefObject<HTMLElement | null>;
|
||||
activeIndex: number; // controlled by parent
|
||||
onDominantSectionChange: (idx: number) => void; // observer guess; parent decides
|
||||
}
|
||||
```
|
||||
|
||||
## Custom Hooks
|
||||
|
||||
@@ -1,104 +1,131 @@
|
||||
# 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**
|
||||
- Endpoint: `https://api.audnex.us/books/{asin}`
|
||||
- Structured JSON response (no parsing needed)
|
||||
- Provides: title, authors, narrators, description, duration, rating, genres, cover art
|
||||
- Free, no API key required
|
||||
- ~95% success rate for popular audiobooks
|
||||
- **Nightly discovery refresh** (popular / new releases / category lists) — scraped from Audible's **curated HTML storefronts** (`www.audible.<tld>/adblbestsellers`, `/newreleases`, `/search?node=<id>`). The HTML pages reflect Audible's own editorial picks.
|
||||
- **User-facing real-time** (search, author books, categories listing, per-ASIN details) — Audible's unauthenticated public **JSON catalog API** (`api.audible.<tld>/1.0/catalog/*`).
|
||||
- **Per-ASIN detail lookups** — Audnexus (`api.audnex.us/books/{asin}`) primary; catalog API used as fallback when Audnexus returns 404.
|
||||
|
||||
**Fallback: Audible Scraping**
|
||||
- Used when Audnexus returns 404
|
||||
- Parse Audible HTML with Cheerio
|
||||
- Multiple selector strategies with promotional text filtering
|
||||
- Extract JSON-LD structured data when available
|
||||
## Architecture
|
||||
|
||||
- **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.
|
||||
- **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.
|
||||
- **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
|
||||
|
||||
**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:**
|
||||
- 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:**
|
||||
- Each region has `isEnglish: boolean` in `AudibleRegionConfig`
|
||||
- Non-English regions (`isEnglish: false`) display an amber warning in all region dropdowns (setup wizard + admin settings)
|
||||
- Warning text: "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 (e.g., "Germany *")
|
||||
| Code | Name | HTML baseUrl | apiBaseUrl | isEnglish |
|
||||
|---|---|---|---|---|
|
||||
| `us` | United States | `https://www.audible.com` | `https://api.audible.com` | true (default) |
|
||||
| `ca` | Canada | `https://www.audible.ca` | `https://api.audible.ca` | true |
|
||||
| `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:**
|
||||
- Each Audible region uses different ASINs for the same audiobook
|
||||
- Metadata engines (Audnexus/Audible Agent) in Plex/Audiobookshelf must match RMAB's region
|
||||
- Mismatched regions cause poor search results and failed metadata matching
|
||||
**`AudibleRegionConfig` fields:** `code`, `name`, `baseUrl`, `apiBaseUrl`, `audnexusParam`, `language`.
|
||||
|
||||
**`isEnglish` flag:**
|
||||
- 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:**
|
||||
- Key: `audible.region` (stored in database)
|
||||
- Default: `us`
|
||||
- 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:**
|
||||
- `AudibleService` loads region from config on initialization
|
||||
- Dynamically builds base URL: `AUDIBLE_REGIONS[region].baseUrl`
|
||||
- Audnexus API calls include region parameter: `?region={code}`
|
||||
- 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
|
||||
**Per-region HTTP clients (on init):**
|
||||
- `apiClient` — `baseURL=apiBaseUrl`, `Accept: application/json`, `User-Agent: ReadMeABook/1.0`, no language/ipRedirect params. Used for the real-time JSON catalog operations (search, author books, categories listing, per-ASIN details fallback).
|
||||
- `htmlClient` — `baseURL=baseUrl`, rotating browser headers (`pickUserAgent` + `getBrowserHeaders`), default params `ipRedirectOverride=true` + `language=<audibleLocaleParam>`. Used by the nightly discovery refresh (`/adblbestsellers`, `/newreleases`, `/search?node=...`), by `audible-series.ts`, and by `getBaseUrl()`-based link generation.
|
||||
- Audnexus calls include `region=<audnexusParam>`.
|
||||
|
||||
**Files:**
|
||||
- Types: `src/lib/types/audible.ts`
|
||||
- Service: `src/lib/integrations/audible.service.ts`
|
||||
- Series (HTML): `src/lib/integrations/audible-series.ts`
|
||||
- Config: `src/lib/services/config.service.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`)
|
||||
|
||||
**Status:** ✅ Production Ready (ASIN-Only Matching)
|
||||
**Status:** Production Ready (ASIN-Only Matching)
|
||||
|
||||
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
|
||||
- `matchAudiobook()`: ASIN → ISBN → null
|
||||
|
||||
**Benefits:**
|
||||
- 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. Library availability checks require exact ASIN matches only.
|
||||
|
||||
**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
|
||||
|
||||
**Status:** ✅ Implemented
|
||||
**Status:** Implemented
|
||||
|
||||
Discovery APIs serve cached data from DB with real-time matching.
|
||||
|
||||
**Flow:**
|
||||
1. `audible_refresh` job runs daily → fetches 200 popular + 200 new releases
|
||||
2. Downloads and caches cover thumbnails locally (reduces Audible load)
|
||||
3. Stores in DB with flags (`isPopular`, `isNewRelease`) and rankings
|
||||
4. Cleans up unused thumbnails after sync
|
||||
5. API routes query DB → apply real-time matching → return enriched results
|
||||
6. Homepage loads instantly (no Audible API hits)
|
||||
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.
|
||||
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.
|
||||
5. API routes query `AudibleCacheCategory` by categoryId → join with `AudibleCache` metadata → apply real-time matching → return enriched results.
|
||||
6. Homepage loads instantly (no Audible HTTP hits at request time).
|
||||
|
||||
## 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
|
||||
- Stores in `/app/cache/thumbnails` (Docker volume)
|
||||
- Serves via `/api/cache/thumbnails/[filename]`
|
||||
- Auto-cleanup of unused thumbnails
|
||||
- Falls back to original URL if cache fails
|
||||
- 24-hour browser cache headers
|
||||
- Downloads covers during `audible_refresh` job.
|
||||
- Stores in `/app/cache/thumbnails` (Docker volume).
|
||||
- Serves via `/api/cache/thumbnails/[filename]`.
|
||||
- Auto-cleanup of unused thumbnails.
|
||||
- Falls back to original URL if cache fails.
|
||||
- 24-hour browser cache headers.
|
||||
- Filename: `{asin}.{ext}` (e.g. `B08G9PRS1K.jpg`).
|
||||
|
||||
**Implementation:**
|
||||
**Files:**
|
||||
- Service: `src/lib/services/thumbnail-cache.service.ts`
|
||||
- API Route: `src/app/api/cache/thumbnails/[filename]/route.ts`
|
||||
- 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/new-releases?page=1&limit=20**
|
||||
@@ -182,6 +239,7 @@ interface AudibleAudiobook {
|
||||
asin: string;
|
||||
title: string;
|
||||
author: string;
|
||||
authorAsin?: string;
|
||||
narrator?: string;
|
||||
description?: string;
|
||||
coverArtUrl?: string;
|
||||
@@ -189,6 +247,12 @@ interface AudibleAudiobook {
|
||||
releaseDate?: string;
|
||||
rating?: number;
|
||||
genres?: string[];
|
||||
series?: string;
|
||||
seriesPart?: string;
|
||||
seriesAsin?: string;
|
||||
language?: string;
|
||||
formatType?: string;
|
||||
publisherName?: string;
|
||||
}
|
||||
|
||||
interface EnrichedAudibleAudiobook extends AudibleAudiobook {
|
||||
@@ -197,48 +261,58 @@ interface EnrichedAudibleAudiobook extends AudibleAudiobook {
|
||||
plexGuid: string | null;
|
||||
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
|
||||
|
||||
- axios (HTTP)
|
||||
- cheerio (HTML parsing)
|
||||
- Redis (caching, optional)
|
||||
- Database (PostgreSQL)
|
||||
- string-similarity (matching)
|
||||
- `axios` (HTTP, two clients: `apiClient` for JSON catalog API, `htmlClient` for HTML refresh + series scraping)
|
||||
- `cheerio` (HTML parsing for refresh job and `audible-series.ts`)
|
||||
- Audnexus API (per-ASIN details, primary)
|
||||
- PostgreSQL (`audible_cache`, `audible_cache_categories`)
|
||||
|
||||
## Fixed Issues
|
||||
|
||||
**Search returning empty results (2026-01-07)**
|
||||
- **Problem:** Audible changed HTML structure for search results from `.productListItem` to `.s-result-item`
|
||||
- **Impact:** All search queries returned 0 results
|
||||
- **Fix:** Updated `search()` method to support both `.s-result-item` (current) and `.productListItem` (legacy)
|
||||
- **Selectors 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`
|
||||
**Series-page duplicates not collapsing across user views (2026-05-14)**
|
||||
- **Problem:** Two re-listings of the same audiobook (same title, same narrator set, same duration, different ASINs) showed as two cards on series detail pages, even after the works table had already linked them via search-page dedup.
|
||||
- **Root cause (two-part):** (1) HTML scrapers used `$el.find('a[href*="searchNarrator="]').first()` for multi-narrator productions, capturing only the first co-narrator. So two listings of the same recording landed in `deduplicateAndCollectGroups` with mismatched single-narrator strings and never merged. (2) `deduplicateAndCollectGroups` was stateless — it wrote to the works table but never read it back, so even when one path (e.g. search) successfully merged two ASINs and persisted the Work, every other path (series, author books) re-derived the dedup decision from scratch and split them again.
|
||||
- **Fix:** (1) New `extractAllNarrators()` helper (`src/lib/utils/extract-narrator.ts`) captures every `searchNarrator=` anchor and joins them; all three HTML scrapers route through it. (2) New `collapseByExistingWorks()` consults the works table after the local pass and collapses any remaining books sharing a `workId`. Wired into the three user-facing discovery routes (search / author books / series detail). Skipped for watched-list background jobs — those feed `request-creator.service.ts` which already does sibling-aware dedup.
|
||||
- **Location:** `src/lib/utils/extract-narrator.ts` (new); `src/lib/integrations/audible-series.ts` (parseSeriesBooks); `src/lib/integrations/audible.service.ts` (parseProductListItems + parseSearchResultItems); `src/lib/utils/deduplicate-audiobooks.ts` (`metadataScore` exported); `src/lib/services/works.service.ts` (`collapseByExistingWorks` added); three API routes updated.
|
||||
|
||||
**Some audiobooks missing from search results (2026-01-07)**
|
||||
- **Problem:** ASIN extraction only matched `/pd/` URLs but some audiobooks use `/ac/` URLs
|
||||
- **Impact:** Books like "Beatitude" by DJ Krimmer (ASIN: B0DVH7XL36) were skipped
|
||||
- **Fix:** Updated ASIN regex to match both `/pd/` and `/ac/` URL patterns: `/\/(?:pd|ac)\/[^\/]+\/([A-Z0-9]{10})/`
|
||||
- **Location:** `src/lib/integrations/audible.service.ts:75, 161, 240`
|
||||
- **Affects:** `getPopularAudiobooks()`, `getNewReleases()`, `search()` methods
|
||||
**Discovery refresh reverted to curated HTML scraping (2026-05-14)**
|
||||
- **Problem:** After switching all catalog ops to the JSON catalog API in `f564d0a`, the nightly discovery refresh (Popular / New Releases / user-configured Categories) started serving junk: New Releases became 100% preorders out to 2027, and Popular was dominated by launch-day no-name shovelware.
|
||||
- **Root cause:** `products_sort_by=BestSellers` is a right-now sales velocity rank that spikes on launch promos and preorder windows; `-ReleaseDate` returns all catalog items in date order with no released-only filter. The catalog API exposes no server-side filter to exclude preorders or sort by established popularity (verified by exhaustively testing `release_time`, `availability_status`, `customer_rights`, `Reviewed`/`MostListened`/`SalesRank` sorts — all silently ignored or rejected). Doing the curation client-side would have made RMAB the editorial curator, which Audible's storefront pages already do well.
|
||||
- **Fix:** Hybrid architecture — the three refresh-only methods (`getPopularAudiobooks`, `getNewReleases`, `getCategoryBooks`) went back to scraping Audible's curated HTML storefronts (`/adblbestsellers`, `/newreleases`, `/search?node=<id>&sort=popularity-rank`). All user-facing real-time paths (search, author books, categories listing, per-ASIN details) stayed on the JSON catalog API. To keep the higher-503-risk HTML traffic resilient on the unattended nightly job, `fetchWithRetry()` accepts an optional `maxBackoffMs` cap and HTML callers use `HTML_MAX_RETRIES=12` + `HTML_MAX_BACKOFF_MS=180_000` (3-min cap). Healthy users finish quickly; 503-blocked users grind through patiently.
|
||||
- **Location:** `src/lib/integrations/audible.service.ts` (three methods + two private parsers `parseProductListItems` / `parseSearchResultItems`); `src/lib/utils/scrape-resilience.ts` (`jitteredBackoff` cap parameter).
|
||||
|
||||
**Audiobookshelf metadata matching not respecting configured region (2026-01-28)**
|
||||
- **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
|
||||
- **Fix:** Added `mapRegionToABSProvider()` to convert RMAB region codes to AudiobookShelf provider values. US → `'audible'`, others → `'audible.{region}'` (e.g., `'audible.ca'`, `'audible.uk'`)
|
||||
- **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.
|
||||
- **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`
|
||||
- **Affects:** All Audiobookshelf metadata matching operations
|
||||
|
||||
**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.
|
||||
- **Impact:** Users self-hosting from non-English-speaking countries got non-English bestsellers/new releases on their homepage.
|
||||
- **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.
|
||||
- **Location:** `src/lib/integrations/audible.service.ts` — `initialize()` (axios default params)
|
||||
- **Affects:** All Audible scraping: popular, new releases, search, detail pages
|
||||
- **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 content on HTML-scraped surfaces.
|
||||
- **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()` (htmlClient params)
|
||||
|
||||
## Related
|
||||
|
||||
- [Audiobookshelf Integration](./audiobookshelf.md)
|
||||
- [Plex Integration](./plex.md)
|
||||
- [Ranking Algorithm](../phase3/ranking-algorithm.md)
|
||||
|
||||
@@ -51,7 +51,7 @@ Ebooks are first-class citizens in RMAB, with their own request type, tracking,
|
||||
| Key | Default | Description |
|
||||
|-----|---------|-------------|
|
||||
| `ebook_annas_archive_enabled` | `false` | Enable Anna's Archive downloads |
|
||||
| `ebook_sidecar_base_url` | `https://annas-archive.li` | Base URL for mirror |
|
||||
| `ebook_sidecar_base_url` | `https://annas-archive.gl` | Base URL for mirror |
|
||||
| `ebook_sidecar_flaresolverr_url` | `` (empty) | FlareSolverr proxy URL (optional) |
|
||||
|
||||
#### Section 2: Indexer Search
|
||||
@@ -72,6 +72,10 @@ Ebooks are first-class citizens in RMAB, with their own request type, tracking,
|
||||
- *Auto-grab is automatically disabled if no ebook sources are enabled. Manual fetch via admin buttons still works.*
|
||||
- *Kindle fix toggle only visible when preferred format is EPUB.*
|
||||
|
||||
### Safety-Net: Find Missing Ebooks Job
|
||||
|
||||
A scheduled `find_missing_ebooks` job (daily midnight, enabled by default) backstops the auto-grab path for cases where it silently misses books (race conditions, transient indexer failures, requests created before sources were configured, books from Goodreads/Hardcover sync). Per run it scans up to 50 audiobook requests in `downloaded`/`available` status and triggers the existing ebook fetch flow for any audiobook missing a successful ebook companion. **Lifetime auto-retry cap: 5 per audiobook** — after 5 failed auto-attempts the job stops retrying that audiobook (admin Manual "Fetch Ebook" remains available). Counter is tracked in `Request.ebookAutoRetryCount` and is **processor-private**: manual Fetch Ebook routes never read, write, or reset it. Gated by `ebook_auto_grab_enabled` AND at least one source enabled; logs no-op runs honestly. See `documentation/backend/services/scheduler.md` for full details.
|
||||
|
||||
### Kindle EPUB Fix
|
||||
|
||||
**Purpose:** Apply compatibility fixes to EPUB files before organizing, ensuring successful Kindle import.
|
||||
@@ -180,18 +184,18 @@ Configure URL in Admin Settings → E-book Sidecar: `http://localhost:8191`
|
||||
|
||||
### Method 1: ASIN Search (exact match)
|
||||
```
|
||||
Search: https://annas-archive.li/search?ext=epub&lang=en&q="asin:B09TWSRMCB"
|
||||
Search: https://annas-archive.gl/search?ext=epub&lang=en&q="asin:B09TWSRMCB"
|
||||
↓
|
||||
MD5 Page: https://annas-archive.li/md5/[md5]
|
||||
MD5 Page: https://annas-archive.gl/md5/[md5]
|
||||
↓
|
||||
Slow Download: https://annas-archive.li/slow_download/[md5]/0/5
|
||||
Slow Download: https://annas-archive.gl/slow_download/[md5]/0/5
|
||||
↓
|
||||
File Server: http://[server]/path/to/file.epub
|
||||
```
|
||||
|
||||
### Method 2: Title + Author (fallback)
|
||||
```
|
||||
Search: https://annas-archive.li/search?q=Title+Author&ext=epub&lang=en
|
||||
Search: https://annas-archive.gl/search?q=Title+Author&ext=epub&lang=en
|
||||
↓ (Same flow from MD5 page)
|
||||
```
|
||||
|
||||
|
||||
@@ -44,10 +44,11 @@ Result: Douglas Adams/Stephen Fry/The Hitchhiker's Guide to the Galaxy/
|
||||
5. **Copy** files (not move - originals stay for seeding)
|
||||
6. **Tag metadata** (if enabled) - writes correct title, author, narrator, ASIN to audio files
|
||||
7. Copy cover art if found, else download from Audible
|
||||
8. **Generate file hash** - SHA256 of sorted audio filenames for library matching (see: [fixes/file-hash-matching.md](../fixes/file-hash-matching.md))
|
||||
9. Update request status to `downloaded` and store file hash in `audiobooks.files_hash`
|
||||
10. **Trigger filesystem scan** (if enabled) - tells Plex/ABS to scan for new files
|
||||
11. Originals remain until seeding requirements met
|
||||
8. **Coerce file formats** (if enabled) - rename .mp4 → .m4b and single-file .m4a → .m4b for Plex compatibility (see: Plex Format Coercion below)
|
||||
9. **Generate file hash** - SHA256 of sorted audio filenames for library matching (see: [fixes/file-hash-matching.md](../fixes/file-hash-matching.md))
|
||||
10. Update request status to `downloaded` and store file hash in `audiobooks.files_hash`
|
||||
11. **Trigger filesystem scan** (if enabled) - tells Plex/ABS to scan for new files
|
||||
12. Originals remain until seeding requirements met
|
||||
|
||||
## Filesystem Scan Triggering
|
||||
|
||||
@@ -150,6 +151,61 @@ exiftool "audiobook.m4b" | grep -i asin
|
||||
- Multi-container: `docker exec readmeabook ffmpeg -version`
|
||||
- Unified: `docker exec readmeabook-unified ffmpeg -version`
|
||||
|
||||
## Plex Format Coercion
|
||||
|
||||
**Status:** ✅ Implemented | Issue #166
|
||||
|
||||
**Purpose:** Rename audiobook files to Plex-recognized extensions before the library scan. Plex silently ignores `.mp4` files in audiobook libraries; this step prevents that silent-failure mode. Rename-only — no transcoding.
|
||||
|
||||
**When:** After file organization and metadata tagging, before file-hash generation and before library scan trigger.
|
||||
|
||||
**Scope:** Audio path only. Not applied to ebook organization.
|
||||
|
||||
**Coercion Table:**
|
||||
|
||||
| Source ext | Action |
|
||||
|---|---|
|
||||
| `.mp4` | Rename to `.m4b` |
|
||||
| `.m4a` (single audio file in folder) | Rename to `.m4b` |
|
||||
| `.m4a` (multi-file folder) | No-op |
|
||||
| `.m4b`, `.mp3`, `.flac`, `.aac`, `.wav`, `.alac` | No-op |
|
||||
| `.aa`, `.aax` | No-op + warn ("DRM, Plex cannot import") |
|
||||
| `.ogg`, `.opus`, `.wma`, other | No-op + warn ("requires transcode, not supported in v1") |
|
||||
|
||||
**Configuration:**
|
||||
- Key: `plex_format_coercion_enabled` (Configuration table)
|
||||
- Default: `true`
|
||||
- Read contract: `value !== 'false'` enables (default-on semantics)
|
||||
- Configurable in: Setup wizard (Paths step), Admin settings (Paths tab)
|
||||
|
||||
**Behavior:**
|
||||
- Each audio file evaluated independently (mixed-format folders supported).
|
||||
- Pre-rename collision check: if target exists → no-op + info log. Never overwrites.
|
||||
- Idempotent: re-running on already-coerced folder is a no-op (extension is the signal — no marker files).
|
||||
- Operates on `targetPath` (organized library files) only — never touches `/downloads` (seeding-safe).
|
||||
|
||||
**Failure Isolation:**
|
||||
- Coercion wrapped in try/catch at processor level.
|
||||
- Any failure (e.g., EPERM) logs a warning; request remains organized; original file untouched.
|
||||
- A failed rename never regresses the request to "stuck."
|
||||
|
||||
**Tech Stack:**
|
||||
- `src/lib/utils/format-coercion.ts` — coercion module
|
||||
- `src/lib/constants/audio-formats.ts` — `PLEX_COMPATIBLE_EXTENSIONS`, `COERCION_RENAME_MAP`, `DRM_EXTENSIONS`, `TRANSCODE_REQUIRED_EXTENSIONS`
|
||||
- Invoked from `src/lib/processors/organize-files.processor.ts` between file organization and `generateFilesHash`
|
||||
- `fs.rename` (same filesystem — no cross-mount issues)
|
||||
|
||||
**Hash Interaction:**
|
||||
- File hash (`audiobooks.files_hash`) is generated AFTER coercion → reflects post-coercion filenames.
|
||||
- See: [fixes/file-hash-matching.md](../fixes/file-hash-matching.md) for hash semantics.
|
||||
|
||||
**Out of Scope (v1):**
|
||||
- Transcoding (`.ogg`, `.opus`, `.wma`)
|
||||
- DRM decoding (`.aa`, `.aax`)
|
||||
- FLAC → M4B (already Plex-recognized)
|
||||
- Per-request override UI
|
||||
- Retroactive library sweep (new downloads only)
|
||||
|
||||
## Seeding Support
|
||||
|
||||
**Config:** `seeding_time_minutes` (0 = unlimited, never cleanup)
|
||||
@@ -203,6 +259,7 @@ async function organize(
|
||||
- **Path template:** Read from database config key `audiobook_path_template` (default: `{author}/{title} {asin}`)
|
||||
- **Metadata tagging:** `metadata_tagging_enabled` (boolean, default: true)
|
||||
- **Chapter merging:** `chapter_merging_enabled` (boolean, default: false)
|
||||
- **Plex format coercion:** `plex_format_coercion_enabled` (boolean, default: true)
|
||||
- **Fallback:** `/media/audiobooks` if media_dir not configured
|
||||
- **Temp directory:** `/tmp/readmeabook` (or `TEMP_DIR` env var)
|
||||
|
||||
|
||||
@@ -68,7 +68,7 @@ src/app/admin/settings/
|
||||
2. **Audiobookshelf** - URL, API token (masked), library ID, Audible region, filesystem scan trigger toggle
|
||||
3. **Prowlarr** - URL, API key (masked), indexer selection with priority, seeding time, RSS monitoring toggle, **audiobook/ebook categories per indexer**
|
||||
4. **Download Client** - Type (qBittorrent, Transmission, SABnzbd), URL, credentials (masked), custom download path (per-client relative sub-path with live preview)
|
||||
5. **Paths** - Download + media directories, audiobook organization template, metadata tagging toggle, chapter merging toggle
|
||||
5. **Paths** - Download + media directories, audiobook organization template, metadata tagging toggle, chapter merging toggle, Plex format coercion toggle
|
||||
6. **E-book Sidecar** - Multi-source ebook downloads (Anna's Archive + Indexer Search), preferred format
|
||||
7. **BookDate** - AI provider, API key (encrypted), model selection, library scope, custom prompt, swipe history
|
||||
8. **Notifications** - Multiple backends (Discord, Pushover), event subscriptions, test functionality
|
||||
@@ -81,7 +81,7 @@ src/app/admin/settings/
|
||||
|
||||
1. **Anna's Archive Section**
|
||||
- Enable toggle for Anna's Archive downloads
|
||||
- Base URL (default: `https://annas-archive.li`)
|
||||
- Base URL (default: `https://annas-archive.gl`)
|
||||
- FlareSolverr URL (optional, for Cloudflare bypass)
|
||||
|
||||
2. **Indexer Search Section**
|
||||
@@ -101,7 +101,7 @@ src/app/admin/settings/
|
||||
| `ebook_sidecar_preferred_format` | `epub` | Preferred format |
|
||||
| `ebook_auto_grab_enabled` | `true` | Auto-create ebook requests after audiobook downloads |
|
||||
| `ebook_kindle_fix_enabled` | `false` | Apply Kindle compatibility fixes to EPUB files |
|
||||
| `ebook_sidecar_base_url` | `https://annas-archive.li` | Anna's Archive mirror |
|
||||
| `ebook_sidecar_base_url` | `https://annas-archive.gl` | Anna's Archive mirror |
|
||||
| `ebook_sidecar_flaresolverr_url` | `` | FlareSolverr URL |
|
||||
|
||||
**Behavior:**
|
||||
@@ -130,6 +130,25 @@ src/app/admin/settings/
|
||||
}
|
||||
```
|
||||
|
||||
## Auto-Search Behavior (Indexers tab)
|
||||
|
||||
**Purpose:** Control how ReadMeABook performs automatic indexer searches. Lives on the Indexers tab between the Prowlarr connection block and the IndexerManagement list.
|
||||
|
||||
**Toggle:** Skip unreleased books in automatic searches
|
||||
- When ON: auto-search skips books whose release date is in the future. Those requests automatically start searching once the book is released. Manual searches are unaffected.
|
||||
- When OFF: auto-search proceeds regardless of release date.
|
||||
|
||||
**Configuration Key:**
|
||||
| Key | Default | Category | Description |
|
||||
|-----|---------|----------|-------------|
|
||||
| `indexer.skip_unreleased` | `true` (ON) | `indexer` | Skip auto-searches for books with future release dates |
|
||||
|
||||
**Read contract (consumed by background workers):**
|
||||
- `value !== 'false'` → ON (skip enabled). Missing key OR any non-`'false'` value → ON.
|
||||
- Only the exact string `'false'` disables the toggle. Workers MUST match this.
|
||||
|
||||
**API:** Persisted via `PUT /api/admin/settings/indexer-options`. Saved alongside Prowlarr connection + indexer config when the Indexers tab Save button is clicked.
|
||||
|
||||
## Audible Region
|
||||
|
||||
**Purpose:** Configure which Audible region to use for metadata and search to ensure accurate ASIN matching with your metadata engine.
|
||||
@@ -203,6 +222,27 @@ src/app/admin/settings/
|
||||
- When disabled: User relies on media server's filesystem watcher or manual scans
|
||||
- Error handling: Scan failures logged but don't fail organize job (graceful degradation)
|
||||
|
||||
## Plex Format Coercion
|
||||
|
||||
**Purpose:** Rename audiobook files to Plex-recognized extensions (`.mp4` → `.m4b`, single-file `.m4a` → `.m4b`) before the library scan. Prevents Plex silently ignoring `.mp4` audiobooks. Rename-only — no transcoding. See: [phase3/file-organization.md](phase3/file-organization.md#plex-format-coercion).
|
||||
|
||||
**Configuration:**
|
||||
- Key: `plex_format_coercion_enabled` (boolean, default: `true`)
|
||||
- Read contract: `value !== 'false'` enables (default-on)
|
||||
- Configurable in: Setup wizard (Paths step), Admin settings (Paths tab)
|
||||
|
||||
**UI:**
|
||||
- Checkbox toggle in PathsTab, between metadata tagging and chapter merging
|
||||
- Default: Checked (enabled)
|
||||
- Label: "Coerce file formats for Plex compatibility"
|
||||
- Sub-text: "Rename .mp4 audiobook files (and single-file .m4a) to .m4b before Plex scans. No re-encoding."
|
||||
|
||||
**Behavior:**
|
||||
- When enabled: After organize, rename files per coercion table before scan trigger
|
||||
- When disabled: Files left as-is (Plex may silently skip `.mp4`)
|
||||
- Failure isolation: Rename errors logged but don't fail organize job
|
||||
- Universal (Plex + ABS) — rename is lossless, no per-backend distinction
|
||||
|
||||
## Validation Flow
|
||||
|
||||
**Plex, Download Client, Paths:**
|
||||
@@ -279,6 +319,17 @@ src/app/admin/settings/
|
||||
- No test required if URL/API key unchanged
|
||||
- Saves only enabled indexers to database
|
||||
|
||||
**GET /api/admin/settings/indexer-options**
|
||||
- Returns `{ skipUnreleased: boolean }`
|
||||
- Default ON: missing or non-`'false'` value resolves to `true`
|
||||
- Admin auth required
|
||||
|
||||
**PUT /api/admin/settings/indexer-options**
|
||||
- Updates indexer-wide auto-search options
|
||||
- Body: `{ skipUnreleased: boolean }` (strict boolean validation)
|
||||
- Persists `indexer.skip_unreleased` (category: `indexer`)
|
||||
- No connection test required
|
||||
|
||||
**PUT /api/admin/settings/download-client**
|
||||
- Updates download client config
|
||||
- Requires prior successful test if credentials changed
|
||||
@@ -323,7 +374,7 @@ src/app/admin/settings/
|
||||
## Validation
|
||||
|
||||
**Plex:** Valid HTTP/HTTPS URL, non-empty token, library ID selected
|
||||
**Prowlarr:** Valid URL, non-empty API key, ≥1 indexer configured, priority 1-25, seedingTimeMinutes ≥0, rssEnabled boolean
|
||||
**Prowlarr:** Valid URL, non-empty API key, ≥1 indexer configured, priority 1-25, seedingTimeMinutes ≥0, ratioLimit ≥0 (torrents only; decimal, `0` = no requirement), rssEnabled boolean
|
||||
**Download Client:** Valid URL, credentials required, type must be 'qbittorrent', 'transmission', or 'sabnzbd'
|
||||
**Paths:** Absolute paths, exist or creatable, writable, cannot be same directory, template must contain `{author}` or `{title}`
|
||||
|
||||
|
||||
Generated
+6
-6
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "readmeabook",
|
||||
"version": "1.0.14",
|
||||
"version": "1.2.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "readmeabook",
|
||||
"version": "1.0.14",
|
||||
"version": "1.2.0",
|
||||
"dependencies": {
|
||||
"@heroicons/react": "^2.2.0",
|
||||
"@prisma/client": "^6.19.0",
|
||||
@@ -299,7 +299,7 @@
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
|
||||
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -309,7 +309,7 @@
|
||||
"version": "7.28.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
|
||||
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -343,7 +343,7 @@
|
||||
"version": "7.28.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz",
|
||||
"integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/types": "^7.28.5"
|
||||
@@ -403,7 +403,7 @@
|
||||
"version": "7.28.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz",
|
||||
"integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-string-parser": "^7.27.1",
|
||||
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "readmeabook",
|
||||
"version": "1.0.16",
|
||||
"version": "1.2.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
@@ -13,7 +13,8 @@
|
||||
"prisma:generate": "prisma generate",
|
||||
"prisma:migrate": "prisma migrate dev",
|
||||
"prisma:studio": "prisma studio",
|
||||
"db:push": "prisma db push"
|
||||
"db:push": "prisma db push",
|
||||
"rmab:recover": "node scripts/recover-credentials.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@heroicons/react": "^2.2.0",
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
-- Normalize existing local usernames to lowercase (idempotent - safe to run multiple times)
|
||||
-- Only affects local auth users, not Plex/OIDC users
|
||||
UPDATE users SET plex_username = LOWER(plex_username)
|
||||
WHERE auth_provider = 'local' AND deleted_at IS NULL AND plex_username != LOWER(plex_username);
|
||||
|
||||
UPDATE users SET plex_id = 'local-' || LOWER(SUBSTRING(plex_id FROM 7))
|
||||
WHERE plex_id LIKE 'local-%' AND plex_id NOT LIKE 'local-%-deleted-%' AND plex_id != LOWER(plex_id);
|
||||
@@ -0,0 +1,7 @@
|
||||
-- Reset works table to fix incorrect dedup groupings (v1.1.2)
|
||||
-- Books with "Series: Title" naming (e.g. "Eden's Gate: The Reborn" vs
|
||||
-- "Eden's Gate: The Spartan") were incorrectly merged into the same work
|
||||
-- because subtitle stripping collapsed them to the same base title.
|
||||
-- The works table auto-rebuilds from dedup logic as users browse.
|
||||
DELETE FROM work_asins;
|
||||
DELETE FROM works;
|
||||
@@ -0,0 +1,49 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "hardcover_shelves" (
|
||||
"id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"list_id" TEXT NOT NULL,
|
||||
"api_token" TEXT NOT NULL,
|
||||
"last_sync_at" TIMESTAMP(3),
|
||||
"book_count" INTEGER,
|
||||
"cover_urls" TEXT,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "hardcover_shelves_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "hardcover_book_mappings" (
|
||||
"id" TEXT NOT NULL,
|
||||
"hardcover_book_id" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"author" TEXT NOT NULL,
|
||||
"audible_asin" TEXT,
|
||||
"cover_url" TEXT,
|
||||
"no_match" BOOLEAN NOT NULL DEFAULT false,
|
||||
"last_search_at" TIMESTAMP(3),
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "hardcover_book_mappings_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "hardcover_shelves_user_id_idx" ON "hardcover_shelves"("user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "hardcover_shelves_user_id_list_id_key" ON "hardcover_shelves"("user_id", "list_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "hardcover_book_mappings_hardcover_book_id_key" ON "hardcover_book_mappings"("hardcover_book_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "hardcover_book_mappings_hardcover_book_id_idx" ON "hardcover_book_mappings"("hardcover_book_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "hardcover_book_mappings_audible_asin_idx" ON "hardcover_book_mappings"("audible_asin");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "hardcover_shelves" ADD CONSTRAINT "hardcover_shelves_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,41 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "book_mappings" (
|
||||
"id" TEXT NOT NULL,
|
||||
"provider" TEXT NOT NULL,
|
||||
"external_book_id" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"author" TEXT NOT NULL,
|
||||
"audible_asin" TEXT,
|
||||
"cover_url" TEXT,
|
||||
"no_match" BOOLEAN NOT NULL DEFAULT false,
|
||||
"last_search_at" TIMESTAMP(3),
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "book_mappings_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- Migrate data from goodreads_book_mappings
|
||||
INSERT INTO "book_mappings" ("id", "provider", "external_book_id", "title", "author", "audible_asin", "cover_url", "no_match", "last_search_at", "created_at", "updated_at")
|
||||
SELECT "id", 'goodreads', "goodreads_book_id", "title", "author", "audible_asin", "cover_url", "no_match", "last_search_at", "created_at", "updated_at"
|
||||
FROM "goodreads_book_mappings";
|
||||
|
||||
-- Migrate data from hardcover_book_mappings
|
||||
INSERT INTO "book_mappings" ("id", "provider", "external_book_id", "title", "author", "audible_asin", "cover_url", "no_match", "last_search_at", "created_at", "updated_at")
|
||||
SELECT "id", 'hardcover', "hardcover_book_id", "title", "author", "audible_asin", "cover_url", "no_match", "last_search_at", "created_at", "updated_at"
|
||||
FROM "hardcover_book_mappings";
|
||||
|
||||
-- DropTable
|
||||
DROP TABLE "goodreads_book_mappings";
|
||||
|
||||
-- DropTable
|
||||
DROP TABLE "hardcover_book_mappings";
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "book_mappings_provider_external_book_id_key" ON "book_mappings"("provider", "external_book_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "book_mappings_provider_external_book_id_idx" ON "book_mappings"("provider", "external_book_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "book_mappings_audible_asin_idx" ON "book_mappings"("audible_asin");
|
||||
@@ -0,0 +1,49 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "user_home_sections" (
|
||||
"id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"section_type" TEXT NOT NULL,
|
||||
"category_id" TEXT,
|
||||
"category_name" TEXT,
|
||||
"sort_order" INTEGER NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "user_home_sections_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "audible_cache_categories" (
|
||||
"id" TEXT NOT NULL,
|
||||
"asin" TEXT NOT NULL,
|
||||
"category_id" TEXT NOT NULL,
|
||||
"rank" INTEGER NOT NULL,
|
||||
"last_synced_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "audible_cache_categories_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "user_home_sections_user_id_idx" ON "user_home_sections"("user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "user_home_sections_sort_order_idx" ON "user_home_sections"("sort_order");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "user_home_sections_user_id_section_type_category_id_key" ON "user_home_sections"("user_id", "section_type", "category_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "audible_cache_categories_category_id_idx" ON "audible_cache_categories"("category_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "audible_cache_categories_asin_idx" ON "audible_cache_categories"("asin");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "audible_cache_categories_category_id_rank_idx" ON "audible_cache_categories"("category_id", "rank");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "audible_cache_categories_asin_category_id_key" ON "audible_cache_categories"("asin", "category_id");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "user_home_sections" ADD CONSTRAINT "user_home_sections_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,17 @@
|
||||
-- DropIndex
|
||||
DROP INDEX IF EXISTS "audible_cache_is_popular_idx";
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX IF EXISTS "audible_cache_is_new_release_idx";
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX IF EXISTS "audible_cache_popular_rank_idx";
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX IF EXISTS "audible_cache_new_release_rank_idx";
|
||||
|
||||
-- AlterTable - Remove legacy discovery flag columns (now stored in audible_cache_categories)
|
||||
ALTER TABLE "audible_cache" DROP COLUMN "is_popular",
|
||||
DROP COLUMN "is_new_release",
|
||||
DROP COLUMN "popular_rank",
|
||||
DROP COLUMN "new_release_rank";
|
||||
@@ -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;
|
||||
+185
-28
@@ -57,6 +57,12 @@ model User {
|
||||
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
|
||||
|
||||
// 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
|
||||
deletedAt DateTime? @map("deleted_at")
|
||||
deletedBy String? @map("deleted_by") // Admin user ID who deleted this user
|
||||
@@ -66,12 +72,15 @@ model User {
|
||||
bookDateRecommendations BookDateRecommendation[]
|
||||
bookDateSwipes BookDateSwipe[]
|
||||
goodreadsShelves GoodreadsShelf[]
|
||||
hardcoverShelves HardcoverShelf[]
|
||||
reportedIssues ReportedIssue[] @relation("Reporter")
|
||||
resolvedIssues ReportedIssue[] @relation("Resolver")
|
||||
createdApiTokens ApiToken[] @relation("CreatedApiTokens")
|
||||
apiTokens ApiToken[] @relation("UserApiTokens")
|
||||
watchedSeries WatchedSeries[]
|
||||
watchedAuthors WatchedAuthor[]
|
||||
homeSections UserHomeSection[]
|
||||
ignoredAudiobooks IgnoredAudiobook[]
|
||||
|
||||
@@index([plexId])
|
||||
@@index([role])
|
||||
@@ -98,12 +107,6 @@ model AudibleCache {
|
||||
rating Decimal? @db.Decimal(3, 2)
|
||||
genres Json @default("[]")
|
||||
|
||||
// Discovery categories
|
||||
isPopular Boolean @default(false) @map("is_popular")
|
||||
isNewRelease Boolean @default(false) @map("is_new_release")
|
||||
popularRank Int? @map("popular_rank")
|
||||
newReleaseRank Int? @map("new_release_rank")
|
||||
|
||||
lastSyncedAt DateTime @default(now()) @map("last_synced_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
@@ -111,10 +114,6 @@ model AudibleCache {
|
||||
@@index([asin])
|
||||
@@index([title])
|
||||
@@index([author])
|
||||
@@index([isPopular])
|
||||
@@index([isNewRelease])
|
||||
@@index([popularRank])
|
||||
@@index([newReleaseRank])
|
||||
@@map("audible_cache")
|
||||
}
|
||||
|
||||
@@ -133,7 +132,7 @@ model PlexLibrary {
|
||||
author String
|
||||
narrator String?
|
||||
summary String? @db.Text
|
||||
duration Int? // Duration in milliseconds (Plex format)
|
||||
duration BigInt? // Duration in milliseconds (Plex format)
|
||||
year Int?
|
||||
userRating Decimal? @map("user_rating") @db.Decimal(3, 1) // User's rating (0-10 scale from Plex)
|
||||
|
||||
@@ -224,7 +223,7 @@ model Request {
|
||||
userId String @map("user_id")
|
||||
audiobookId String @map("audiobook_id")
|
||||
status String @default("pending")
|
||||
// Status values: pending, awaiting_approval, denied, searching, downloading, processing, downloaded, available, failed, cancelled, awaiting_search, awaiting_import, warn
|
||||
// Status values: pending, awaiting_approval, denied, searching, downloading, processing, downloaded, available, failed, cancelled, awaiting_search, awaiting_import, awaiting_release, warn
|
||||
// Flow (audiobook): pending → searching → downloading → processing → downloaded → available (when matched in Plex)
|
||||
// Flow (ebook): pending → searching → downloading → processing → downloaded (terminal - no available state)
|
||||
progress Int @default(0) // 0-100
|
||||
@@ -235,12 +234,14 @@ model Request {
|
||||
downloadAttempts Int @default(0) @map("download_attempts")
|
||||
importAttempts Int @default(0) @map("import_attempts")
|
||||
maxImportRetries Int @default(5) @map("max_import_retries")
|
||||
ebookAutoRetryCount Int? @map("ebook_auto_retry_count")
|
||||
lastSearchAt DateTime? @map("last_search_at")
|
||||
customSearchTerms String? @map("custom_search_terms") @db.Text
|
||||
lastImportAt DateTime? @map("last_import_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
completedAt DateTime? @map("completed_at")
|
||||
releaseDate DateTime? @map("release_date") @db.Date // Book release date (copied from Audnexus on creation). Used by skip-unreleased-auto-search gate.
|
||||
|
||||
// Request type: 'audiobook' (default) or 'ebook'
|
||||
// Ebook requests are created automatically when an audiobook is organized (if ebook downloads enabled)
|
||||
@@ -258,6 +259,7 @@ model Request {
|
||||
jobs Job[]
|
||||
parentRequest Request? @relation("EbookParent", fields: [parentRequestId], references: [id], onDelete: SetNull)
|
||||
childRequests Request[] @relation("EbookParent")
|
||||
blockedReleases BlockedRelease[]
|
||||
|
||||
@@index([userId])
|
||||
@@index([audiobookId])
|
||||
@@ -269,6 +271,42 @@ model Request {
|
||||
@@map("requests")
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// BLOCKED RELEASES TABLE
|
||||
// Per-request blocklist of failed releases (organize-fail or download-fail).
|
||||
// Search processors filter their candidate set against this table so future
|
||||
// searches skip releases that have already failed for the same request.
|
||||
// Documentation: documentation/backend/database.md
|
||||
// ============================================================================
|
||||
|
||||
model BlockedRelease {
|
||||
id String @id @default(uuid())
|
||||
requestId String @map("request_id")
|
||||
releaseName String @map("release_name") @db.Text
|
||||
releaseKey String @map("release_key") @db.Text // normalized: trim + lowercase
|
||||
releaseHash String? @map("release_hash") // torrentHash OR nzbId (mutually exclusive in source)
|
||||
indexerName String? @map("indexer_name")
|
||||
indexerId Int? @map("indexer_id")
|
||||
source String // 'organize_fail' | 'download_fail' | 'manual' (manual reserved for v2)
|
||||
reason String @db.Text // short reason, e.g. "No audiobook files found"
|
||||
reasonDetail String? @map("reason_detail") @db.Text // raw client error (SAB failMessage, NZBGet Par/Unpack)
|
||||
downloadHistoryId String? @map("download_history_id") // traceability to the DownloadHistory row that failed
|
||||
jobId String? @map("job_id") // origin job (also drives JobEvent emission via logger)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
// Relations
|
||||
// Cascade: hard-delete of Request wipes its blocklist rows.
|
||||
// Soft-delete (Request.deletedAt) does NOT cascade — entries survive.
|
||||
request Request @relation(fields: [requestId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([requestId, releaseKey]) // idempotency: one row per (request, normalized name)
|
||||
@@index([requestId])
|
||||
@@index([releaseKey])
|
||||
@@index([releaseHash])
|
||||
@@index([createdAt(sort: Desc)])
|
||||
@@map("blocked_releases")
|
||||
}
|
||||
|
||||
model DownloadHistory {
|
||||
id String @id @default(uuid())
|
||||
requestId String @map("request_id")
|
||||
@@ -535,9 +573,10 @@ model GoodreadsShelf {
|
||||
rssUrl String @map("rss_url") @db.Text
|
||||
lastSyncAt DateTime? @map("last_sync_at")
|
||||
bookCount Int? @map("book_count")
|
||||
coverUrls String? @map("cover_urls") @db.Text // JSON array of cover image URLs
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
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")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
// Relations
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
@@ -547,21 +586,55 @@ model GoodreadsShelf {
|
||||
@@map("goodreads_shelves")
|
||||
}
|
||||
|
||||
model GoodreadsBookMapping {
|
||||
id String @id @default(uuid())
|
||||
goodreadsBookId String @unique @map("goodreads_book_id")
|
||||
title String
|
||||
author String
|
||||
audibleAsin String? @map("audible_asin")
|
||||
coverUrl String? @map("cover_url") @db.Text
|
||||
noMatch Boolean @default(false) @map("no_match")
|
||||
lastSearchAt DateTime? @map("last_search_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
// ============================================================================
|
||||
// UNIFIED BOOK MAPPING TABLE
|
||||
// Global book-to-ASIN mapping cache shared across all shelf providers.
|
||||
// Uses provider + externalBookId composite key for cross-provider dedup.
|
||||
// ============================================================================
|
||||
|
||||
@@index([goodreadsBookId])
|
||||
model BookMapping {
|
||||
id String @id @default(uuid())
|
||||
provider String // "goodreads", "hardcover", etc.
|
||||
externalBookId String @map("external_book_id")
|
||||
title String
|
||||
author String
|
||||
audibleAsin String? @map("audible_asin")
|
||||
coverUrl String? @map("cover_url") @db.Text
|
||||
noMatch Boolean @default(false) @map("no_match")
|
||||
lastSearchAt DateTime? @map("last_search_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@unique([provider, externalBookId])
|
||||
@@index([provider, externalBookId])
|
||||
@@index([audibleAsin])
|
||||
@@map("goodreads_book_mappings")
|
||||
@@map("book_mappings")
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// HARDCOVER SYNC TABLES
|
||||
// Per-user Hardcover list subscriptions
|
||||
// ============================================================================
|
||||
|
||||
model HardcoverShelf {
|
||||
id String @id @default(uuid())
|
||||
userId String @map("user_id")
|
||||
name String // Extracted from Hardcover API list name or status
|
||||
listId String @map("list_id") // Hardcover List ID or Status ID
|
||||
apiToken String @map("api_token") @db.Text // User's personal access token for hardcover api
|
||||
lastSyncAt DateTime? @map("last_sync_at")
|
||||
bookCount Int? @map("book_count")
|
||||
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")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
// Relations
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([userId, listId])
|
||||
@@index([userId])
|
||||
@@map("hardcover_shelves")
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -647,3 +720,87 @@ model WatchedAuthor {
|
||||
@@index([authorAsin])
|
||||
@@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
|
||||
// Per-user configurable home page sections (popular, new_releases, category)
|
||||
// Documentation: documentation/features/home-sections.md
|
||||
// ============================================================================
|
||||
|
||||
model UserHomeSection {
|
||||
id String @id @default(uuid())
|
||||
userId String @map("user_id")
|
||||
sectionType String @map("section_type") // 'popular' | 'new_releases' | 'category'
|
||||
categoryId String? @map("category_id") // Audible category node ID (only for type 'category')
|
||||
categoryName String? @map("category_name") // Display name (only for type 'category')
|
||||
sortOrder Int @map("sort_order")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
// Relations
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([userId, sectionType, categoryId])
|
||||
@@index([userId])
|
||||
@@index([sortOrder])
|
||||
@@map("user_home_sections")
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// AUDIBLE CACHE CATEGORY TABLE
|
||||
// Join table linking AudibleCache entries to Audible categories with ranking
|
||||
// Documentation: documentation/features/home-sections.md
|
||||
// ============================================================================
|
||||
|
||||
model AudibleCacheCategory {
|
||||
id String @id @default(uuid())
|
||||
asin String
|
||||
categoryId String @map("category_id")
|
||||
rank Int
|
||||
lastSyncedAt DateTime @default(now()) @map("last_synced_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
@@unique([asin, categoryId])
|
||||
@@index([categoryId])
|
||||
@@index([asin])
|
||||
@@index([categoryId, rank])
|
||||
@@map("audible_cache_categories")
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// DATA MIGRATION TRACKING
|
||||
// Tracks which data migration SQL scripts have been executed (run-once).
|
||||
// ============================================================================
|
||||
|
||||
model DataMigration {
|
||||
name String @id
|
||||
executedAt DateTime @default(now()) @map("executed_at")
|
||||
|
||||
@@map("_data_migrations")
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-9 group-hover:rotate-12 transition-all duration-300" fill="none" viewBox="0 0 40 40"><path d="M12.8889 32.5982C12.666 31.7661 13.1598 30.9108 13.9919 30.6879L30.2971 26.3189C31.1292 26.096 31.9845 26.5898 32.2075 27.4219L32.8739 29.9089C33.1711 31.0183 32.5127 32.1587 31.4033 32.456L18.1113 36.0176C15.8924 36.6121 13.6116 35.2953 13.0171 33.0764L12.8889 32.5982Z" fill="#4F46E5"></path><path d="M7.62314 12.946C7.05137 10.8121 8.3177 8.61876 10.4516 8.04699L16.8851 32.0571L13.0214 33.0924L7.62314 12.946Z" fill="#4F46E5"></path><path d="M29.3358 24.432L31.2677 23.9144L32.3584 27.985C32.6443 29.052 32.0111 30.1486 30.9442 30.4345L29.3358 24.432Z" fill="#4338CA"></path><path d="M26.4446 5.91475C26.1474 4.80529 25.007 4.14688 23.8975 4.44416L10.5286 8.02636C9.41911 8.32364 8.7607 9.46403 9.05798 10.5735L14.9532 32.5748L22.6461 30.5135C23.1986 30.3654 23.5265 29.7975 23.3785 29.245C23.2304 28.6925 23.5583 28.1245 24.1108 27.9765L29.7949 26.4535C30.9043 26.1562 31.5628 25.0158 31.2655 23.9063L26.4446 5.91475Z" fill="#6366F1"></path><path d="M21.0947 11.2811C21.145 10.6645 21.9408 10.4512 22.2927 10.9601L22.442 11.1761C22.5512 11.3341 22.724 11.4365 22.9151 11.4565L23.2375 11.4902C23.838 11.553 24.0445 12.3235 23.5558 12.6781L23.2935 12.8685C23.138 12.9813 23.0395 13.1564 23.0239 13.3479L23.0026 13.6096C22.9523 14.2262 22.1564 14.4394 21.8046 13.9306L21.6553 13.7146C21.546 13.5566 21.3732 13.4542 21.1821 13.4342L20.8598 13.4005C20.2592 13.3377 20.0528 12.5672 20.5415 12.2126L20.8038 12.0222C20.9593 11.9094 21.0577 11.7343 21.0734 11.5428L21.0947 11.2811Z" fill="#312E81"></path><path d="M18.3031 16.3181C18.3533 15.7015 19.1492 15.4882 19.501 15.9971L20.5634 17.5337C20.6727 17.6917 20.8455 17.7941 21.0366 17.8141L22.9139 18.0104C23.5144 18.0732 23.7208 18.8436 23.2321 19.1983L21.7045 20.3069C21.549 20.4197 21.4506 20.5949 21.435 20.7863L21.2832 22.6482C21.2329 23.2649 20.4371 23.4781 20.0852 22.9692L19.0228 21.4327C18.9136 21.2747 18.7407 21.1722 18.5497 21.1522L16.6724 20.956C16.0719 20.8932 15.8654 20.1227 16.3541 19.7681L17.8817 18.6594C18.0372 18.5466 18.1357 18.3715 18.1513 18.18L18.3031 16.3181Z" fill="#312E81"></path><path d="M14.9532 32.5748C14.6571 31.4697 15.3129 30.3339 16.4179 30.0378L29.8719 26.4328L30.9441 30.4345L17.4902 34.0395C16.3851 34.3356 15.2493 33.6798 14.9532 32.5748Z" fill="#EEF2FF"></path></svg>
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg width="500px" height="500px" viewBox="0 0 500 500" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<!-- Generator: Sketch 41.2 (35397) - http://www.bohemiancoding.com/sketch -->
|
||||
<title>img-coverart</title>
|
||||
<desc>Created with Sketch.</desc>
|
||||
<defs>
|
||||
<rect id="path-1" x="0" y="0" width="500" height="500"></rect>
|
||||
</defs>
|
||||
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="Account-details:-membership-asin-doc" transform="translate(-87.000000, -867.000000)">
|
||||
<g id="Group" transform="translate(65.000000, 780.000000)">
|
||||
<g id="img-coverart" transform="translate(22.000000, 87.000000)">
|
||||
<mask id="mask-2" fill="white">
|
||||
<use xlink:href="#path-1"></use>
|
||||
</mask>
|
||||
<use id="mask" fill="#BBBBBB" xlink:href="#path-1"></use>
|
||||
<path d="M251.314605,307.191176 L126.315789,229.090627 L126.315789,250.186562 L251.314605,328.289474 L376.315789,250.186562 L376.315789,229.090627 L251.314605,307.191176 Z M300.338486,257.198698 L318.743522,245.697622 L318.757695,245.697622 C304.238718,223.902504 279.436447,209.540923 251.277279,209.540923 C223.146464,209.540923 198.363093,223.878883 183.839389,245.643293 L183.952782,245.655104 C184.933157,244.762229 185.930063,243.885889 186.955321,243.03317 C222.033803,213.960416 272.668324,220.342816 300.338486,257.198698 Z M214.370819,264.53208 C220.980666,259.892912 228.629944,257.226098 236.796575,257.226098 C250.228874,257.226098 262.283922,264.413975 270.556862,275.811119 L288.30989,264.716324 L288.319343,264.716324 C280.157438,253.040453 266.61174,245.39669 251.277753,245.39669 C236.026448,245.39669 222.549266,252.95778 214.370819,264.53208 Z M166.789394,213.901363 C218.255462,173.164548 291.088955,184.079823 329.878678,238.171964 L330.136173,238.568797 L349.186133,226.701596 C328.31953,194.777784 292.263042,173.684211 251.278701,173.684211 C210.866048,173.684211 174.47174,194.572281 153.416152,226.633095 C157.283311,222.56083 162.29621,217.458689 166.789394,213.901363 Z" id="icn-audible" fill="#FFFFFF" mask="url(#mask-2)"></path>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
@@ -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 { useToast } from '@/components/ui/Toast';
|
||||
import { AudiobookDetailsModal } from '@/components/audiobooks/AudiobookDetailsModal';
|
||||
import { BlockedReleasesChip } from './BlockedReleasesChip';
|
||||
|
||||
interface RecentRequest {
|
||||
requestId: string;
|
||||
@@ -30,6 +31,7 @@ interface RecentRequest {
|
||||
torrentUrl?: string | null;
|
||||
downloadAttempts?: number;
|
||||
customSearchTerms?: string | null;
|
||||
blockedCount?: number;
|
||||
}
|
||||
|
||||
interface User {
|
||||
@@ -55,6 +57,7 @@ const STATUS_OPTIONS = [
|
||||
{ value: 'pending', label: 'Pending' },
|
||||
{ value: 'awaiting_approval', label: 'Awaiting Approval' },
|
||||
{ value: 'awaiting_search', label: 'Awaiting Search' },
|
||||
{ value: 'awaiting_release', label: 'Awaiting Release' },
|
||||
{ value: 'searching', label: 'Searching' },
|
||||
{ value: 'downloading', label: 'Downloading' },
|
||||
{ value: 'processing', label: 'Processing' },
|
||||
@@ -78,6 +81,7 @@ function getStatusBadge(status: string) {
|
||||
pending: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200',
|
||||
awaiting_approval: 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200',
|
||||
awaiting_search: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200',
|
||||
awaiting_release: 'bg-teal-100 text-teal-800 dark:bg-teal-900 dark:text-teal-200',
|
||||
searching: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200',
|
||||
downloading: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200',
|
||||
downloaded: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
|
||||
@@ -95,6 +99,7 @@ function getStatusBadge(status: string) {
|
||||
|
||||
const labels: Record<string, string> = {
|
||||
awaiting_search: 'Awaiting Search',
|
||||
awaiting_release: 'Awaiting Release',
|
||||
awaiting_import: 'Awaiting Import',
|
||||
awaiting_approval: 'Awaiting Approval',
|
||||
};
|
||||
@@ -163,7 +168,7 @@ function getInitialParams(): {
|
||||
};
|
||||
}
|
||||
|
||||
export function RecentRequestsTable({ ebookSidecarEnabled = false, annasArchiveBaseUrl = 'https://annas-archive.li' }: RecentRequestsTableProps) {
|
||||
export function RecentRequestsTable({ ebookSidecarEnabled = false, annasArchiveBaseUrl = 'https://annas-archive.gl' }: RecentRequestsTableProps) {
|
||||
const toast = useToast();
|
||||
|
||||
// Get initial filter state from URL (only evaluated once due to lazy init)
|
||||
@@ -674,6 +679,13 @@ export function RecentRequestsTable({ ebookSidecarEnabled = false, annasArchiveB
|
||||
Custom Search
|
||||
</span>
|
||||
)}
|
||||
{(request.blockedCount ?? 0) > 0 && (
|
||||
<BlockedReleasesChip
|
||||
requestId={request.requestId}
|
||||
blockedCount={request.blockedCount ?? 0}
|
||||
onChange={() => mutate(apiUrl)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{request.author}
|
||||
|
||||
@@ -114,23 +114,13 @@ export function ReportedIssuesSection({ issues }: ReportedIssuesSectionProps) {
|
||||
<div className="flex gap-3">
|
||||
{/* Cover Image */}
|
||||
<div className="flex-shrink-0">
|
||||
{issue.audiobook.coverArtUrl ? (
|
||||
<img
|
||||
src={issue.audiobook.coverArtUrl}
|
||||
alt={issue.audiobook.title}
|
||||
className="w-16 h-16 rounded object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-16 h-16 rounded bg-gray-200 dark:bg-gray-700 flex items-center justify-center">
|
||||
<svg
|
||||
className="w-8 h-8 text-gray-400 dark:text-gray-600"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path d="M9 4.804A7.968 7.968 0 005.5 4c-1.255 0-2.443.29-3.5.804v10A7.969 7.969 0 015.5 14c1.669 0 3.218.51 4.5 1.385A7.962 7.962 0 0114.5 14c1.255 0 2.443.29 3.5.804v-10A7.968 7.968 0 0014.5 4c-1.255 0-2.443.29-3.5.804V12a1 1 0 11-2 0V4.804z" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={issue.audiobook.coverArtUrl || '/placeholder_cover.svg'}
|
||||
alt={issue.audiobook.title}
|
||||
className="w-16 h-16 rounded object-cover"
|
||||
onError={(e) => { (e.target as HTMLImageElement).src = '/placeholder_cover.svg'; }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
|
||||
@@ -12,6 +12,8 @@ import { createPortal } from 'react-dom';
|
||||
import { InteractiveTorrentSearchModal } from '@/components/requests/InteractiveTorrentSearchModal';
|
||||
import { AdjustSearchTermsModal } from './AdjustSearchTermsModal';
|
||||
import { useSmartDropdownPosition } from '@/hooks/useSmartDropdownPosition';
|
||||
import { ConfirmModal } from '@/components/ui/ConfirmModal';
|
||||
import { CANCELLABLE_STATUSES } from '@/lib/constants/request-statuses';
|
||||
|
||||
export interface RequestActionsDropdownProps {
|
||||
request: {
|
||||
@@ -47,27 +49,30 @@ export function RequestActionsDropdown({
|
||||
onFetchEbook,
|
||||
onSearchTermsUpdated,
|
||||
ebookSidecarEnabled = false,
|
||||
annasArchiveBaseUrl = 'https://annas-archive.li',
|
||||
annasArchiveBaseUrl = 'https://annas-archive.gl',
|
||||
isLoading = false,
|
||||
}: RequestActionsDropdownProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [showInteractiveSearch, setShowInteractiveSearch] = useState(false);
|
||||
const [showInteractiveSearchEbook, setShowInteractiveSearchEbook] = 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 isAwaitingApproval = request.status === 'awaiting_approval';
|
||||
|
||||
// Determine request type
|
||||
const isEbook = request.type === 'ebook';
|
||||
|
||||
// View Details: available when ASIN exists (audiobook requests only)
|
||||
const canViewDetails = !isEbook && !!request.asin && !!onViewDetails;
|
||||
|
||||
// Determine available actions based on status and type
|
||||
// Ebooks don't support manual/interactive search (Anna's Archive only)
|
||||
const canSearch = !isEbook && ['pending', 'failed', 'awaiting_search'].includes(request.status);
|
||||
const canAdjustSearchTerms = !isEbook && ['pending', 'failed', 'awaiting_search', 'searching'].includes(request.status);
|
||||
// Determine available actions based on status
|
||||
const canSearch = ['pending', 'failed', 'awaiting_search', 'awaiting_release'].includes(request.status);
|
||||
const canAdjustSearchTerms = ['pending', 'failed', 'awaiting_search', 'awaiting_release', 'searching'].includes(request.status);
|
||||
const canRetryDownload = request.status === 'failed' && (request.downloadAttempts ?? 0) > 0 && !!onRetryDownload;
|
||||
const canCancel = ['pending', 'searching', 'downloading'].includes(request.status);
|
||||
const canCancel = (CANCELLABLE_STATUSES as readonly string[]).includes(request.status);
|
||||
const canDelete = true; // Admins can always delete
|
||||
|
||||
// View Source: For ebooks, extract MD5 from slow download URL and link to Anna's Archive
|
||||
@@ -130,7 +135,11 @@ export function RequestActionsDropdown({
|
||||
|
||||
const handleInteractiveSearch = () => {
|
||||
setIsOpen(false);
|
||||
setShowInteractiveSearch(true);
|
||||
if (isEbook) {
|
||||
setShowInteractiveSearchEbook(true);
|
||||
} else {
|
||||
setShowInteractiveSearch(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAdjustSearchTerms = () => {
|
||||
@@ -154,14 +163,21 @@ export function RequestActionsDropdown({
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = async () => {
|
||||
const handleCancel = () => {
|
||||
setIsOpen(false);
|
||||
if (window.confirm(`Are you sure you want to cancel the request for "${request.title}"?`)) {
|
||||
try {
|
||||
await onCancel(request.requestId);
|
||||
} catch (error) {
|
||||
console.error('Failed to cancel request:', error);
|
||||
}
|
||||
setConfirmCancelOpen(true);
|
||||
};
|
||||
|
||||
const handleConfirmCancel = async () => {
|
||||
setIsCancelling(true);
|
||||
try {
|
||||
await onCancel(request.requestId);
|
||||
setConfirmCancelOpen(false);
|
||||
} catch (error) {
|
||||
console.error('Failed to cancel request:', error);
|
||||
setConfirmCancelOpen(false);
|
||||
} finally {
|
||||
setIsCancelling(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -513,6 +529,7 @@ export function RequestActionsDropdown({
|
||||
author: request.author,
|
||||
}}
|
||||
searchMode="ebook"
|
||||
customSearchTerms={request.customSearchTerms}
|
||||
/>
|
||||
|
||||
{/* Adjust Search Terms Modal */}
|
||||
@@ -525,6 +542,22 @@ export function RequestActionsDropdown({
|
||||
currentSearchTerms={request.customSearchTerms}
|
||||
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;
|
||||
}
|
||||
|
||||
// Plain-English subtitle shown under each job's name on /admin/jobs.
|
||||
// Keyed by ScheduledJobType. Unknown types render no subtitle (silent absence —
|
||||
// we never leak raw type keys like `plex_library_scan` into the UI).
|
||||
const JOB_DESCRIPTIONS: Record<string, string> = {
|
||||
plex_library_scan: 'Scans your full media library to detect newly added audiobooks.',
|
||||
plex_recently_added_check: 'Checks for the newest items added to your library since the last scan.',
|
||||
audible_refresh: 'Refreshes popular & new-release audiobooks from Audible.',
|
||||
retry_missing_torrents: 'Retries searches for requests that previously found no results.',
|
||||
retry_failed_imports: 'Re-attempts import for downloads that failed to organize.',
|
||||
find_missing_ebooks: 'Looks for ebook companions to audiobooks you already have.',
|
||||
cleanup_seeded_torrents: "Removes torrents once they've met your seeding requirements.",
|
||||
monitor_rss_feeds: 'Watches indexer RSS feeds for matches against pending requests.',
|
||||
sync_reading_shelves: 'Pulls new books from your Goodreads/Hardcover shelves.',
|
||||
check_watched_lists: 'Checks watched series & authors for new releases.',
|
||||
};
|
||||
|
||||
function AdminJobsPageContent() {
|
||||
const [jobs, setJobs] = useState<ScheduledJob[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -214,7 +230,7 @@ function AdminJobsPageContent() {
|
||||
{job.name}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{job.type}
|
||||
{JOB_DESCRIPTIONS[job.type] ?? ''}
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
@@ -322,7 +338,7 @@ function AdminJobsPageContent() {
|
||||
{job.name}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{job.type}
|
||||
{JOB_DESCRIPTIONS[job.type] ?? ''}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
@@ -395,19 +411,6 @@ function AdminJobsPageContent() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Info Box */}
|
||||
<div className="mt-6 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
||||
<h3 className="text-sm font-medium text-blue-800 dark:text-blue-200 mb-2">
|
||||
About Scheduled Jobs
|
||||
</h3>
|
||||
<ul className="text-sm text-blue-700 dark:text-blue-300 space-y-1">
|
||||
<li>• <strong>Library Scan:</strong> Automatically scans your media library for new audiobooks</li>
|
||||
<li>• <strong>Audible Data Refresh:</strong> Caches popular and new release audiobooks from Audible</li>
|
||||
<li>• Trigger jobs manually using the "Trigger Now" button</li>
|
||||
<li>• Schedule format follows cron syntax (minute hour day month weekday)</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Confirmation Dialog */}
|
||||
{confirmDialog.isOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center bg-black bg-opacity-50 p-4">
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* Component: Admin Logs — Active Filter Chips
|
||||
* Documentation: documentation/admin-dashboard.md
|
||||
*
|
||||
* Dismissable chip strip showing every active (non-default) filter PLUS
|
||||
* the search term and the Errors-only flag. Each chip is a real <button>
|
||||
* with aria-label="Remove filter: <name>" and a visible × glyph.
|
||||
*
|
||||
* Not sticky — scrolls away with content (Zach Resolution #6).
|
||||
*
|
||||
* Consumes useLogsUrlState() directly; chips drive removal via removeFilter
|
||||
* (with a small atomic exception for the date-range chip which clears both
|
||||
* dateFrom and dateTo at once via setFilters).
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { JOB_TYPE_LABELS } from '@/lib/constants/job-labels';
|
||||
import { getActivePresetId, getStatusLabel, DATE_PRESETS } from '@/lib/constants/log-filters';
|
||||
import { useLogsUrlState } from '../hooks/useLogsUrlState';
|
||||
import { useUserSearch } from '../hooks/useUserSearch';
|
||||
|
||||
export default function ActiveFilterChips() {
|
||||
const { filters, setFilters, removeFilter } = useLogsUrlState();
|
||||
const { findUserById } = useUserSearch();
|
||||
|
||||
const chips: ChipDescriptor[] = [];
|
||||
|
||||
if (filters.search !== '') {
|
||||
chips.push({
|
||||
key: 'search',
|
||||
name: 'search',
|
||||
label: `Search: "${filters.search}"`,
|
||||
onRemove: () => removeFilter('search'),
|
||||
});
|
||||
}
|
||||
if (filters.hasError) {
|
||||
chips.push({
|
||||
key: 'hasError',
|
||||
name: 'errors only',
|
||||
label: 'Errors only',
|
||||
onRemove: () => removeFilter('hasError'),
|
||||
});
|
||||
}
|
||||
if (filters.status !== 'all') {
|
||||
chips.push({
|
||||
key: 'status',
|
||||
name: 'status',
|
||||
label: `Status: ${getStatusLabel(filters.status)}`,
|
||||
onRemove: () => removeFilter('status'),
|
||||
});
|
||||
}
|
||||
if (filters.type !== 'all') {
|
||||
const typeLabel = JOB_TYPE_LABELS[filters.type] ?? filters.type;
|
||||
chips.push({
|
||||
key: 'type',
|
||||
name: 'job type',
|
||||
label: `Type: ${typeLabel}`,
|
||||
onRemove: () => removeFilter('type'),
|
||||
});
|
||||
}
|
||||
if (filters.dateFrom !== null || filters.dateTo !== null) {
|
||||
chips.push({
|
||||
key: 'date',
|
||||
name: 'date range',
|
||||
label: `Date: ${formatDateChipLabel(filters.dateFrom, filters.dateTo)}`,
|
||||
onRemove: () => setFilters({ dateFrom: null, dateTo: null }),
|
||||
});
|
||||
}
|
||||
if (filters.userId !== null) {
|
||||
const user = findUserById(filters.userId);
|
||||
chips.push({
|
||||
key: 'userId',
|
||||
name: 'user',
|
||||
label: `User: ${user?.plexUsername ?? filters.userId}`,
|
||||
onRemove: () => removeFilter('userId'),
|
||||
});
|
||||
}
|
||||
if (filters.audiobookQuery !== '') {
|
||||
chips.push({
|
||||
key: 'audiobookQuery',
|
||||
name: 'audiobook',
|
||||
label: `Book: "${filters.audiobookQuery}"`,
|
||||
onRemove: () => removeFilter('audiobookQuery'),
|
||||
});
|
||||
}
|
||||
|
||||
if (chips.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="mb-4 flex flex-wrap gap-2" role="group" aria-label="Active filters">
|
||||
{chips.map((chip) => (
|
||||
<Chip key={chip.key} chip={chip} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ChipDescriptor {
|
||||
key: string;
|
||||
name: string;
|
||||
label: string;
|
||||
onRemove: () => void;
|
||||
}
|
||||
|
||||
function Chip({ chip }: { chip: ChipDescriptor }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={chip.onRemove}
|
||||
aria-label={`Remove filter: ${chip.name}`}
|
||||
className="inline-flex items-center gap-1.5 pl-3 pr-2 py-1.5 bg-blue-50 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-800 text-blue-800 dark:text-blue-200 rounded-full text-sm font-medium hover:bg-blue-100 dark:hover:bg-blue-900/50 transition-colors min-h-[36px]"
|
||||
>
|
||||
<span className="truncate max-w-[20rem]">{chip.label}</span>
|
||||
<svg
|
||||
className="w-3.5 h-3.5 flex-shrink-0"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDateChipLabel(dateFrom: string | null, dateTo: string | null): string {
|
||||
const presetId = getActivePresetId(dateFrom, dateTo);
|
||||
if (presetId === 'custom') {
|
||||
return `${formatLocal(dateFrom)} – ${formatLocal(dateTo)}`;
|
||||
}
|
||||
const preset = DATE_PRESETS.find((p) => p.id === presetId);
|
||||
return preset?.label ?? 'Custom';
|
||||
}
|
||||
|
||||
function formatLocal(iso: string | null): string {
|
||||
if (!iso) return '…';
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return '…';
|
||||
return d.toLocaleString([], {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* Component: Admin Logs — Date Range Picker
|
||||
* Documentation: documentation/admin-dashboard.md
|
||||
*
|
||||
* Compact preset <select> over DATE_PRESETS plus an optional pair of
|
||||
* <input type="datetime-local"> fields for Custom mode. Local times entered
|
||||
* are converted to UTC ISO before being emitted on the wire.
|
||||
*
|
||||
* Pause-on-interact: registers `'logs-date-picker'` while the picker subtree
|
||||
* has focus.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import {
|
||||
DATE_PRESETS,
|
||||
getActivePresetId,
|
||||
presetToRange,
|
||||
type DatePresetId,
|
||||
} from '@/lib/constants/log-filters';
|
||||
import { useRegisterPauseReason } from '../hooks/useAutoRefreshControl';
|
||||
import { INPUT_CLASS, LABEL_CLASS } from './filter-styles';
|
||||
|
||||
interface DateRangePickerProps {
|
||||
dateFrom: string | null;
|
||||
dateTo: string | null;
|
||||
onChange: (next: { dateFrom: string | null; dateTo: string | null }) => void;
|
||||
}
|
||||
|
||||
export default function DateRangePicker({ dateFrom, dateTo, onChange }: DateRangePickerProps) {
|
||||
const [focused, setFocused] = useState(false);
|
||||
useRegisterPauseReason('logs-date-picker', focused);
|
||||
|
||||
// Force-custom keeps the datetime-local inputs visible while the user is
|
||||
// entering values — without it, derived state (both null) would snap back
|
||||
// to "all_time" the moment they pick Custom but before they type anything.
|
||||
const [forceCustom, setForceCustom] = useState(false);
|
||||
const derivedPreset = useMemo(
|
||||
() => getActivePresetId(dateFrom, dateTo),
|
||||
[dateFrom, dateTo]
|
||||
);
|
||||
const activePreset: DatePresetId = forceCustom ? 'custom' : derivedPreset;
|
||||
const showCustom = activePreset === 'custom';
|
||||
|
||||
const handlePresetChange = (id: DatePresetId) => {
|
||||
if (id === 'custom') {
|
||||
setForceCustom(true);
|
||||
return;
|
||||
}
|
||||
setForceCustom(false);
|
||||
onChange(presetToRange(id));
|
||||
};
|
||||
|
||||
const handleCustomChange = (next: { dateFrom: string | null; dateTo: string | null }) => {
|
||||
setForceCustom(true);
|
||||
onChange(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
onFocus={() => setFocused(true)}
|
||||
onBlur={(e) => {
|
||||
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) {
|
||||
setFocused(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<label className={LABEL_CLASS} htmlFor="logs-date-preset">Date Range</label>
|
||||
<select
|
||||
id="logs-date-preset"
|
||||
value={activePreset}
|
||||
onChange={(e) => handlePresetChange(e.target.value as DatePresetId)}
|
||||
className={INPUT_CLASS}
|
||||
>
|
||||
{DATE_PRESETS.map((p) => (
|
||||
<option key={p.id} value={p.id}>{p.label}</option>
|
||||
))}
|
||||
</select>
|
||||
{showCustom && (
|
||||
<CustomDateInputs dateFrom={dateFrom} dateTo={dateTo} onChange={handleCustomChange} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CustomDateInputs({
|
||||
dateFrom,
|
||||
dateTo,
|
||||
onChange,
|
||||
}: {
|
||||
dateFrom: string | null;
|
||||
dateTo: string | null;
|
||||
onChange: (next: { dateFrom: string | null; dateTo: string | null }) => void;
|
||||
}) {
|
||||
const fromLocal = useMemo(() => isoToLocalInputValue(dateFrom), [dateFrom]);
|
||||
const toLocal = useMemo(() => isoToLocalInputValue(dateTo), [dateTo]);
|
||||
|
||||
return (
|
||||
<div className="mt-2 space-y-2">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<input
|
||||
type="datetime-local"
|
||||
aria-label="Date from"
|
||||
value={fromLocal}
|
||||
onChange={(e) =>
|
||||
onChange({ dateFrom: localInputToIso(e.target.value), dateTo })
|
||||
}
|
||||
className={INPUT_CLASS}
|
||||
/>
|
||||
<input
|
||||
type="datetime-local"
|
||||
aria-label="Date to"
|
||||
value={toLocal}
|
||||
onChange={(e) =>
|
||||
onChange({ dateFrom, dateTo: localInputToIso(e.target.value) })
|
||||
}
|
||||
className={INPUT_CLASS}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Times are in your local timezone (sent as UTC).
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function isoToLocalInputValue(iso: string | null): string {
|
||||
if (!iso) return '';
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return '';
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return (
|
||||
`${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` +
|
||||
`T${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||
);
|
||||
}
|
||||
|
||||
function localInputToIso(value: string): string | null {
|
||||
if (!value) return null;
|
||||
const d = new Date(value);
|
||||
if (Number.isNaN(d.getTime())) return null;
|
||||
return d.toISOString();
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
/**
|
||||
* Component: LogDetailPanel
|
||||
* Documentation: documentation/admin-dashboard.md
|
||||
*
|
||||
* Three collapsible sub-sections (Event Log / Result / Error) with count badges.
|
||||
* Per-event level filter. Copy-to-clipboard on each event, full event log,
|
||||
* result JSON, error block, and Bull Job ID. Toast confirmations.
|
||||
* Default open on desktop (`defaultOpen` prop), collapsed on mobile.
|
||||
*
|
||||
* NO "View related request" link — no admin request detail page exists (Zach #4).
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useToast } from '@/components/ui/Toast';
|
||||
import { JobEvent, Log } from '../types';
|
||||
|
||||
type Level = 'all' | 'info' | 'warn' | 'error';
|
||||
|
||||
// ===========================================================================
|
||||
// CopyButton — extracted because used 5+ times
|
||||
// ===========================================================================
|
||||
|
||||
interface CopyButtonProps {
|
||||
text: string;
|
||||
label: string;
|
||||
className?: string;
|
||||
/** When true, render as a compact icon-only button. */
|
||||
iconOnly?: boolean;
|
||||
}
|
||||
|
||||
function CopyButton({ text, label, className, iconOnly = false }: CopyButtonProps) {
|
||||
const toast = useToast();
|
||||
|
||||
const handleClick = async () => {
|
||||
const ok = await copyToClipboard(text);
|
||||
if (ok) toast.success(`Copied ${label}`);
|
||||
else toast.error('Copy unavailable on insecure connection');
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
aria-label={`Copy ${label}`}
|
||||
className={
|
||||
className ??
|
||||
'inline-flex items-center gap-1 px-2 py-1 text-xs font-medium text-gray-600 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors'
|
||||
}
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
{!iconOnly && <span>Copy</span>}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
async function copyToClipboard(text: string): Promise<boolean> {
|
||||
if (typeof navigator !== 'undefined' && navigator.clipboard && window.isSecureContext) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} catch {
|
||||
// fall through to textarea fallback
|
||||
}
|
||||
}
|
||||
try {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
ta.setAttribute('readonly', '');
|
||||
ta.style.position = 'fixed';
|
||||
ta.style.top = '0';
|
||||
ta.style.left = '0';
|
||||
ta.style.opacity = '0';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
const ok = document.execCommand('copy');
|
||||
document.body.removeChild(ta);
|
||||
return ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// EventLine — single row in the event log
|
||||
// ===========================================================================
|
||||
|
||||
function levelColorClass(level: string): string {
|
||||
if (level === 'error') return 'text-red-400';
|
||||
if (level === 'warn') return 'text-amber-400';
|
||||
return 'text-emerald-400';
|
||||
}
|
||||
|
||||
function formatEventLine(e: JobEvent): string {
|
||||
const ts = (() => {
|
||||
try {
|
||||
return new Date(e.createdAt).toISOString().split('T')[1].split('.')[0];
|
||||
} catch {
|
||||
return e.createdAt;
|
||||
}
|
||||
})();
|
||||
const meta = e.metadata && Object.keys(e.metadata).length > 0
|
||||
? '\n' + JSON.stringify(e.metadata, null, 2)
|
||||
: '';
|
||||
return `${ts} [${e.level.toUpperCase()}] [${e.context}] ${e.message}${meta}`;
|
||||
}
|
||||
|
||||
function EventLine({ event }: { event: JobEvent }) {
|
||||
const ts = (() => {
|
||||
try {
|
||||
return new Date(event.createdAt).toISOString().split('T')[1].split('.')[0];
|
||||
} catch {
|
||||
return event.createdAt;
|
||||
}
|
||||
})();
|
||||
return (
|
||||
<div className="group relative text-gray-300 leading-relaxed pr-10">
|
||||
<span className={levelColorClass(event.level)}>[{event.context}]</span>{' '}
|
||||
<span className="break-words">{event.message}</span>
|
||||
<span className="text-gray-500 ml-2">{ts}</span>
|
||||
{event.metadata && Object.keys(event.metadata).length > 0 && (
|
||||
<pre className="ml-4 mt-1 text-gray-400 text-xs overflow-x-auto">
|
||||
{JSON.stringify(event.metadata, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
<div className="absolute top-0 right-0 opacity-0 group-hover:opacity-100 focus-within:opacity-100 transition-opacity">
|
||||
<CopyButton text={formatEventLine(event)} label="event" iconOnly />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Collapsible — a sub-section with title, count badge, chevron toggle
|
||||
// ===========================================================================
|
||||
|
||||
interface CollapsibleProps {
|
||||
title: string;
|
||||
count?: number;
|
||||
defaultOpen: boolean;
|
||||
children: React.ReactNode;
|
||||
headerRight?: React.ReactNode;
|
||||
}
|
||||
|
||||
function Collapsible({ title, count, defaultOpen, children, headerRight }: CollapsibleProps) {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between gap-2 mb-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-expanded={open}
|
||||
className="inline-flex items-center gap-1.5 min-h-[44px] py-1 text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide hover:text-gray-900 dark:hover:text-gray-100"
|
||||
>
|
||||
<svg
|
||||
className={`w-3.5 h-3.5 transition-transform duration-200 ${open ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
<span>{title}</span>
|
||||
{typeof count === 'number' && (
|
||||
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded-full bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 normal-case tracking-normal">
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{open && headerRight}
|
||||
</div>
|
||||
{open && children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// LogDetailPanel
|
||||
// ===========================================================================
|
||||
|
||||
interface LogDetailPanelProps {
|
||||
log: Log;
|
||||
/** Default-open state for the three sub-sections. Desktop: true; Mobile: false. */
|
||||
defaultOpen: boolean;
|
||||
}
|
||||
|
||||
export function LogDetailPanel({ log, defaultOpen }: LogDetailPanelProps) {
|
||||
const [level, setLevel] = useState<Level>('all');
|
||||
|
||||
const filteredEvents = useMemo(() => {
|
||||
if (level === 'all') return log.events;
|
||||
return log.events.filter((e) => e.level === level);
|
||||
}, [log.events, level]);
|
||||
|
||||
const fullEventLog = useMemo(
|
||||
() => log.events.map(formatEventLine).join('\n'),
|
||||
[log.events]
|
||||
);
|
||||
|
||||
const resultText = useMemo(
|
||||
() => (log.result ? JSON.stringify(log.result, null, 2) : ''),
|
||||
[log.result]
|
||||
);
|
||||
|
||||
const hasResult = !!(log.result && Object.keys(log.result).length > 0);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{log.bullJobId && (
|
||||
<div className="flex flex-wrap gap-1.5 items-center">
|
||||
<span className="text-xs font-medium text-gray-500 dark:text-gray-400">
|
||||
Bull Job ID:
|
||||
</span>
|
||||
<span className="text-xs text-gray-700 dark:text-gray-300 font-mono break-all">
|
||||
{log.bullJobId}
|
||||
</span>
|
||||
<CopyButton text={log.bullJobId} label="Bull Job ID" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{log.events.length > 0 && (
|
||||
<Collapsible
|
||||
title="Event Log"
|
||||
count={log.events.length}
|
||||
defaultOpen={defaultOpen}
|
||||
headerRight={
|
||||
<div className="flex items-center gap-2">
|
||||
<LevelFilterPills value={level} onChange={setLevel} />
|
||||
<CopyButton text={fullEventLog} label="full event log" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{filteredEvents.length === 0 ? (
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 italic">
|
||||
No events at level "{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,
|
||||
};
|
||||
}
|
||||
+215
-468
@@ -1,504 +1,251 @@
|
||||
/**
|
||||
* Component: Admin System Logs Page
|
||||
* Documentation: documentation/admin-dashboard.md
|
||||
*
|
||||
* Thin orchestrator: reads URL via useLogsUrlState, owns SWR + pause registry,
|
||||
* composes sub-components. Empty-state copy as a pure function of
|
||||
* { totalResults, hasActiveFilters, hasActiveSearch }.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Suspense, useEffect, useRef, useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import Link from 'next/link';
|
||||
import { ToastProvider } from '@/components/ui/Toast';
|
||||
import { authenticatedFetcher } from '@/lib/utils/api';
|
||||
import {
|
||||
buildLogsApiKey,
|
||||
computeEmptyState,
|
||||
hasActiveFilters,
|
||||
hasActiveSearch,
|
||||
Log,
|
||||
LogsData,
|
||||
ValidLimit,
|
||||
} from './types';
|
||||
import { useLogsUrlState } from './hooks/useLogsUrlState';
|
||||
import {
|
||||
AutoRefreshControlProvider,
|
||||
useAutoRefreshControl,
|
||||
} from './hooks/useAutoRefreshControl';
|
||||
import { LogsToolbar } from './components/LogsToolbar';
|
||||
import { LogSkeleton } from './components/LogSkeleton';
|
||||
import { LogsPagination } from './components/LogsPagination';
|
||||
import { LogRow } from './components/LogRow';
|
||||
import LogsFilters from './components/LogsFilters';
|
||||
import ActiveFilterChips from './components/ActiveFilterChips';
|
||||
|
||||
interface JobEvent {
|
||||
id: string;
|
||||
level: string;
|
||||
context: string;
|
||||
message: string;
|
||||
metadata: any;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface Log {
|
||||
id: string;
|
||||
bullJobId: string | null;
|
||||
type: string;
|
||||
status: string;
|
||||
priority: number;
|
||||
attempts: number;
|
||||
maxAttempts: number;
|
||||
errorMessage: string | null;
|
||||
startedAt: string | null;
|
||||
completedAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
result: any;
|
||||
events: JobEvent[];
|
||||
request: {
|
||||
id: string;
|
||||
audiobook: {
|
||||
title: string;
|
||||
author: string;
|
||||
} | null;
|
||||
user: {
|
||||
plexUsername: string;
|
||||
};
|
||||
} | null;
|
||||
}
|
||||
|
||||
interface LogsData {
|
||||
logs: Log[];
|
||||
pagination: {
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
};
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const config: Record<string, { dot: string; text: string; bg: string }> = {
|
||||
completed: { dot: 'bg-emerald-500', text: 'text-emerald-700 dark:text-emerald-400', bg: 'bg-emerald-500/10' },
|
||||
failed: { dot: 'bg-red-500', text: 'text-red-700 dark:text-red-400', bg: 'bg-red-500/10' },
|
||||
active: { dot: 'bg-blue-500', text: 'text-blue-700 dark:text-blue-400', bg: 'bg-blue-500/10' },
|
||||
pending: { dot: 'bg-amber-500', text: 'text-amber-700 dark:text-amber-400', bg: 'bg-amber-500/10' },
|
||||
delayed: { dot: 'bg-orange-500', text: 'text-orange-700 dark:text-orange-400', bg: 'bg-orange-500/10' },
|
||||
stuck: { dot: 'bg-purple-500', text: 'text-purple-700 dark:text-purple-400', bg: 'bg-purple-500/10' },
|
||||
};
|
||||
const c = config[status] ?? { dot: 'bg-gray-400', text: 'text-gray-600 dark:text-gray-400', bg: 'bg-gray-500/10' };
|
||||
|
||||
function EmptyState({
|
||||
kind,
|
||||
onClearFilters,
|
||||
onClearSearch,
|
||||
searchValue,
|
||||
}: {
|
||||
kind: 'fresh' | 'filters-too-tight' | 'search-no-match';
|
||||
onClearFilters: () => void;
|
||||
onClearSearch: () => void;
|
||||
searchValue: string;
|
||||
}) {
|
||||
if (kind === 'fresh') {
|
||||
return (
|
||||
<div className="text-center py-16">
|
||||
<p className="text-gray-700 dark:text-gray-300 text-base font-medium">
|
||||
No background jobs have run yet.
|
||||
</p>
|
||||
<p className="text-gray-500 dark:text-gray-400 text-sm mt-1">
|
||||
New jobs will appear here as they start.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (kind === 'search-no-match') {
|
||||
return (
|
||||
<div className="text-center py-16">
|
||||
<p className="text-gray-700 dark:text-gray-300 text-base font-medium">
|
||||
No matches for “{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>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium ${c.bg} ${c.text}`}>
|
||||
<span className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${c.dot}`} />
|
||||
{status.charAt(0).toUpperCase() + status.slice(1)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function LogDetails({ log }: { log: Log }) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{log.bullJobId && (
|
||||
<div className="flex flex-wrap gap-1.5 items-baseline">
|
||||
<span className="text-xs font-medium text-gray-500 dark:text-gray-400">Bull Job ID:</span>
|
||||
<span className="text-xs text-gray-700 dark:text-gray-300 font-mono break-all">{log.bullJobId}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{log.events.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide mb-2">
|
||||
Event Log
|
||||
</h4>
|
||||
<div className="space-y-px max-h-72 sm:max-h-96 overflow-y-auto bg-gray-950 dark:bg-black/60 rounded-xl p-3 font-mono text-xs">
|
||||
{log.events.map((event) => {
|
||||
const timestamp = new Date(event.createdAt).toISOString().split('T')[1].split('.')[0];
|
||||
const levelColor = event.level === 'error'
|
||||
? 'text-red-400'
|
||||
: event.level === 'warn'
|
||||
? 'text-amber-400'
|
||||
: 'text-emerald-400';
|
||||
|
||||
return (
|
||||
<div key={event.id} className="text-gray-300 leading-relaxed">
|
||||
<span className={levelColor}>[{event.context}]</span>
|
||||
{' '}
|
||||
<span className="break-words">{event.message}</span>
|
||||
<span className="text-gray-500 ml-2">{timestamp}</span>
|
||||
{event.metadata && Object.keys(event.metadata).length > 0 && (
|
||||
<pre className="ml-4 mt-1 text-gray-400 text-xs overflow-x-auto">
|
||||
{JSON.stringify(event.metadata, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{log.result && Object.keys(log.result).length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide mb-2">
|
||||
Job Result
|
||||
</h4>
|
||||
<pre className="p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-xl text-xs text-blue-900 dark:text-blue-300 font-mono overflow-x-auto max-h-48">
|
||||
{JSON.stringify(log.result, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{log.errorMessage && (
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide mb-2">
|
||||
Error
|
||||
</h4>
|
||||
<div className="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-xl text-xs text-red-700 dark:text-red-300 font-mono whitespace-pre-wrap break-words">
|
||||
{log.errorMessage}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="text-center py-16">
|
||||
<p className="text-gray-700 dark:text-gray-300 text-base font-medium">
|
||||
No logs match your current filters.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClearFilters}
|
||||
className="mt-3 inline-flex items-center gap-1.5 min-h-[44px] px-4 py-2 text-sm font-medium text-blue-600 dark:text-blue-400 hover:underline"
|
||||
>
|
||||
Clear filters
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDuration(startedAt: string | null, completedAt: string | null) {
|
||||
if (!startedAt) return 'N/A';
|
||||
if (!completedAt) return 'Running…';
|
||||
const durationMs = new Date(completedAt).getTime() - new Date(startedAt).getTime();
|
||||
const seconds = Math.floor(durationMs / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours > 0) return `${hours}h ${minutes % 60}m`;
|
||||
if (minutes > 0) return `${minutes}m ${seconds % 60}s`;
|
||||
return `${seconds}s`;
|
||||
}
|
||||
function AdminLogsPageContent() {
|
||||
const { filters, setFilters, clearAll, usingHydrateDateDefault } = useLogsUrlState();
|
||||
const { effectiveInterval, setMutate, setLastUpdatedAt } = useAutoRefreshControl();
|
||||
|
||||
function formatType(type: string) {
|
||||
return type.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase());
|
||||
}
|
||||
const key = buildLogsApiKey(filters);
|
||||
|
||||
function formatDateShort(dateStr: string) {
|
||||
const d = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const isToday = d.toDateString() === now.toDateString();
|
||||
if (isToday) {
|
||||
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
}
|
||||
return d.toLocaleDateString([], { month: 'short', day: 'numeric' }) + ' ' +
|
||||
d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
// Track previous key to distinguish initial-load / filter-change skeleton
|
||||
// from auto-refresh (which preserves rows).
|
||||
const previousKeyRef = useRef<string>(key);
|
||||
const [keyChanging, setKeyChanging] = useState(false);
|
||||
|
||||
export default function AdminLogsPage() {
|
||||
const [page, setPage] = useState(1);
|
||||
const [statusFilter, setStatusFilter] = useState('all');
|
||||
const [typeFilter, setTypeFilter] = useState('all');
|
||||
const [expandedLog, setExpandedLog] = useState<string | null>(null);
|
||||
const { data, error, mutate } = useSWR<LogsData>(key, authenticatedFetcher, {
|
||||
refreshInterval: effectiveInterval,
|
||||
keepPreviousData: true,
|
||||
});
|
||||
|
||||
const { data, error } = useSWR<LogsData>(
|
||||
`/api/admin/logs?page=${page}&limit=50&status=${statusFilter}&type=${typeFilter}`,
|
||||
authenticatedFetcher,
|
||||
{ refreshInterval: 10000 }
|
||||
);
|
||||
// Wire SWR's mutate into the auto-refresh control so "Refresh now" works.
|
||||
useEffect(() => {
|
||||
setMutate(() => mutate());
|
||||
return () => setMutate(null);
|
||||
}, [mutate, setMutate]);
|
||||
|
||||
const isLoading = !data && !error;
|
||||
// Broadcast a "fresh data" timestamp when SWR data lands.
|
||||
useEffect(() => {
|
||||
if (data) setLastUpdatedAt(Date.now());
|
||||
}, [data, setLastUpdatedAt]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 p-8">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// Skeleton-vs-rows decision:
|
||||
// - !data → initial skeleton.
|
||||
// - key changed AND no data for the new key yet → skeleton on transition.
|
||||
// SWR's `keepPreviousData` makes data === previous response until the new
|
||||
// one lands, so we explicitly track key changes.
|
||||
useEffect(() => {
|
||||
if (previousKeyRef.current !== key) {
|
||||
previousKeyRef.current = key;
|
||||
setKeyChanging(true);
|
||||
}
|
||||
}, [key]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 p-8">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
|
||||
<h3 className="text-sm font-medium text-red-800 dark:text-red-200">Error Loading Logs</h3>
|
||||
<p className="text-sm text-red-700 dark:text-red-300 mt-1">
|
||||
{error?.message || 'Failed to load system logs'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
useEffect(() => {
|
||||
if (keyChanging && data) setKeyChanging(false);
|
||||
}, [data, keyChanging]);
|
||||
|
||||
const logs = data?.logs || [];
|
||||
const pagination = data?.pagination;
|
||||
const hasDetails = (log: Log) => log.events.length > 0 || !!log.errorMessage || !!log.bullJobId || (log.result && Object.keys(log.result).length > 0);
|
||||
const showSkeleton = !data || keyChanging;
|
||||
const logs: Log[] = data?.logs ?? [];
|
||||
const pagination = data?.pagination ?? { page: filters.page, limit: filters.limit, total: 0, totalPages: 1 };
|
||||
|
||||
// When the hydrate-time "Last 7 days" default is in effect (the user hasn't
|
||||
// explicitly chosen a date range), don't count it as a user-applied filter
|
||||
// for empty-state branching — show the "fresh" message, not "filters too
|
||||
// tight". hasActiveFilters() is otherwise the canonical check.
|
||||
const filtersForEmptyState = usingHydrateDateDefault
|
||||
? { ...filters, dateFrom: null, dateTo: null }
|
||||
: filters;
|
||||
const emptyKind = computeEmptyState({
|
||||
total: pagination.total,
|
||||
hasFilters: hasActiveFilters(filtersForEmptyState),
|
||||
hasSearch: hasActiveSearch(filters),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 sm:py-8">
|
||||
<LogsToolbar />
|
||||
|
||||
{/* Header — stacks on mobile, row on sm+ */}
|
||||
<div className="sticky top-0 z-10 mb-6 sm:mb-8 bg-gray-50 dark:bg-gray-900 py-4 -mx-4 px-4 sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8 border-b border-gray-200 dark:border-gray-800">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-gray-100">
|
||||
System Logs
|
||||
</h1>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
View background jobs and system activity
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/admin"
|
||||
className="inline-flex items-center gap-2 px-4 py-2.5 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-900 dark:text-gray-100 rounded-lg transition-colors text-sm font-medium self-start sm:self-auto flex-shrink-0"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
<span>Back to Dashboard</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
{/* Filter dropdowns + chip strip — owned by ben-filters, rendered here. */}
|
||||
<LogsFilters />
|
||||
<ActiveFilterChips />
|
||||
|
||||
{/* Filters — full-width stacked on mobile */}
|
||||
<div className="mb-6 grid grid-cols-1 sm:grid-cols-2 gap-3 sm:gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-1.5">
|
||||
Status
|
||||
</label>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => { setStatusFilter(e.target.value); setPage(1); }}
|
||||
className="w-full px-3 py-2.5 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 text-sm"
|
||||
>
|
||||
<option value="all">All Statuses</option>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="completed">Completed</option>
|
||||
<option value="failed">Failed</option>
|
||||
<option value="delayed">Delayed</option>
|
||||
<option value="stuck">Stuck</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-1.5">
|
||||
Job Type
|
||||
</label>
|
||||
<select
|
||||
value={typeFilter}
|
||||
onChange={(e) => { setTypeFilter(e.target.value); setPage(1); }}
|
||||
className="w-full px-3 py-2.5 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 text-sm"
|
||||
>
|
||||
<option value="all">All Types</option>
|
||||
<option value="search_indexers">Search Indexers</option>
|
||||
<option value="download_torrent">Download Torrent</option>
|
||||
<option value="monitor_download">Monitor Download</option>
|
||||
<option value="organize_files">Organize Files</option>
|
||||
<option value="scan_plex">Library Scan</option>
|
||||
<option value="match_plex">Library Match</option>
|
||||
<option value="plex_library_scan">Library Scan (Scheduled)</option>
|
||||
<option value="plex_recently_added_check">Recently Added Check</option>
|
||||
<option value="audible_refresh">Audible Refresh</option>
|
||||
<option value="retry_missing_torrents">Retry Missing Torrents</option>
|
||||
<option value="retry_failed_imports">Retry Failed Imports</option>
|
||||
<option value="cleanup_seeded_torrents">Cleanup Seeded Torrents</option>
|
||||
<option value="monitor_rss_feeds">Monitor RSS Feeds</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile card list — hidden on sm+ */}
|
||||
<div className="space-y-3 sm:hidden">
|
||||
{logs.map((log) => (
|
||||
<div
|
||||
key={log.id}
|
||||
className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden"
|
||||
>
|
||||
{/* Card header */}
|
||||
<div className="px-4 py-3">
|
||||
<div className="flex items-start justify-between gap-3 mb-2">
|
||||
<div className="font-semibold text-gray-900 dark:text-gray-100 text-sm leading-snug">
|
||||
{formatType(log.type)}
|
||||
</div>
|
||||
<StatusBadge status={log.status} />
|
||||
</div>
|
||||
|
||||
{/* Related item */}
|
||||
{log.request?.audiobook ? (
|
||||
<div className="text-sm mb-2">
|
||||
<div className="text-gray-700 dark:text-gray-300 font-medium leading-snug">
|
||||
{log.request.audiobook.title}
|
||||
</div>
|
||||
<div className="text-gray-500 dark:text-gray-400 text-xs">
|
||||
by {log.request.audiobook.author} · {log.request.user.plexUsername}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mb-2">System job</div>
|
||||
)}
|
||||
|
||||
{/* Meta row */}
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
<span>{formatDateShort(log.createdAt)}</span>
|
||||
<span>Duration: {formatDuration(log.startedAt, log.completedAt)}</span>
|
||||
<span>Attempts: {log.attempts}/{log.maxAttempts}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expandable details */}
|
||||
{hasDetails(log) && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setExpandedLog(expandedLog === log.id ? null : log.id)}
|
||||
className="w-full flex items-center justify-between px-4 py-2.5 border-t border-gray-100 dark:border-gray-700/60 text-xs font-medium text-blue-600 dark:text-blue-400 hover:bg-gray-50 dark:hover:bg-gray-700/40 transition-colors"
|
||||
>
|
||||
<span>{expandedLog === log.id ? 'Hide Details' : 'Show Details'}</span>
|
||||
<svg
|
||||
className={`w-4 h-4 transition-transform duration-200 ${expandedLog === log.id ? 'rotate-180' : ''}`}
|
||||
fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
{expandedLog === log.id && (
|
||||
<div className="px-4 pb-4 pt-3 bg-gray-50 dark:bg-gray-900/50 border-t border-gray-100 dark:border-gray-700/60">
|
||||
<LogDetails log={log} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{logs.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-500 dark:text-gray-400">No logs found</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Desktop table — hidden on mobile */}
|
||||
<div className="hidden sm:block bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead className="bg-gray-50 dark:bg-gray-900">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Time
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Type
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Related Item
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Duration
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Attempts
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{logs.map((log) => (
|
||||
<>
|
||||
<tr key={log.id} className="hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
|
||||
{new Date(log.createdAt).toLocaleString()}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{formatType(log.type)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<StatusBadge status={log.status} />
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
{log.request?.audiobook ? (
|
||||
<div className="text-sm">
|
||||
<div className="font-medium text-gray-900 dark:text-gray-100">
|
||||
{log.request.audiobook.title}
|
||||
</div>
|
||||
<div className="text-gray-500 dark:text-gray-400">
|
||||
by {log.request.audiobook.author}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 dark:text-gray-500">
|
||||
User: {log.request.user.plexUsername}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">System job</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
|
||||
{formatDuration(log.startedAt, log.completedAt)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
|
||||
{log.attempts}/{log.maxAttempts}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
{hasDetails(log) && (
|
||||
<button
|
||||
onClick={() => setExpandedLog(expandedLog === log.id ? null : log.id)}
|
||||
className="text-blue-600 hover:text-blue-900 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
>
|
||||
{expandedLog === log.id ? 'Hide Details' : 'Show Details'}
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
{expandedLog === log.id && (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-6 py-4 bg-gray-50 dark:bg-gray-900">
|
||||
<LogDetails log={log} />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{logs.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-500 dark:text-gray-400">No logs found</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{pagination && pagination.totalPages > 1 && (
|
||||
<div className="mt-6 flex flex-col sm:flex-row items-center gap-3 sm:justify-between">
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400 order-2 sm:order-1">
|
||||
Page {pagination.page} of {pagination.totalPages}
|
||||
<span className="hidden sm:inline"> ({pagination.total} total logs)</span>
|
||||
</div>
|
||||
<div className="flex gap-2 order-1 sm:order-2">
|
||||
<button
|
||||
onClick={() => setPage(page - 1)}
|
||||
disabled={page === 1}
|
||||
className="flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPage(page + 1)}
|
||||
disabled={page === pagination.totalPages}
|
||||
className="flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Next
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{error && (
|
||||
<div className="mb-6 p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
<h3 className="text-sm font-medium text-red-800 dark:text-red-200">
|
||||
Error Loading Logs
|
||||
</h3>
|
||||
<p className="text-sm text-red-700 dark:text-red-300 mt-1">
|
||||
{error?.message || 'Failed to load system logs'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info Box */}
|
||||
<div className="mt-6 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
||||
<h3 className="text-sm font-medium text-blue-800 dark:text-blue-200 mb-2">
|
||||
About System Logs
|
||||
</h3>
|
||||
<ul className="text-sm text-blue-700 dark:text-blue-300 space-y-1">
|
||||
<li>• Logs are automatically refreshed every 10 seconds</li>
|
||||
<li>• Tap "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>
|
||||
{showSkeleton ? (
|
||||
<LogSkeleton />
|
||||
) : emptyKind ? (
|
||||
<EmptyState
|
||||
kind={emptyKind}
|
||||
onClearFilters={clearAll}
|
||||
onClearSearch={() => setFilters({ search: '' })}
|
||||
searchValue={filters.search}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{/* Mobile cards */}
|
||||
<div className="space-y-3 sm:hidden">
|
||||
{logs.map((log) => (
|
||||
<LogRow.Mobile key={log.id} log={log} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Desktop table */}
|
||||
<div className="hidden sm:block bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead className="bg-gray-50 dark:bg-gray-900">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Time
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Type
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Related Item
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Duration
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Attempts
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{logs.map((log) => (
|
||||
<LogRow.Desktop key={log.id} log={log} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LogsPagination
|
||||
pagination={pagination}
|
||||
onPageChange={(page) => setFilters({ page })}
|
||||
onLimitChange={(limit: ValidLimit) => setFilters({ limit })}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AdminLogsPage() {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<ToastProvider>
|
||||
<AutoRefreshControlProvider>
|
||||
<AdminLogsPageContent />
|
||||
</AutoRefreshControlProvider>
|
||||
</ToastProvider>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
+183
-62
@@ -14,7 +14,10 @@ import { RecentRequestsTable } from './components/RecentRequestsTable';
|
||||
import { ToastProvider, useToast } from '@/components/ui/Toast';
|
||||
import { ReportedIssuesSection } from './components/ReportedIssuesSection';
|
||||
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 { InformationCircleIcon } from '@heroicons/react/24/outline';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { useState } from 'react';
|
||||
|
||||
@@ -55,15 +58,78 @@ function formatTorrentSize(bytes: number): string {
|
||||
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[] }) {
|
||||
const toast = useToast();
|
||||
const [loadingStates, setLoadingStates] = useState<Record<string, boolean>>({});
|
||||
const [searchModalRequestId, setSearchModalRequestId] = useState<string | null>(null);
|
||||
const [detailsAsin, setDetailsAsin] = useState<string | null>(null);
|
||||
const [detailsRequestId, setDetailsRequestId] = useState<string | null>(null);
|
||||
|
||||
const searchModalRequest = searchModalRequestId
|
||||
? requests.find((r) => r.id === searchModalRequestId)
|
||||
: null;
|
||||
|
||||
const detailsRequest = detailsRequestId
|
||||
? requests.find((r) => r.id === detailsRequestId)
|
||||
: null;
|
||||
|
||||
const handleApproveRequest = async (requestId: string) => {
|
||||
setLoadingStates((prev) => ({ ...prev, [requestId]: true }));
|
||||
|
||||
@@ -124,13 +190,6 @@ function PendingApprovalSection({ requests }: { requests: PendingApprovalRequest
|
||||
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 (
|
||||
<div className="mb-8">
|
||||
{/* Section Header */}
|
||||
@@ -169,30 +228,35 @@ function PendingApprovalSection({ requests }: { requests: PendingApprovalRequest
|
||||
return (
|
||||
<div
|
||||
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 */}
|
||||
<div className="p-4">
|
||||
<div className="flex gap-3">
|
||||
{/* Cover Image */}
|
||||
<div className="flex-shrink-0">
|
||||
{request.audiobook.coverArtUrl ? (
|
||||
<img
|
||||
src={request.audiobook.coverArtUrl}
|
||||
alt={request.audiobook.title}
|
||||
className="w-16 h-16 rounded object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-16 h-16 rounded bg-gray-200 dark:bg-gray-700 flex items-center justify-center">
|
||||
<svg
|
||||
className="w-8 h-8 text-gray-400 dark:text-gray-600"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path d="M9 4.804A7.968 7.968 0 005.5 4c-1.255 0-2.443.29-3.5.804v10A7.969 7.969 0 015.5 14c1.669 0 3.218.51 4.5 1.385A7.962 7.962 0 0114.5 14c1.255 0 2.443.29 3.5.804v-10A7.968 7.968 0 0014.5 4c-1.255 0-2.443.29-3.5.804V12a1 1 0 11-2 0V4.804z" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={request.audiobook.coverArtUrl || '/placeholder_cover.svg'}
|
||||
alt={request.audiobook.title}
|
||||
className="w-16 h-16 rounded object-cover"
|
||||
onError={(e) => { (e.target as HTMLImageElement).src = '/placeholder_cover.svg'; }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Book Info */}
|
||||
@@ -323,42 +387,12 @@ function PendingApprovalSection({ requests }: { requests: PendingApprovalRequest
|
||||
|
||||
{/* 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">
|
||||
<button
|
||||
onClick={() => handleApproveRequest(request.id)}
|
||||
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={() => 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>
|
||||
<ApprovalActionButtons
|
||||
isLoading={isLoading}
|
||||
onApprove={() => handleApproveRequest(request.id)}
|
||||
onSearch={() => setSearchModalRequestId(request.id)}
|
||||
onDeny={() => handleDenyRequest(request.id)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -384,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>
|
||||
);
|
||||
}
|
||||
|
||||
function AdminDashboardContent() {
|
||||
const [isBulkImportOpen, setIsBulkImportOpen] = useState(false);
|
||||
|
||||
// Fetch data with auto-refresh every 10 seconds
|
||||
const { data: metrics, error: metricsError } = useSWR(
|
||||
'/api/admin/metrics',
|
||||
@@ -582,7 +649,7 @@ function AdminDashboardContent() {
|
||||
</div>
|
||||
|
||||
{/* 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
|
||||
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"
|
||||
@@ -667,8 +734,62 @@ function AdminDashboardContent() {
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/admin/blocklist"
|
||||
className="block p-6 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg hover:shadow-md transition-all"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<svg
|
||||
className="w-6 h-6 text-gray-600 dark:text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"
|
||||
/>
|
||||
</svg>
|
||||
<span className="font-medium text-gray-900 dark:text-gray-100">
|
||||
Blocklist
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<button
|
||||
onClick={() => setIsBulkImportOpen(true)}
|
||||
className="block p-6 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg hover:shadow-md transition-all text-left"
|
||||
>
|
||||
<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 */}
|
||||
{pendingApprovalData?.requests && pendingApprovalData.requests.length > 0 && (
|
||||
<PendingApprovalSection requests={pendingApprovalData.requests} />
|
||||
|
||||
@@ -113,6 +113,17 @@ export const saveTabSettings = async (
|
||||
}).then(res => {
|
||||
if (!res.ok) throw new Error('Failed to save indexer configuration');
|
||||
});
|
||||
|
||||
// Save indexer-wide options (auto-search behavior, etc.)
|
||||
await fetchWithAuth('/api/admin/settings/indexer-options', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
skipUnreleased: settings.indexerOptions.skipUnreleased,
|
||||
}),
|
||||
}).then(res => {
|
||||
if (!res.ok) throw new Error('Failed to save indexer options');
|
||||
});
|
||||
break;
|
||||
|
||||
case 'download':
|
||||
|
||||
@@ -16,6 +16,7 @@ export interface Settings {
|
||||
oidc: OIDCSettings;
|
||||
registration: RegistrationSettings;
|
||||
prowlarr: ProwlarrSettings;
|
||||
indexerOptions: IndexerOptionsSettings;
|
||||
downloadClient: DownloadClientSettings;
|
||||
paths: PathsSettings;
|
||||
ebook: EbookSettings;
|
||||
@@ -76,6 +77,19 @@ export interface ProwlarrSettings {
|
||||
apiKey: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Indexer-wide behavioral options (not tied to a specific indexer connection).
|
||||
* Persisted via `/api/admin/settings/indexer-options`.
|
||||
*/
|
||||
export interface IndexerOptionsSettings {
|
||||
/**
|
||||
* When true, automatic indexer searches skip books whose release date is
|
||||
* in the future. Default ON. Manual searches are unaffected.
|
||||
* Backing config key: `indexer.skip_unreleased`.
|
||||
*/
|
||||
skipUnreleased: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Download client (qBittorrent) configuration
|
||||
*/
|
||||
@@ -99,9 +113,12 @@ export interface PathsSettings {
|
||||
audiobookPathTemplate?: string;
|
||||
ebookPathTemplate?: string;
|
||||
metadataTaggingEnabled: boolean;
|
||||
plexFormatCoercionEnabled: boolean;
|
||||
chapterMergingEnabled: boolean;
|
||||
fileRenameEnabled: boolean;
|
||||
fileRenameTemplate?: string;
|
||||
fileChmod?: string;
|
||||
dirChmod?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -152,6 +169,7 @@ export interface IndexerConfig {
|
||||
enabled: boolean;
|
||||
priority: number;
|
||||
seedingTimeMinutes?: number; // Torrents only
|
||||
ratioLimit?: number; // Torrents only (0 = no ratio requirement)
|
||||
removeAfterProcessing?: boolean; // Usenet only
|
||||
rssEnabled: boolean;
|
||||
audiobookCategories?: number[]; // Category IDs for audiobook searches (default: [3030])
|
||||
@@ -168,6 +186,7 @@ export interface SavedIndexerConfig {
|
||||
protocol: string;
|
||||
priority: number;
|
||||
seedingTimeMinutes?: number; // Torrents only
|
||||
ratioLimit?: number; // Torrents only (0 = no ratio requirement)
|
||||
removeAfterProcessing?: boolean; // Usenet only
|
||||
rssEnabled: boolean;
|
||||
audiobookCategories: number[]; // Category IDs for audiobook searches (default: [3030])
|
||||
|
||||
@@ -90,9 +90,9 @@ export function EbookTab({ ebook, onChange, onSuccess, onError, markAsSaved }: E
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={ebook.baseUrl || 'https://annas-archive.li'}
|
||||
value={ebook.baseUrl || 'https://annas-archive.gl'}
|
||||
onChange={(e) => updateEbook('baseUrl', e.target.value)}
|
||||
placeholder="https://annas-archive.li"
|
||||
placeholder="https://annas-archive.gl"
|
||||
className="font-mono"
|
||||
/>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
|
||||
@@ -53,7 +53,7 @@ export function useEbookSettings({ ebook, onChange, onSuccess, onError, markAsSa
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
url: ebook.flaresolverrUrl,
|
||||
baseUrl: ebook.baseUrl || 'https://annas-archive.li',
|
||||
baseUrl: ebook.baseUrl || 'https://annas-archive.gl',
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -83,7 +83,7 @@ export function useEbookSettings({ ebook, onChange, onSuccess, onError, markAsSa
|
||||
annasArchiveEnabled: ebook.annasArchiveEnabled || false,
|
||||
indexerSearchEnabled: ebook.indexerSearchEnabled || false,
|
||||
format: ebook.preferredFormat || 'epub',
|
||||
baseUrl: ebook.baseUrl || 'https://annas-archive.li',
|
||||
baseUrl: ebook.baseUrl || 'https://annas-archive.gl',
|
||||
flaresolverrUrl: ebook.flaresolverrUrl || '',
|
||||
autoGrabEnabled: ebook.autoGrabEnabled ?? true,
|
||||
kindleFixEnabled: ebook.kindleFixEnabled ?? false,
|
||||
|
||||
@@ -136,6 +136,48 @@ export function IndexersTab({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||
<div className="mb-4">
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">
|
||||
Auto-Search Behavior
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Control how ReadMeABook performs automatic background searches across your indexers.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-start gap-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="indexer-skip-unreleased"
|
||||
checked={settings.indexerOptions.skipUnreleased}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
...settings,
|
||||
indexerOptions: {
|
||||
...settings.indexerOptions,
|
||||
skipUnreleased: e.target.checked,
|
||||
},
|
||||
})
|
||||
}
|
||||
className="mt-1 h-5 w-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<label
|
||||
htmlFor="indexer-skip-unreleased"
|
||||
className="block text-sm font-medium text-gray-900 dark:text-gray-100 cursor-pointer"
|
||||
>
|
||||
Skip unreleased books in automatic searches
|
||||
</label>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
When ON, ReadMeABook will not search indexers for books whose release date is in the future. These requests will automatically begin searching once the book is released. Manual searches are not affected.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||
<IndexerManagement
|
||||
prowlarrUrl={settings.prowlarr.url}
|
||||
|
||||
@@ -414,6 +414,30 @@ export function PathsTab({ paths, onChange, onValidationChange }: PathsTabProps)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Plex Format Coercion Toggle */}
|
||||
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-start gap-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="plex-format-coercion-settings"
|
||||
checked={paths.plexFormatCoercionEnabled}
|
||||
onChange={(e) => updatePath('plexFormatCoercionEnabled', e.target.checked)}
|
||||
className="mt-1 h-5 w-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<label
|
||||
htmlFor="plex-format-coercion-settings"
|
||||
className="block text-sm font-medium text-gray-900 dark:text-gray-100 cursor-pointer"
|
||||
>
|
||||
Coerce file formats for Plex compatibility
|
||||
</label>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
Rename .mp4 audiobook files (and single-file .m4a) to .m4b before Plex scans. No re-encoding.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chapter Merging Toggle */}
|
||||
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-start gap-4">
|
||||
@@ -439,6 +463,54 @@ export function PathsTab({ paths, onChange, onValidationChange }: PathsTabProps)
|
||||
</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 */}
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||
<Button
|
||||
|
||||
@@ -29,6 +29,7 @@ interface User {
|
||||
autoApproveRequests: boolean | null;
|
||||
interactiveSearchAccess: boolean | null;
|
||||
downloadAccess: boolean | null;
|
||||
hasLoginToken: boolean;
|
||||
_count: {
|
||||
requests: number;
|
||||
};
|
||||
@@ -220,6 +221,7 @@ function AdminUsersPageContent() {
|
||||
const [globalDownloadAccess, setGlobalDownloadAccess] = useState<boolean>(true);
|
||||
const [globalSettingsOpen, setGlobalSettingsOpen] = useState(false);
|
||||
const [permissionsUserId, setPermissionsUserId] = useState<string | null>(null);
|
||||
const [generatedToken, setGeneratedToken] = useState<string | null>(null);
|
||||
const toast = useToast();
|
||||
|
||||
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) => {
|
||||
setEditRole(user.role);
|
||||
setEditDialog({ isOpen: true, user });
|
||||
@@ -968,11 +988,15 @@ function AdminUsersPageContent() {
|
||||
{/* User Permissions Modal */}
|
||||
<UserPermissionsModal
|
||||
isOpen={permissionsUser !== null}
|
||||
onClose={() => setPermissionsUserId(null)}
|
||||
onClose={() => {
|
||||
setPermissionsUserId(null);
|
||||
setGeneratedToken(null);
|
||||
}}
|
||||
user={permissionsUser}
|
||||
globalAutoApprove={globalAutoApprove}
|
||||
globalInteractiveSearch={globalInteractiveSearch}
|
||||
globalDownloadAccess={globalDownloadAccess}
|
||||
generatedToken={generatedToken}
|
||||
onToggleAutoApprove={(user, newValue) => {
|
||||
handleUserAutoApproveToggle(user as User, newValue);
|
||||
}}
|
||||
@@ -982,6 +1006,9 @@ function AdminUsersPageContent() {
|
||||
onToggleDownloadAccess={(user, newValue) => {
|
||||
handleUserDownloadAccessToggle(user as User, newValue);
|
||||
}}
|
||||
onToggleToken={(user, newValue) => {
|
||||
handleToggleToken(user, newValue);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -131,7 +131,9 @@ export default function ApiDocsPage() {
|
||||
{/* Footer note */}
|
||||
<div className="mt-10 text-center">
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500">
|
||||
API tokens are restricted to the endpoints listed above.
|
||||
API tokens are restricted to the endpoints listed above. Endpoints
|
||||
flagged <span className="font-semibold text-amber-600 dark:text-amber-400">Write</span> mutate
|
||||
state on behalf of the token owner — keep your tokens private.
|
||||
JWT session authentication has access to all endpoints.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,7 @@ 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 { checkApiTokenRevokeRateLimit } from '@/lib/utils/apiTokenRateLimit';
|
||||
import { checkApiTokenRevokeRateLimit } from '@/lib/utils/rateLimit';
|
||||
|
||||
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 { prisma } from '@/lib/db';
|
||||
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 { generateApiToken } from '@/lib/utils/api-token';
|
||||
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 {
|
||||
name: string;
|
||||
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
|
||||
const dirEntries = await fs.readdir(normalizedPath, { withFileTypes: true });
|
||||
|
||||
// Gather stats for each subdirectory (parallel for performance)
|
||||
const directoryEntries = dirEntries.filter((e) => e.isDirectory());
|
||||
const statsPromises = directoryEntries.map(async (entry): Promise<DirectoryEntry> => {
|
||||
const fullPath = pathModule.join(normalizedPath, entry.name);
|
||||
const stats = await getDirectoryStats(fullPath);
|
||||
return {
|
||||
name: entry.name,
|
||||
type: 'directory',
|
||||
...stats,
|
||||
};
|
||||
});
|
||||
|
||||
const entries = await Promise.all(statsPromises);
|
||||
entries.sort((a, b) => a.name.localeCompare(b.name));
|
||||
// List subdirectories (no nested stat calls — keeps browsing fast)
|
||||
const entries: DirectoryEntry[] = dirEntries
|
||||
.filter((e) => e.isDirectory())
|
||||
.map((entry) => ({ name: entry.name, type: 'directory' as const }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
// Gather audio files in the current directory
|
||||
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 VALID_LIMITS = [25, 50, 100] as const;
|
||||
const DEFAULT_LIMIT = 50;
|
||||
const ERROR_STATUSES = ['failed', 'stuck'] as const;
|
||||
|
||||
export interface LogsWhereParams {
|
||||
status?: string | null;
|
||||
type?: string | null;
|
||||
search?: string | null;
|
||||
dateFrom?: string | null;
|
||||
dateTo?: string | null;
|
||||
hasError?: string | null;
|
||||
userId?: string | null;
|
||||
audiobookQuery?: string | null;
|
||||
}
|
||||
|
||||
function parseLimit(raw: string | null): number {
|
||||
const n = Number(raw);
|
||||
return (VALID_LIMITS as readonly number[]).includes(n) ? n : DEFAULT_LIMIT;
|
||||
}
|
||||
|
||||
function parsePage(raw: string | null): number {
|
||||
const n = parseInt(raw ?? '1', 10);
|
||||
return Number.isFinite(n) && n >= 1 ? n : 1;
|
||||
}
|
||||
|
||||
function isTruthy(raw: string | null | undefined): boolean {
|
||||
if (!raw) return false;
|
||||
const v = raw.toLowerCase();
|
||||
return v === 'true' || v === '1';
|
||||
}
|
||||
|
||||
function parseDate(raw: string | null | undefined): Date | null {
|
||||
if (!raw) return null;
|
||||
const d = new Date(raw);
|
||||
return Number.isNaN(d.getTime()) ? null : d;
|
||||
}
|
||||
|
||||
function trim(raw: string | null | undefined): string | null {
|
||||
if (!raw) return null;
|
||||
const t = raw.trim();
|
||||
return t.length > 0 ? t : null;
|
||||
}
|
||||
|
||||
export function buildLogsWhere(params: LogsWhereParams): Record<string, any> {
|
||||
const where: Record<string, any> = {};
|
||||
|
||||
const status = params.status ?? 'all';
|
||||
if (status !== 'all' && status !== '') {
|
||||
where.status = status;
|
||||
}
|
||||
|
||||
const type = params.type ?? 'all';
|
||||
if (type !== 'all' && type !== '') {
|
||||
where.type = type;
|
||||
}
|
||||
|
||||
const from = parseDate(params.dateFrom);
|
||||
const to = parseDate(params.dateTo);
|
||||
if (from || to) {
|
||||
where.createdAt = {
|
||||
...(from ? { gte: from } : {}),
|
||||
...(to ? { lte: to } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
const userId = trim(params.userId);
|
||||
if (userId) {
|
||||
where.request = { is: { userId } };
|
||||
}
|
||||
|
||||
const audiobookQuery = trim(params.audiobookQuery);
|
||||
if (audiobookQuery) {
|
||||
where.request = {
|
||||
is: {
|
||||
...(where.request?.is ?? {}),
|
||||
audiobook: {
|
||||
is: {
|
||||
OR: [
|
||||
{ title: { contains: audiobookQuery, mode: 'insensitive' } },
|
||||
{ author: { contains: audiobookQuery, mode: 'insensitive' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const errorsOnly = isTruthy(params.hasError);
|
||||
const search = trim(params.search);
|
||||
|
||||
const errorsOr = errorsOnly
|
||||
? [
|
||||
{ status: { in: [...ERROR_STATUSES] } },
|
||||
{ errorMessage: { not: null } },
|
||||
]
|
||||
: null;
|
||||
|
||||
const searchOr = search
|
||||
? [
|
||||
{ bullJobId: { startsWith: search } },
|
||||
{ errorMessage: { contains: search, mode: 'insensitive' } },
|
||||
// TODO: revisit if slow — consider denormalized lastEventMessage on Job
|
||||
{ events: { some: { message: { contains: search, mode: 'insensitive' } } } },
|
||||
{ request: { is: { audiobook: { is: { title: { contains: search, mode: 'insensitive' } } } } } },
|
||||
{ request: { is: { audiobook: { is: { author: { contains: search, mode: 'insensitive' } } } } } },
|
||||
{ request: { is: { user: { is: { plexUsername: { contains: search, mode: 'insensitive' } } } } } },
|
||||
]
|
||||
: null;
|
||||
|
||||
if (errorsOr && searchOr) {
|
||||
where.AND = [{ OR: errorsOr }, { OR: searchOr }];
|
||||
} else if (errorsOr) {
|
||||
where.OR = errorsOr;
|
||||
} else if (searchOr) {
|
||||
where.OR = searchOr;
|
||||
}
|
||||
|
||||
return where;
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
return requireAdmin(req, async () => {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const page = parseInt(searchParams.get('page') || '1');
|
||||
const limit = parseInt(searchParams.get('limit') || '100');
|
||||
const status = searchParams.get('status') || 'all';
|
||||
const type = searchParams.get('type') || 'all';
|
||||
const page = parsePage(searchParams.get('page'));
|
||||
const limit = parseLimit(searchParams.get('limit'));
|
||||
|
||||
const where = buildLogsWhere({
|
||||
status: searchParams.get('status'),
|
||||
type: searchParams.get('type'),
|
||||
search: searchParams.get('search'),
|
||||
dateFrom: searchParams.get('dateFrom'),
|
||||
dateTo: searchParams.get('dateTo'),
|
||||
hasError: searchParams.get('hasError'),
|
||||
userId: searchParams.get('userId'),
|
||||
audiobookQuery: searchParams.get('audiobookQuery'),
|
||||
});
|
||||
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
// Build where clause
|
||||
const where: any = {};
|
||||
if (status !== 'all') {
|
||||
where.status = status;
|
||||
}
|
||||
if (type !== 'all') {
|
||||
where.type = type;
|
||||
}
|
||||
|
||||
const [logs, totalCount] = await Promise.all([
|
||||
prisma.job.findMany({
|
||||
where,
|
||||
|
||||
@@ -55,9 +55,25 @@ export async function POST(request: NextRequest) {
|
||||
const fs = await import('fs/promises');
|
||||
|
||||
const body = await request.json();
|
||||
const { folderPath, asin, cleanupSource } = body;
|
||||
const { folderPath, asin, cleanupSource, selectedFiles } = 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
|
||||
if ((!audiobookId && !asin) || !folderPath) {
|
||||
return NextResponse.json(
|
||||
@@ -120,13 +136,52 @@ export async function POST(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
// Verify folder contains audio files
|
||||
const audioCheck = await hasAudioFiles(normalizedPath);
|
||||
if (!audioCheck.found) {
|
||||
return NextResponse.json(
|
||||
{ error: 'No audio files found in the selected directory' },
|
||||
{ status: 400 }
|
||||
);
|
||||
// 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);
|
||||
if (!audioCheck.found) {
|
||||
return NextResponse.json(
|
||||
{ error: 'No audio files found in the selected directory' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
audioFileCount = audioCheck.count;
|
||||
}
|
||||
|
||||
// Resolve audiobook by ASIN if audiobookId not provided
|
||||
@@ -155,10 +210,42 @@ export async function POST(request: NextRequest) {
|
||||
audiobookId = newBook.id;
|
||||
logger.info(`Created audiobook record from cache for ASIN ${asin}: ${newBook.id}`);
|
||||
} else {
|
||||
return NextResponse.json(
|
||||
{ error: 'Audiobook not found for the given ASIN' },
|
||||
{ status: 404 }
|
||||
);
|
||||
// Not in DB — fetch live from Audnexus and create a record
|
||||
try {
|
||||
const audibleService = getAudibleService();
|
||||
const liveData = await audibleService.getAudiobookDetails(asin);
|
||||
if (liveData) {
|
||||
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;
|
||||
logger.info(`Created audiobook record from Audnexus for ASIN ${asin}: ${newBook.id}`);
|
||||
} else {
|
||||
return NextResponse.json(
|
||||
{ error: 'Audiobook not found for the given ASIN' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
} catch (audnexusError) {
|
||||
logger.error(`Failed to fetch ASIN ${asin} from Audnexus: ${audnexusError instanceof Error ? audnexusError.message : String(audnexusError)}`);
|
||||
return NextResponse.json(
|
||||
{ error: 'Audiobook not found for the given ASIN' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -285,9 +372,16 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
// Queue organize_files job
|
||||
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({
|
||||
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 jobQueue = getJobQueueService();
|
||||
await jobQueue.addSearchJob(id, {
|
||||
const audiobookData = {
|
||||
id: existingRequest.audiobook.id,
|
||||
title: existingRequest.audiobook.title,
|
||||
author: existingRequest.audiobook.author,
|
||||
asin: existingRequest.audiobook.audibleAsin || undefined,
|
||||
});
|
||||
};
|
||||
|
||||
if (existingRequest.type === 'ebook') {
|
||||
await jobQueue.addSearchEbookJob(id, audiobookData);
|
||||
} else {
|
||||
await jobQueue.addSearchJob(id, audiobookData);
|
||||
}
|
||||
|
||||
searchTriggered = true;
|
||||
logger.info(`Search triggered for request ${id} with custom terms`, { requestId: id });
|
||||
|
||||
@@ -119,6 +119,11 @@ export async function GET(request: NextRequest) {
|
||||
},
|
||||
take: 1,
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
blockedReleases: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy,
|
||||
skip: (page - 1) * pageSize,
|
||||
@@ -141,6 +146,7 @@ export async function GET(request: NextRequest) {
|
||||
torrentUrl: request.downloadHistory[0]?.torrentUrl || null,
|
||||
downloadAttempts: request.downloadAttempts,
|
||||
customSearchTerms: request.customSearchTerms || null,
|
||||
blockedCount: request._count?.blockedReleases ?? 0,
|
||||
}));
|
||||
|
||||
return NextResponse.json({
|
||||
|
||||
@@ -78,7 +78,7 @@ export async function PUT(request: NextRequest) {
|
||||
// Anna's Archive specific settings
|
||||
{
|
||||
key: 'ebook_sidecar_base_url',
|
||||
value: baseUrl || 'https://annas-archive.li',
|
||||
value: baseUrl || 'https://annas-archive.gl',
|
||||
category: 'ebook',
|
||||
description: 'Base URL for Anna\'s Archive',
|
||||
},
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user