mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 12:50:09 +00:00
Compare commits
81 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| c0cff56b47 | |||
| e2ae4c7eef | |||
| a564fefd7c |
@@ -4,6 +4,14 @@
|
||||
|
||||
**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.
|
||||
|
||||
**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.
|
||||
|
||||
---
|
||||
|
||||
## 1. Token-Efficient Documentation System
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -44,6 +46,8 @@
|
||||
- **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)
|
||||
@@ -98,6 +102,7 @@
|
||||
|
||||
## Admin Features
|
||||
- **Dashboard (metrics, downloads, requests)** → [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)
|
||||
@@ -139,9 +144,12 @@
|
||||
**"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 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)
|
||||
@@ -166,3 +174,6 @@
|
||||
**"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)
|
||||
|
||||
@@ -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
|
||||
@@ -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`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -30,7 +30,7 @@ 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)
|
||||
@@ -113,6 +113,7 @@ interface AudiobookDetailsModalProps {
|
||||
requestStatus?: string | null;
|
||||
isAvailable?: boolean;
|
||||
requestedByUsername?: string | null;
|
||||
adminActions?: React.ReactNode; // Optional admin buttons (Approve/Search/Deny) rendered as second row in action bar
|
||||
}
|
||||
|
||||
interface RequestCardProps {
|
||||
|
||||
@@ -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 + user-configured categories
|
||||
2. Downloads and caches cover thumbnails locally (reduces Audible load)
|
||||
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 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)
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "readmeabook",
|
||||
"version": "1.0.15",
|
||||
"version": "1.2.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "readmeabook",
|
||||
"version": "1.0.15",
|
||||
"version": "1.2.0",
|
||||
"dependencies": {
|
||||
"@heroicons/react": "^2.2.0",
|
||||
"@prisma/client": "^6.19.0",
|
||||
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "readmeabook",
|
||||
"version": "1.1.4",
|
||||
"version": "1.2.0",
|
||||
"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,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;
|
||||
+42
-7
@@ -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
|
||||
@@ -74,6 +80,7 @@ model User {
|
||||
watchedSeries WatchedSeries[]
|
||||
watchedAuthors WatchedAuthor[]
|
||||
homeSections UserHomeSection[]
|
||||
ignoredAudiobooks IgnoredAudiobook[]
|
||||
|
||||
@@index([plexId])
|
||||
@@index([role])
|
||||
@@ -125,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)
|
||||
|
||||
@@ -527,9 +534,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)
|
||||
@@ -577,9 +585,10 @@ model HardcoverShelf {
|
||||
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
|
||||
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)
|
||||
@@ -673,6 +682,32 @@ model WatchedAuthor {
|
||||
@@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)
|
||||
|
||||
@@ -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();
|
||||
@@ -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: {
|
||||
@@ -54,8 +56,12 @@ export function RequestActionsDropdown({
|
||||
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';
|
||||
|
||||
@@ -66,7 +72,7 @@ export function RequestActionsDropdown({
|
||||
const canSearch = ['pending', 'failed', 'awaiting_search'].includes(request.status);
|
||||
const canAdjustSearchTerms = ['pending', 'failed', 'awaiting_search', 'searching'].includes(request.status);
|
||||
const canRetryDownload = request.status === 'failed' && (request.downloadAttempts ?? 0) > 0 && !!onRetryDownload;
|
||||
const canCancel = ['pending', 'searching', 'downloading', 'awaiting_search'].includes(request.status);
|
||||
const canCancel = (CANCELLABLE_STATUSES as readonly string[]).includes(request.status);
|
||||
const canDelete = true; // Admins can always delete
|
||||
|
||||
// View Source: For ebooks, extract MD5 from slow download URL and link to Anna's Archive
|
||||
@@ -157,14 +163,21 @@ export function RequestActionsDropdown({
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = async () => {
|
||||
const handleCancel = () => {
|
||||
setIsOpen(false);
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -529,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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
+152
-45
@@ -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,8 +228,23 @@ 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">
|
||||
@@ -313,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>
|
||||
);
|
||||
@@ -374,11 +418,44 @@ function PendingApprovalSection({ requests }: { requests: PendingApprovalRequest
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Book Details Modal — opened via info button on each approval card */}
|
||||
{detailsAsin && detailsRequestId && (
|
||||
<AudiobookDetailsModal
|
||||
asin={detailsAsin}
|
||||
isOpen={true}
|
||||
onClose={() => { setDetailsAsin(null); setDetailsRequestId(null); }}
|
||||
requestStatus="awaiting_approval"
|
||||
requestedByUsername={detailsRequest?.user.plexUsername ?? null}
|
||||
adminActions={
|
||||
<ApprovalActionButtons
|
||||
isLoading={loadingStates[detailsRequestId] || false}
|
||||
onApprove={async () => {
|
||||
await handleApproveRequest(detailsRequestId);
|
||||
setDetailsAsin(null);
|
||||
setDetailsRequestId(null);
|
||||
}}
|
||||
onSearch={() => {
|
||||
setSearchModalRequestId(detailsRequestId);
|
||||
setDetailsAsin(null);
|
||||
setDetailsRequestId(null);
|
||||
}}
|
||||
onDeny={async () => {
|
||||
await handleDenyRequest(detailsRequestId);
|
||||
setDetailsAsin(null);
|
||||
setDetailsRequestId(null);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AdminDashboardContent() {
|
||||
const [isBulkImportOpen, setIsBulkImportOpen] = useState(false);
|
||||
|
||||
// Fetch data with auto-refresh every 10 seconds
|
||||
const { data: metrics, error: metricsError } = useSWR(
|
||||
'/api/admin/metrics',
|
||||
@@ -572,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-5 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"
|
||||
@@ -657,8 +734,38 @@ function AdminDashboardContent() {
|
||||
</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} />
|
||||
|
||||
@@ -102,6 +102,8 @@ export interface PathsSettings {
|
||||
chapterMergingEnabled: boolean;
|
||||
fileRenameEnabled: boolean;
|
||||
fileRenameTemplate?: string;
|
||||
fileChmod?: string;
|
||||
dirChmod?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -439,6 +439,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>
|
||||
|
||||
@@ -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,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 }> = [];
|
||||
|
||||
@@ -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
|
||||
@@ -317,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,
|
||||
|
||||
@@ -15,7 +15,7 @@ export async function PUT(request: NextRequest) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
return requireAdmin(req, async () => {
|
||||
try {
|
||||
const { downloadDir, mediaDir, audiobookPathTemplate, ebookPathTemplate, metadataTaggingEnabled, chapterMergingEnabled, fileRenameEnabled, fileRenameTemplate } = await request.json();
|
||||
const { downloadDir, mediaDir, audiobookPathTemplate, ebookPathTemplate, metadataTaggingEnabled, chapterMergingEnabled, fileRenameEnabled, fileRenameTemplate, fileChmod, dirChmod } = await request.json();
|
||||
|
||||
if (!downloadDir || !mediaDir) {
|
||||
return NextResponse.json(
|
||||
@@ -32,6 +32,21 @@ export async function PUT(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
// Validate octal permission strings (3-4 digits, each 0-7)
|
||||
const octalRegex = /^[0-7]{3,4}$/;
|
||||
if (fileChmod !== undefined && !octalRegex.test(fileChmod)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'File permissions must be 3-4 octal digits (0-7), e.g. 664' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
if (dirChmod !== undefined && !octalRegex.test(dirChmod)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Directory permissions must be 3-4 octal digits (0-7), e.g. 775' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Update configuration
|
||||
await prisma.configuration.upsert({
|
||||
where: { key: 'download_dir' },
|
||||
@@ -123,6 +138,34 @@ export async function PUT(request: NextRequest) {
|
||||
});
|
||||
}
|
||||
|
||||
// Update file permissions (octal chmod)
|
||||
if (fileChmod !== undefined) {
|
||||
await prisma.configuration.upsert({
|
||||
where: { key: 'file_chmod' },
|
||||
update: { value: fileChmod },
|
||||
create: {
|
||||
key: 'file_chmod',
|
||||
value: fileChmod,
|
||||
category: 'automation',
|
||||
description: 'Octal permissions applied to organized files',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Update directory permissions (octal chmod)
|
||||
if (dirChmod !== undefined) {
|
||||
await prisma.configuration.upsert({
|
||||
where: { key: 'dir_chmod' },
|
||||
update: { value: dirChmod },
|
||||
create: {
|
||||
key: 'dir_chmod',
|
||||
value: dirChmod,
|
||||
category: 'automation',
|
||||
description: 'Octal permissions applied to created directories',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
logger.info('Paths settings updated');
|
||||
|
||||
// Clear config cache for all updated keys so services get fresh values
|
||||
@@ -135,6 +178,8 @@ export async function PUT(request: NextRequest) {
|
||||
configService.clearCache('chapter_merging_enabled');
|
||||
configService.clearCache('file_rename_enabled');
|
||||
configService.clearCache('file_rename_template');
|
||||
configService.clearCache('file_chmod');
|
||||
configService.clearCache('dir_chmod');
|
||||
|
||||
// Invalidate all download client singletons to force reload of download_dir
|
||||
const { invalidateDownloadClientManager } = await import('@/lib/services/download-client-manager.service');
|
||||
|
||||
@@ -130,6 +130,8 @@ export async function GET(request: NextRequest) {
|
||||
chapterMergingEnabled: configMap.get('chapter_merging_enabled') === 'true',
|
||||
fileRenameEnabled: configMap.get('file_rename_enabled') === 'true',
|
||||
fileRenameTemplate: configMap.get('file_rename_template') || '{title}',
|
||||
fileChmod: configMap.get('file_chmod') || '664',
|
||||
dirChmod: configMap.get('dir_chmod') || '775',
|
||||
},
|
||||
ebook: {
|
||||
// New granular source toggles (with migration from legacy ebook_sidecar_enabled)
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Component: Admin User Login Token
|
||||
* Documentation: documentation/backend/services/auth.md
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import { generateApiToken } from '@/lib/utils/api-token';
|
||||
|
||||
const logger = RMABLogger.create('API.Admin.Users.LoginToken');
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
return requireAdmin(req, async () => {
|
||||
try {
|
||||
const { id } = await params;
|
||||
|
||||
const targetUser = await prisma.user.findUnique({
|
||||
where: { id },
|
||||
select: { plexUsername: true, deletedAt: true },
|
||||
});
|
||||
|
||||
if (!targetUser) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
if (targetUser.deletedAt) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Cannot generate token for deleted user' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const { fullToken, tokenHash } = generateApiToken();
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id },
|
||||
data: { loginTokenHash: tokenHash },
|
||||
});
|
||||
|
||||
logger.info('Admin generated login token for user', {
|
||||
targetUser: targetUser.plexUsername,
|
||||
createdBy: req.user!.username,
|
||||
});
|
||||
|
||||
return NextResponse.json({ fullToken }, { status: 201 });
|
||||
} catch (error) {
|
||||
logger.error('Failed to generate login token', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return NextResponse.json({ error: 'Failed to generate login token' }, { status: 500 });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
return requireAdmin(req, async () => {
|
||||
try {
|
||||
const { id } = await params;
|
||||
|
||||
const targetUser = await prisma.user.findUnique({
|
||||
where: { id },
|
||||
select: { plexUsername: true },
|
||||
});
|
||||
|
||||
if (!targetUser) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id },
|
||||
data: { loginTokenHash: null, sessionsInvalidatedAt: new Date() },
|
||||
});
|
||||
|
||||
logger.info('Admin revoked login token for user', {
|
||||
targetUser: targetUser.plexUsername,
|
||||
revokedBy: req.user!.username,
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
logger.error('Failed to revoke login token', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return NextResponse.json({ error: 'Failed to revoke login token' }, { status: 500 });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -33,6 +33,7 @@ export async function GET(request: NextRequest) {
|
||||
autoApproveRequests: true,
|
||||
interactiveSearchAccess: true,
|
||||
downloadAccess: true,
|
||||
loginTokenHash: true,
|
||||
_count: {
|
||||
select: {
|
||||
requests: true,
|
||||
@@ -44,7 +45,12 @@ export async function GET(request: NextRequest) {
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ users });
|
||||
return NextResponse.json({
|
||||
users: users.map(({ loginTokenHash, ...u }) => ({
|
||||
...u,
|
||||
hasLoginToken: loginTokenHash !== null,
|
||||
})),
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch users', { error: error instanceof Error ? error.message : String(error) });
|
||||
return NextResponse.json(
|
||||
|
||||
@@ -11,6 +11,7 @@ import { prisma } from '@/lib/db';
|
||||
import { enrichAudiobooksWithMatches, getAvailableAsins } from '@/lib/utils/audiobook-matcher';
|
||||
import { getCurrentUser } from '@/lib/middleware/auth';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import { annotateWithIgnoreStatus } from '@/lib/utils/ignored-audiobooks';
|
||||
|
||||
const logger = RMABLogger.create('API.Audiobooks.Category');
|
||||
|
||||
@@ -129,12 +130,15 @@ export async function GET(
|
||||
const userId = currentUser?.sub || undefined;
|
||||
const enrichedAudiobooks = await enrichAudiobooksWithMatches(audibleBooks, userId);
|
||||
|
||||
// Annotate with per-user ignore status
|
||||
const annotatedAudiobooks = await annotateWithIgnoreStatus(enrichedAudiobooks, userId);
|
||||
|
||||
const totalPages = Math.ceil(totalCount / limit);
|
||||
const hasMore = page < totalPages;
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
audiobooks: enrichedAudiobooks,
|
||||
audiobooks: annotatedAudiobooks,
|
||||
count: enrichedAudiobooks.length,
|
||||
totalCount,
|
||||
page,
|
||||
|
||||
@@ -11,6 +11,7 @@ import { prisma } from '@/lib/db';
|
||||
import { enrichAudiobooksWithMatches, getAvailableAsins } from '@/lib/utils/audiobook-matcher';
|
||||
import { getCurrentUser } from '@/lib/middleware/auth';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import { annotateWithIgnoreStatus } from '@/lib/utils/ignored-audiobooks';
|
||||
import { NEW_RELEASES_CATEGORY_ID } from '@/lib/processors/audible-refresh.processor';
|
||||
|
||||
const logger = RMABLogger.create('API.Audiobooks.NewReleases');
|
||||
@@ -136,12 +137,15 @@ export async function GET(request: NextRequest) {
|
||||
// Enrich with real-time Plex library matching and request status
|
||||
const enrichedAudiobooks = await enrichAudiobooksWithMatches(audibleBooks, userId);
|
||||
|
||||
// Annotate with per-user ignore status
|
||||
const annotatedAudiobooks = await annotateWithIgnoreStatus(enrichedAudiobooks, userId);
|
||||
|
||||
const totalPages = Math.ceil(totalCount / limit);
|
||||
const hasMore = page < totalPages;
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
audiobooks: enrichedAudiobooks,
|
||||
audiobooks: annotatedAudiobooks,
|
||||
count: enrichedAudiobooks.length,
|
||||
totalCount,
|
||||
page,
|
||||
|
||||
@@ -11,6 +11,7 @@ import { prisma } from '@/lib/db';
|
||||
import { enrichAudiobooksWithMatches, getAvailableAsins } from '@/lib/utils/audiobook-matcher';
|
||||
import { getCurrentUser } from '@/lib/middleware/auth';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import { annotateWithIgnoreStatus } from '@/lib/utils/ignored-audiobooks';
|
||||
import { POPULAR_CATEGORY_ID } from '@/lib/processors/audible-refresh.processor';
|
||||
|
||||
const logger = RMABLogger.create('API.Audiobooks.Popular');
|
||||
@@ -136,12 +137,15 @@ export async function GET(request: NextRequest) {
|
||||
// Enrich with real-time Plex library matching and request status
|
||||
const enrichedAudiobooks = await enrichAudiobooksWithMatches(audibleBooks, userId);
|
||||
|
||||
// Annotate with per-user ignore status
|
||||
const annotatedAudiobooks = await annotateWithIgnoreStatus(enrichedAudiobooks, userId);
|
||||
|
||||
const totalPages = Math.ceil(totalCount / limit);
|
||||
const hasMore = page < totalPages;
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
audiobooks: enrichedAudiobooks,
|
||||
audiobooks: annotatedAudiobooks,
|
||||
count: enrichedAudiobooks.length,
|
||||
totalCount,
|
||||
page,
|
||||
|
||||
@@ -7,9 +7,10 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getAudibleService } from '@/lib/integrations/audible.service';
|
||||
import { enrichAudiobooksWithMatches } from '@/lib/utils/audiobook-matcher';
|
||||
import { deduplicateAndCollectGroups } from '@/lib/utils/deduplicate-audiobooks';
|
||||
import { persistDedupGroups } from '@/lib/services/works.service';
|
||||
import { persistDedupGroups, collapseByExistingWorks } from '@/lib/services/works.service';
|
||||
import { getCurrentUser } from '@/lib/middleware/auth';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import { annotateWithIgnoreStatus } from '@/lib/utils/ignored-audiobooks';
|
||||
|
||||
const logger = RMABLogger.create('API.Audiobooks.Search');
|
||||
|
||||
@@ -40,21 +41,27 @@ export async function GET(request: NextRequest) {
|
||||
const currentUser = getCurrentUser(request);
|
||||
const userId = currentUser?.sub || undefined;
|
||||
|
||||
// Deduplicate before enrichment to avoid wasted DB queries on duplicate entries
|
||||
// Two-pass dedup: local title/narrator/duration matching first, then collapse
|
||||
// any remaining duplicates that the works table already knows are the same book
|
||||
// (handles cases where source metadata diverges across paths or pages).
|
||||
const { books: dedupedResults, groups } = deduplicateAndCollectGroups(results.results);
|
||||
|
||||
// Fire-and-forget: persist dedup groups to works table for cross-ASIN matching
|
||||
if (groups.length > 0) {
|
||||
persistDedupGroups(groups).catch(() => {});
|
||||
}
|
||||
|
||||
const collapsedResults = await collapseByExistingWorks(dedupedResults);
|
||||
|
||||
// Enrich search results with availability and request status information
|
||||
const enrichedResults = await enrichAudiobooksWithMatches(dedupedResults, userId);
|
||||
const enrichedResults = await enrichAudiobooksWithMatches(collapsedResults, userId);
|
||||
|
||||
// Annotate with per-user ignore status
|
||||
const annotatedResults = await annotateWithIgnoreStatus(enrichedResults, userId);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
query: results.query,
|
||||
results: enrichedResults,
|
||||
results: annotatedResults,
|
||||
totalResults: enrichedResults.length,
|
||||
page: results.page,
|
||||
hasMore: results.hasMore,
|
||||
|
||||
@@ -45,9 +45,17 @@ export async function POST(request: NextRequest) {
|
||||
// Get user from database
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: payload.sub },
|
||||
select: {
|
||||
id: true,
|
||||
plexId: true,
|
||||
plexUsername: true,
|
||||
role: true,
|
||||
deletedAt: true,
|
||||
sessionsInvalidatedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
if (!user || user.deletedAt) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Unauthorized',
|
||||
@@ -57,6 +65,19 @@ export async function POST(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
// Check if session was invalidated after this refresh token was issued
|
||||
if (user.sessionsInvalidatedAt && payload.iat &&
|
||||
payload.iat < Math.floor(user.sessionsInvalidatedAt.getTime() / 1000)) {
|
||||
logger.warn('Refresh token issued before session invalidation', { userId: payload.sub });
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Unauthorized',
|
||||
message: 'Session has been revoked',
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// Generate new access token
|
||||
const accessToken = generateAccessToken({
|
||||
sub: user.id,
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Component: Token Login Route
|
||||
* Documentation: documentation/backend/services/auth.md
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { generateAccessToken, generateRefreshToken } from '@/lib/utils/jwt';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import { checkTokenLoginRateLimit } from '@/lib/utils/rateLimit';
|
||||
import crypto from 'crypto';
|
||||
|
||||
const logger = RMABLogger.create('API.Auth.TokenLogin');
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const ip = request.headers.get('x-forwarded-for') ?? 'unknown';
|
||||
const rateLimit = checkTokenLoginRateLimit(ip);
|
||||
if (!rateLimit.allowed) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Too many login attempts. Please try again later.' },
|
||||
{
|
||||
status: 429,
|
||||
headers: { 'Retry-After': String(rateLimit.retryAfterSeconds) },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const { token } = await request.json();
|
||||
|
||||
if (!token) {
|
||||
return NextResponse.json({ error: 'Missing token parameter' }, { status: 400 });
|
||||
}
|
||||
|
||||
const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
|
||||
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
loginTokenHash: tokenHash,
|
||||
deletedAt: null,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
plexId: true,
|
||||
plexUsername: true,
|
||||
plexEmail: true,
|
||||
avatarUrl: true,
|
||||
role: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
logger.warn('Token login failed - not found or user deleted');
|
||||
return NextResponse.json({ error: 'Invalid token' }, { status: 401 });
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { lastLoginAt: new Date() },
|
||||
});
|
||||
|
||||
const accessToken = generateAccessToken({
|
||||
sub: user.id,
|
||||
plexId: user.plexId,
|
||||
username: user.plexUsername,
|
||||
role: user.role,
|
||||
});
|
||||
|
||||
const refreshToken = generateRefreshToken(user.id);
|
||||
|
||||
logger.info('Token login successful', { username: user.plexUsername });
|
||||
|
||||
return NextResponse.json({
|
||||
accessToken,
|
||||
refreshToken,
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.plexUsername,
|
||||
email: user.plexEmail,
|
||||
avatarUrl: user.avatarUrl,
|
||||
role: user.role,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Token login error', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return NextResponse.json({ error: 'Authentication failed' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -7,9 +7,10 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getAudibleService } from '@/lib/integrations/audible.service';
|
||||
import { enrichAudiobooksWithMatches } from '@/lib/utils/audiobook-matcher';
|
||||
import { deduplicateAndCollectGroups } from '@/lib/utils/deduplicate-audiobooks';
|
||||
import { persistDedupGroups } from '@/lib/services/works.service';
|
||||
import { persistDedupGroups, collapseByExistingWorks } from '@/lib/services/works.service';
|
||||
import { getCurrentUser } from '@/lib/middleware/auth';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import { annotateWithIgnoreStatus } from '@/lib/utils/ignored-audiobooks';
|
||||
|
||||
const logger = RMABLogger.create('API.Authors.Books');
|
||||
|
||||
@@ -55,23 +56,29 @@ export async function GET(
|
||||
const audibleService = getAudibleService();
|
||||
const result = await audibleService.searchByAuthorAsin(authorName.trim(), asin, page);
|
||||
|
||||
// Deduplicate before enrichment to avoid wasted DB queries on duplicate entries
|
||||
// Two-pass dedup: local title/narrator/duration matching first, then collapse
|
||||
// any remaining duplicates that the works table already knows are the same book
|
||||
// (handles cases where source metadata diverges across paths or pages).
|
||||
const { books: dedupedBooks, groups } = deduplicateAndCollectGroups(result.books);
|
||||
|
||||
// Fire-and-forget: persist dedup groups to works table for cross-ASIN matching
|
||||
if (groups.length > 0) {
|
||||
persistDedupGroups(groups).catch(() => {});
|
||||
}
|
||||
|
||||
const collapsedBooks = await collapseByExistingWorks(dedupedBooks);
|
||||
|
||||
// Enrich with library availability and request status
|
||||
const userId = currentUser.sub || undefined;
|
||||
const enrichedBooks = await enrichAudiobooksWithMatches(dedupedBooks, userId);
|
||||
const enrichedBooks = await enrichAudiobooksWithMatches(collapsedBooks, userId);
|
||||
|
||||
logger.info(`Author books complete: "${authorName}" → ${enrichedBooks.length} books (page ${page})`);
|
||||
// Annotate with per-user ignore status
|
||||
const annotatedBooks = await annotateWithIgnoreStatus(enrichedBooks, userId);
|
||||
|
||||
logger.info(`Author books complete: "${authorName}" → ${annotatedBooks.length} books (page ${page})`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
books: enrichedBooks,
|
||||
books: annotatedBooks,
|
||||
authorName: authorName.trim(),
|
||||
authorAsin: asin,
|
||||
totalBooks: enrichedBooks.length,
|
||||
|
||||
@@ -4,10 +4,12 @@
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { Prisma } from '@/generated/prisma/client';
|
||||
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import { CLIENT_PROTOCOL_MAP, DownloadClientType } from '@/lib/interfaces/download-client.interface';
|
||||
import { CANCELLABLE_STATUSES } from '@/lib/constants/request-statuses';
|
||||
|
||||
const logger = RMABLogger.create('API.RequestById');
|
||||
|
||||
@@ -112,6 +114,10 @@ export async function PATCH(
|
||||
id,
|
||||
deletedAt: null, // Only allow updates to active requests
|
||||
},
|
||||
include: {
|
||||
audiobook: true,
|
||||
user: { select: { plexUsername: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!requestRecord) {
|
||||
@@ -130,18 +136,44 @@ export async function PATCH(
|
||||
}
|
||||
|
||||
if (action === 'cancel') {
|
||||
// Cancel the request
|
||||
if (!(CANCELLABLE_STATUSES as readonly string[]).includes(requestRecord.status)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'ValidationError',
|
||||
message: `Cannot cancel request with status: ${requestRecord.status}`,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const isAwaitingApproval = requestRecord.status === 'awaiting_approval';
|
||||
|
||||
const updated = await prisma.request.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: 'cancelled',
|
||||
updatedAt: new Date(),
|
||||
...(isAwaitingApproval && { selectedTorrent: Prisma.DbNull }),
|
||||
},
|
||||
include: {
|
||||
audiobook: true,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const { getJobQueueService } = await import('@/lib/services/job-queue.service');
|
||||
const jobQueue = getJobQueueService();
|
||||
await jobQueue.addNotificationJob(
|
||||
'request_cancelled',
|
||||
updated.id,
|
||||
updated.audiobook.title,
|
||||
updated.audiobook.author,
|
||||
requestRecord.user.plexUsername || 'Unknown User'
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error('Failed to queue cancellation notification', { error });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
request: updated,
|
||||
|
||||
@@ -53,7 +53,7 @@ export async function POST(request: NextRequest) {
|
||||
narrator: audiobook.narrator,
|
||||
description: audiobook.description,
|
||||
coverArtUrl: audiobook.coverArtUrl,
|
||||
}, { skipAutoSearch });
|
||||
}, { skipAutoSearch, bypassIgnore: true });
|
||||
|
||||
if (!result.success) {
|
||||
const statusMap: Record<string, { error: string; status: number }> = {
|
||||
@@ -61,6 +61,7 @@ export async function POST(request: NextRequest) {
|
||||
being_processed: { error: 'BeingProcessed', status: 409 },
|
||||
duplicate: { error: 'DuplicateRequest', status: 409 },
|
||||
user_not_found: { error: 'UserNotFound', status: 404 },
|
||||
ignored: { error: 'Ignored', status: 409 },
|
||||
};
|
||||
const mapped = statusMap[result.reason] || { error: 'RequestError', status: 500 };
|
||||
return NextResponse.json(
|
||||
|
||||
@@ -9,7 +9,8 @@ import { RMABLogger } from '@/lib/utils/logger';
|
||||
import { scrapeSeriesPage } from '@/lib/integrations/audible-series';
|
||||
import { enrichAudiobooksWithMatches } from '@/lib/utils/audiobook-matcher';
|
||||
import { deduplicateAndCollectGroups } from '@/lib/utils/deduplicate-audiobooks';
|
||||
import { persistDedupGroups } from '@/lib/services/works.service';
|
||||
import { persistDedupGroups, collapseByExistingWorks } from '@/lib/services/works.service';
|
||||
import { annotateWithIgnoreStatus } from '@/lib/utils/ignored-audiobooks';
|
||||
|
||||
const logger = RMABLogger.create('API.Series.Detail');
|
||||
|
||||
@@ -51,25 +52,31 @@ export async function GET(
|
||||
);
|
||||
}
|
||||
|
||||
// Deduplicate before enrichment to avoid wasted DB queries on duplicate entries
|
||||
// Two-pass dedup: local title/narrator/duration matching first, then collapse
|
||||
// any remaining duplicates that the works table already knows are the same book
|
||||
// (handles cases where source metadata diverges across paths or pages).
|
||||
const { books: dedupedBooks, groups } = deduplicateAndCollectGroups(detail.books);
|
||||
|
||||
// Fire-and-forget: persist dedup groups to works table for cross-ASIN matching
|
||||
if (groups.length > 0) {
|
||||
persistDedupGroups(groups).catch(() => {});
|
||||
}
|
||||
|
||||
const collapsedBooks = await collapseByExistingWorks(dedupedBooks);
|
||||
|
||||
// Enrich books with library availability and request status
|
||||
const userId = currentUser.sub || undefined;
|
||||
const enrichedBooks = await enrichAudiobooksWithMatches(dedupedBooks, userId);
|
||||
const enrichedBooks = await enrichAudiobooksWithMatches(collapsedBooks, userId);
|
||||
|
||||
logger.info(`Series detail complete: "${detail.title}" (${enrichedBooks.length} books, page ${page})`);
|
||||
// Annotate with per-user ignore status
|
||||
const annotatedBooks = await annotateWithIgnoreStatus(enrichedBooks, userId);
|
||||
|
||||
logger.info(`Series detail complete: "${detail.title}" (${annotatedBooks.length} books, page ${page})`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
series: {
|
||||
...detail,
|
||||
books: enrichedBooks,
|
||||
books: annotatedBooks,
|
||||
},
|
||||
hasMore: detail.hasMore,
|
||||
page: detail.page,
|
||||
|
||||
@@ -7,7 +7,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, 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.User.ApiTokens');
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, 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';
|
||||
|
||||
@@ -13,7 +13,8 @@ import { z } from 'zod';
|
||||
const logger = RMABLogger.create('API.GoodreadsShelves');
|
||||
|
||||
const UpdateGoodreadsSchema = z.object({
|
||||
rssUrl: z.string().url('Must be a valid URL'),
|
||||
rssUrl: z.string().url('Must be a valid URL').optional(),
|
||||
autoRequest: z.boolean().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -81,21 +82,37 @@ export async function PATCH(
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { rssUrl } = UpdateGoodreadsSchema.parse(body);
|
||||
const { rssUrl, autoRequest } = UpdateGoodreadsSchema.parse(body);
|
||||
|
||||
const updateData: Record<string, unknown> = {};
|
||||
let needsResync = false;
|
||||
|
||||
if (rssUrl !== undefined) {
|
||||
updateData.rssUrl = rssUrl;
|
||||
updateData.lastSyncAt = null;
|
||||
updateData.bookCount = null;
|
||||
updateData.coverUrls = null;
|
||||
needsResync = true;
|
||||
}
|
||||
|
||||
if (autoRequest !== undefined) {
|
||||
updateData.autoRequest = autoRequest;
|
||||
}
|
||||
|
||||
// Force re-fetch by clearing metadata
|
||||
const updated = await prisma.goodreadsShelf.update({
|
||||
where: { id },
|
||||
data: { rssUrl, lastSyncAt: null, bookCount: null, coverUrls: null },
|
||||
data: updateData,
|
||||
});
|
||||
|
||||
try {
|
||||
const jobQueue = getJobQueueService();
|
||||
await jobQueue.addSyncShelvesJob(undefined, updated.id, 'goodreads', 0);
|
||||
} catch (error) {
|
||||
logger.error('Failed to trigger immediate list sync', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
if (needsResync) {
|
||||
try {
|
||||
const jobQueue = getJobQueueService();
|
||||
await jobQueue.addSyncShelvesJob(undefined, updated.id, 'goodreads', 0, req.user.id);
|
||||
} catch (error) {
|
||||
logger.error('Failed to trigger immediate list sync', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, shelf: updated });
|
||||
|
||||
@@ -20,6 +20,7 @@ const AddShelfSchema = z.object({
|
||||
(url) => GOODREADS_RSS_PATTERN.test(url),
|
||||
{ message: 'URL must be a Goodreads shelf RSS URL (goodreads.com/review/list_rss/...)' }
|
||||
),
|
||||
autoRequest: z.boolean().optional().default(true),
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -66,6 +67,7 @@ export async function GET(request: NextRequest) {
|
||||
lastSyncAt: shelf.lastSyncAt,
|
||||
createdAt: shelf.createdAt,
|
||||
bookCount: shelf.bookCount ?? null,
|
||||
autoRequest: shelf.autoRequest,
|
||||
books,
|
||||
};
|
||||
});
|
||||
@@ -90,7 +92,7 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const { rssUrl } = AddShelfSchema.parse(body);
|
||||
const { rssUrl, autoRequest } = AddShelfSchema.parse(body);
|
||||
|
||||
// Check for duplicate
|
||||
const existing = await prisma.goodreadsShelf.findUnique({
|
||||
@@ -132,6 +134,7 @@ export async function POST(request: NextRequest) {
|
||||
name: shelfName,
|
||||
rssUrl,
|
||||
bookCount,
|
||||
autoRequest,
|
||||
coverUrls: initialBooks.length > 0 ? JSON.stringify(initialBooks) : null,
|
||||
},
|
||||
});
|
||||
@@ -139,7 +142,7 @@ export async function POST(request: NextRequest) {
|
||||
// Trigger immediate sync for this shelf (unlimited lookups, process all books)
|
||||
try {
|
||||
const jobQueue = getJobQueueService();
|
||||
await jobQueue.addSyncShelvesJob(undefined, shelf.id, 'goodreads', 0);
|
||||
await jobQueue.addSyncShelvesJob(undefined, shelf.id, 'goodreads', 0, req.user.id);
|
||||
logger.info(`Triggered immediate sync for Goodreads shelf "${shelfName}" (${shelf.id})`);
|
||||
} catch (error) {
|
||||
logger.error('Failed to trigger immediate shelf sync', { error: error instanceof Error ? error.message : String(error) });
|
||||
@@ -154,6 +157,7 @@ export async function POST(request: NextRequest) {
|
||||
lastSyncAt: shelf.lastSyncAt,
|
||||
createdAt: shelf.createdAt,
|
||||
bookCount: shelf.bookCount,
|
||||
autoRequest: shelf.autoRequest,
|
||||
books: initialBooks,
|
||||
},
|
||||
bookCount,
|
||||
|
||||
@@ -17,6 +17,8 @@ const logger = RMABLogger.create('API.HardcoverShelves');
|
||||
const UpdateHardcoverSchema = z.object({
|
||||
listId: z.string().min(1, 'List ID is required').optional(),
|
||||
apiToken: z.string().optional(),
|
||||
forceSync: z.boolean().optional(),
|
||||
autoRequest: z.boolean().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -89,10 +91,14 @@ export async function PATCH(
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { listId, apiToken } = UpdateHardcoverSchema.parse(body);
|
||||
const { listId, apiToken, forceSync, autoRequest } = UpdateHardcoverSchema.parse(body);
|
||||
|
||||
const updateData: { listId?: string; apiToken?: string; lastSyncAt?: null; bookCount?: null; coverUrls?: null } = {};
|
||||
let needsResync = false;
|
||||
const updateData: { listId?: string; apiToken?: string; autoRequest?: boolean; lastSyncAt?: null; bookCount?: null; coverUrls?: null } = {};
|
||||
|
||||
if (autoRequest !== undefined) {
|
||||
updateData.autoRequest = autoRequest;
|
||||
}
|
||||
let needsResync = !!forceSync;
|
||||
|
||||
let cleanedToken: string | undefined;
|
||||
if (apiToken && apiToken.trim() !== '') {
|
||||
@@ -155,7 +161,7 @@ export async function PATCH(
|
||||
if (needsResync) {
|
||||
try {
|
||||
const jobQueue = getJobQueueService();
|
||||
await jobQueue.addSyncShelvesJob(undefined, updated.id, 'hardcover', 0);
|
||||
await jobQueue.addSyncShelvesJob(undefined, updated.id, 'hardcover', 0, req.user.id);
|
||||
} catch (error) {
|
||||
logger.error('Failed to trigger immediate list sync', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
|
||||
@@ -18,6 +18,7 @@ const logger = RMABLogger.create('API.HardcoverShelves');
|
||||
const AddShelfSchema = z.object({
|
||||
listId: z.string().min(1, { message: 'List ID is required' }),
|
||||
apiToken: z.string().min(1, { message: 'API Token is required' }),
|
||||
autoRequest: z.boolean().optional().default(true),
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -46,6 +47,7 @@ export async function GET(request: NextRequest) {
|
||||
lastSyncAt: shelf.lastSyncAt,
|
||||
createdAt: shelf.createdAt,
|
||||
bookCount: shelf.bookCount ?? null,
|
||||
autoRequest: shelf.autoRequest,
|
||||
books,
|
||||
};
|
||||
});
|
||||
@@ -75,7 +77,9 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
let { listId, apiToken } = AddShelfSchema.parse(body);
|
||||
const parsed = AddShelfSchema.parse(body);
|
||||
let { listId, apiToken } = parsed;
|
||||
const { autoRequest } = parsed;
|
||||
|
||||
// Clean up token in case user pasted "Bearer " prefix
|
||||
apiToken = apiToken.trim();
|
||||
@@ -139,6 +143,7 @@ export async function POST(request: NextRequest) {
|
||||
name: listName,
|
||||
listId,
|
||||
apiToken: encryptedToken,
|
||||
autoRequest,
|
||||
bookCount,
|
||||
coverUrls:
|
||||
initialBooks.length > 0 ? JSON.stringify(initialBooks) : null,
|
||||
@@ -148,7 +153,7 @@ export async function POST(request: NextRequest) {
|
||||
// Trigger immediate sync for this shelf (unlimited lookups, process all books)
|
||||
try {
|
||||
const jobQueue = getJobQueueService();
|
||||
await jobQueue.addSyncShelvesJob(undefined, shelf.id, 'hardcover', 0);
|
||||
await jobQueue.addSyncShelvesJob(undefined, shelf.id, 'hardcover', 0, req.user.id);
|
||||
logger.info(
|
||||
`Triggered immediate sync for Hardcover list "${listName}" (${shelf.id})`,
|
||||
);
|
||||
@@ -168,6 +173,7 @@ export async function POST(request: NextRequest) {
|
||||
lastSyncAt: shelf.lastSyncAt,
|
||||
createdAt: shelf.createdAt,
|
||||
bookCount: shelf.bookCount,
|
||||
autoRequest: shelf.autoRequest,
|
||||
books: initialBooks,
|
||||
},
|
||||
bookCount,
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Component: Ignored Audiobook Delete Route
|
||||
* Documentation: documentation/features/ignored-audiobooks.md
|
||||
*
|
||||
* DELETE removes a single entry from the user's ignore list (un-ignore).
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
|
||||
const logger = RMABLogger.create('API.IgnoredAudiobooks');
|
||||
|
||||
/**
|
||||
* DELETE /api/user/ignored-audiobooks/[id]
|
||||
* Remove an audiobook from the user's ignore list
|
||||
*/
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
// Verify ownership before deleting
|
||||
const existing = await prisma.ignoredAudiobook.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
return NextResponse.json(
|
||||
{ error: 'NotFound', message: 'Ignored audiobook entry not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
if (existing.userId !== req.user.id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Forbidden', message: 'Cannot modify another user\'s ignore list' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
await prisma.ignoredAudiobook.delete({ where: { id } });
|
||||
|
||||
logger.info(`User ${req.user.id} un-ignored ASIN ${existing.asin} ("${existing.title}")`);
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
logger.error('Failed to remove ignored audiobook', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ error: 'DeleteError', message: 'Failed to remove ignored audiobook' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Component: Ignored Audiobook Check Route
|
||||
* Documentation: documentation/features/ignored-audiobooks.md
|
||||
*
|
||||
* Quick check whether a specific ASIN is ignored by the current user.
|
||||
* Includes works-system expansion to catch sibling ASINs.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { getSiblingAsins } from '@/lib/services/works.service';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
|
||||
const logger = RMABLogger.create('API.IgnoredAudiobooks.Check');
|
||||
|
||||
/**
|
||||
* GET /api/user/ignored-audiobooks/check/[asin]
|
||||
* Returns { ignored: boolean, ignoredId?: string } for the given ASIN.
|
||||
* ignoredId is the ID of the matching IgnoredAudiobook record (for un-ignore).
|
||||
*/
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ asin: string }> }
|
||||
) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { asin } = await params;
|
||||
|
||||
// Direct check
|
||||
const directIgnore = await prisma.ignoredAudiobook.findUnique({
|
||||
where: { userId_asin: { userId: req.user.id, asin } },
|
||||
});
|
||||
|
||||
if (directIgnore) {
|
||||
return NextResponse.json({
|
||||
ignored: true,
|
||||
ignoredId: directIgnore.id,
|
||||
});
|
||||
}
|
||||
|
||||
// Works-system expansion: check sibling ASINs
|
||||
try {
|
||||
const siblingMap = await getSiblingAsins([asin]);
|
||||
const siblings = siblingMap.get(asin);
|
||||
if (siblings && siblings.length > 0) {
|
||||
const siblingIgnore = await prisma.ignoredAudiobook.findFirst({
|
||||
where: {
|
||||
userId: req.user.id,
|
||||
asin: { in: siblings },
|
||||
},
|
||||
});
|
||||
if (siblingIgnore) {
|
||||
return NextResponse.json({
|
||||
ignored: true,
|
||||
ignoredId: siblingIgnore.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Works expansion is best-effort
|
||||
}
|
||||
|
||||
return NextResponse.json({ ignored: false });
|
||||
} catch (error) {
|
||||
logger.error('Failed to check ignored status', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ error: 'CheckError', message: 'Failed to check ignored status' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Component: Ignored Audiobooks API Routes
|
||||
* Documentation: documentation/features/ignored-audiobooks.md
|
||||
*
|
||||
* Per-user ignore list for auto-request suppression.
|
||||
* GET returns the user's full ignore list; POST adds a new entry.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { z } from 'zod';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
|
||||
const logger = RMABLogger.create('API.IgnoredAudiobooks');
|
||||
|
||||
const AddIgnoredSchema = z.object({
|
||||
asin: z.string().min(1).max(20),
|
||||
title: z.string().min(1).max(500),
|
||||
author: z.string().min(1).max(500),
|
||||
coverArtUrl: z.string().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/user/ignored-audiobooks
|
||||
* List the current user's ignored audiobooks
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const ignored = await prisma.ignoredAudiobook.findMany({
|
||||
where: { userId: req.user.id },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
ignoredAudiobooks: ignored.map((item) => ({
|
||||
id: item.id,
|
||||
asin: item.asin,
|
||||
title: item.title,
|
||||
author: item.author,
|
||||
coverArtUrl: item.coverArtUrl,
|
||||
createdAt: item.createdAt.toISOString(),
|
||||
})),
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to list ignored audiobooks', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ error: 'FetchError', message: 'Failed to fetch ignored audiobooks' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/user/ignored-audiobooks
|
||||
* Add an audiobook to the user's ignore list
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const data = AddIgnoredSchema.parse(body);
|
||||
|
||||
// Upsert to handle duplicate gracefully
|
||||
const ignored = await prisma.ignoredAudiobook.upsert({
|
||||
where: {
|
||||
userId_asin: { userId: req.user.id, asin: data.asin },
|
||||
},
|
||||
update: {}, // Already exists — no-op
|
||||
create: {
|
||||
userId: req.user.id,
|
||||
asin: data.asin,
|
||||
title: data.title,
|
||||
author: data.author,
|
||||
coverArtUrl: data.coverArtUrl,
|
||||
},
|
||||
});
|
||||
|
||||
logger.info(`User ${req.user.id} ignored ASIN ${data.asin} ("${data.title}")`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
ignoredAudiobook: {
|
||||
id: ignored.id,
|
||||
asin: ignored.asin,
|
||||
title: ignored.title,
|
||||
author: ignored.author,
|
||||
coverArtUrl: ignored.coverArtUrl,
|
||||
createdAt: ignored.createdAt.toISOString(),
|
||||
},
|
||||
}, { status: 201 });
|
||||
} catch (error) {
|
||||
logger.error('Failed to add ignored audiobook', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'ValidationError', details: error.errors },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: 'CreateError', message: 'Failed to ignore audiobook' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -42,6 +42,7 @@ export async function GET(request: NextRequest) {
|
||||
lastSyncAt: s.lastSyncAt,
|
||||
createdAt: s.createdAt,
|
||||
bookCount: s.bookCount ?? null,
|
||||
autoRequest: s.autoRequest,
|
||||
books: processBooks(s.coverUrls),
|
||||
})),
|
||||
...hardcover.map((s) => ({
|
||||
@@ -52,6 +53,7 @@ export async function GET(request: NextRequest) {
|
||||
lastSyncAt: s.lastSyncAt,
|
||||
createdAt: s.createdAt,
|
||||
bookCount: s.bookCount ?? null,
|
||||
autoRequest: s.autoRequest,
|
||||
books: processBooks(s.coverUrls),
|
||||
})),
|
||||
].sort(
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Component: Manual Shelf Sync API Route
|
||||
* Documentation: documentation/backend/services/goodreads-sync.md
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, 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 { z } from 'zod';
|
||||
|
||||
const logger = RMABLogger.create('API.ShelvesSync');
|
||||
|
||||
const SyncSchema = z.object({
|
||||
shelfId: z.string().optional(),
|
||||
shelfType: z.enum(['goodreads', 'hardcover']).optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/user/shelves/sync
|
||||
* Trigger a manual sync for all or a specific shelf belonging to the user.
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const { shelfId, shelfType } = SyncSchema.parse(body);
|
||||
|
||||
// Set lastSyncAt to null so the frontend SWR refresh catches the "Syncing..." state immediately
|
||||
if (!shelfType || shelfType === 'goodreads') {
|
||||
await prisma.goodreadsShelf.updateMany({
|
||||
where: { userId: req.user.id, ...(shelfId ? { id: shelfId } : {}) },
|
||||
data: { lastSyncAt: null },
|
||||
});
|
||||
}
|
||||
|
||||
if (!shelfType || shelfType === 'hardcover') {
|
||||
await prisma.hardcoverShelf.updateMany({
|
||||
where: { userId: req.user.id, ...(shelfId ? { id: shelfId } : {}) },
|
||||
data: { lastSyncAt: null },
|
||||
});
|
||||
}
|
||||
|
||||
const jobQueue = getJobQueueService();
|
||||
|
||||
// Trigger sync job with userId filter
|
||||
await jobQueue.addSyncShelvesJob(
|
||||
undefined,
|
||||
shelfId,
|
||||
shelfType,
|
||||
0, // unlimited lookups for manual trigger
|
||||
req.user.id
|
||||
);
|
||||
|
||||
logger.info(`Manual sync triggered for user ${req.user.id}${shelfId ? ` (shelf: ${shelfId})` : ' (all shelves)'}`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: shelfId ? 'Shelf sync triggered' : 'All shelves sync triggered'
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json({ error: 'ValidationError', details: error.errors }, { status: 400 });
|
||||
}
|
||||
logger.error('Failed to trigger manual sync', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to trigger manual sync' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Component: Token Login Page
|
||||
* Documentation: documentation/backend/services/auth.md
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { Suspense, useEffect } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
|
||||
function TokenLoginContent() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { setAuthData } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
const token = searchParams.get('token');
|
||||
|
||||
if (!token) {
|
||||
router.replace('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
// Scrub token from browser URL/history immediately after extraction
|
||||
window.history.replaceState({}, '', '/auth/token/login');
|
||||
|
||||
fetch('/api/auth/token/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token }),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (data.error) {
|
||||
router.replace('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
localStorage.setItem('accessToken', data.accessToken);
|
||||
localStorage.setItem('refreshToken', data.refreshToken);
|
||||
localStorage.setItem('user', JSON.stringify(data.user));
|
||||
|
||||
setAuthData(data.user, data.accessToken);
|
||||
window.location.href = '/';
|
||||
})
|
||||
.catch(() => {
|
||||
router.replace('/login');
|
||||
});
|
||||
}, [searchParams, router, setAuthData]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-900">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-orange-500 mx-auto mb-4"></div>
|
||||
<p className="text-gray-400 text-sm">Authenticating...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TokenLoginPage() {
|
||||
return (
|
||||
<Suspense>
|
||||
<TokenLoginContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -265,11 +265,15 @@ function LoginContent() {
|
||||
}
|
||||
|
||||
// Poll for authorization
|
||||
await login(pinId);
|
||||
const loginResult = await login(pinId);
|
||||
|
||||
// Close popup
|
||||
authWindow.close();
|
||||
|
||||
if (loginResult === 'profile-selection-required') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Redirect to intended page or homepage
|
||||
const redirect = searchParams.get('redirect') || '/';
|
||||
router.push(redirect);
|
||||
|
||||
@@ -0,0 +1,932 @@
|
||||
/**
|
||||
* Component: Path Mapping Helper
|
||||
* Documentation: documentation/deployment/volume-mapping.md
|
||||
*
|
||||
* Public, unprotected page that guides users through configuring
|
||||
* Docker volume mappings for their download clients and RMAB.
|
||||
* Purely client-side — no API calls, no real data access.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import {
|
||||
CLIENT_DISPLAY_NAMES,
|
||||
CLIENT_PROTOCOL_MAP,
|
||||
type DownloadClientType,
|
||||
} from '@/lib/interfaces/download-client.interface';
|
||||
|
||||
// =========================================================================
|
||||
// TYPES
|
||||
// =========================================================================
|
||||
|
||||
interface ClientConfig {
|
||||
type: DownloadClientType;
|
||||
/** The path inside the download client container where completed downloads land */
|
||||
savePath: string;
|
||||
/** The volume mapping from the client's docker-compose (host:container) — host side */
|
||||
hostPath: string;
|
||||
/** The volume mapping from the client's docker-compose (host:container) — container side */
|
||||
containerMountPath: string;
|
||||
/** Whether this client needs remote path mapping */
|
||||
remotePathMapping: boolean;
|
||||
/** The path as seen by the remote download client (for remote path mapping) */
|
||||
remotePath: string;
|
||||
}
|
||||
|
||||
type Step = 'clients' | 'save-paths' | 'host-paths' | 'results';
|
||||
|
||||
const STEPS: { key: Step; title: string }[] = [
|
||||
{ key: 'clients', title: 'Clients' },
|
||||
{ key: 'save-paths', title: 'Save Paths' },
|
||||
{ key: 'host-paths', title: 'Volume Mapping' },
|
||||
{ key: 'results', title: 'Results' },
|
||||
];
|
||||
|
||||
const ALL_CLIENTS: DownloadClientType[] = ['qbittorrent', 'transmission', 'deluge', 'sabnzbd', 'nzbget'];
|
||||
|
||||
const DEFAULT_SAVE_PATHS: Record<DownloadClientType, string> = {
|
||||
qbittorrent: '/downloads',
|
||||
transmission: '/downloads/complete',
|
||||
deluge: '/downloads',
|
||||
sabnzbd: '/downloads/complete',
|
||||
nzbget: '/downloads/completed',
|
||||
};
|
||||
|
||||
// =========================================================================
|
||||
// UTILITY FUNCTIONS
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Find the longest common path prefix across multiple paths.
|
||||
* Only meaningful when there are multiple DIFFERENT paths.
|
||||
*/
|
||||
function findCommonRoot(paths: string[]): string {
|
||||
if (paths.length === 0) return '';
|
||||
if (paths.length === 1) return paths[0];
|
||||
|
||||
const unique = [...new Set(paths)];
|
||||
if (unique.length === 1) return unique[0];
|
||||
|
||||
// Split each path into segments
|
||||
const segmentArrays = unique.map((p) => p.replace(/\/+$/, '').split('/').filter(Boolean));
|
||||
const minLength = Math.min(...segmentArrays.map((s) => s.length));
|
||||
|
||||
const commonSegments: string[] = [];
|
||||
for (let i = 0; i < minLength; i++) {
|
||||
const segment = segmentArrays[0][i];
|
||||
if (segmentArrays.every((s) => s[i] === segment)) {
|
||||
commonSegments.push(segment);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (commonSegments.length === 0) return '/';
|
||||
return '/' + commonSegments.join('/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the relative path from a root to a full path.
|
||||
* Returns empty string if they're the same.
|
||||
*/
|
||||
function getRelativePath(root: string, fullPath: string): string {
|
||||
const normalizedRoot = root.replace(/\/+$/, '');
|
||||
const normalizedFull = fullPath.replace(/\/+$/, '');
|
||||
|
||||
if (normalizedRoot === normalizedFull) return '';
|
||||
|
||||
if (normalizedFull.startsWith(normalizedRoot + '/')) {
|
||||
return normalizedFull.slice(normalizedRoot.length + 1);
|
||||
}
|
||||
|
||||
// Shouldn't happen if common root is correct, but fallback
|
||||
return normalizedFull;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the common root of the host paths to build the RMAB volume mapping.
|
||||
* Maps from the host path hierarchy to the container path hierarchy.
|
||||
*/
|
||||
function findHostCommonRoot(configs: ClientConfig[]): string {
|
||||
const hostPaths = configs.map((c) => c.hostPath);
|
||||
if (hostPaths.length === 0) return '';
|
||||
if (hostPaths.length === 1) return hostPaths[0];
|
||||
|
||||
const unique = [...new Set(hostPaths)];
|
||||
if (unique.length === 1) return unique[0];
|
||||
|
||||
return findCommonRoot(hostPaths);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// COMPONENTS
|
||||
// =========================================================================
|
||||
|
||||
function StepIndicator({ currentStep }: { currentStep: Step }) {
|
||||
const currentIndex = STEPS.findIndex((s) => s.key === currentStep);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between py-4">
|
||||
{STEPS.map((step, index) => (
|
||||
<div key={step.key} className="flex items-center flex-1">
|
||||
<div className="flex flex-col items-center flex-1">
|
||||
<div
|
||||
className={`
|
||||
w-10 h-10 rounded-full flex items-center justify-center font-semibold text-sm
|
||||
${
|
||||
index < currentIndex
|
||||
? 'bg-green-500 text-white'
|
||||
: index === currentIndex
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-400'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{index < currentIndex ? (
|
||||
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
index + 1
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={`
|
||||
text-xs mt-2 text-center whitespace-nowrap
|
||||
${
|
||||
index === currentIndex
|
||||
? 'text-blue-600 dark:text-blue-400 font-medium'
|
||||
: 'text-gray-600 dark:text-gray-400'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{step.title}
|
||||
</span>
|
||||
</div>
|
||||
{index < STEPS.length - 1 && (
|
||||
<div
|
||||
className={`
|
||||
h-1 flex-1 mx-1 rounded
|
||||
${index < currentIndex ? 'bg-green-500' : 'bg-gray-200 dark:bg-gray-700'}
|
||||
`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoBox({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4 border border-blue-200 dark:border-blue-800">
|
||||
<div className="flex gap-3">
|
||||
<svg
|
||||
className="w-6 h-6 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<div className="text-sm text-blue-800 dark:text-blue-200">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WarningBox({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="bg-amber-50 dark:bg-amber-900/20 rounded-lg p-4 border border-amber-200 dark:border-amber-800">
|
||||
<div className="flex gap-3">
|
||||
<svg
|
||||
className="w-6 h-6 text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<div className="text-sm text-amber-800 dark:text-amber-200">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CodeBlock({ children, label, onCopy }: { children: string; label?: string; onCopy?: () => void }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(children);
|
||||
setCopied(true);
|
||||
onCopy?.();
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{label && (
|
||||
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">{label}</div>
|
||||
)}
|
||||
<div className="bg-gray-900 dark:bg-gray-950 rounded-lg p-4 font-mono text-sm text-gray-100 overflow-x-auto">
|
||||
<pre className="whitespace-pre">{children}</pre>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="absolute top-2 right-2 px-2 py-1 text-xs rounded bg-gray-700 hover:bg-gray-600 text-gray-300 transition-colors"
|
||||
style={label ? { top: '1.75rem' } : undefined}
|
||||
>
|
||||
{copied ? 'Copied!' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// STEP COMPONENTS
|
||||
// =========================================================================
|
||||
|
||||
function ClientSelectionStep({
|
||||
selectedClients,
|
||||
onToggle,
|
||||
onNext,
|
||||
}: {
|
||||
selectedClients: Set<DownloadClientType>;
|
||||
onToggle: (client: DownloadClientType) => void;
|
||||
onNext: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
Which download clients do you use?
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-2">
|
||||
Select all the download clients you have configured or plan to use with ReadMeABook.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{ALL_CLIENTS.map((client) => {
|
||||
const protocol = CLIENT_PROTOCOL_MAP[client];
|
||||
const isSelected = selectedClients.has(client);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={client}
|
||||
onClick={() => onToggle(client)}
|
||||
className={`
|
||||
w-full flex items-center gap-4 p-4 rounded-lg border-2 transition-all text-left
|
||||
${
|
||||
isSelected
|
||||
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
|
||||
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<div
|
||||
className={`
|
||||
w-6 h-6 rounded border-2 flex items-center justify-center flex-shrink-0
|
||||
${
|
||||
isSelected
|
||||
? 'border-blue-500 bg-blue-500'
|
||||
: 'border-gray-300 dark:border-gray-600'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{isSelected && (
|
||||
<svg className="w-4 h-4 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium text-gray-900 dark:text-gray-100">
|
||||
{CLIENT_DISPLAY_NAMES[client]}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400 capitalize">
|
||||
{protocol} client
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end pt-4">
|
||||
<Button onClick={onNext} disabled={selectedClients.size === 0}>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SavePathsStep({
|
||||
configs,
|
||||
onUpdateConfig,
|
||||
onNext,
|
||||
onBack,
|
||||
}: {
|
||||
configs: ClientConfig[];
|
||||
onUpdateConfig: (type: DownloadClientType, field: keyof ClientConfig, value: string) => void;
|
||||
onNext: () => void;
|
||||
onBack: () => void;
|
||||
}) {
|
||||
const allFilled = configs.every((c) => c.savePath.trim() !== '');
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
Download client save paths
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-2">
|
||||
For each client, enter the path <strong>inside that client's container</strong> where
|
||||
completed downloads are saved. This is the path you see in the client's own settings
|
||||
(e.g., qBittorrent Web UI → Options → Downloads → Default Save Path).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<InfoBox>
|
||||
<p>
|
||||
<strong>This is the container path, not the host path.</strong> For example, if your
|
||||
qBittorrent docker-compose has <code className="bg-blue-100 dark:bg-blue-800 px-1 rounded">-
|
||||
/mnt/data/torrents:/downloads</code>, and qBittorrent is configured to save
|
||||
to <code className="bg-blue-100 dark:bg-blue-800 px-1 rounded">/downloads</code>, then
|
||||
enter <code className="bg-blue-100 dark:bg-blue-800 px-1 rounded">/downloads</code> here.
|
||||
</p>
|
||||
</InfoBox>
|
||||
|
||||
<div className="space-y-4">
|
||||
{configs.map((config) => (
|
||||
<div key={config.type} className="bg-gray-50 dark:bg-gray-800/50 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="font-medium text-gray-900 dark:text-gray-100">
|
||||
{CLIENT_DISPLAY_NAMES[config.type]}
|
||||
</span>
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-400 capitalize">
|
||||
{CLIENT_PROTOCOL_MAP[config.type]}
|
||||
</span>
|
||||
</div>
|
||||
<Input
|
||||
placeholder={DEFAULT_SAVE_PATHS[config.type]}
|
||||
value={config.savePath}
|
||||
onChange={(e) => onUpdateConfig(config.type, 'savePath', e.target.value)}
|
||||
className="font-mono"
|
||||
helperText={`Default: ${DEFAULT_SAVE_PATHS[config.type]}`}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between pt-4">
|
||||
<Button onClick={onBack} variant="outline">
|
||||
Back
|
||||
</Button>
|
||||
<Button onClick={onNext} disabled={!allFilled}>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HostPathsStep({
|
||||
configs,
|
||||
onUpdateConfig,
|
||||
onNext,
|
||||
onBack,
|
||||
}: {
|
||||
configs: ClientConfig[];
|
||||
onUpdateConfig: (type: DownloadClientType, field: keyof ClientConfig, value: string | boolean) => void;
|
||||
onNext: () => void;
|
||||
onBack: () => void;
|
||||
}) {
|
||||
const allFilled = configs.every(
|
||||
(c) => c.hostPath.trim() !== '' && c.containerMountPath.trim() !== '' && (!c.remotePathMapping || c.remotePath.trim() !== '')
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
Docker volume mappings
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-2">
|
||||
For each client, enter the volume mapping from <strong>that client's</strong> docker-compose
|
||||
file. This tells us where on your host machine the downloads actually end up.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<InfoBox>
|
||||
<p>
|
||||
A Docker volume mapping looks like <code className="bg-blue-100 dark:bg-blue-800 px-1 rounded">/host/path:/container/path</code> in
|
||||
your docker-compose.yml. We need both sides so we know how to map RMAB to the same files.
|
||||
</p>
|
||||
</InfoBox>
|
||||
|
||||
<div className="space-y-6">
|
||||
{configs.map((config) => (
|
||||
<div key={config.type} className="bg-gray-50 dark:bg-gray-800/50 rounded-lg p-5 border border-gray-200 dark:border-gray-700 space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-gray-900 dark:text-gray-100">
|
||||
{CLIENT_DISPLAY_NAMES[config.type]}
|
||||
</span>
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-400 capitalize">
|
||||
{CLIENT_PROTOCOL_MAP[config.type]}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Host path (left side of :)"
|
||||
placeholder="/mnt/data/downloads"
|
||||
value={config.hostPath}
|
||||
onChange={(e) => onUpdateConfig(config.type, 'hostPath', e.target.value)}
|
||||
className="font-mono"
|
||||
helperText="The real path on your server"
|
||||
/>
|
||||
<Input
|
||||
label="Container path (right side of :)"
|
||||
placeholder="/downloads"
|
||||
value={config.containerMountPath}
|
||||
onChange={(e) => onUpdateConfig(config.type, 'containerMountPath', e.target.value)}
|
||||
className="font-mono"
|
||||
helperText="The path inside the container"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{config.containerMountPath && config.hostPath && (
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400 font-mono bg-gray-100 dark:bg-gray-900 rounded px-3 py-2">
|
||||
{config.hostPath}:{config.containerMountPath}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Remote path mapping toggle */}
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
id={`remote-${config.type}`}
|
||||
checked={config.remotePathMapping}
|
||||
onChange={(e) => onUpdateConfig(config.type, 'remotePathMapping', 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={`remote-${config.type}`}
|
||||
className="block text-sm font-medium text-gray-900 dark:text-gray-100 cursor-pointer"
|
||||
>
|
||||
This client runs on a different machine than RMAB
|
||||
</label>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
Enable this if the download client is on a seedbox, separate server, or otherwise has a
|
||||
different filesystem than where RMAB runs. Also enable this if the client runs on the
|
||||
host (not in Docker) while RMAB runs in Docker.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{config.remotePathMapping && (
|
||||
<div className="mt-3 ml-8">
|
||||
<Input
|
||||
label="Remote path (as seen by the download client)"
|
||||
placeholder="/remote/mnt/downloads/complete"
|
||||
value={config.remotePath}
|
||||
onChange={(e) => onUpdateConfig(config.type, 'remotePath', e.target.value)}
|
||||
className="font-mono"
|
||||
helperText="The path the download client reports when a download completes. This is often the same as the client's save path."
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between pt-4">
|
||||
<Button onClick={onBack} variant="outline">
|
||||
Back
|
||||
</Button>
|
||||
<Button onClick={onNext} disabled={!allFilled}>
|
||||
Generate Configuration
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ResultsStep({
|
||||
configs,
|
||||
onBack,
|
||||
onRestart,
|
||||
}: {
|
||||
configs: ClientConfig[];
|
||||
onBack: () => void;
|
||||
onRestart: () => void;
|
||||
}) {
|
||||
// Determine if we need custom paths (multiple clients with different save paths)
|
||||
const savePaths = configs.map((c) => c.savePath.replace(/\/+$/, ''));
|
||||
const uniqueSavePaths = [...new Set(savePaths)];
|
||||
const needsCustomPaths = configs.length > 1 && uniqueSavePaths.length > 1;
|
||||
|
||||
// Calculate RMAB download directory
|
||||
const rmabDownloadDir = needsCustomPaths ? findCommonRoot(savePaths) : savePaths[0];
|
||||
|
||||
// Calculate custom paths per client (only if needed)
|
||||
const clientCustomPaths = needsCustomPaths
|
||||
? configs.map((c) => ({
|
||||
type: c.type,
|
||||
customPath: getRelativePath(rmabDownloadDir, c.savePath.replace(/\/+$/, '')),
|
||||
}))
|
||||
: [];
|
||||
|
||||
// Calculate RMAB volume mapping
|
||||
// We need the host path that corresponds to the rmabDownloadDir
|
||||
// If all clients share the same save path, we use that client's host path directly.
|
||||
// If multiple different paths, we find the common host root.
|
||||
let rmabHostPath: string;
|
||||
let rmabContainerPath: string;
|
||||
|
||||
if (!needsCustomPaths) {
|
||||
// Single path scenario — use the first client's host path
|
||||
// But we need to consider if the container mount path differs from the save path
|
||||
const config = configs[0];
|
||||
const saveRelativeToMount = getRelativePath(
|
||||
config.containerMountPath.replace(/\/+$/, ''),
|
||||
config.savePath.replace(/\/+$/, '')
|
||||
);
|
||||
|
||||
if (saveRelativeToMount) {
|
||||
// Save path is deeper than the mount: host must include that extra depth
|
||||
rmabHostPath = config.hostPath.replace(/\/+$/, '') + '/' + saveRelativeToMount;
|
||||
} else {
|
||||
rmabHostPath = config.hostPath;
|
||||
}
|
||||
rmabContainerPath = rmabDownloadDir;
|
||||
} else {
|
||||
// Multiple different paths — we need to find the host root that covers all
|
||||
// For each client, compute the host path that corresponds to the common container root
|
||||
const hostRoots = configs.map((c) => {
|
||||
const mountRelativeToCommon = getRelativePath(
|
||||
rmabDownloadDir,
|
||||
c.containerMountPath.replace(/\/+$/, '')
|
||||
);
|
||||
const saveRelativeToMount = getRelativePath(
|
||||
c.containerMountPath.replace(/\/+$/, ''),
|
||||
c.savePath.replace(/\/+$/, '')
|
||||
);
|
||||
// The host path maps to containerMountPath. We need to go up if rmabDownloadDir
|
||||
// is a parent of the container mount path.
|
||||
const containerMountNorm = c.containerMountPath.replace(/\/+$/, '');
|
||||
const rmabDirNorm = rmabDownloadDir.replace(/\/+$/, '');
|
||||
|
||||
if (containerMountNorm === rmabDirNorm) {
|
||||
return c.hostPath.replace(/\/+$/, '');
|
||||
} else if (containerMountNorm.startsWith(rmabDirNorm + '/')) {
|
||||
// Container mount is deeper than RMAB dir — we need to go up on the host side
|
||||
const depth = containerMountNorm.slice(rmabDirNorm.length + 1).split('/').length;
|
||||
const hostSegments = c.hostPath.replace(/\/+$/, '').split('/');
|
||||
return hostSegments.slice(0, -depth).join('/') || '/';
|
||||
} else if (rmabDirNorm.startsWith(containerMountNorm + '/')) {
|
||||
// RMAB dir is deeper than container mount — append the extra to host
|
||||
const extra = rmabDirNorm.slice(containerMountNorm.length + 1);
|
||||
return c.hostPath.replace(/\/+$/, '') + '/' + extra;
|
||||
}
|
||||
return c.hostPath.replace(/\/+$/, '');
|
||||
});
|
||||
|
||||
rmabHostPath = findHostCommonRoot(
|
||||
configs.map((c, i) => ({ ...c, hostPath: hostRoots[i] }))
|
||||
);
|
||||
rmabContainerPath = rmabDownloadDir;
|
||||
}
|
||||
|
||||
// Build the RMAB compose snippet
|
||||
const composeSnippet = `services:
|
||||
readmeabook:
|
||||
volumes:
|
||||
- ${rmabHostPath}:${rmabContainerPath}
|
||||
# ... your other RMAB volumes (config, media, etc.)`;
|
||||
|
||||
// Build remote path mapping info
|
||||
const remoteClients = configs.filter((c) => c.remotePathMapping);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
Your recommended configuration
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-2">
|
||||
Based on your inputs, here's how to configure ReadMeABook and your download clients.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* RMAB Download Directory */}
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
1. RMAB Download Directory Setting
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Set this in RMAB's settings under <strong>Admin → Settings → Paths → Download Directory</strong>.
|
||||
</p>
|
||||
<CodeBlock label="Download Directory">{rmabDownloadDir}</CodeBlock>
|
||||
</div>
|
||||
|
||||
{/* Custom paths per client */}
|
||||
{needsCustomPaths && clientCustomPaths.some((c) => c.customPath) && (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
2. Client Custom Paths
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Since your clients save to different locations, set these custom paths on each download client
|
||||
in RMAB (<strong>Admin → Settings → Download Clients → Edit → Custom Path</strong>).
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{clientCustomPaths.map((c) => (
|
||||
<div key={c.type} className="flex items-center gap-3 bg-gray-50 dark:bg-gray-800/50 rounded-lg p-3 border border-gray-200 dark:border-gray-700">
|
||||
<span className="font-medium text-gray-900 dark:text-gray-100 min-w-[120px]">
|
||||
{CLIENT_DISPLAY_NAMES[c.type as DownloadClientType]}:
|
||||
</span>
|
||||
<code className="font-mono text-sm bg-gray-200 dark:bg-gray-700 px-2 py-1 rounded text-gray-800 dark:text-gray-200">
|
||||
{c.customPath || '(none — same as download directory)'}
|
||||
</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* RMAB Docker Compose Volume */}
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
{needsCustomPaths ? '3' : '2'}. RMAB Docker Compose Volume Mapping
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Add this volume mapping to your RMAB docker-compose.yml. This ensures RMAB can see the
|
||||
same files your download clients produce.
|
||||
</p>
|
||||
<CodeBlock label="docker-compose.yml">{composeSnippet}</CodeBlock>
|
||||
</div>
|
||||
|
||||
{/* Golden Rule explanation */}
|
||||
<WarningBox>
|
||||
<p className="font-semibold mb-1">The Golden Rule</p>
|
||||
<p>
|
||||
Both your download client and RMAB must see files at the <strong>same container path</strong>.
|
||||
The volume mapping above ensures that when your download client saves a file
|
||||
to <code className="bg-amber-100 dark:bg-amber-800 px-1 rounded">{configs[0]?.savePath}</code>,
|
||||
RMAB can also find it at that same path.
|
||||
</p>
|
||||
</WarningBox>
|
||||
|
||||
{/* Verification */}
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
{needsCustomPaths ? '4' : '3'}. Verify your setup
|
||||
</h3>
|
||||
<div className="bg-gray-50 dark:bg-gray-800/50 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
|
||||
<ul className="space-y-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
{configs.map((c) => (
|
||||
<li key={c.type} className="flex items-start gap-2">
|
||||
<span className="text-gray-400 mt-0.5">•</span>
|
||||
<span>
|
||||
<strong>{CLIENT_DISPLAY_NAMES[c.type]}</strong> saves
|
||||
to <code className="bg-gray-200 dark:bg-gray-700 px-1 rounded font-mono text-xs">{c.savePath}</code>
|
||||
{' '}→ host path <code className="bg-gray-200 dark:bg-gray-700 px-1 rounded font-mono text-xs">{c.hostPath}</code>
|
||||
{needsCustomPaths && (
|
||||
<>
|
||||
{' '}→ RMAB custom
|
||||
path: <code className="bg-gray-200 dark:bg-gray-700 px-1 rounded font-mono text-xs">
|
||||
{getRelativePath(rmabDownloadDir, c.savePath.replace(/\/+$/, '')) || '(none)'}
|
||||
</code>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-gray-400 mt-0.5">•</span>
|
||||
<span>
|
||||
<strong>RMAB</strong> mounts <code className="bg-gray-200 dark:bg-gray-700 px-1 rounded font-mono text-xs">{rmabHostPath}:{rmabContainerPath}</code>
|
||||
{' '}→ download directory set
|
||||
to <code className="bg-gray-200 dark:bg-gray-700 px-1 rounded font-mono text-xs">{rmabDownloadDir}</code>
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Remote Path Mapping */}
|
||||
{remoteClients.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
Remote Path Mapping
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
These clients run on a different machine. Configure remote path mapping for each in
|
||||
RMAB (<strong>Admin → Settings → Download Clients → Edit</strong>).
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
{remoteClients.map((c) => {
|
||||
const localPath = needsCustomPaths
|
||||
? rmabDownloadDir + '/' + getRelativePath(rmabDownloadDir, c.savePath.replace(/\/+$/, ''))
|
||||
: rmabDownloadDir;
|
||||
|
||||
return (
|
||||
<div key={c.type} className="bg-gray-50 dark:bg-gray-800/50 rounded-lg p-4 border border-gray-200 dark:border-gray-700 space-y-2">
|
||||
<div className="font-medium text-gray-900 dark:text-gray-100">
|
||||
{CLIENT_DISPLAY_NAMES[c.type]}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-500 dark:text-gray-400 block mb-1">Enable Remote Path Mapping:</span>
|
||||
<code className="bg-gray-200 dark:bg-gray-700 px-2 py-1 rounded font-mono text-gray-800 dark:text-gray-200">Yes</code>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500 dark:text-gray-400 block mb-1">Remote Path:</span>
|
||||
<code className="bg-gray-200 dark:bg-gray-700 px-2 py-1 rounded font-mono text-gray-800 dark:text-gray-200">{c.remotePath}</code>
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<span className="text-gray-500 dark:text-gray-400 block mb-1">Local Path:</span>
|
||||
<code className="bg-gray-200 dark:bg-gray-700 px-2 py-1 rounded font-mono text-gray-800 dark:text-gray-200">{localPath}</code>
|
||||
</div>
|
||||
</div>
|
||||
<InfoBox>
|
||||
<p>
|
||||
When this client reports a file at <code className="bg-blue-100 dark:bg-blue-800 px-1 rounded">{c.remotePath}/audiobook.m4b</code>,
|
||||
RMAB will translate it to <code className="bg-blue-100 dark:bg-blue-800 px-1 rounded">{localPath}/audiobook.m4b</code>.
|
||||
</p>
|
||||
</InfoBox>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-between pt-4">
|
||||
<Button onClick={onBack} variant="outline">
|
||||
Back
|
||||
</Button>
|
||||
<Button onClick={onRestart} variant="secondary">
|
||||
Start Over
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// MAIN PAGE
|
||||
// =========================================================================
|
||||
|
||||
export default function PathHelperPage() {
|
||||
const [step, setStep] = useState<Step>('clients');
|
||||
const [selectedClients, setSelectedClients] = useState<Set<DownloadClientType>>(new Set());
|
||||
const [clientConfigs, setClientConfigs] = useState<Map<DownloadClientType, ClientConfig>>(new Map());
|
||||
|
||||
// Build ordered configs array from selected clients
|
||||
const configs = useMemo(() => {
|
||||
return ALL_CLIENTS
|
||||
.filter((c) => selectedClients.has(c))
|
||||
.map((type) => {
|
||||
const existing = clientConfigs.get(type);
|
||||
return (
|
||||
existing || {
|
||||
type,
|
||||
savePath: DEFAULT_SAVE_PATHS[type],
|
||||
hostPath: '',
|
||||
containerMountPath: '',
|
||||
remotePathMapping: false,
|
||||
remotePath: '',
|
||||
}
|
||||
);
|
||||
});
|
||||
}, [selectedClients, clientConfigs]);
|
||||
|
||||
const toggleClient = (client: DownloadClientType) => {
|
||||
setSelectedClients((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(client)) {
|
||||
next.delete(client);
|
||||
} else {
|
||||
next.add(client);
|
||||
// Initialize config if not exists
|
||||
if (!clientConfigs.has(client)) {
|
||||
setClientConfigs((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(client, {
|
||||
type: client,
|
||||
savePath: DEFAULT_SAVE_PATHS[client],
|
||||
hostPath: '',
|
||||
containerMountPath: '',
|
||||
remotePathMapping: false,
|
||||
remotePath: '',
|
||||
});
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const updateConfig = (type: DownloadClientType, field: keyof ClientConfig, value: string | boolean) => {
|
||||
setClientConfigs((prev) => {
|
||||
const next = new Map(prev);
|
||||
const existing = next.get(type);
|
||||
if (existing) {
|
||||
next.set(type, { ...existing, [field]: value });
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const goToStep = (target: Step) => setStep(target);
|
||||
|
||||
const restart = () => {
|
||||
setStep('clients');
|
||||
setSelectedClients(new Set());
|
||||
setClientConfigs(new Map());
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
{/* Header */}
|
||||
<div className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 shadow-sm">
|
||||
<div className="container mx-auto px-4 py-6 max-w-4xl">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
Path Mapping Helper
|
||||
</h1>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
Get your download client volume mappings configured correctly for ReadMeABook
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step Indicator */}
|
||||
<div className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="container mx-auto px-2 sm:px-4 max-w-4xl">
|
||||
<StepIndicator currentStep={step} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="container mx-auto px-4 py-8 max-w-4xl">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-8">
|
||||
{step === 'clients' && (
|
||||
<ClientSelectionStep
|
||||
selectedClients={selectedClients}
|
||||
onToggle={toggleClient}
|
||||
onNext={() => goToStep('save-paths')}
|
||||
/>
|
||||
)}
|
||||
{step === 'save-paths' && (
|
||||
<SavePathsStep
|
||||
configs={configs}
|
||||
onUpdateConfig={updateConfig}
|
||||
onNext={() => goToStep('host-paths')}
|
||||
onBack={() => goToStep('clients')}
|
||||
/>
|
||||
)}
|
||||
{step === 'host-paths' && (
|
||||
<HostPathsStep
|
||||
configs={configs}
|
||||
onUpdateConfig={updateConfig}
|
||||
onNext={() => goToStep('results')}
|
||||
onBack={() => goToStep('save-paths')}
|
||||
/>
|
||||
)}
|
||||
{step === 'results' && (
|
||||
<ResultsStep
|
||||
configs={configs}
|
||||
onBack={() => goToStep('host-paths')}
|
||||
onRestart={restart}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -272,7 +272,7 @@ export function OIDCConfigStep({
|
||||
<ul className="text-sm text-blue-700 dark:text-blue-300 mt-1 space-y-1">
|
||||
<li>• The redirect URI will be: {typeof window !== 'undefined' ? `${window.location.origin}/api/auth/oidc/callback` : '[Your Domain]/api/auth/oidc/callback'}</li>
|
||||
<li>• Configure this redirect URI in your OIDC provider settings</li>
|
||||
<li>• Required scopes: openid, profile, email, groups</li>
|
||||
<li>• Required scopes: openid, profile, email (groups is added automatically when group-based access control is enabled)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,349 @@
|
||||
/**
|
||||
* Component: Bulk Import Wizard
|
||||
* Documentation: documentation/features/bulk-import.md
|
||||
*
|
||||
* Multi-step modal wizard for bulk importing audiobooks from server folders.
|
||||
* Step 1: Select root folder to scan.
|
||||
* Step 2: Scanning/matching progress.
|
||||
* Step 3: Review matches and start import.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React, { useState, useCallback, useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { XMarkIcon, FolderArrowDownIcon } from '@heroicons/react/24/outline';
|
||||
import { ScanFolderStep } from './bulk-import/ScanFolderStep';
|
||||
import { ScanProgressStep } from './bulk-import/ScanProgressStep';
|
||||
import { MatchReviewStep } from './bulk-import/MatchReviewStep';
|
||||
import { WizardStep, ScannedBook, ScanProgressEvent, MatchingProgressEvent } from './bulk-import/types';
|
||||
import { fetchWithAuth } from '@/lib/utils/api';
|
||||
|
||||
interface BulkImportWizardProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const STEP_LABELS: Record<WizardStep, string> = {
|
||||
select_folder: 'Select Folder',
|
||||
scanning: 'Scanning',
|
||||
review: 'Review & Import',
|
||||
};
|
||||
|
||||
const STEP_ORDER: WizardStep[] = ['select_folder', 'scanning', 'review'];
|
||||
|
||||
export function BulkImportWizard({ isOpen, onClose }: BulkImportWizardProps) {
|
||||
const [step, setStep] = useState<WizardStep>('select_folder');
|
||||
const [selectedRootPath, setSelectedRootPath] = useState<string | null>(null);
|
||||
|
||||
// Scanning state
|
||||
const [scanProgress, setScanProgress] = useState<ScanProgressEvent | null>(null);
|
||||
const [matchingProgress, setMatchingProgress] = useState<MatchingProgressEvent | null>(null);
|
||||
const [scanPhase, setScanPhase] = useState<'discovering' | 'matching' | 'idle'>('idle');
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
// Results state
|
||||
const [scannedBooks, setScannedBooks] = useState<ScannedBook[]>([]);
|
||||
const [scanError, setScanError] = useState<string | null>(null);
|
||||
|
||||
// Import state
|
||||
const [isImporting, setIsImporting] = useState(false);
|
||||
const [importResults, setImportResults] = useState<any>(null);
|
||||
|
||||
const resetWizard = useCallback(() => {
|
||||
setStep('select_folder');
|
||||
setSelectedRootPath(null);
|
||||
setScanProgress(null);
|
||||
setMatchingProgress(null);
|
||||
setScanPhase('idle');
|
||||
setScannedBooks([]);
|
||||
setScanError(null);
|
||||
setIsImporting(false);
|
||||
setImportResults(null);
|
||||
if (abortRef.current) {
|
||||
abortRef.current.abort();
|
||||
abortRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
if (abortRef.current) {
|
||||
abortRef.current.abort();
|
||||
abortRef.current = null;
|
||||
}
|
||||
resetWizard();
|
||||
onClose();
|
||||
}, [onClose, resetWizard]);
|
||||
|
||||
const handleFolderSelected = useCallback(async (rootPath: string) => {
|
||||
setSelectedRootPath(rootPath);
|
||||
setStep('scanning');
|
||||
setScanPhase('discovering');
|
||||
setScanError(null);
|
||||
setScannedBooks([]);
|
||||
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
try {
|
||||
const response = await fetchWithAuth('/api/admin/bulk-import/scan', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ rootPath }),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errData = await response.json().catch(() => ({ error: 'Scan failed' }));
|
||||
throw new Error(errData.error || 'Scan failed');
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) throw new Error('No response stream');
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
let eventType = '';
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
// Parse SSE events from buffer
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('event: ')) {
|
||||
eventType = line.slice(7).trim();
|
||||
} else if (line.startsWith('data: ') && eventType) {
|
||||
try {
|
||||
const data = JSON.parse(line.slice(6));
|
||||
handleSSEEvent(eventType, data);
|
||||
} catch {
|
||||
/* ignore parse errors */
|
||||
}
|
||||
eventType = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) return;
|
||||
setScanError(error instanceof Error ? error.message : 'Scan failed');
|
||||
setScanPhase('idle');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSSEEvent = useCallback((event: string, data: any) => {
|
||||
switch (event) {
|
||||
case 'progress':
|
||||
setScanProgress(data);
|
||||
break;
|
||||
|
||||
case 'discovery_complete':
|
||||
setScanPhase('matching');
|
||||
break;
|
||||
|
||||
case 'matching':
|
||||
setMatchingProgress(data);
|
||||
break;
|
||||
|
||||
case 'book_matched': {
|
||||
const book: ScannedBook = {
|
||||
...data,
|
||||
skipped: data.inLibrary || data.hasActiveRequest || data.match === null,
|
||||
};
|
||||
setScannedBooks((prev) => [...prev, book]);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'complete':
|
||||
setScanPhase('idle');
|
||||
setStep('review');
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
setScanError(data.message || 'Scan failed');
|
||||
setScanPhase('idle');
|
||||
break;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleCancelScan = useCallback(() => {
|
||||
if (abortRef.current) {
|
||||
abortRef.current.abort();
|
||||
abortRef.current = null;
|
||||
}
|
||||
setScanPhase('idle');
|
||||
setStep('select_folder');
|
||||
}, []);
|
||||
|
||||
const handleToggleSkip = useCallback((index: number) => {
|
||||
setScannedBooks((prev) =>
|
||||
prev.map((book) =>
|
||||
book.index === index ? { ...book, skipped: !book.skipped } : book
|
||||
)
|
||||
);
|
||||
}, []);
|
||||
|
||||
const handleStartImport = useCallback(async () => {
|
||||
const booksToImport = scannedBooks.filter(
|
||||
(b) => !b.skipped && b.match !== null
|
||||
);
|
||||
|
||||
if (booksToImport.length === 0) return;
|
||||
|
||||
setIsImporting(true);
|
||||
|
||||
try {
|
||||
const response = await fetchWithAuth('/api/admin/bulk-import/execute', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
imports: booksToImport.map((b) => ({
|
||||
folderPath: b.folderPath,
|
||||
asin: b.match!.asin,
|
||||
audioFiles: b.audioFiles,
|
||||
})),
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Import failed');
|
||||
}
|
||||
|
||||
setImportResults(data);
|
||||
} catch (error) {
|
||||
setImportResults({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Import failed',
|
||||
});
|
||||
} finally {
|
||||
setIsImporting(false);
|
||||
}
|
||||
}, [scannedBooks]);
|
||||
|
||||
const handleBackToFolderSelect = useCallback(() => {
|
||||
setStep('select_folder');
|
||||
setScanError(null);
|
||||
setScannedBooks([]);
|
||||
setScanPhase('idle');
|
||||
}, []);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const currentStepIndex = STEP_ORDER.indexOf(step);
|
||||
|
||||
const modalContent = (
|
||||
<div
|
||||
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
||||
style={{ height: '100dvh' }}
|
||||
onClick={handleClose}
|
||||
>
|
||||
<div
|
||||
className="relative w-full max-w-4xl bg-white dark:bg-gray-900 rounded-2xl shadow-2xl overflow-hidden flex flex-col"
|
||||
style={{ height: 'min(720px, 90vh)' }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-200 dark:border-gray-700/50">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<FolderArrowDownIcon className="w-5 h-5 text-blue-600 dark:text-blue-400" />
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
Bulk Import
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="p-1.5 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
<XMarkIcon className="w-5 h-5 text-gray-500 dark:text-gray-400" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Step Indicator */}
|
||||
<div className="flex items-center justify-center gap-2 px-5 py-3 bg-gray-50 dark:bg-gray-800/50 border-b border-gray-200 dark:border-gray-700/50">
|
||||
{STEP_ORDER.map((s, i) => (
|
||||
<React.Fragment key={s}>
|
||||
{i > 0 && (
|
||||
<div
|
||||
className={`w-8 h-px ${
|
||||
i <= currentStepIndex
|
||||
? 'bg-blue-400 dark:bg-blue-500'
|
||||
: 'bg-gray-300 dark:bg-gray-600'
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div
|
||||
className={`w-6 h-6 rounded-full flex items-center justify-center text-xs font-medium ${
|
||||
i < currentStepIndex
|
||||
? 'bg-blue-600 text-white'
|
||||
: i === currentStepIndex
|
||||
? 'bg-blue-600 text-white ring-2 ring-blue-200 dark:ring-blue-800'
|
||||
: 'bg-gray-200 dark:bg-gray-700 text-gray-500 dark:text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{i < currentStepIndex ? (
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
) : (
|
||||
i + 1
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={`text-xs font-medium hidden sm:inline ${
|
||||
i <= currentStepIndex
|
||||
? 'text-gray-900 dark:text-gray-100'
|
||||
: 'text-gray-400 dark:text-gray-500'
|
||||
}`}
|
||||
>
|
||||
{STEP_LABELS[s]}
|
||||
</span>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
{step === 'select_folder' && (
|
||||
<ScanFolderStep onFolderSelected={handleFolderSelected} />
|
||||
)}
|
||||
|
||||
{step === 'scanning' && (
|
||||
<ScanProgressStep
|
||||
scanProgress={scanProgress}
|
||||
matchingProgress={matchingProgress}
|
||||
scanPhase={scanPhase}
|
||||
error={scanError}
|
||||
booksFound={scannedBooks.length}
|
||||
onCancel={handleCancelScan}
|
||||
onRetry={() => selectedRootPath && handleFolderSelected(selectedRootPath)}
|
||||
onBack={handleBackToFolderSelect}
|
||||
/>
|
||||
)}
|
||||
|
||||
{step === 'review' && (
|
||||
<MatchReviewStep
|
||||
books={scannedBooks}
|
||||
onToggleSkip={handleToggleSkip}
|
||||
onStartImport={handleStartImport}
|
||||
isImporting={isImporting}
|
||||
importResults={importResults}
|
||||
onClose={handleClose}
|
||||
onBack={handleBackToFolderSelect}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return createPortal(modalContent, document.body);
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
/**
|
||||
* Component: Bulk Import - Match Review Step
|
||||
* Documentation: documentation/features/bulk-import.md
|
||||
*
|
||||
* Scrollable list of discovered audiobooks with Audible matches,
|
||||
* skip toggles, library status badges, and import controls.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
CheckCircleIcon,
|
||||
ExclamationTriangleIcon,
|
||||
MusicalNoteIcon,
|
||||
XCircleIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { CheckCircleIcon as CheckCircleSolid } from '@heroicons/react/24/solid';
|
||||
import { ScannedBook, formatBytes } from './types';
|
||||
|
||||
interface MatchReviewStepProps {
|
||||
books: ScannedBook[];
|
||||
onToggleSkip: (index: number) => void;
|
||||
onStartImport: () => void;
|
||||
isImporting: boolean;
|
||||
importResults: any;
|
||||
onClose: () => void;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
function BookRow({
|
||||
book,
|
||||
onToggleSkip,
|
||||
}: {
|
||||
book: ScannedBook;
|
||||
onToggleSkip: () => void;
|
||||
}) {
|
||||
const isDisabled = book.inLibrary || book.hasActiveRequest;
|
||||
const isSkipped = book.skipped;
|
||||
const hasMatch = book.match !== null;
|
||||
// Low confidence when search term came from a filename or folder name fallback,
|
||||
// BUT not when an ASIN was extracted directly from the folder name (that's a
|
||||
// direct lookup and is as reliable as embedded metadata tags).
|
||||
const isLowConfidence =
|
||||
(book.metadataSource === 'file_name' || book.metadataSource === 'folder_name') &&
|
||||
!book.extractedAsin;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center gap-3 px-4 py-3 transition-opacity ${
|
||||
isSkipped ? 'opacity-40' : ''
|
||||
}`}
|
||||
>
|
||||
{/* Cover Art */}
|
||||
<div className="flex-shrink-0 w-12 h-12 rounded-lg overflow-hidden bg-gray-100 dark:bg-gray-800">
|
||||
{hasMatch && book.match!.coverArtUrl ? (
|
||||
/* eslint-disable-next-line @next/next/no-img-element */
|
||||
<img
|
||||
src={book.match!.coverArtUrl}
|
||||
alt={book.match!.title}
|
||||
className="w-12 h-12 object-cover"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).src = '/placeholder_cover.svg';
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-12 h-12 flex items-center justify-center">
|
||||
<MusicalNoteIcon className="w-6 h-6 text-gray-400 dark:text-gray-600" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Book Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{hasMatch ? (
|
||||
<>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100 truncate">
|
||||
{book.match!.title}
|
||||
</p>
|
||||
{isLowConfidence && (
|
||||
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300 flex-shrink-0">
|
||||
Low Confidence
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400 truncate">
|
||||
{book.match!.author}
|
||||
{book.match!.narrator && (
|
||||
<span className="text-gray-400 dark:text-gray-500">
|
||||
{' '}· {book.match!.narrator}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100 truncate">
|
||||
{book.folderName}
|
||||
</p>
|
||||
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 flex-shrink-0">
|
||||
No Match
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 italic">
|
||||
Could not find this title on Audible
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
<p className="text-[11px] text-gray-400 dark:text-gray-500 font-mono truncate mt-0.5">
|
||||
{book.relativePath}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Badges */}
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{/* Audio file count */}
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 text-xs font-medium">
|
||||
<MusicalNoteIcon className="w-3 h-3" />
|
||||
{book.audioFileCount}
|
||||
</span>
|
||||
|
||||
{/* Status badges */}
|
||||
{book.inLibrary && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300 text-xs font-medium">
|
||||
<CheckCircleSolid className="w-3 h-3" />
|
||||
In Library
|
||||
</span>
|
||||
)}
|
||||
{book.hasActiveRequest && !book.inLibrary && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 text-xs font-medium">
|
||||
Requested
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Skip Toggle */}
|
||||
<button
|
||||
onClick={onToggleSkip}
|
||||
disabled={isDisabled}
|
||||
className={`flex-shrink-0 relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900 ${
|
||||
isDisabled
|
||||
? 'cursor-not-allowed opacity-50'
|
||||
: 'cursor-pointer'
|
||||
} ${
|
||||
isSkipped
|
||||
? 'bg-gray-200 dark:bg-gray-700'
|
||||
: 'bg-blue-600'
|
||||
}`}
|
||||
title={
|
||||
isDisabled
|
||||
? book.inLibrary
|
||||
? 'Already in your library'
|
||||
: 'Already requested'
|
||||
: isSkipped
|
||||
? 'Click to include in import'
|
||||
: 'Click to skip this book'
|
||||
}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white shadow-sm transition-transform ${
|
||||
isSkipped ? 'translate-x-1' : 'translate-x-6'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MatchReviewStep({
|
||||
books,
|
||||
onToggleSkip,
|
||||
onStartImport,
|
||||
isImporting,
|
||||
importResults,
|
||||
onClose,
|
||||
onBack,
|
||||
}: MatchReviewStepProps) {
|
||||
const toImport = books.filter((b) => !b.skipped && b.match !== null);
|
||||
const skippedCount = books.filter((b) => b.skipped).length;
|
||||
const inLibraryCount = books.filter((b) => b.inLibrary).length;
|
||||
const noMatchCount = books.filter((b) => b.match === null).length;
|
||||
const matchedCount = books.filter((b) => b.match !== null).length;
|
||||
|
||||
// Import completed state
|
||||
if (importResults) {
|
||||
const succeeded = importResults.summary?.succeeded || 0;
|
||||
const failed = importResults.summary?.failed || 0;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full px-6 py-16">
|
||||
{importResults.success !== false ? (
|
||||
<>
|
||||
<CheckCircleSolid className="w-14 h-14 text-green-500 mb-4" />
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">
|
||||
Import Started
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 text-center mb-2">
|
||||
{succeeded} audiobook{succeeded !== 1 ? 's' : ''} queued for import.
|
||||
</p>
|
||||
{failed > 0 && (
|
||||
<p className="text-sm text-amber-600 dark:text-amber-400 text-center mb-2">
|
||||
{failed} book{failed !== 1 ? 's' : ''} could not be queued.
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 text-center max-w-sm">
|
||||
Files will be organized, tagged, and imported into your library. Check the admin
|
||||
dashboard for progress.
|
||||
</p>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="mt-6 px-6 py-2.5 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-xl transition-colors"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<XCircleIcon className="w-14 h-14 text-red-500 mb-4" />
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">
|
||||
Import Failed
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 text-center mb-6">
|
||||
{importResults.error || 'An unexpected error occurred'}
|
||||
</p>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-6 py-2.5 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 text-gray-900 dark:text-gray-100 text-sm font-medium rounded-xl transition-colors"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Empty state (no audiobooks found)
|
||||
if (books.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full px-6 py-16">
|
||||
<ExclamationTriangleIcon className="w-12 h-12 text-gray-300 dark:text-gray-600 mb-4" />
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">
|
||||
No Audiobooks Found
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 text-center max-w-sm mb-6">
|
||||
The selected folder does not contain any folders with audio files. Try selecting a
|
||||
different folder.
|
||||
</p>
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-xl transition-colors"
|
||||
>
|
||||
<ArrowLeftIcon className="w-4 h-4" />
|
||||
Select Different Folder
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Summary header */}
|
||||
<div className="px-5 py-3 bg-gray-50 dark:bg-gray-800/50 border-b border-gray-200 dark:border-gray-700/50">
|
||||
<div className="flex items-center gap-4 text-xs">
|
||||
<span className="text-gray-500 dark:text-gray-400">
|
||||
<span className="font-semibold text-gray-900 dark:text-gray-100">{books.length}</span> discovered
|
||||
</span>
|
||||
<span className="text-gray-300 dark:text-gray-600">·</span>
|
||||
<span className="text-gray-500 dark:text-gray-400">
|
||||
<span className="font-semibold text-blue-600 dark:text-blue-400">{matchedCount}</span> matched
|
||||
</span>
|
||||
{noMatchCount > 0 && (
|
||||
<>
|
||||
<span className="text-gray-300 dark:text-gray-600">·</span>
|
||||
<span className="text-gray-500 dark:text-gray-400">
|
||||
<span className="font-semibold text-red-600 dark:text-red-400">{noMatchCount}</span> unmatched
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{inLibraryCount > 0 && (
|
||||
<>
|
||||
<span className="text-gray-300 dark:text-gray-600">·</span>
|
||||
<span className="text-gray-500 dark:text-gray-400">
|
||||
<span className="font-semibold text-green-600 dark:text-green-400">{inLibraryCount}</span> in library
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scrollable book list */}
|
||||
<div className="flex-1 overflow-y-auto divide-y divide-gray-100 dark:divide-gray-800">
|
||||
{books.map((book) => (
|
||||
<BookRow
|
||||
key={book.index}
|
||||
book={book}
|
||||
onToggleSkip={() => onToggleSkip(book.index)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Import footer */}
|
||||
<div className="px-5 py-3.5 border-t border-gray-200 dark:border-gray-700/50 bg-gray-50/50 dark:bg-gray-800/30 flex items-center justify-between gap-4">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 transition-colors"
|
||||
>
|
||||
<ArrowLeftIcon className="w-4 h-4" />
|
||||
Back
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">
|
||||
<span className="font-semibold text-gray-900 dark:text-gray-100">
|
||||
{toImport.length}
|
||||
</span>{' '}
|
||||
book{toImport.length !== 1 ? 's' : ''} to import
|
||||
{skippedCount > 0 && (
|
||||
<span className="text-gray-400 dark:text-gray-500">
|
||||
{' '}({skippedCount} skipped)
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
<button
|
||||
onClick={onStartImport}
|
||||
disabled={toImport.length === 0 || isImporting}
|
||||
className="flex items-center gap-2 px-5 py-2.5 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 disabled:cursor-not-allowed text-white text-sm font-medium rounded-xl transition-colors"
|
||||
>
|
||||
{isImporting ? (
|
||||
<>
|
||||
<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>
|
||||
Importing...
|
||||
</>
|
||||
) : (
|
||||
<>Start Import</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
/**
|
||||
* Component: Bulk Import - Folder Selection Step
|
||||
* Documentation: documentation/features/bulk-import.md
|
||||
*
|
||||
* Filesystem browser for selecting a root folder to scan for audiobooks.
|
||||
* Adapted from the manual import BrowsePhase patterns.
|
||||
* Any folder is selectable (not just audio-containing folders).
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
FolderIcon,
|
||||
FolderOpenIcon,
|
||||
FolderArrowDownIcon,
|
||||
InboxArrowDownIcon,
|
||||
HomeIcon,
|
||||
ChevronRightIcon,
|
||||
ArrowLeftIcon,
|
||||
ExclamationTriangleIcon,
|
||||
ArrowPathIcon,
|
||||
MagnifyingGlassIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { fetchWithAuth } from '@/lib/utils/api';
|
||||
import { RootEntry, DirectoryEntry } from './types';
|
||||
|
||||
function SkeletonRow() {
|
||||
return (
|
||||
<div className="flex items-center gap-3 px-4 py-3 animate-pulse">
|
||||
<div className="w-5 h-5 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-48" />
|
||||
<div className="h-3 bg-gray-100 dark:bg-gray-800 rounded w-32" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ScanFolderStepProps {
|
||||
onFolderSelected: (rootPath: string) => void;
|
||||
}
|
||||
|
||||
export function ScanFolderStep({ onFolderSelected }: ScanFolderStepProps) {
|
||||
const [roots, setRoots] = useState<RootEntry[]>([]);
|
||||
const [currentPath, setCurrentPath] = useState<string | null>(null);
|
||||
const [entries, setEntries] = useState<DirectoryEntry[]>([]);
|
||||
const [pathHistory, setPathHistory] = useState<string[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [hoveredFolder, setHoveredFolder] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchRoots();
|
||||
}, []);
|
||||
|
||||
const fetchRoots = async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetchWithAuth('/api/admin/filesystem/browse');
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({ error: 'Failed to load' }));
|
||||
throw new Error(data.error || 'Failed to load directories');
|
||||
}
|
||||
const data = await res.json();
|
||||
setRoots(data.roots || []);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load directories');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchDirectory = useCallback(async (dirPath: string) => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetchWithAuth(
|
||||
`/api/admin/filesystem/browse?path=${encodeURIComponent(dirPath)}`
|
||||
);
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({ error: 'Failed to load' }));
|
||||
throw new Error(data.error || 'Failed to browse directory');
|
||||
}
|
||||
const data = await res.json();
|
||||
setEntries(data.entries || []);
|
||||
setCurrentPath(data.path || dirPath);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to browse directory');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const navigateInto = (dirPath: string) => {
|
||||
if (currentPath) {
|
||||
setPathHistory((prev) => [...prev, currentPath]);
|
||||
}
|
||||
fetchDirectory(dirPath);
|
||||
};
|
||||
|
||||
const navigateBack = () => {
|
||||
if (pathHistory.length > 0) {
|
||||
const prevPath = pathHistory[pathHistory.length - 1];
|
||||
setPathHistory((prev) => prev.slice(0, -1));
|
||||
fetchDirectory(prevPath);
|
||||
} else {
|
||||
setCurrentPath(null);
|
||||
setEntries([]);
|
||||
}
|
||||
};
|
||||
|
||||
const navigateToRoot = () => {
|
||||
setCurrentPath(null);
|
||||
setEntries([]);
|
||||
setPathHistory([]);
|
||||
};
|
||||
|
||||
const navigateToBreadcrumb = (index: number) => {
|
||||
if (!currentPath) return;
|
||||
const allPaths = [...pathHistory, currentPath];
|
||||
const targetPath = allPaths[index];
|
||||
if (targetPath) {
|
||||
setPathHistory(allPaths.slice(0, index));
|
||||
fetchDirectory(targetPath);
|
||||
} else {
|
||||
navigateToRoot();
|
||||
}
|
||||
};
|
||||
|
||||
// Build breadcrumb segments
|
||||
const breadcrumbs = (() => {
|
||||
if (!currentPath) return [];
|
||||
const allPaths = [...pathHistory, currentPath];
|
||||
return allPaths.map((p) => {
|
||||
const parts = p.replace(/\\/g, '/').split('/');
|
||||
return parts[parts.length - 1] || p;
|
||||
});
|
||||
})();
|
||||
|
||||
const visibleBreadcrumbs = (() => {
|
||||
if (breadcrumbs.length <= 3) return breadcrumbs.map((b, i) => ({ label: b, index: i }));
|
||||
return [
|
||||
{ label: breadcrumbs[0], index: 0 },
|
||||
{ label: '...', index: -1 },
|
||||
{ label: breadcrumbs[breadcrumbs.length - 1], index: breadcrumbs.length - 1 },
|
||||
];
|
||||
})();
|
||||
|
||||
// Count subfolders in current listing
|
||||
const totalSubfolders = entries.length;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Breadcrumb bar */}
|
||||
{currentPath && (
|
||||
<div className="flex items-center gap-1 px-5 py-2.5 bg-gray-50 dark:bg-gray-800/50 border-b border-gray-100 dark:border-gray-800 text-sm overflow-x-auto">
|
||||
<button
|
||||
onClick={navigateToRoot}
|
||||
className="flex-shrink-0 p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
<HomeIcon className="w-4 h-4 text-gray-500 dark:text-gray-400" />
|
||||
</button>
|
||||
{visibleBreadcrumbs.map((crumb, i) => (
|
||||
<React.Fragment key={i}>
|
||||
<ChevronRightIcon className="w-3.5 h-3.5 text-gray-400 flex-shrink-0" />
|
||||
{crumb.index === -1 ? (
|
||||
<span className="text-gray-400 px-1">...</span>
|
||||
) : i === visibleBreadcrumbs.length - 1 ? (
|
||||
<span className="font-medium text-gray-900 dark:text-gray-100 truncate">
|
||||
{crumb.label}
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => navigateToBreadcrumb(crumb.index)}
|
||||
className="text-gray-600 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 truncate transition-colors"
|
||||
>
|
||||
{crumb.label}
|
||||
</button>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Listing */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{/* Loading */}
|
||||
{isLoading && (
|
||||
<div className="py-2">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<SkeletonRow key={i} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && !isLoading && (
|
||||
<div className="flex flex-col items-center justify-center py-16 px-6">
|
||||
<ExclamationTriangleIcon className="w-10 h-10 text-red-400 mb-3" />
|
||||
<p className="text-gray-900 dark:text-gray-100 font-medium text-center">{error}</p>
|
||||
<button
|
||||
onClick={currentPath ? () => fetchDirectory(currentPath) : fetchRoots}
|
||||
className="mt-4 flex items-center gap-2 px-4 py-2 text-sm font-medium text-blue-600 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded-lg transition-colors"
|
||||
>
|
||||
<ArrowPathIcon className="w-4 h-4" />
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Root view */}
|
||||
{!currentPath && !isLoading && !error && (
|
||||
<div className="p-5">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">
|
||||
Select a folder to scan for audiobooks. All subfolders will be searched recursively.
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{roots.map((root) => (
|
||||
<button
|
||||
key={root.path}
|
||||
onClick={() => navigateInto(root.path)}
|
||||
className="flex flex-col items-center gap-3 p-6 rounded-xl border border-gray-200 dark:border-gray-700 hover:border-blue-300 dark:hover:border-blue-700 hover:bg-blue-50/50 dark:hover:bg-blue-900/10 transition-all group"
|
||||
>
|
||||
{root.icon === 'download' ? (
|
||||
<FolderArrowDownIcon className="w-10 h-10 text-blue-500 group-hover:text-blue-600 transition-colors" />
|
||||
) : root.icon === 'bookdrop' ? (
|
||||
<InboxArrowDownIcon className="w-10 h-10 text-amber-500 group-hover:text-amber-600 transition-colors" />
|
||||
) : (
|
||||
<FolderIcon className="w-10 h-10 text-emerald-500 group-hover:text-emerald-600 transition-colors" />
|
||||
)}
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{root.name}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 font-mono truncate max-w-full">
|
||||
{root.path}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Directory listing */}
|
||||
{currentPath && !isLoading && !error && entries.length > 0 && (
|
||||
<div className="divide-y divide-gray-100 dark:divide-gray-800">
|
||||
{entries.map((entry) => {
|
||||
const isHovered = hoveredFolder === entry.name;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={`dir-${entry.name}`}
|
||||
onClick={() => navigateInto(currentPath + '/' + entry.name)}
|
||||
onMouseEnter={() => setHoveredFolder(entry.name)}
|
||||
onMouseLeave={() => setHoveredFolder(null)}
|
||||
className="w-full flex items-center gap-3 px-4 py-3 text-left transition-all duration-150 hover:bg-gray-50 dark:hover:bg-gray-800/50"
|
||||
>
|
||||
<div className="flex-shrink-0 w-5 h-5 text-gray-400 dark:text-gray-500 transition-all duration-150">
|
||||
{isHovered ? (
|
||||
<FolderOpenIcon className="w-5 h-5 text-blue-500" />
|
||||
) : (
|
||||
<FolderIcon className="w-5 h-5" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="flex-1 min-w-0 text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
|
||||
{entry.name}
|
||||
</p>
|
||||
|
||||
<ChevronRightIcon className="w-4 h-4 text-gray-300 dark:text-gray-600 flex-shrink-0" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{currentPath && !isLoading && !error && entries.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-16 px-6 text-center">
|
||||
<FolderOpenIcon className="w-10 h-10 text-gray-300 dark:text-gray-600 mb-3" />
|
||||
<p className="text-gray-500 dark:text-gray-400 font-medium">This folder is empty</p>
|
||||
<button
|
||||
onClick={navigateBack}
|
||||
className="mt-4 flex items-center gap-2 text-sm text-blue-600 dark:text-blue-400 hover:underline"
|
||||
>
|
||||
<ArrowLeftIcon className="w-4 h-4" />
|
||||
Go back
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer: Scan this folder */}
|
||||
{currentPath && !isLoading && (
|
||||
<div className="px-5 py-3.5 border-t border-gray-200 dark:border-gray-700/50 bg-gray-50/50 dark:bg-gray-800/30 flex items-center justify-between gap-4">
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400 min-w-0">
|
||||
<p className="font-mono text-xs text-gray-500 dark:text-gray-500 truncate">{currentPath}</p>
|
||||
{entries.length > 0 && (
|
||||
<p className="mt-0.5">
|
||||
{totalSubfolders} subfolder{totalSubfolders !== 1 ? 's' : ''}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onFolderSelected(currentPath)}
|
||||
className="flex-shrink-0 flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-xl transition-colors"
|
||||
>
|
||||
<MagnifyingGlassIcon className="w-4 h-4" />
|
||||
Scan for Audiobooks
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* Component: Bulk Import - Scan Progress Step
|
||||
* Documentation: documentation/features/bulk-import.md
|
||||
*
|
||||
* Displays progress during folder discovery and Audible matching phases.
|
||||
* Shows animated indicators, counts, and cancel/retry controls.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
FolderIcon,
|
||||
ExclamationTriangleIcon,
|
||||
ArrowPathIcon,
|
||||
ArrowLeftIcon,
|
||||
XMarkIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { ScanProgressEvent, MatchingProgressEvent } from './types';
|
||||
|
||||
interface ScanProgressStepProps {
|
||||
scanProgress: ScanProgressEvent | null;
|
||||
matchingProgress: MatchingProgressEvent | null;
|
||||
scanPhase: 'discovering' | 'matching' | 'idle';
|
||||
error: string | null;
|
||||
booksFound: number;
|
||||
onCancel: () => void;
|
||||
onRetry: () => void;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
export function ScanProgressStep({
|
||||
scanProgress,
|
||||
matchingProgress,
|
||||
scanPhase,
|
||||
error,
|
||||
booksFound,
|
||||
onCancel,
|
||||
onRetry,
|
||||
onBack,
|
||||
}: ScanProgressStepProps) {
|
||||
// Error state
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full px-6 py-16">
|
||||
<ExclamationTriangleIcon className="w-12 h-12 text-red-400 mb-4" />
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">
|
||||
Scan Failed
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 text-center max-w-md mb-6">
|
||||
{error}
|
||||
</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-xl transition-colors"
|
||||
>
|
||||
<ArrowLeftIcon className="w-4 h-4" />
|
||||
Go Back
|
||||
</button>
|
||||
<button
|
||||
onClick={onRetry}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-xl transition-colors"
|
||||
>
|
||||
<ArrowPathIcon className="w-4 h-4" />
|
||||
Retry Scan
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const matchPercent = matchingProgress
|
||||
? Math.round((matchingProgress.current / matchingProgress.total) * 100)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full px-6 py-16">
|
||||
{/* Animated icon */}
|
||||
<div className="relative mb-6">
|
||||
<div className="w-16 h-16 rounded-full border-4 border-blue-200 dark:border-blue-800 flex items-center justify-center">
|
||||
<FolderIcon className="w-8 h-8 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div className="absolute inset-0 w-16 h-16 rounded-full border-4 border-transparent border-t-blue-600 dark:border-t-blue-400 animate-spin" />
|
||||
</div>
|
||||
|
||||
{/* Phase-specific content */}
|
||||
{scanPhase === 'discovering' && (
|
||||
<>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">
|
||||
Scanning Folders
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 text-center mb-4">
|
||||
Searching for folders containing audiobook files...
|
||||
</p>
|
||||
|
||||
{scanProgress && (
|
||||
<div className="flex items-center gap-6 text-sm">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{scanProgress.foldersScanned}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Folders Scanned
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-px h-8 bg-gray-200 dark:bg-gray-700" />
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-blue-600 dark:text-blue-400">
|
||||
{scanProgress.audiobooksFound}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Audiobooks Found
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{scanProgress?.currentFolder && (
|
||||
<p className="mt-4 text-xs text-gray-400 dark:text-gray-500 font-mono truncate max-w-md">
|
||||
{scanProgress.currentFolder}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{scanPhase === 'matching' && (
|
||||
<>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">
|
||||
Matching Against Audible
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 text-center mb-6">
|
||||
Searching Audible for each discovered audiobook...
|
||||
</p>
|
||||
|
||||
{matchingProgress && (
|
||||
<>
|
||||
{/* Progress bar */}
|
||||
<div className="w-full max-w-sm mb-3">
|
||||
<div className="h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-blue-600 dark:bg-blue-500 rounded-full transition-all duration-500"
|
||||
style={{ width: `${matchPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-gray-700 dark:text-gray-300 font-medium">
|
||||
{matchingProgress.current} / {matchingProgress.total}
|
||||
</div>
|
||||
|
||||
{matchingProgress.folderName && (
|
||||
<p className="mt-2 text-xs text-gray-400 dark:text-gray-500 truncate max-w-md">
|
||||
{matchingProgress.folderName}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Books matched so far count */}
|
||||
{booksFound > 0 && (
|
||||
<p className="mt-4 text-xs text-gray-500 dark:text-gray-400">
|
||||
{booksFound} book{booksFound !== 1 ? 's' : ''} matched so far
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Cancel button */}
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="mt-8 flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-xl transition-colors"
|
||||
>
|
||||
<XMarkIcon className="w-4 h-4" />
|
||||
Cancel Scan
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Component: Bulk Import Shared Types
|
||||
* Documentation: documentation/features/bulk-import.md
|
||||
*/
|
||||
|
||||
/** Root directory entry from the filesystem browse API. */
|
||||
export interface RootEntry {
|
||||
name: string;
|
||||
path: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
/** Directory entry from the filesystem browse API. */
|
||||
export interface DirectoryEntry {
|
||||
name: string;
|
||||
type: 'directory';
|
||||
}
|
||||
|
||||
/** Audible match data for a discovered audiobook. */
|
||||
export interface AudibleMatch {
|
||||
asin: string;
|
||||
title: string;
|
||||
author: string;
|
||||
narrator?: string;
|
||||
coverArtUrl?: string;
|
||||
durationMinutes?: number;
|
||||
}
|
||||
|
||||
/** A scanned audiobook result with its Audible match status. */
|
||||
export interface ScannedBook {
|
||||
index: number;
|
||||
folderPath: string;
|
||||
folderName: string;
|
||||
relativePath: string;
|
||||
audioFileCount: number;
|
||||
totalSizeBytes: number;
|
||||
metadataSource: 'tags' | 'folder_name' | 'file_name';
|
||||
/** ASIN extracted directly from the folder name, if present. */
|
||||
extractedAsin?: string;
|
||||
searchTerm: string;
|
||||
audioFiles: string[];
|
||||
match: AudibleMatch | null;
|
||||
inLibrary: boolean;
|
||||
hasActiveRequest: boolean;
|
||||
/** User toggle: true = skip this book during import. */
|
||||
skipped: boolean;
|
||||
}
|
||||
|
||||
/** Progress event from the SSE scan stream. */
|
||||
export interface ScanProgressEvent {
|
||||
phase: 'discovering' | 'reading_metadata' | 'grouping';
|
||||
foldersScanned: number;
|
||||
audiobooksFound: number;
|
||||
currentFolder?: string;
|
||||
}
|
||||
|
||||
/** Matching progress event from the SSE scan stream. */
|
||||
export interface MatchingProgressEvent {
|
||||
current: number;
|
||||
total: number;
|
||||
folderName: string;
|
||||
searchTerm: string;
|
||||
}
|
||||
|
||||
/** Discovery complete event from the SSE scan stream. */
|
||||
export interface DiscoveryCompleteEvent {
|
||||
totalFound: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/** Wizard step identifiers. */
|
||||
export type WizardStep = 'select_folder' | 'scanning' | 'review';
|
||||
|
||||
/** Format bytes into a human-readable string. */
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
'use client';
|
||||
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { useToast } from '@/components/ui/Toast';
|
||||
|
||||
interface UserPermissionsUser {
|
||||
id: string;
|
||||
@@ -16,6 +17,7 @@ interface UserPermissionsUser {
|
||||
autoApproveRequests: boolean | null;
|
||||
interactiveSearchAccess: boolean | null;
|
||||
downloadAccess: boolean | null;
|
||||
hasLoginToken: boolean;
|
||||
}
|
||||
|
||||
interface UserPermissionsModalProps {
|
||||
@@ -25,9 +27,11 @@ interface UserPermissionsModalProps {
|
||||
globalAutoApprove: boolean;
|
||||
globalInteractiveSearch: boolean;
|
||||
globalDownloadAccess: boolean;
|
||||
generatedToken: string | null;
|
||||
onToggleAutoApprove: (user: UserPermissionsUser, newValue: boolean) => void;
|
||||
onToggleInteractiveSearch: (user: UserPermissionsUser, newValue: boolean) => void;
|
||||
onToggleDownloadAccess: (user: UserPermissionsUser, newValue: boolean) => void;
|
||||
onToggleToken: (user: UserPermissionsUser, newValue: boolean) => void;
|
||||
}
|
||||
|
||||
interface PermissionToggleProps {
|
||||
@@ -83,6 +87,79 @@ function PermissionToggle({ label, ariaLabel, value, disabled, disabledMessage,
|
||||
);
|
||||
}
|
||||
|
||||
interface LoginTokenRowProps {
|
||||
value: boolean;
|
||||
generatedToken: string | null;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
function LoginTokenRow({ value, generatedToken, onToggle }: LoginTokenRowProps) {
|
||||
const toast = useToast();
|
||||
const loginUrl = generatedToken
|
||||
? `${typeof window !== 'undefined' ? window.location.origin : ''}/auth/token/login?token=${generatedToken}`
|
||||
: null;
|
||||
|
||||
const copyUrl = async () => {
|
||||
if (!loginUrl) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(loginUrl);
|
||||
} catch {
|
||||
toast.error('Failed to copy to clipboard');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 p-3 border border-gray-200 dark:border-gray-700 rounded-lg">
|
||||
<div className="flex items-start gap-4">
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="relative inline-flex h-5 w-10 flex-shrink-0 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800 mt-0.5"
|
||||
style={{ backgroundColor: value ? '#3b82f6' : '#d1d5db' }}
|
||||
role="switch"
|
||||
aria-checked={value}
|
||||
aria-label="Login Token"
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-3 w-3 transform rounded-full bg-white transition-transform ${
|
||||
value ? 'translate-x-6' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
Login Token
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
When enabled, this user can log in via a direct URL without credentials
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loginUrl && (
|
||||
<div className="mt-1 p-2 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-700 rounded-md">
|
||||
<p className="text-xs font-medium text-amber-800 dark:text-amber-300 mb-1">
|
||||
Copy the login URL - it won't be shown again
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 text-xs font-mono text-amber-900 dark:text-amber-200 break-all select-all">
|
||||
{loginUrl}
|
||||
</code>
|
||||
<button
|
||||
onClick={copyUrl}
|
||||
className="flex-shrink-0 p-1.5 rounded text-amber-700 dark:text-amber-300 hover:bg-amber-100 dark:hover:bg-amber-800/50 transition-colors"
|
||||
aria-label="Copy login URL"
|
||||
>
|
||||
<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>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function UserPermissionsModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
@@ -90,9 +167,11 @@ export function UserPermissionsModal({
|
||||
globalAutoApprove,
|
||||
globalInteractiveSearch,
|
||||
globalDownloadAccess,
|
||||
generatedToken,
|
||||
onToggleAutoApprove,
|
||||
onToggleInteractiveSearch,
|
||||
onToggleDownloadAccess,
|
||||
onToggleToken,
|
||||
}: UserPermissionsModalProps) {
|
||||
if (!user) return null;
|
||||
|
||||
@@ -201,6 +280,13 @@ export function UserPermissionsModal({
|
||||
description="When enabled, this user can download audiobook files directly"
|
||||
onToggle={() => onToggleDownloadAccess(user, !downloadValue)}
|
||||
/>
|
||||
|
||||
{/* Login Token */}
|
||||
<LoginTokenRow
|
||||
value={user.hasLoginToken || generatedToken !== null}
|
||||
generatedToken={generatedToken}
|
||||
onToggle={() => onToggleToken(user, !(user.hasLoginToken || generatedToken !== null))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -59,13 +59,15 @@ export function AudiobookCard({
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [localRequestStatus, setLocalRequestStatus] = useState<string | undefined>(undefined);
|
||||
const [localIsIgnored, setLocalIsIgnored] = useState<boolean | undefined>(undefined);
|
||||
const [coverError, setCoverError] = useState(false);
|
||||
|
||||
// Build a display-only audiobook with the local status override
|
||||
// Build a display-only audiobook with local overrides
|
||||
const displayAudiobook = localRequestStatus !== undefined
|
||||
? { ...audiobook, requestStatus: localRequestStatus }
|
||||
: audiobook;
|
||||
const status = getStatusConfig(displayAudiobook);
|
||||
const isIgnored = localIsIgnored !== undefined ? localIsIgnored : audiobook.isIgnored;
|
||||
|
||||
const handleRequest = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
@@ -218,6 +220,19 @@ export function AudiobookCard({
|
||||
<span>{audiobook.rating.toFixed(1)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Ignored Indicator - Bottom Left */}
|
||||
{isIgnored && (
|
||||
<div
|
||||
className="absolute bottom-3 left-3 flex items-center gap-1 px-2 py-1 rounded-lg bg-black/50 backdrop-blur-md text-gray-300 text-xs font-medium transition-opacity duration-300 group-hover:opacity-0"
|
||||
title="Ignored from auto-requests"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3.98 8.223A10.477 10.477 0 001.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.45 10.45 0 0112 4.5c4.756 0 8.773 3.162 10.065 7.498a10.523 10.523 0 01-4.293 5.774M6.228 6.228L3 3m3.228 3.228l3.65 3.65m7.894 7.894L21 21m-3.228-3.228l-3.65-3.65m0 0a3 3 0 10-4.243-4.243m4.242 4.242L9.88 9.88" />
|
||||
</svg>
|
||||
<span>Ignored</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -253,6 +268,7 @@ export function AudiobookCard({
|
||||
onClose={() => setShowModal(false)}
|
||||
onRequestSuccess={onRequestSuccess}
|
||||
onStatusChange={(newStatus) => setLocalRequestStatus(newStatus)}
|
||||
onIgnoreChange={(ignored) => setLocalIsIgnored(ignored)}
|
||||
isRequested={audiobook.isRequested || localRequestStatus !== undefined}
|
||||
requestStatus={displayAudiobook.requestStatus}
|
||||
isAvailable={audiobook.isAvailable}
|
||||
|
||||
@@ -19,8 +19,10 @@ import { usePreferences } from '@/contexts/PreferencesContext';
|
||||
import { InteractiveTorrentSearchModal } from '@/components/requests/InteractiveTorrentSearchModal';
|
||||
import { ReportIssueModal } from '@/components/audiobooks/ReportIssueModal';
|
||||
import { ManualImportBrowser } from '@/components/audiobooks/ManualImportBrowser';
|
||||
import { FolderArrowDownIcon } from '@heroicons/react/24/outline';
|
||||
import { FolderArrowDownIcon, EyeSlashIcon } from '@heroicons/react/24/outline';
|
||||
import { EyeSlashIcon as EyeSlashSolidIcon } from '@heroicons/react/24/solid';
|
||||
import { fetchWithAuth } from '@/lib/utils/api';
|
||||
import { useIsIgnored, useToggleIgnore } from '@/lib/hooks/useIgnoredAudiobooks';
|
||||
|
||||
interface AudiobookDetailsModalProps {
|
||||
asin: string;
|
||||
@@ -28,6 +30,7 @@ interface AudiobookDetailsModalProps {
|
||||
onClose: () => void;
|
||||
onRequestSuccess?: () => void;
|
||||
onStatusChange?: (newStatus: string) => void;
|
||||
onIgnoreChange?: (isIgnored: boolean) => void;
|
||||
isRequested?: boolean;
|
||||
requestStatus?: string | null;
|
||||
isAvailable?: boolean;
|
||||
@@ -35,6 +38,8 @@ interface AudiobookDetailsModalProps {
|
||||
hideRequestActions?: boolean;
|
||||
hasReportedIssue?: boolean;
|
||||
aiReason?: string | null;
|
||||
/** Optional admin action buttons (Approve / Search / Deny) rendered as a second row in the action bar */
|
||||
adminActions?: React.ReactNode;
|
||||
}
|
||||
|
||||
// Status helper
|
||||
@@ -69,6 +74,7 @@ export function AudiobookDetailsModal({
|
||||
onClose,
|
||||
onRequestSuccess,
|
||||
onStatusChange,
|
||||
onIgnoreChange,
|
||||
isRequested = false,
|
||||
requestStatus = null,
|
||||
isAvailable = false,
|
||||
@@ -76,6 +82,7 @@ export function AudiobookDetailsModal({
|
||||
hideRequestActions = false,
|
||||
hasReportedIssue = false,
|
||||
aiReason = null,
|
||||
adminActions,
|
||||
}: AudiobookDetailsModalProps) {
|
||||
const { user } = useAuth();
|
||||
const { squareCovers } = usePreferences();
|
||||
@@ -85,6 +92,9 @@ export function AudiobookDetailsModal({
|
||||
const { downloadAvailable, requestId } = useDownloadStatus(isOpen ? asin : null);
|
||||
const { fetchEbook, isLoading: isFetchingEbook } = useFetchEbookByAsin();
|
||||
|
||||
const { isIgnored, ignoredId, isLoading: isLoadingIgnore } = useIsIgnored(isOpen ? asin : null);
|
||||
const { addIgnore, removeIgnore } = useToggleIgnore();
|
||||
|
||||
const [showToast, setShowToast] = useState(false);
|
||||
const [toastMessage, setToastMessage] = useState('');
|
||||
const [toastType, setToastType] = useState<'success' | 'error'>('success');
|
||||
@@ -97,6 +107,7 @@ export function AudiobookDetailsModal({
|
||||
const [localRequestStatus, setLocalRequestStatus] = useState<string | null>(requestStatus ?? null);
|
||||
const [isDownloading, setIsDownloading] = useState(false);
|
||||
const [coverError, setCoverError] = useState(false);
|
||||
const [isTogglingIgnore, setIsTogglingIgnore] = useState(false);
|
||||
|
||||
// Sync local status when the prop changes (e.g. page data refreshes)
|
||||
useEffect(() => {
|
||||
@@ -196,6 +207,31 @@ export function AudiobookDetailsModal({
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleIgnore = async () => {
|
||||
if (!user || !audiobook) return;
|
||||
setIsTogglingIgnore(true);
|
||||
try {
|
||||
if (isIgnored && ignoredId) {
|
||||
await removeIgnore(ignoredId, asin);
|
||||
onIgnoreChange?.(false);
|
||||
showNotification('Removed from ignore list');
|
||||
} else {
|
||||
await addIgnore({
|
||||
asin,
|
||||
title: audiobook.title,
|
||||
author: audiobook.author,
|
||||
coverArtUrl: audiobook.coverArtUrl,
|
||||
});
|
||||
onIgnoreChange?.(true);
|
||||
showNotification('Added to ignore list — auto-requests will skip this book');
|
||||
}
|
||||
} catch (err) {
|
||||
showNotification(err instanceof Error ? err.message : 'Failed to update ignore status', 'error');
|
||||
} finally {
|
||||
setIsTogglingIgnore(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDuration = (minutes?: number) => {
|
||||
if (!minutes) return null;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
@@ -515,6 +551,30 @@ export function AudiobookDetailsModal({
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Language */}
|
||||
{audiobook.language && (
|
||||
<div>
|
||||
<p className="text-gray-500 dark:text-gray-400">Language</p>
|
||||
<p className="text-gray-900 dark:text-gray-100 capitalize">{audiobook.language}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Format */}
|
||||
{audiobook.formatType && (
|
||||
<div>
|
||||
<p className="text-gray-500 dark:text-gray-400">Format</p>
|
||||
<p className="text-gray-900 dark:text-gray-100 capitalize">{audiobook.formatType}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Publisher */}
|
||||
{audiobook.publisherName && (
|
||||
<div>
|
||||
<p className="text-gray-500 dark:text-gray-400">Publisher</p>
|
||||
<p className="text-gray-900 dark:text-gray-100">{audiobook.publisherName}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Download Link - subtle utility, visible from any context */}
|
||||
{isAvailable && downloadAvailable && requestId && user?.permissions?.download !== false && (
|
||||
<div>
|
||||
@@ -685,7 +745,34 @@ export function AudiobookDetailsModal({
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Ignore Toggle - always visible when user is logged in */}
|
||||
{user && !isLoadingIgnore && (
|
||||
<button
|
||||
onClick={handleToggleIgnore}
|
||||
disabled={isTogglingIgnore}
|
||||
className={`p-3 rounded-xl transition-colors disabled:opacity-50 ${
|
||||
isIgnored
|
||||
? 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
|
||||
: 'bg-gray-100 dark:bg-gray-800/50 text-gray-400 dark:text-gray-500 hover:bg-gray-200 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
title={isIgnored ? 'Stop Ignoring — auto-requests will resume for this book' : 'Ignore from Auto-Requests'}
|
||||
>
|
||||
{isIgnored ? (
|
||||
<EyeSlashSolidIcon className="w-6 h-6" />
|
||||
) : (
|
||||
<EyeSlashIcon className="w-6 h-6" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
{/* Admin Actions Row (Approve / Search / Deny) — injected by admin pages */}
|
||||
{adminActions && (
|
||||
<div className="flex items-center gap-2 mt-3 pt-3 border-t border-amber-200 dark:border-amber-700/50">
|
||||
{adminActions}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -47,8 +47,6 @@ export function ManualImportBrowser({
|
||||
const [currentPath, setCurrentPath] = useState<string | null>(null);
|
||||
const [entries, setEntries] = useState<DirectoryEntry[]>([]);
|
||||
const [selectedPath, setSelectedPath] = useState<string | null>(null);
|
||||
const [selectedAudioCount, setSelectedAudioCount] = useState(0);
|
||||
const [selectedSize, setSelectedSize] = useState(0);
|
||||
const [selectedAudioFiles, setSelectedAudioFiles] = useState<AudioFileEntry[]>([]);
|
||||
const [currentAudioFiles, setCurrentAudioFiles] = useState<AudioFileEntry[]>([]);
|
||||
const [pathHistory, setPathHistory] = useState<string[]>([]);
|
||||
@@ -62,6 +60,9 @@ export function ManualImportBrowser({
|
||||
// Cleanup source toggle
|
||||
const [cleanupSource, setCleanupSource] = useState(false);
|
||||
|
||||
// File selection state (shared between BrowsePhase and ConfirmPhase)
|
||||
const [checkedFiles, setCheckedFiles] = useState<Set<string>>(new Set());
|
||||
|
||||
// Hover state for folder icon swap
|
||||
const [hoveredFolder, setHoveredFolder] = useState<string | null>(null);
|
||||
|
||||
@@ -96,6 +97,7 @@ export function ManualImportBrowser({
|
||||
const fetchDirectory = useCallback(async (dirPath: string) => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
setCheckedFiles(new Set());
|
||||
try {
|
||||
const res = await fetchWithAuth(
|
||||
`/api/admin/filesystem/browse?path=${encodeURIComponent(dirPath)}`
|
||||
@@ -105,8 +107,9 @@ export function ManualImportBrowser({
|
||||
throw new Error(data.error || 'Failed to browse directory');
|
||||
}
|
||||
const data = await res.json();
|
||||
const audioFiles: AudioFileEntry[] = data.audioFiles || [];
|
||||
setEntries(data.entries || []);
|
||||
setCurrentAudioFiles(data.audioFiles || []);
|
||||
setCurrentAudioFiles(audioFiles);
|
||||
setCurrentPath(data.path || dirPath);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to browse directory');
|
||||
@@ -165,12 +168,38 @@ export function ManualImportBrowser({
|
||||
navigateInto(fullPath);
|
||||
};
|
||||
|
||||
const handleSelectCurrentFolder = () => {
|
||||
const handleToggleFile = (fileName: string) => {
|
||||
setCheckedFiles((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(fileName)) {
|
||||
next.delete(fileName);
|
||||
} else {
|
||||
next.add(fileName);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleToggleAll = () => {
|
||||
// In confirm phase, toggle against selectedAudioFiles; in browse phase, against currentAudioFiles
|
||||
const files = phase === 'confirm' ? selectedAudioFiles : currentAudioFiles;
|
||||
if (checkedFiles.size === files.length) {
|
||||
setCheckedFiles(new Set());
|
||||
} else {
|
||||
setCheckedFiles(new Set(files.map((f) => f.name)));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectFiles = () => {
|
||||
if (!currentPath || currentAudioFiles.length === 0) return;
|
||||
// No individual selection = whole folder; otherwise only checked files
|
||||
const selected = checkedFiles.size > 0
|
||||
? currentAudioFiles.filter((f) => checkedFiles.has(f.name))
|
||||
: currentAudioFiles;
|
||||
setSelectedPath(currentPath);
|
||||
setSelectedAudioCount(currentAudioFiles.length);
|
||||
setSelectedSize(currentAudioFiles.reduce((sum, f) => sum + f.size, 0));
|
||||
setSelectedAudioFiles(currentAudioFiles);
|
||||
setSelectedAudioFiles(selected);
|
||||
// Ensure checkedFiles reflects what we're importing for ConfirmPhase
|
||||
setCheckedFiles(new Set(selected.map((f) => f.name)));
|
||||
setSlideDirection('right');
|
||||
setPhase('confirm');
|
||||
};
|
||||
@@ -185,12 +214,18 @@ export function ManualImportBrowser({
|
||||
setIsImporting(true);
|
||||
setImportError(null);
|
||||
try {
|
||||
// Send only the files that are still checked in ConfirmPhase
|
||||
const fileNames = selectedAudioFiles
|
||||
.filter((f) => checkedFiles.has(f.name))
|
||||
.map((f) => f.name);
|
||||
if (fileNames.length === 0) return;
|
||||
const res = await fetchWithAuth('/api/admin/manual-import', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
asin: audiobook.asin,
|
||||
folderPath: selectedPath,
|
||||
selectedFiles: fileNames,
|
||||
cleanupSource,
|
||||
}),
|
||||
});
|
||||
@@ -268,6 +303,7 @@ export function ManualImportBrowser({
|
||||
currentPath={currentPath}
|
||||
entries={entries}
|
||||
currentAudioFiles={currentAudioFiles}
|
||||
checkedFiles={checkedFiles}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
hoveredFolder={hoveredFolder}
|
||||
@@ -278,7 +314,8 @@ export function ManualImportBrowser({
|
||||
onNavigateToRoot={navigateToRoot}
|
||||
onNavigateToBreadcrumb={navigateToBreadcrumb}
|
||||
onFolderClick={handleFolderClick}
|
||||
onSelectCurrentFolder={handleSelectCurrentFolder}
|
||||
onSelectFiles={handleSelectFiles}
|
||||
onToggleFile={handleToggleFile}
|
||||
onHoverFolder={setHoveredFolder}
|
||||
onRetry={currentPath ? () => fetchDirectory(currentPath) : fetchRoots}
|
||||
/>
|
||||
@@ -286,14 +323,15 @@ export function ManualImportBrowser({
|
||||
<ConfirmPhase
|
||||
audiobook={audiobook}
|
||||
selectedPath={selectedPath!}
|
||||
audioFileCount={selectedAudioCount}
|
||||
totalSize={selectedSize}
|
||||
audioFiles={selectedAudioFiles}
|
||||
checkedFiles={checkedFiles}
|
||||
isImporting={isImporting}
|
||||
importError={importError}
|
||||
slideClass={slideClass}
|
||||
cleanupSource={cleanupSource}
|
||||
onCleanupSourceChange={setCleanupSource}
|
||||
onToggleFile={handleToggleFile}
|
||||
onToggleAll={handleToggleAll}
|
||||
onBack={handleBackToBrowse}
|
||||
onStartImport={handleStartImport}
|
||||
/>
|
||||
|
||||
@@ -40,6 +40,7 @@ interface BrowsePhaseProps {
|
||||
currentPath: string | null;
|
||||
entries: DirectoryEntry[];
|
||||
currentAudioFiles: AudioFileEntry[];
|
||||
checkedFiles: Set<string>;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
hoveredFolder: string | null;
|
||||
@@ -50,7 +51,8 @@ interface BrowsePhaseProps {
|
||||
onNavigateToRoot: () => void;
|
||||
onNavigateToBreadcrumb: (index: number) => void;
|
||||
onFolderClick: (entry: DirectoryEntry) => void;
|
||||
onSelectCurrentFolder: () => void;
|
||||
onSelectFiles: () => void;
|
||||
onToggleFile: (fileName: string) => void;
|
||||
onHoverFolder: (name: string | null) => void;
|
||||
onRetry: () => void;
|
||||
}
|
||||
@@ -60,6 +62,7 @@ export function BrowsePhase({
|
||||
currentPath,
|
||||
entries,
|
||||
currentAudioFiles,
|
||||
checkedFiles,
|
||||
isLoading,
|
||||
error,
|
||||
hoveredFolder,
|
||||
@@ -70,10 +73,16 @@ export function BrowsePhase({
|
||||
onNavigateToRoot,
|
||||
onNavigateToBreadcrumb,
|
||||
onFolderClick,
|
||||
onSelectCurrentFolder,
|
||||
onSelectFiles,
|
||||
onToggleFile,
|
||||
onHoverFolder,
|
||||
onRetry,
|
||||
}: BrowsePhaseProps) {
|
||||
const hasSelection = checkedFiles.size > 0;
|
||||
const totalSize = currentAudioFiles.reduce((sum, f) => sum + f.size, 0);
|
||||
const checkedSize = hasSelection
|
||||
? currentAudioFiles.filter((f) => checkedFiles.has(f.name)).reduce((sum, f) => sum + f.size, 0)
|
||||
: totalSize;
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Breadcrumb bar */}
|
||||
@@ -165,7 +174,6 @@ export function BrowsePhase({
|
||||
<div className="divide-y divide-gray-100 dark:divide-gray-800">
|
||||
{/* Subdirectories */}
|
||||
{entries.map((entry) => {
|
||||
const hasAudio = entry.audioFileCount > 0;
|
||||
const isHovered = hoveredFolder === entry.name;
|
||||
|
||||
return (
|
||||
@@ -184,33 +192,9 @@ export function BrowsePhase({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
|
||||
{entry.name}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{entry.subfolderCount > 0 && (
|
||||
<span>{entry.subfolderCount} folder{entry.subfolderCount !== 1 ? 's' : ''}</span>
|
||||
)}
|
||||
{entry.subfolderCount > 0 && entry.audioFileCount > 0 && <span> · </span>}
|
||||
{entry.audioFileCount > 0 && (
|
||||
<span>{entry.audioFileCount} audio file{entry.audioFileCount !== 1 ? 's' : ''}</span>
|
||||
)}
|
||||
{entry.totalSize > 0 && (
|
||||
<span> · {formatBytes(entry.totalSize)}</span>
|
||||
)}
|
||||
{entry.subfolderCount === 0 && entry.audioFileCount === 0 && (
|
||||
<span className="italic">Empty</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{hasAudio && (
|
||||
<span className="flex-shrink-0 inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 text-xs font-medium">
|
||||
<MusicalNoteIcon className="w-3 h-3" />
|
||||
{entry.audioFileCount}
|
||||
</span>
|
||||
)}
|
||||
<p className="flex-1 min-w-0 text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
|
||||
{entry.name}
|
||||
</p>
|
||||
|
||||
<ChevronRightIcon className="w-4 h-4 text-gray-300 dark:text-gray-600 flex-shrink-0" />
|
||||
</button>
|
||||
@@ -221,24 +205,38 @@ export function BrowsePhase({
|
||||
{currentAudioFiles.length > 0 && entries.length > 0 && (
|
||||
<div className="px-4 py-2 bg-gray-50/50 dark:bg-gray-800/20">
|
||||
<p className="text-xs font-medium text-gray-400 dark:text-gray-500 uppercase tracking-wider">
|
||||
Audio Files
|
||||
Audio Files {hasSelection && `\u00B7 click to select`}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{currentAudioFiles.map((file) => (
|
||||
<div
|
||||
key={`file-${file.name}`}
|
||||
className="flex items-center gap-3 px-4 py-2.5"
|
||||
>
|
||||
<MusicalNoteIcon className="w-4 h-4 text-blue-500 dark:text-blue-400 flex-shrink-0" />
|
||||
<span className="flex-1 min-w-0 text-sm text-gray-700 dark:text-gray-300 truncate">
|
||||
{file.name}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500 flex-shrink-0">
|
||||
{formatBytes(file.size)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{currentAudioFiles.map((file) => {
|
||||
const isSelected = checkedFiles.has(file.name);
|
||||
return (
|
||||
<button
|
||||
key={`file-${file.name}`}
|
||||
onClick={() => onToggleFile(file.name)}
|
||||
className={`w-full flex items-center gap-3 px-4 py-2.5 text-left transition-colors ${
|
||||
isSelected
|
||||
? 'bg-blue-50 dark:bg-blue-900/20 border-l-2 border-blue-500'
|
||||
: 'hover:bg-gray-50 dark:hover:bg-gray-800/50 border-l-2 border-transparent'
|
||||
}`}
|
||||
>
|
||||
<MusicalNoteIcon className={`w-4 h-4 flex-shrink-0 ${
|
||||
isSelected ? 'text-blue-600 dark:text-blue-400' : 'text-blue-500/50 dark:text-blue-400/50'
|
||||
}`} />
|
||||
<span className={`flex-1 min-w-0 text-sm truncate ${
|
||||
isSelected
|
||||
? 'text-blue-900 dark:text-blue-100 font-medium'
|
||||
: 'text-gray-700 dark:text-gray-300'
|
||||
}`}>
|
||||
{file.name}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500 flex-shrink-0">
|
||||
{formatBytes(file.size)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -258,18 +256,33 @@ export function BrowsePhase({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer: Select this folder */}
|
||||
{/* Footer */}
|
||||
{currentPath && !isLoading && currentAudioFiles.length > 0 && (
|
||||
<div className="px-5 py-3.5 border-t border-gray-200 dark:border-gray-700/50 bg-gray-50/50 dark:bg-gray-800/30 flex items-center justify-between gap-4">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
<span className="font-medium text-gray-900 dark:text-gray-100">{currentAudioFiles.length}</span>
|
||||
{' '}audio file{currentAudioFiles.length !== 1 ? 's' : ''} in this folder
|
||||
{hasSelection ? (
|
||||
<>
|
||||
<span className="font-medium text-gray-900 dark:text-gray-100">{checkedFiles.size}</span>
|
||||
{' '}of {currentAudioFiles.length} file{currentAudioFiles.length !== 1 ? 's' : ''} selected
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="font-medium text-gray-900 dark:text-gray-100">{currentAudioFiles.length}</span>
|
||||
{' '}audio file{currentAudioFiles.length !== 1 ? 's' : ''} in this folder
|
||||
</>
|
||||
)}
|
||||
{checkedSize > 0 && (
|
||||
<span className="text-gray-400 dark:text-gray-500"> · {formatBytes(checkedSize)}</span>
|
||||
)}
|
||||
</p>
|
||||
<button
|
||||
onClick={onSelectCurrentFolder}
|
||||
onClick={onSelectFiles}
|
||||
className="flex-shrink-0 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-xl transition-colors"
|
||||
>
|
||||
Select This Folder →
|
||||
{hasSelection
|
||||
? `Select ${checkedFiles.size} File${checkedFiles.size !== 1 ? 's' : ''}`
|
||||
: 'Select This Folder'
|
||||
} →
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -16,14 +16,15 @@ import { AudioFileEntry, formatBytes } from './types';
|
||||
interface ConfirmPhaseProps {
|
||||
audiobook: { asin: string; title: string; author: string; coverArtUrl?: string };
|
||||
selectedPath: string;
|
||||
audioFileCount: number;
|
||||
totalSize: number;
|
||||
audioFiles: AudioFileEntry[];
|
||||
checkedFiles: Set<string>;
|
||||
isImporting: boolean;
|
||||
importError: string | null;
|
||||
slideClass: string;
|
||||
cleanupSource: boolean;
|
||||
onCleanupSourceChange: (value: boolean) => void;
|
||||
onToggleFile: (fileName: string) => void;
|
||||
onToggleAll: () => void;
|
||||
onBack: () => void;
|
||||
onStartImport: () => void;
|
||||
}
|
||||
@@ -31,17 +32,23 @@ interface ConfirmPhaseProps {
|
||||
export function ConfirmPhase({
|
||||
audiobook,
|
||||
selectedPath,
|
||||
audioFileCount,
|
||||
totalSize,
|
||||
audioFiles,
|
||||
checkedFiles,
|
||||
isImporting,
|
||||
importError,
|
||||
slideClass,
|
||||
cleanupSource,
|
||||
onCleanupSourceChange,
|
||||
onToggleFile,
|
||||
onToggleAll,
|
||||
onBack,
|
||||
onStartImport,
|
||||
}: ConfirmPhaseProps) {
|
||||
const allChecked = audioFiles.length > 0 && checkedFiles.size === audioFiles.length;
|
||||
const someChecked = checkedFiles.size > 0 && !allChecked;
|
||||
const checkedSize = audioFiles
|
||||
.filter((f) => checkedFiles.has(f.name))
|
||||
.reduce((sum, f) => sum + f.size, 0);
|
||||
return (
|
||||
<div className={`flex flex-col h-full ${slideClass}`}>
|
||||
<div className="flex-1 overflow-y-auto p-6 space-y-6">
|
||||
@@ -79,28 +86,51 @@ export function ConfirmPhase({
|
||||
{selectedPath}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1.5">
|
||||
{audioFileCount} audio file{audioFileCount !== 1 ? 's' : ''}
|
||||
{totalSize > 0 ? ` \u00B7 ${formatBytes(totalSize)}` : ''}
|
||||
{checkedFiles.size} of {audioFiles.length} file{audioFiles.length !== 1 ? 's' : ''} selected
|
||||
{checkedSize > 0 ? ` \u00B7 ${formatBytes(checkedSize)}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Audio files to import */}
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-3">
|
||||
Files to import
|
||||
</h4>
|
||||
<div className="rounded-xl border border-gray-200 dark:border-gray-700 divide-y divide-gray-100 dark:divide-gray-800 overflow-hidden">
|
||||
{audioFiles.map((file) => (
|
||||
<div key={file.name} className="flex items-center gap-3 px-3.5 py-2.5">
|
||||
<MusicalNoteIcon className="w-4 h-4 text-blue-500 dark:text-blue-400 flex-shrink-0" />
|
||||
<span className="flex-1 min-w-0 text-sm text-gray-700 dark:text-gray-300 truncate">
|
||||
{file.name}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500 flex-shrink-0">
|
||||
{formatBytes(file.size)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allChecked}
|
||||
ref={(el) => { if (el) el.indeterminate = someChecked; }}
|
||||
onChange={onToggleAll}
|
||||
disabled={isImporting}
|
||||
className="w-4 h-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500 cursor-pointer"
|
||||
/>
|
||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||
Files to import
|
||||
</h4>
|
||||
</div>
|
||||
<div className="rounded-xl border border-gray-200 dark:border-gray-700 divide-y divide-gray-100 dark:divide-gray-800 overflow-hidden max-h-48 overflow-y-auto">
|
||||
{audioFiles.map((file) => {
|
||||
const isChecked = checkedFiles.has(file.name);
|
||||
return (
|
||||
<label
|
||||
key={file.name}
|
||||
className={`flex items-center gap-3 px-3.5 py-2.5 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors ${!isChecked ? 'opacity-50' : ''}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isChecked}
|
||||
onChange={() => onToggleFile(file.name)}
|
||||
disabled={isImporting}
|
||||
className="w-4 h-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500 cursor-pointer"
|
||||
/>
|
||||
<MusicalNoteIcon className="w-4 h-4 text-blue-500 dark:text-blue-400 flex-shrink-0" />
|
||||
<span className="flex-1 min-w-0 text-sm text-gray-700 dark:text-gray-300 truncate">
|
||||
{file.name}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500 flex-shrink-0">
|
||||
{formatBytes(file.size)}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -149,7 +179,7 @@ export function ConfirmPhase({
|
||||
</button>
|
||||
<button
|
||||
onClick={onStartImport}
|
||||
disabled={isImporting}
|
||||
disabled={isImporting || checkedFiles.size === 0}
|
||||
className="px-5 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-xl transition-colors disabled:opacity-70 flex items-center gap-2"
|
||||
>
|
||||
{isImporting ? (
|
||||
|
||||
@@ -12,9 +12,6 @@ export interface RootEntry {
|
||||
export interface DirectoryEntry {
|
||||
name: string;
|
||||
type: 'directory';
|
||||
audioFileCount: number;
|
||||
subfolderCount: number;
|
||||
totalSize: number;
|
||||
}
|
||||
|
||||
export interface AudioFileEntry {
|
||||
|
||||
@@ -6,9 +6,13 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useShelves, GenericShelf } from '@/lib/hooks/useShelves';
|
||||
import { useDeleteGoodreadsShelf } from '@/lib/hooks/useGoodreadsShelves';
|
||||
import { useDeleteHardcoverShelf } from '@/lib/hooks/useHardcoverShelves';
|
||||
import {
|
||||
useShelves,
|
||||
GenericShelf,
|
||||
useSyncShelves,
|
||||
} from '@/lib/hooks/useShelves';
|
||||
import { useDeleteGoodreadsShelf, useUpdateGoodreadsShelf } from '@/lib/hooks/useGoodreadsShelves';
|
||||
import { useDeleteHardcoverShelf, useUpdateHardcoverShelf } from '@/lib/hooks/useHardcoverShelves';
|
||||
import { AddShelfModal } from '@/components/ui/AddShelfModal';
|
||||
import { AudiobookDetailsModal } from '@/components/audiobooks/AudiobookDetailsModal';
|
||||
import { usePreferences } from '@/contexts/PreferencesContext';
|
||||
@@ -37,6 +41,9 @@ export function ShelvesSection() {
|
||||
useDeleteGoodreadsShelf();
|
||||
const { deleteShelf: deleteHardcover, isLoading: isDeletingHardcover } =
|
||||
useDeleteHardcoverShelf();
|
||||
const { syncShelves, isSyncing: isSyncingAll } = useSyncShelves();
|
||||
const { updateShelf: updateGoodreads } = useUpdateGoodreadsShelf();
|
||||
const { updateShelf: updateHardcover } = useUpdateHardcoverShelf();
|
||||
const { squareCovers } = usePreferences();
|
||||
|
||||
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
|
||||
@@ -57,6 +64,18 @@ export function ShelvesSection() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleAutoRequest = async (shelf: GenericShelf) => {
|
||||
try {
|
||||
if (shelf.type === 'goodreads') {
|
||||
await updateGoodreads(shelf.id, { autoRequest: !shelf.autoRequest });
|
||||
} else {
|
||||
await updateHardcover(shelf.id, { autoRequest: !shelf.autoRequest });
|
||||
}
|
||||
} catch {
|
||||
// Error handled by hook
|
||||
}
|
||||
};
|
||||
|
||||
const isDeleting = isDeletingGoodreads || isDeletingHardcover;
|
||||
|
||||
return (
|
||||
@@ -93,25 +112,48 @@ export function ShelvesSection() {
|
||||
</div>
|
||||
|
||||
{shelves.length > 0 && (
|
||||
<button
|
||||
onClick={() => setShowAddShelf(true)}
|
||||
className="inline-flex items-center gap-1.5 px-3.5 py-2 text-sm font-medium text-gray-600 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl hover:bg-gray-50 dark:hover:bg-gray-700/70 hover:border-gray-300 dark:hover:border-gray-600 transition-all duration-200 shadow-sm"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={2}
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => syncShelves()}
|
||||
disabled={isSyncingAll}
|
||||
className="inline-flex items-center gap-1.5 px-3.5 py-2 text-sm font-medium text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-500/10 border border-blue-100 dark:border-blue-500/20 rounded-xl hover:bg-blue-100 dark:hover:bg-blue-500/20 transition-all duration-200 shadow-sm disabled:opacity-50"
|
||||
title="Resync all shelves"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 4.5v15m7.5-7.5h-15"
|
||||
/>
|
||||
</svg>
|
||||
Add Shelf
|
||||
</button>
|
||||
<svg
|
||||
className={cn('w-4 h-4', isSyncingAll && 'animate-spin')}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
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>
|
||||
{isSyncingAll ? 'Syncing...' : 'Resync All'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowAddShelf(true)}
|
||||
className="inline-flex items-center gap-1.5 px-3.5 py-2 text-sm font-medium text-gray-600 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl hover:bg-gray-50 dark:hover:bg-gray-700/70 hover:border-gray-300 dark:hover:border-gray-600 transition-all duration-200 shadow-sm"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 4.5v15m7.5-7.5h-15"
|
||||
/>
|
||||
</svg>
|
||||
Add Shelf
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -131,6 +173,7 @@ export function ShelvesSection() {
|
||||
onConfirmDelete={() => setConfirmDeleteId(shelf.id)}
|
||||
onCancelDelete={() => setConfirmDeleteId(null)}
|
||||
onManage={() => setManageShelf(shelf)}
|
||||
onToggleAutoRequest={() => handleToggleAutoRequest(shelf)}
|
||||
onBookClick={(asin) => setSelectedAsin(asin)}
|
||||
/>
|
||||
))}
|
||||
@@ -254,6 +297,7 @@ interface ShelfCardProps {
|
||||
onConfirmDelete: () => void;
|
||||
onCancelDelete: () => void;
|
||||
onManage: () => void;
|
||||
onToggleAutoRequest: () => void;
|
||||
onBookClick: (asin: string) => void;
|
||||
}
|
||||
|
||||
@@ -266,8 +310,10 @@ function ShelfCard({
|
||||
onConfirmDelete,
|
||||
onCancelDelete,
|
||||
onManage,
|
||||
onToggleAutoRequest,
|
||||
onBookClick,
|
||||
}: ShelfCardProps) {
|
||||
const { syncShelves, isSyncing: isManualSyncing } = useSyncShelves();
|
||||
const displayBooks = shelf.books.slice(0, 6);
|
||||
const hasCovers = displayBooks.length > 0;
|
||||
const remainingCount = Math.max(
|
||||
@@ -292,7 +338,12 @@ function ShelfCard({
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="group rounded-2xl bg-white dark:bg-gray-800 border border-gray-100 dark:border-gray-700/30 p-6 sm:p-7 transition-all duration-300 hover:shadow-lg hover:shadow-black/[0.04] dark:hover:shadow-black/20 hover:border-gray-200 dark:hover:border-gray-600/40">
|
||||
<div className={cn(
|
||||
'group rounded-2xl bg-white dark:bg-gray-800 border p-6 sm:p-7 transition-all duration-300',
|
||||
shelf.autoRequest
|
||||
? 'border-gray-100 dark:border-gray-700/30 hover:shadow-lg hover:shadow-black/[0.04] dark:hover:shadow-black/20 hover:border-gray-200 dark:hover:border-gray-600/40'
|
||||
: 'border-gray-200/60 dark:border-gray-700/20 bg-gray-50/50 dark:bg-gray-800/60',
|
||||
)}>
|
||||
{/* Top: Shelf info + actions */}
|
||||
<div
|
||||
className={cn(
|
||||
@@ -301,7 +352,12 @@ function ShelfCard({
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="font-semibold text-[15px] text-gray-900 dark:text-white truncate leading-snug flex items-center">
|
||||
<h3 className={cn(
|
||||
'font-semibold text-[15px] truncate leading-snug flex items-center',
|
||||
shelf.autoRequest
|
||||
? 'text-gray-900 dark:text-white'
|
||||
: 'text-gray-400 dark:text-gray-500',
|
||||
)}>
|
||||
{shelf.name} {providerIcon}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
@@ -310,6 +366,14 @@ function ShelfCard({
|
||||
{shelf.bookCount} {shelf.bookCount === 1 ? 'book' : 'books'}
|
||||
</span>
|
||||
)}
|
||||
{!shelf.autoRequest && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-xs font-medium bg-amber-50 dark:bg-amber-500/10 text-amber-600 dark:text-amber-400 ring-1 ring-amber-200/50 dark:ring-amber-500/20">
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 5.25v13.5m-7.5-13.5v13.5" />
|
||||
</svg>
|
||||
Paused
|
||||
</span>
|
||||
)}
|
||||
<span className="inline-flex items-center gap-1.5 text-xs text-gray-400 dark:text-gray-500">
|
||||
{isSyncing ? (
|
||||
<>
|
||||
@@ -352,6 +416,27 @@ function ShelfCard({
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={onToggleAutoRequest}
|
||||
className={cn(
|
||||
'p-2 transition-all duration-200 rounded-xl outline-none',
|
||||
shelf.autoRequest
|
||||
? 'text-gray-400 hover:text-amber-500 dark:text-gray-500 dark:hover:text-amber-400 hover:bg-amber-50 dark:hover:bg-amber-500/10 opacity-40 hover:opacity-100 focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-amber-500/40'
|
||||
: 'text-amber-500 dark:text-amber-400 bg-amber-50 dark:bg-amber-500/10 opacity-100',
|
||||
)}
|
||||
title={shelf.autoRequest ? 'Pause auto-requesting' : 'Resume auto-requesting'}
|
||||
aria-label={shelf.autoRequest ? 'Pause auto-requesting' : 'Resume auto-requesting'}
|
||||
>
|
||||
{shelf.autoRequest ? (
|
||||
<svg className="w-[18px] h-[18px]" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 5.25v13.5m-7.5-13.5v13.5" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-[18px] h-[18px]" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.348a1.125 1.125 0 010 1.971l-11.54 6.347a1.125 1.125 0 01-1.667-.985V5.653z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={onManage}
|
||||
className="p-2 text-gray-400 hover:text-blue-500 dark:text-gray-500 dark:hover:text-blue-400 transition-all duration-200 rounded-xl hover:bg-blue-50 dark:hover:bg-blue-500/10 opacity-40 hover:opacity-100 focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-blue-500/40 outline-none"
|
||||
@@ -372,6 +457,30 @@ function ShelfCard({
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => syncShelves(shelf.id, shelf.type)}
|
||||
disabled={isManualSyncing}
|
||||
className="p-2 text-gray-400 hover:text-emerald-500 dark:text-gray-500 dark:hover:text-emerald-400 transition-all duration-200 rounded-xl hover:bg-emerald-50 dark:hover:bg-emerald-500/10 opacity-40 hover:opacity-100 focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-emerald-500/40 outline-none disabled:opacity-30"
|
||||
title="Resync shelf"
|
||||
aria-label="Resync shelf"
|
||||
>
|
||||
<svg
|
||||
className={cn(
|
||||
'w-[18px] h-[18px]',
|
||||
isManualSyncing && 'animate-spin',
|
||||
)}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
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>
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirmDelete}
|
||||
className="p-2 text-gray-400 hover:text-red-400 dark:text-gray-500 dark:hover:text-red-400 transition-all duration-200 rounded-xl hover:bg-red-50 dark:hover:bg-red-500/10 opacity-40 hover:opacity-100 focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-red-500/40 outline-none"
|
||||
@@ -398,6 +507,7 @@ function ShelfCard({
|
||||
</div>
|
||||
|
||||
{/* Bottom: Stacked book covers */}
|
||||
<div className={cn(!shelf.autoRequest && 'opacity-50 grayscale-[30%]')}>
|
||||
{hasCovers ? (
|
||||
<CoverStack
|
||||
books={displayBooks}
|
||||
@@ -419,6 +529,7 @@ function ShelfCard({
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,7 +13,8 @@ import { useCancelRequest } from '@/lib/hooks/useRequests';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { usePreferences } from '@/contexts/PreferencesContext';
|
||||
import { AudiobookDetailsModal } from '@/components/audiobooks/AudiobookDetailsModal';
|
||||
import { COMPLETED_STATUSES } from '@/lib/constants/request-statuses';
|
||||
import { ConfirmModal } from '@/components/ui/ConfirmModal';
|
||||
import { COMPLETED_STATUSES, CANCELLABLE_STATUSES } from '@/lib/constants/request-statuses';
|
||||
|
||||
interface RequestCardProps {
|
||||
request: {
|
||||
@@ -45,22 +46,25 @@ export function RequestCard({ request, showActions = true }: RequestCardProps) {
|
||||
const [showError, setShowError] = React.useState(false);
|
||||
const [showDetailsModal, setShowDetailsModal] = React.useState(false);
|
||||
const [coverError, setCoverError] = React.useState(false);
|
||||
const [confirmCancelOpen, setConfirmCancelOpen] = React.useState(false);
|
||||
|
||||
const isAwaitingApproval = request.status === 'awaiting_approval';
|
||||
|
||||
const requestType = request.type || 'audiobook';
|
||||
const isEbook = requestType === 'ebook';
|
||||
|
||||
const isCompleted = COMPLETED_STATUSES.includes(request.status as typeof COMPLETED_STATUSES[number]);
|
||||
const canCancel = ['pending', 'searching', 'downloading', 'awaiting_search'].includes(request.status);
|
||||
const canCancel = (CANCELLABLE_STATUSES as readonly string[]).includes(request.status);
|
||||
const isActive = ['searching', 'downloading', 'processing'].includes(request.status);
|
||||
const isFailed = request.status === 'failed';
|
||||
|
||||
const handleCancel = async () => {
|
||||
if (window.confirm('Are you sure you want to cancel this request?')) {
|
||||
try {
|
||||
await cancelRequest(request.id);
|
||||
} catch (error) {
|
||||
console.error('Failed to cancel request:', error);
|
||||
}
|
||||
const handleConfirmCancel = async () => {
|
||||
try {
|
||||
await cancelRequest(request.id);
|
||||
setConfirmCancelOpen(false);
|
||||
} catch (error) {
|
||||
console.error('Failed to cancel request:', error);
|
||||
setConfirmCancelOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -228,13 +232,13 @@ export function RequestCard({ request, showActions = true }: RequestCardProps) {
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{canCancel && (
|
||||
<Button
|
||||
onClick={handleCancel}
|
||||
onClick={() => setConfirmCancelOpen(true)}
|
||||
loading={isLoading}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs sm:text-sm text-red-600 border-red-600 hover:bg-red-50 dark:hover:bg-red-900/20"
|
||||
>
|
||||
Cancel
|
||||
{isAwaitingApproval ? 'Withdraw' : 'Cancel'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -254,6 +258,22 @@ export function RequestCard({ request, showActions = true }: RequestCardProps) {
|
||||
hideRequestActions
|
||||
/>
|
||||
)}
|
||||
|
||||
<ConfirmModal
|
||||
isOpen={confirmCancelOpen}
|
||||
onClose={() => !isLoading && setConfirmCancelOpen(false)}
|
||||
onConfirm={handleConfirmCancel}
|
||||
title={isAwaitingApproval ? 'Withdraw request' : 'Cancel request'}
|
||||
message={
|
||||
isAwaitingApproval
|
||||
? 'This request is pending admin approval and will be withdrawn. You can request it again later.'
|
||||
: 'This request has already been approved and is actively being processed. Cancelling will stop the download.'
|
||||
}
|
||||
confirmText={isAwaitingApproval ? 'Withdraw request' : 'Cancel request'}
|
||||
cancelText="Keep request"
|
||||
variant="danger"
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -32,6 +32,8 @@ export function AddShelfModal({ isOpen, onClose }: AddShelfModalProps) {
|
||||
const [statusId, setStatusId] = useState('1');
|
||||
const [customListId, setCustomListId] = useState('');
|
||||
|
||||
// Shared State
|
||||
const [autoRequest, setAutoRequest] = useState(true);
|
||||
const [validationError, setValidationError] = useState('');
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [successMessage, setSuccessMessage] = useState('');
|
||||
@@ -72,12 +74,12 @@ export function AddShelfModal({ isOpen, onClose }: AddShelfModalProps) {
|
||||
|
||||
try {
|
||||
if (provider === 'goodreads') {
|
||||
const shelf = await addGoodreads(rssUrl);
|
||||
const shelf = await addGoodreads(rssUrl, autoRequest);
|
||||
setSuccessMessage(`Added shelf "${shelf.name}" successfully!`);
|
||||
setRssUrl('');
|
||||
} else {
|
||||
const finalId = listType === 'status' ? `status-${statusId}` : customListId.trim();
|
||||
const shelf = await addHardcover(apiToken.trim(), finalId);
|
||||
const shelf = await addHardcover(apiToken.trim(), finalId, autoRequest);
|
||||
setSuccessMessage(`Added list "${shelf.name}" successfully!`);
|
||||
setApiToken('');
|
||||
setCustomListId('');
|
||||
@@ -98,6 +100,7 @@ export function AddShelfModal({ isOpen, onClose }: AddShelfModalProps) {
|
||||
setRssUrl('');
|
||||
setApiToken('');
|
||||
setCustomListId('');
|
||||
setAutoRequest(true);
|
||||
setValidationError('');
|
||||
setSuccess(false);
|
||||
setSuccessMessage('');
|
||||
@@ -215,6 +218,32 @@ export function AddShelfModal({ isOpen, onClose }: AddShelfModalProps) {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Auto-Request Toggle */}
|
||||
<label className="flex items-center justify-between gap-3 p-3 rounded-xl bg-gray-50 dark:bg-gray-800/50 border border-gray-100 dark:border-gray-700/30 cursor-pointer select-none">
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">Auto-request books</span>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-0.5">
|
||||
Automatically request audiobooks from this shelf
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={autoRequest}
|
||||
onClick={() => setAutoRequest(!autoRequest)}
|
||||
disabled={isLoading || success}
|
||||
className={`relative inline-flex h-5 w-9 flex-shrink-0 rounded-full transition-colors duration-200 ease-in-out focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/40 ${
|
||||
autoRequest ? 'bg-blue-600' : 'bg-gray-300 dark:bg-gray-600'
|
||||
} ${(isLoading || success) ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
>
|
||||
<span
|
||||
className={`pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow-sm ring-0 transition duration-200 ease-in-out ${
|
||||
autoRequest ? 'translate-x-4' : 'translate-x-0.5'
|
||||
} mt-0.5`}
|
||||
/>
|
||||
</button>
|
||||
</label>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<Button type="button" variant="ghost" size="sm" onClick={handleClose} disabled={isLoading || success}>
|
||||
Cancel
|
||||
|
||||
@@ -45,12 +45,13 @@ export function ManageShelfModal({ shelf, isOpen, onClose }: ManageShelfModalPro
|
||||
try {
|
||||
if (shelf.type === 'goodreads') {
|
||||
if (!rssUrl.trim()) return;
|
||||
await updateGoodreads(shelf.id, rssUrl.trim());
|
||||
await updateGoodreads(shelf.id, { rssUrl: rssUrl.trim() });
|
||||
} else {
|
||||
if (!listId.trim()) return;
|
||||
await updateHardcover(shelf.id, {
|
||||
listId: listId.trim(),
|
||||
apiToken: apiToken.trim() || undefined,
|
||||
forceSync: true,
|
||||
});
|
||||
}
|
||||
onClose();
|
||||
|
||||
@@ -24,11 +24,13 @@ interface User {
|
||||
permissions?: UserPermissions;
|
||||
}
|
||||
|
||||
export type LoginResult = 'authenticated' | 'profile-selection-required';
|
||||
|
||||
interface AuthContextType {
|
||||
user: User | null;
|
||||
accessToken: string | null;
|
||||
isLoading: boolean;
|
||||
login: (pinId: number) => Promise<void>;
|
||||
login: (pinId: number) => Promise<LoginResult>;
|
||||
logout: () => void;
|
||||
refreshToken: () => Promise<void>;
|
||||
setAuthData: (user: User, accessToken: string) => void;
|
||||
@@ -182,7 +184,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
};
|
||||
|
||||
// Poll Plex OAuth callback during login
|
||||
const login = async (pinId: number) => {
|
||||
const login = async (pinId: number): Promise<LoginResult> => {
|
||||
const maxAttempts = 60; // 2 minutes total
|
||||
let attempts = 0;
|
||||
|
||||
@@ -211,7 +213,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
// Redirect to profile selection page
|
||||
// Note: Plex token is stored server-side for security, not in sessionStorage
|
||||
window.location.href = data.redirectUrl;
|
||||
return;
|
||||
return 'profile-selection-required';
|
||||
}
|
||||
|
||||
// Login successful (no profile selection needed)
|
||||
@@ -226,7 +228,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
// Schedule auto-refresh
|
||||
scheduleTokenRefresh(data.accessToken);
|
||||
|
||||
return;
|
||||
return 'authenticated';
|
||||
}
|
||||
|
||||
// Still waiting for authorization
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/**
|
||||
/**
|
||||
* Component: Notification Event Constants
|
||||
* Documentation: documentation/backend/services/notifications.md
|
||||
*
|
||||
@@ -10,16 +10,28 @@ export type NotificationSeverity = 'info' | 'success' | 'error' | 'warning';
|
||||
export type NotificationPriority = 'normal' | 'high';
|
||||
|
||||
/**
|
||||
* Central registry of notification events.
|
||||
* Normalized interface for event metadata.
|
||||
* Each entry in NOTIFICATION_EVENTS is structurally validated against this via `satisfies`.
|
||||
*
|
||||
* Each entry defines:
|
||||
* - `label`: Human-readable name shown in the UI
|
||||
* - `title`: Default title used in notification messages
|
||||
* - `titleByRequestType`: Optional map of request-type-specific titles (e.g. audiobook → "Audiobook Available")
|
||||
* - `emoji`: Emoji prefix for notification titles
|
||||
* - `severity`: Drives provider formatting (colors, Apprise types, ntfy tags)
|
||||
* - `priority`: Drives notification urgency (Pushover/ntfy priority levels)
|
||||
* - `messageLabel`: Optional label for the `message` payload field (defaults to "Error" if omitted)
|
||||
*/
|
||||
export interface NotificationEventConfig {
|
||||
label: string;
|
||||
title: string;
|
||||
titleByRequestType?: Record<string, string>;
|
||||
emoji: string;
|
||||
severity: NotificationSeverity;
|
||||
priority: NotificationPriority;
|
||||
messageLabel?: string;
|
||||
}
|
||||
|
||||
/** Central registry of notification events. */
|
||||
export const NOTIFICATION_EVENTS = {
|
||||
request_pending_approval: {
|
||||
label: 'Request Pending Approval',
|
||||
@@ -31,17 +43,29 @@ export const NOTIFICATION_EVENTS = {
|
||||
request_approved: {
|
||||
label: 'Request Approved',
|
||||
title: 'Request Approved',
|
||||
emoji: '\u2705',
|
||||
emoji: '✅',
|
||||
severity: 'success' as const,
|
||||
priority: 'normal' as const,
|
||||
},
|
||||
request_grabbed: {
|
||||
label: 'Request Grabbed',
|
||||
title: 'Download Grabbed',
|
||||
titleByRequestType: {
|
||||
audiobook: 'Audiobook Grabbed',
|
||||
ebook: 'Ebook Grabbed',
|
||||
},
|
||||
emoji: '\u{1F4E5}',
|
||||
severity: 'info' as const,
|
||||
priority: 'normal' as const,
|
||||
messageLabel: 'Details',
|
||||
},
|
||||
request_available: {
|
||||
label: 'Request Available',
|
||||
title: 'Request Available',
|
||||
titleByRequestType: {
|
||||
audiobook: 'Audiobook Available',
|
||||
ebook: 'Ebook Available',
|
||||
} as Record<string, string>,
|
||||
},
|
||||
emoji: '\u{1F389}',
|
||||
severity: 'success' as const,
|
||||
priority: 'high' as const,
|
||||
@@ -49,18 +73,26 @@ export const NOTIFICATION_EVENTS = {
|
||||
request_error: {
|
||||
label: 'Request Error',
|
||||
title: 'Request Error',
|
||||
emoji: '\u274C',
|
||||
emoji: '❌',
|
||||
severity: 'error' as const,
|
||||
priority: 'high' as const,
|
||||
},
|
||||
request_cancelled: {
|
||||
label: 'Request Cancelled',
|
||||
title: 'Request Cancelled',
|
||||
emoji: '\u{1F6AB}',
|
||||
severity: 'warning' as const,
|
||||
priority: 'normal' as const,
|
||||
},
|
||||
issue_reported: {
|
||||
label: 'Issue Reported',
|
||||
title: 'Issue Reported',
|
||||
emoji: '\u{1F6A9}',
|
||||
severity: 'warning' as const,
|
||||
priority: 'high' as const,
|
||||
messageLabel: 'Reason',
|
||||
},
|
||||
} as const;
|
||||
} satisfies Record<string, NotificationEventConfig>;
|
||||
|
||||
/** Union type of all valid notification event keys */
|
||||
export type NotificationEvent = keyof typeof NOTIFICATION_EVENTS;
|
||||
@@ -72,7 +104,7 @@ export const NOTIFICATION_EVENT_KEYS = Object.keys(NOTIFICATION_EVENTS) as [Noti
|
||||
export type NotificationEventMeta = (typeof NOTIFICATION_EVENTS)[NotificationEvent];
|
||||
|
||||
/** Helper: get event metadata by key */
|
||||
export function getEventMeta(event: NotificationEvent) {
|
||||
export function getEventMeta(event: NotificationEvent): NotificationEventConfig {
|
||||
return NOTIFICATION_EVENTS[event];
|
||||
}
|
||||
|
||||
@@ -82,9 +114,9 @@ export function getEventMeta(event: NotificationEvent) {
|
||||
* returns the type-specific title. Otherwise falls back to the default `title`.
|
||||
*/
|
||||
export function getEventTitle(event: NotificationEvent, requestType?: string): string {
|
||||
const meta = NOTIFICATION_EVENTS[event];
|
||||
if (requestType && 'titleByRequestType' in meta) {
|
||||
const typeTitle = (meta as typeof meta & { titleByRequestType: Record<string, string> }).titleByRequestType[requestType];
|
||||
const meta = getEventMeta(event);
|
||||
if (requestType && meta.titleByRequestType) {
|
||||
const typeTitle = meta.titleByRequestType[requestType];
|
||||
if (typeTitle) return typeTitle;
|
||||
}
|
||||
return meta.title;
|
||||
|
||||
@@ -5,3 +5,12 @@
|
||||
|
||||
/** Terminal statuses indicating a request has been fulfilled and files are ready */
|
||||
export const COMPLETED_STATUSES = ['available', 'downloaded'] as const;
|
||||
|
||||
/** Statuses from which a request can be cancelled (server-enforced and UI-gated) */
|
||||
export const CANCELLABLE_STATUSES = [
|
||||
'pending',
|
||||
'searching',
|
||||
'downloading',
|
||||
'awaiting_search',
|
||||
'awaiting_approval',
|
||||
] as const;
|
||||
|
||||
@@ -46,7 +46,13 @@ export function createShelfHooks<TShelf>(endpoint: string) {
|
||||
const key = accessToken ? endpoint : null;
|
||||
|
||||
const { data, error, isLoading } = useSWR(key, fetcher, {
|
||||
refreshInterval: 30000,
|
||||
refreshInterval: (latestData: { shelves: TShelf[] } | undefined) => {
|
||||
const shelves = latestData?.shelves || [];
|
||||
const hasSyncing = shelves.some(
|
||||
(s) => !(s as Record<string, unknown>)['lastSyncAt'],
|
||||
);
|
||||
return hasSyncing ? 3000 : 30000;
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -33,6 +33,10 @@ export interface Audiobook {
|
||||
requestId?: string | null; // ID of request (if any)
|
||||
requestedByUsername?: string | null; // Username who requested (only if not current user)
|
||||
hasReportedIssue?: boolean; // True if an open issue exists for this audiobook
|
||||
isIgnored?: boolean; // True if this user has ignored this audiobook from auto-requests
|
||||
language?: string;
|
||||
formatType?: string;
|
||||
publisherName?: string;
|
||||
}
|
||||
|
||||
export function useAudiobooks(type: 'popular' | 'new-releases', limit: number = 20, page: number = 1, hideAvailable: boolean = false) {
|
||||
|
||||
@@ -16,6 +16,7 @@ export interface GoodreadsShelf {
|
||||
lastSyncAt: string | null;
|
||||
createdAt: string;
|
||||
bookCount: number | null;
|
||||
autoRequest: boolean;
|
||||
books: ShelfBook[];
|
||||
}
|
||||
|
||||
@@ -27,8 +28,8 @@ export const useGoodreadsShelves = useList;
|
||||
export function useAddGoodreadsShelf() {
|
||||
const { addShelf: addGeneric, isLoading, error } = useAdd();
|
||||
|
||||
const addShelf = async (rssUrl: string) => {
|
||||
return addGeneric({ rssUrl });
|
||||
const addShelf = async (rssUrl: string, autoRequest: boolean = true) => {
|
||||
return addGeneric({ rssUrl, autoRequest });
|
||||
};
|
||||
|
||||
return { addShelf, isLoading, error };
|
||||
@@ -39,8 +40,8 @@ export const useDeleteGoodreadsShelf = useDelete;
|
||||
export function useUpdateGoodreadsShelf() {
|
||||
const { updateShelf: updateGeneric, isLoading, error } = useUpdate();
|
||||
|
||||
const updateShelf = async (shelfId: string, rssUrl: string) => {
|
||||
return updateGeneric(shelfId, { rssUrl });
|
||||
const updateShelf = async (shelfId: string, updates: { rssUrl?: string; autoRequest?: boolean }) => {
|
||||
return updateGeneric(shelfId, updates);
|
||||
};
|
||||
|
||||
return { updateShelf, isLoading, error };
|
||||
|
||||
@@ -16,6 +16,7 @@ export interface HardcoverShelf {
|
||||
lastSyncAt: string | null;
|
||||
createdAt: string;
|
||||
bookCount: number | null;
|
||||
autoRequest: boolean;
|
||||
books: ShelfBook[];
|
||||
}
|
||||
|
||||
@@ -27,8 +28,8 @@ export const useHardcoverShelves = useList;
|
||||
export function useAddHardcoverShelf() {
|
||||
const { addShelf: addGeneric, isLoading, error } = useAdd();
|
||||
|
||||
const addShelf = async (apiToken: string, listId: string) => {
|
||||
return addGeneric({ apiToken, listId });
|
||||
const addShelf = async (apiToken: string, listId: string, autoRequest: boolean = true) => {
|
||||
return addGeneric({ apiToken, listId, autoRequest });
|
||||
};
|
||||
|
||||
return { addShelf, isLoading, error };
|
||||
@@ -41,7 +42,7 @@ export function useUpdateHardcoverShelf() {
|
||||
|
||||
const updateShelf = async (
|
||||
shelfId: string,
|
||||
updates: { listId?: string; apiToken?: string },
|
||||
updates: { listId?: string; apiToken?: string; forceSync?: boolean; autoRequest?: boolean },
|
||||
) => {
|
||||
return updateGeneric(shelfId, updates);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Component: Ignored Audiobooks Hook
|
||||
* Documentation: documentation/features/ignored-audiobooks.md
|
||||
*
|
||||
* Provides hooks for checking and toggling audiobook ignore status.
|
||||
* - useIsIgnored(asin): check if a specific book is ignored
|
||||
* - useToggleIgnore(): toggle ignore on/off for a book
|
||||
* - useIgnoredList(): list all ignored books for the current user
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import useSWR, { mutate } from 'swr';
|
||||
import { authenticatedFetcher, fetchWithAuth } from '@/lib/utils/api';
|
||||
|
||||
interface IgnoredAudiobook {
|
||||
id: string;
|
||||
asin: string;
|
||||
title: string;
|
||||
author: string;
|
||||
coverArtUrl?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface IgnoreCheckResult {
|
||||
ignored: boolean;
|
||||
ignoredId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a specific ASIN is ignored by the current user.
|
||||
* Includes works-system expansion on the server side.
|
||||
*/
|
||||
export function useIsIgnored(asin: string | null) {
|
||||
const endpoint = asin ? `/api/user/ignored-audiobooks/check/${asin}` : null;
|
||||
|
||||
const { data, error, isLoading } = useSWR<IgnoreCheckResult>(
|
||||
endpoint,
|
||||
authenticatedFetcher,
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
dedupingInterval: 30000,
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
isIgnored: data?.ignored ?? false,
|
||||
ignoredId: data?.ignoredId ?? null,
|
||||
isLoading,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle ignore status for an audiobook.
|
||||
* Returns { addIgnore, removeIgnore } functions.
|
||||
*/
|
||||
export function useToggleIgnore() {
|
||||
const addIgnore = async (book: {
|
||||
asin: string;
|
||||
title: string;
|
||||
author: string;
|
||||
coverArtUrl?: string;
|
||||
}): Promise<IgnoredAudiobook> => {
|
||||
const res = await fetchWithAuth('/api/user/ignored-audiobooks', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(book),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err.message || 'Failed to ignore audiobook');
|
||||
}
|
||||
|
||||
const result = await res.json();
|
||||
|
||||
// Invalidate the check cache for this ASIN
|
||||
mutate(`/api/user/ignored-audiobooks/check/${book.asin}`);
|
||||
// Invalidate the full list
|
||||
mutate('/api/user/ignored-audiobooks');
|
||||
|
||||
return result.ignoredAudiobook;
|
||||
};
|
||||
|
||||
const removeIgnore = async (id: string, asin: string): Promise<void> => {
|
||||
const res = await fetchWithAuth(`/api/user/ignored-audiobooks/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err.message || 'Failed to un-ignore audiobook');
|
||||
}
|
||||
|
||||
// Invalidate the check cache for this ASIN
|
||||
mutate(`/api/user/ignored-audiobooks/check/${asin}`);
|
||||
// Invalidate the full list
|
||||
mutate('/api/user/ignored-audiobooks');
|
||||
};
|
||||
|
||||
return { addIgnore, removeIgnore };
|
||||
}
|
||||
|
||||
/**
|
||||
* List all ignored audiobooks for the current user.
|
||||
*/
|
||||
export function useIgnoredList() {
|
||||
const { data, error, isLoading } = useSWR<{ ignoredAudiobooks: IgnoredAudiobook[] }>(
|
||||
'/api/user/ignored-audiobooks',
|
||||
authenticatedFetcher,
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
dedupingInterval: 60000,
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
ignoredAudiobooks: data?.ignoredAudiobooks ?? [],
|
||||
isLoading,
|
||||
error,
|
||||
};
|
||||
}
|
||||
@@ -2,10 +2,10 @@
|
||||
* Component: Shelves Hook
|
||||
* Documentation: documentation/frontend/components.md
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import useSWR from 'swr';
|
||||
import { useState } from 'react';
|
||||
import useSWR, { mutate } from 'swr';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { fetchWithAuth } from '@/lib/utils/api';
|
||||
import { ShelfBook } from './useGoodreadsShelves';
|
||||
@@ -18,6 +18,7 @@ export interface GenericShelf {
|
||||
lastSyncAt: string | null;
|
||||
createdAt: string;
|
||||
bookCount: number | null;
|
||||
autoRequest: boolean;
|
||||
books: ShelfBook[];
|
||||
}
|
||||
|
||||
@@ -29,7 +30,11 @@ export function useShelves() {
|
||||
const endpoint = accessToken ? '/api/user/shelves' : null;
|
||||
|
||||
const { data, error, isLoading } = useSWR(endpoint, fetcher, {
|
||||
refreshInterval: 30000,
|
||||
refreshInterval: (latestData: { shelves: GenericShelf[] } | undefined) => {
|
||||
const shelves = latestData?.shelves || [];
|
||||
const hasSyncing = shelves.some((s) => !s.lastSyncAt);
|
||||
return hasSyncing ? 3000 : 30000;
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -38,3 +43,52 @@ export function useShelves() {
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
export function useSyncShelves() {
|
||||
const { accessToken } = useAuth();
|
||||
const [isSyncing, setIsSyncing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const syncShelves = async (
|
||||
shelfId?: string,
|
||||
shelfType?: 'goodreads' | 'hardcover',
|
||||
) => {
|
||||
if (!accessToken) throw new Error('Not authenticated');
|
||||
|
||||
setIsSyncing(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetchWithAuth('/api/user/shelves/sync', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ shelfId, shelfType }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || data.error || 'Failed to trigger sync');
|
||||
}
|
||||
|
||||
// Invalidate both the provider-specific endpoints and the combined endpoint
|
||||
mutate(
|
||||
(key) =>
|
||||
typeof key === 'string' &&
|
||||
(key.includes('/api/user/shelves') ||
|
||||
key.includes('/api/user/goodreads-shelves') ||
|
||||
key.includes('/api/user/hardcover-shelves')),
|
||||
);
|
||||
|
||||
return true;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||
setError(message);
|
||||
throw err;
|
||||
} finally {
|
||||
setIsSyncing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return { syncShelves, isSyncing, error };
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
import { RMABLogger } from '../utils/logger';
|
||||
import { parseRuntime } from '../utils/parse-runtime';
|
||||
import { randomDelay } from '../utils/scrape-resilience';
|
||||
import { extractAllNarrators } from '../utils/extract-narrator';
|
||||
|
||||
const logger = RMABLogger.create('Audible.Series');
|
||||
|
||||
@@ -442,10 +443,8 @@ function parseSeriesBooks(
|
||||
const authorHref = authorLink.attr('href') || '';
|
||||
const authorAsinMatch = authorHref.match(/\/author\/[^/]+\/([A-Z0-9]{10})/);
|
||||
|
||||
// Narrator
|
||||
const narratorText = $el.find('a[href*="searchNarrator="]').first().text().trim() ||
|
||||
$el.find('.narratorLabel').text().trim() ||
|
||||
'';
|
||||
// Narrator — capture all narrator links (multi-narrator productions are common)
|
||||
const narratorText = extractAllNarrators($, $el);
|
||||
|
||||
// Cover art
|
||||
const coverArtUrl = $el.find('img').first().attr('src')?.replace(/\._.*_\./, '._SL500_.') || '';
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -226,6 +226,7 @@ export class NZBGetService implements IDownloadClient {
|
||||
responseType: 'arraybuffer',
|
||||
timeout: 30000,
|
||||
maxRedirects: 5,
|
||||
headers: options?.sourceHeaders,
|
||||
httpsAgent: url.startsWith('https') ? this.httpsAgent : undefined,
|
||||
});
|
||||
|
||||
|
||||
@@ -315,6 +315,9 @@ export class ProwlarrService {
|
||||
limit: 100,
|
||||
extended: 1,
|
||||
},
|
||||
headers: {
|
||||
'User-Agent': 'ReadMeABook',
|
||||
},
|
||||
timeout: DOWNLOAD_CLIENT_TIMEOUT,
|
||||
responseType: 'text', // Get XML as text
|
||||
});
|
||||
|
||||
@@ -108,6 +108,7 @@ export class QBittorrentService implements IDownloadClient {
|
||||
private username: string;
|
||||
private password: string;
|
||||
private cookie?: string;
|
||||
private authOptional: boolean;
|
||||
private defaultSavePath: string;
|
||||
private defaultCategory: string;
|
||||
private disableSSLVerify: boolean;
|
||||
@@ -126,11 +127,16 @@ export class QBittorrentService implements IDownloadClient {
|
||||
this.baseUrl = baseUrl.replace(/\/$/, '');
|
||||
this.username = username;
|
||||
this.password = password;
|
||||
this.authOptional = !username && !password;
|
||||
this.defaultSavePath = defaultSavePath;
|
||||
this.defaultCategory = defaultCategory;
|
||||
this.disableSSLVerify = disableSSLVerify;
|
||||
this.pathMappingConfig = pathMappingConfig || { enabled: false, remotePath: '', localPath: '' };
|
||||
|
||||
if (this.authOptional) {
|
||||
logger.info('[QBittorrent] No credentials configured — running in auth-optional mode (suitable for IP-whitelisted qBittorrent or auth-less proxies like Decypharr)');
|
||||
}
|
||||
|
||||
// Create HTTPS agent if SSL verification is disabled
|
||||
if (disableSSLVerify && this.baseUrl.startsWith('https')) {
|
||||
this.httpsAgent = new https.Agent({
|
||||
@@ -152,9 +158,23 @@ export class QBittorrentService implements IDownloadClient {
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate and establish session
|
||||
* Build request headers including the session cookie when one exists.
|
||||
* In auth-optional mode no cookie is set and the Cookie header is omitted.
|
||||
*/
|
||||
private authHeaders(): Record<string, string> {
|
||||
return this.cookie ? { Cookie: this.cookie } : {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate and establish session.
|
||||
* In auth-optional mode (no username/password configured) this is a no-op.
|
||||
*/
|
||||
async login(): Promise<void> {
|
||||
if (this.authOptional) {
|
||||
logger.debug('[QBittorrent] Skipping login — auth-optional mode');
|
||||
return;
|
||||
}
|
||||
|
||||
const loginUrl = `${this.baseUrl}/api/v2/auth/login`;
|
||||
|
||||
logger.debug('[QBittorrent] Attempting login', {
|
||||
@@ -241,7 +261,7 @@ export class QBittorrentService implements IDownloadClient {
|
||||
}
|
||||
|
||||
// Ensure we're authenticated
|
||||
if (!this.cookie) {
|
||||
if (!this.cookie && !this.authOptional) {
|
||||
await this.login();
|
||||
}
|
||||
|
||||
@@ -260,8 +280,10 @@ export class QBittorrentService implements IDownloadClient {
|
||||
return await this.addTorrentFile(url, category, options);
|
||||
}
|
||||
} catch (error) {
|
||||
// Try re-authenticating once if we get a 403
|
||||
if (!retried && axios.isAxiosError(error) && error.response?.status === 403) {
|
||||
// Try re-authenticating once if we get a 403 — only meaningful when credentials are configured.
|
||||
// In auth-optional mode a 403 means the server actually wants auth (e.g. IP no longer whitelisted),
|
||||
// so retrying login is pointless and would mask the real error.
|
||||
if (!retried && !this.authOptional && axios.isAxiosError(error) && error.response?.status === 403) {
|
||||
logger.info('[QBittorrent] Session expired, re-authenticating...');
|
||||
await this.login();
|
||||
return this.addTorrent(url, options, true);
|
||||
@@ -322,7 +344,7 @@ export class QBittorrentService implements IDownloadClient {
|
||||
|
||||
const response = await this.client.post('/torrents/add', form, {
|
||||
headers: {
|
||||
Cookie: this.cookie,
|
||||
...this.authHeaders(),
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
});
|
||||
@@ -470,7 +492,7 @@ export class QBittorrentService implements IDownloadClient {
|
||||
|
||||
const response = await this.client.post('/torrents/add', formData, {
|
||||
headers: {
|
||||
Cookie: this.cookie,
|
||||
...this.authHeaders(),
|
||||
...formData.getHeaders(),
|
||||
},
|
||||
maxBodyLength: Infinity,
|
||||
@@ -491,7 +513,7 @@ export class QBittorrentService implements IDownloadClient {
|
||||
* Applies reverse path mapping (local → remote) for remote seedbox scenarios
|
||||
*/
|
||||
protected async ensureCategory(category: string): Promise<void> {
|
||||
if (!this.cookie) {
|
||||
if (!this.cookie && !this.authOptional) {
|
||||
await this.login();
|
||||
}
|
||||
|
||||
@@ -501,7 +523,7 @@ export class QBittorrentService implements IDownloadClient {
|
||||
try {
|
||||
// First, get all categories to check if it exists and what save path it has
|
||||
const categoriesResponse = await this.client.get('/torrents/categories', {
|
||||
headers: { Cookie: this.cookie },
|
||||
headers: this.authHeaders(),
|
||||
});
|
||||
|
||||
const categories = categoriesResponse.data;
|
||||
@@ -519,7 +541,7 @@ export class QBittorrentService implements IDownloadClient {
|
||||
}),
|
||||
{
|
||||
headers: {
|
||||
Cookie: this.cookie,
|
||||
...this.authHeaders(),
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
}
|
||||
@@ -541,7 +563,7 @@ export class QBittorrentService implements IDownloadClient {
|
||||
}),
|
||||
{
|
||||
headers: {
|
||||
Cookie: this.cookie,
|
||||
...this.authHeaders(),
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
}
|
||||
@@ -572,13 +594,13 @@ export class QBittorrentService implements IDownloadClient {
|
||||
* Get torrent status and progress
|
||||
*/
|
||||
async getTorrent(hash: string): Promise<TorrentInfo> {
|
||||
if (!this.cookie) {
|
||||
if (!this.cookie && !this.authOptional) {
|
||||
await this.login();
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await this.client.get('/torrents/info', {
|
||||
headers: { Cookie: this.cookie },
|
||||
headers: this.authHeaders(),
|
||||
params: { hashes: hash },
|
||||
});
|
||||
|
||||
@@ -610,7 +632,7 @@ export class QBittorrentService implements IDownloadClient {
|
||||
* Get all torrents (optionally filtered by category)
|
||||
*/
|
||||
async getTorrents(category?: string): Promise<TorrentInfo[]> {
|
||||
if (!this.cookie) {
|
||||
if (!this.cookie && !this.authOptional) {
|
||||
await this.login();
|
||||
}
|
||||
|
||||
@@ -621,7 +643,7 @@ export class QBittorrentService implements IDownloadClient {
|
||||
}
|
||||
|
||||
const response = await this.client.get('/torrents/info', {
|
||||
headers: { Cookie: this.cookie },
|
||||
headers: this.authHeaders(),
|
||||
params,
|
||||
});
|
||||
|
||||
@@ -636,7 +658,7 @@ export class QBittorrentService implements IDownloadClient {
|
||||
* Pause torrent
|
||||
*/
|
||||
async pauseTorrent(hash: string): Promise<void> {
|
||||
if (!this.cookie) {
|
||||
if (!this.cookie && !this.authOptional) {
|
||||
await this.login();
|
||||
}
|
||||
|
||||
@@ -646,7 +668,7 @@ export class QBittorrentService implements IDownloadClient {
|
||||
new URLSearchParams({ hashes: hash }),
|
||||
{
|
||||
headers: {
|
||||
Cookie: this.cookie,
|
||||
...this.authHeaders(),
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
}
|
||||
@@ -663,7 +685,7 @@ export class QBittorrentService implements IDownloadClient {
|
||||
* Resume torrent
|
||||
*/
|
||||
async resumeTorrent(hash: string): Promise<void> {
|
||||
if (!this.cookie) {
|
||||
if (!this.cookie && !this.authOptional) {
|
||||
await this.login();
|
||||
}
|
||||
|
||||
@@ -673,7 +695,7 @@ export class QBittorrentService implements IDownloadClient {
|
||||
new URLSearchParams({ hashes: hash }),
|
||||
{
|
||||
headers: {
|
||||
Cookie: this.cookie,
|
||||
...this.authHeaders(),
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
}
|
||||
@@ -690,7 +712,7 @@ export class QBittorrentService implements IDownloadClient {
|
||||
* Delete torrent
|
||||
*/
|
||||
async deleteTorrent(hash: string, deleteFiles: boolean = false): Promise<void> {
|
||||
if (!this.cookie) {
|
||||
if (!this.cookie && !this.authOptional) {
|
||||
await this.login();
|
||||
}
|
||||
|
||||
@@ -703,7 +725,7 @@ export class QBittorrentService implements IDownloadClient {
|
||||
}),
|
||||
{
|
||||
headers: {
|
||||
Cookie: this.cookie,
|
||||
...this.authHeaders(),
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
}
|
||||
@@ -720,13 +742,13 @@ export class QBittorrentService implements IDownloadClient {
|
||||
* Get files in torrent
|
||||
*/
|
||||
async getFiles(hash: string): Promise<TorrentFile[]> {
|
||||
if (!this.cookie) {
|
||||
if (!this.cookie && !this.authOptional) {
|
||||
await this.login();
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await this.client.get('/torrents/files', {
|
||||
headers: { Cookie: this.cookie },
|
||||
headers: this.authHeaders(),
|
||||
params: { hash },
|
||||
});
|
||||
|
||||
@@ -741,13 +763,13 @@ export class QBittorrentService implements IDownloadClient {
|
||||
* Get all configured categories from qBittorrent
|
||||
*/
|
||||
async getCategories(): Promise<string[]> {
|
||||
if (!this.cookie) {
|
||||
if (!this.cookie && !this.authOptional) {
|
||||
await this.login();
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await this.client.get('/torrents/categories', {
|
||||
headers: { Cookie: this.cookie },
|
||||
headers: this.authHeaders(),
|
||||
});
|
||||
|
||||
return Object.keys(response.data || {});
|
||||
@@ -761,7 +783,7 @@ export class QBittorrentService implements IDownloadClient {
|
||||
* Set category for torrent
|
||||
*/
|
||||
async setCategory(hash: string, category: string): Promise<void> {
|
||||
if (!this.cookie) {
|
||||
if (!this.cookie && !this.authOptional) {
|
||||
await this.login();
|
||||
}
|
||||
|
||||
@@ -774,7 +796,7 @@ export class QBittorrentService implements IDownloadClient {
|
||||
}),
|
||||
{
|
||||
headers: {
|
||||
Cookie: this.cookie,
|
||||
...this.authHeaders(),
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
}
|
||||
@@ -788,26 +810,36 @@ export class QBittorrentService implements IDownloadClient {
|
||||
}
|
||||
|
||||
/**
|
||||
* Test connection to qBittorrent
|
||||
* Test connection to qBittorrent.
|
||||
* In auth-optional mode the /app/version probe IS the connectivity check, so it must succeed.
|
||||
* In credentialed mode login() is the connectivity check and version is best-effort.
|
||||
*/
|
||||
async testConnection(): Promise<ConnectionTestResult> {
|
||||
try {
|
||||
await this.login();
|
||||
await this.login(); // no-op when authOptional; throws on real auth failure
|
||||
|
||||
// Fetch version after successful login
|
||||
let version: string | undefined;
|
||||
try {
|
||||
const versionResponse = await this.client.get('/app/version', {
|
||||
headers: { Cookie: this.cookie },
|
||||
headers: this.authHeaders(),
|
||||
});
|
||||
const raw = versionResponse.data || '';
|
||||
version = typeof raw === 'string' ? raw.replace(/^v/i, '') : undefined;
|
||||
} catch {
|
||||
// Version fetch is non-critical - connection is still valid
|
||||
const version = typeof raw === 'string' ? raw.replace(/^v/i, '') : undefined;
|
||||
return { success: true, version, message: `Connected to qBittorrent${version ? ` ${version}` : ''}` };
|
||||
} catch (versionError) {
|
||||
if (this.authOptional) {
|
||||
// No login happened — version probe was our only connectivity signal.
|
||||
const status = axios.isAxiosError(versionError) ? versionError.response?.status : undefined;
|
||||
const baseMessage = versionError instanceof Error ? versionError.message : 'Connection failed';
|
||||
const message = status === 401 || status === 403
|
||||
? `qBittorrent requires authentication (HTTP ${status}). Provide username/password or whitelist this app's IP in qBittorrent.`
|
||||
: `Failed to reach qBittorrent: ${baseMessage}`;
|
||||
logger.error('[QBittorrent] Auth-optional connection probe failed', { status, message: baseMessage });
|
||||
return { success: false, message };
|
||||
}
|
||||
// Credentialed path: login already succeeded, version is nice-to-have.
|
||||
logger.debug('Could not fetch qBittorrent version');
|
||||
return { success: true, message: 'Connected to qBittorrent' };
|
||||
}
|
||||
|
||||
return { success: true, version, message: `Connected to qBittorrent${version ? ` ${version}` : ''}` };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Connection failed';
|
||||
logger.error('Connection test failed', { error: message });
|
||||
@@ -826,6 +858,7 @@ export class QBittorrentService implements IDownloadClient {
|
||||
): Promise<string> {
|
||||
const baseUrl = url.replace(/\/$/, '');
|
||||
const loginUrl = `${baseUrl}/api/v2/auth/login`;
|
||||
const authOptional = !username && !password;
|
||||
|
||||
// Create HTTPS agent if SSL verification is disabled
|
||||
let httpsAgent: https.Agent | undefined;
|
||||
@@ -844,9 +877,25 @@ export class QBittorrentService implements IDownloadClient {
|
||||
passwordLength: password?.length,
|
||||
sslVerifyDisabled: disableSSLVerify,
|
||||
hasHttpsAgent: !!httpsAgent,
|
||||
authOptional,
|
||||
});
|
||||
|
||||
try {
|
||||
if (authOptional) {
|
||||
// No credentials provided — skip /auth/login and probe /app/version directly.
|
||||
// Works for IP-whitelisted qBittorrent and auth-less qBit-compatible proxies (e.g. Decypharr).
|
||||
logger.info('[QBittorrent] No credentials provided, probing /app/version directly');
|
||||
const versionResponse = await axios.get(`${baseUrl}/api/v2/app/version`, {
|
||||
httpsAgent,
|
||||
timeout: DOWNLOAD_CLIENT_TIMEOUT,
|
||||
});
|
||||
logger.info('[QBittorrent] Auth-optional version check successful', {
|
||||
version: versionResponse.data,
|
||||
});
|
||||
const rawVersion = versionResponse.data || '';
|
||||
return typeof rawVersion === 'string' ? rawVersion.replace(/^v/i, '') || 'Connected' : 'Connected';
|
||||
}
|
||||
|
||||
const requestBody = new URLSearchParams({ username, password });
|
||||
const requestHeaders = {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
@@ -980,6 +1029,11 @@ export class QBittorrentService implements IDownloadClient {
|
||||
|
||||
// HTTP status errors
|
||||
if (status === 401 || status === 403) {
|
||||
if (authOptional) {
|
||||
throw new Error(
|
||||
`qBittorrent requires authentication (HTTP ${status}). Provide username/password, or whitelist this app's IP in qBittorrent's Web UI settings.`
|
||||
);
|
||||
}
|
||||
throw new Error(
|
||||
`Authentication failed (HTTP ${status}). Check your username and password.`
|
||||
);
|
||||
|
||||
@@ -24,6 +24,8 @@ export interface AddNZBOptions {
|
||||
category?: string;
|
||||
priority?: 'low' | 'normal' | 'high' | 'force';
|
||||
paused?: boolean;
|
||||
/** Headers to include when fetching the NZB from the source URL */
|
||||
sourceHeaders?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface NZBInfo {
|
||||
@@ -492,6 +494,7 @@ export class SABnzbdService implements IDownloadClient {
|
||||
responseType: 'arraybuffer',
|
||||
timeout: 30000,
|
||||
maxRedirects: 5,
|
||||
headers: options?.sourceHeaders,
|
||||
// Use the same SSL settings as the SABnzbd client if the NZB URL
|
||||
// happens to be served over HTTPS with a self-signed cert
|
||||
httpsAgent: url.startsWith('https') ? this.httpsAgent : undefined,
|
||||
@@ -787,6 +790,7 @@ export class SABnzbdService implements IDownloadClient {
|
||||
category: options?.category,
|
||||
priority: options?.priority ? priorityMap[options.priority] || 'normal' : undefined,
|
||||
paused: options?.paused,
|
||||
sourceHeaders: options?.sourceHeaders,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -102,6 +102,8 @@ export interface AddDownloadOptions {
|
||||
priority?: string;
|
||||
/** Whether to add in paused state */
|
||||
paused?: boolean;
|
||||
/** Headers to include when fetching the source file (e.g. Prowlarr API key for proxy URLs) */
|
||||
sourceHeaders?: Record<string, string>;
|
||||
}
|
||||
|
||||
/** Result of a connection test */
|
||||
|
||||
@@ -172,6 +172,7 @@ export async function requireAuth(
|
||||
select: {
|
||||
id: true,
|
||||
deletedAt: true,
|
||||
sessionsInvalidatedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -186,6 +187,19 @@ export async function requireAuth(
|
||||
);
|
||||
}
|
||||
|
||||
// Check if session was invalidated after this token was issued
|
||||
if (user.sessionsInvalidatedAt && payload.iat &&
|
||||
payload.iat < Math.floor(user.sessionsInvalidatedAt.getTime() / 1000)) {
|
||||
logger.warn('Token issued before session invalidation', { userId: payload.sub });
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Unauthorized',
|
||||
message: 'Session has been revoked',
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// Add user to request
|
||||
const authenticatedRequest = request as AuthenticatedRequest;
|
||||
authenticatedRequest.user = {
|
||||
|
||||
@@ -138,16 +138,37 @@ async function persistSectionBooks(
|
||||
logger: ReturnType<typeof RMABLogger.forJob>,
|
||||
labelForErrors: string,
|
||||
): Promise<number> {
|
||||
// Defensive dedup: the (asin, categoryId) unique constraint means a duplicate ASIN
|
||||
// in `books` crashes the second .create() with P2002. The HTML parser already dedupes
|
||||
// per page and across pages against the cumulative accumulator, but a warn-on-fire
|
||||
// signal here lets us detect upstream surprises (e.g. Audible serving the same item
|
||||
// in both a carousel and the main grid) without the noisy duplicate-key Postgres
|
||||
// errors. Keep the first occurrence so Audible's editorial ordering is preserved.
|
||||
const seenAsins = new Set<string>();
|
||||
const dedupedBooks = books.filter((b) => {
|
||||
if (!b?.asin || seenAsins.has(b.asin)) return false;
|
||||
seenAsins.add(b.asin);
|
||||
return true;
|
||||
});
|
||||
const droppedCount = books.length - dedupedBooks.length;
|
||||
if (droppedCount > 0) {
|
||||
logger.warn(
|
||||
`Dropped ${droppedCount} duplicate ASIN(s) from ${categoryId} input list before persist`,
|
||||
);
|
||||
}
|
||||
|
||||
// Wipe previous entries for this section
|
||||
logger.info(`Clearing previous data for ${categoryId}...`);
|
||||
await prisma.audibleCacheCategory.deleteMany({
|
||||
where: { categoryId },
|
||||
});
|
||||
logger.info(`Cleared previous entries for ${categoryId}, saving ${books.length} books...`);
|
||||
logger.info(
|
||||
`Cleared previous entries for ${categoryId}, saving ${dedupedBooks.length} books...`,
|
||||
);
|
||||
|
||||
let saved = 0;
|
||||
for (let i = 0; i < books.length; i++) {
|
||||
const book = books[i];
|
||||
for (let i = 0; i < dedupedBooks.length; i++) {
|
||||
const book = dedupedBooks[i];
|
||||
try {
|
||||
// Cache thumbnail if coverArtUrl exists
|
||||
let cachedCoverPath: string | null = null;
|
||||
|
||||
@@ -289,8 +289,11 @@ async function downloadFileWithProgress(
|
||||
logger: RMABLogger
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
// Ensure target directory exists
|
||||
await fs.mkdir(path.dirname(targetPath), { recursive: true });
|
||||
// Ensure target directory exists with configured permissions
|
||||
const configService = getConfigService();
|
||||
const dirChmodStr = await configService.get('dir_chmod') || '775';
|
||||
const dirMode = parseInt(dirChmodStr, 8);
|
||||
await fs.mkdir(path.dirname(targetPath), { recursive: true, mode: dirMode });
|
||||
|
||||
// Start download with axios streaming
|
||||
const response = await axios({
|
||||
|
||||
@@ -31,13 +31,16 @@ export async function processDownloadTorrent(payload: DownloadTorrentPayload): P
|
||||
|
||||
try {
|
||||
// Update request status to downloading
|
||||
await prisma.request.update({
|
||||
const request = await prisma.request.update({
|
||||
where: { id: requestId },
|
||||
data: {
|
||||
status: 'downloading',
|
||||
progress: 0,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
include: {
|
||||
user: { select: { plexUsername: true } },
|
||||
},
|
||||
});
|
||||
|
||||
// Detect protocol from result and get appropriate client
|
||||
@@ -58,10 +61,19 @@ export async function processDownloadTorrent(payload: DownloadTorrentPayload): P
|
||||
|
||||
logger.info(`Routing to ${client.clientType} (${client.protocol})`);
|
||||
|
||||
// Include Prowlarr API key as source header so NZB/torrent downloads from
|
||||
// Prowlarr proxy URLs are authenticated (fixes 403 for indexers like NZBFinder)
|
||||
const prowlarrApiKey = (await config.getMany(['prowlarr_api_key'])).prowlarr_api_key || process.env.PROWLARR_API_KEY;
|
||||
const sourceHeaders: Record<string, string> = {};
|
||||
if (prowlarrApiKey) {
|
||||
sourceHeaders['X-Api-Key'] = prowlarrApiKey;
|
||||
}
|
||||
|
||||
// Add download via unified interface
|
||||
const downloadClientId = await client.addDownload(torrent.downloadUrl, {
|
||||
category,
|
||||
priority: 'normal',
|
||||
sourceHeaders,
|
||||
});
|
||||
|
||||
logger.info(`Download added with ID: ${downloadClientId}`);
|
||||
@@ -94,8 +106,22 @@ export async function processDownloadTorrent(payload: DownloadTorrentPayload): P
|
||||
|
||||
logger.info(`Created download history record: ${downloadHistory.id}`);
|
||||
|
||||
// Trigger monitor download job with initial delay
|
||||
// Send grab notification (non-blocking — failures here don't fail the download)
|
||||
const jobQueue = getJobQueueService();
|
||||
const grabMessage = `${torrent.title} via ${torrent.indexer} (${client.clientType})`;
|
||||
await jobQueue.addNotificationJob(
|
||||
'request_grabbed',
|
||||
requestId,
|
||||
audiobook.title,
|
||||
audiobook.author,
|
||||
request.user.plexUsername || 'Unknown User',
|
||||
grabMessage,
|
||||
request.type
|
||||
).catch((error) => {
|
||||
logger.error('Failed to queue grab notification', { error: error instanceof Error ? error.message : String(error) });
|
||||
});
|
||||
|
||||
// Trigger monitor download job with initial delay
|
||||
await jobQueue.addMonitorJob(
|
||||
requestId,
|
||||
downloadHistory.id,
|
||||
|
||||
@@ -23,7 +23,7 @@ import { getAudibleService } from '../integrations/audible.service';
|
||||
* Handles both audiobook and ebook request types with appropriate branching
|
||||
*/
|
||||
export async function processOrganizeFiles(payload: OrganizeFilesPayload): Promise<any> {
|
||||
const { requestId, audiobookId, downloadPath, jobId, cleanupSource } = payload;
|
||||
const { requestId, audiobookId, downloadPath, jobId, cleanupSource, selectedFiles } = payload;
|
||||
|
||||
const logger = RMABLogger.forJob(jobId, 'OrganizeFiles');
|
||||
|
||||
@@ -212,7 +212,8 @@ export async function processOrganizeFiles(payload: OrganizeFilesPayload): Promi
|
||||
},
|
||||
template,
|
||||
jobId ? { jobId, context: 'FileOrganizer' } : undefined,
|
||||
renameConfig
|
||||
renameConfig,
|
||||
selectedFiles
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
@@ -322,7 +323,7 @@ export async function processOrganizeFiles(payload: OrganizeFilesPayload): Promi
|
||||
|
||||
// Cleanup source files if requested (manual import feature)
|
||||
if (cleanupSource) {
|
||||
await cleanupSourceAfterOrganize(downloadPath, configService, jobId, logger);
|
||||
await cleanupSourceAfterOrganize(downloadPath, configService, jobId, logger, selectedFiles);
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -1132,20 +1133,38 @@ async function cleanupSourceAfterOrganize(
|
||||
downloadPath: string,
|
||||
configService: any,
|
||||
jobId: string | undefined,
|
||||
logger: RMABLogger
|
||||
logger: RMABLogger,
|
||||
selectedFiles?: string[]
|
||||
): Promise<void> {
|
||||
try {
|
||||
const fs = await import('fs/promises');
|
||||
const pathModule = await import('path');
|
||||
|
||||
logger.info(`Cleaning up source files: ${downloadPath}`);
|
||||
|
||||
const stats = await fs.stat(downloadPath);
|
||||
if (stats.isDirectory()) {
|
||||
await fs.rm(downloadPath, { recursive: true, force: true });
|
||||
logger.info(`Removed source directory: ${downloadPath}`);
|
||||
if (selectedFiles && selectedFiles.length > 0) {
|
||||
// Only delete the specific files that were imported, not the entire directory
|
||||
for (const fileName of selectedFiles) {
|
||||
const filePath = pathModule.join(downloadPath, fileName);
|
||||
try {
|
||||
await fs.unlink(filePath);
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
logger.warn(`Failed to delete source file: ${filePath}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
logger.info(`Removed ${selectedFiles.length} selected source files from ${downloadPath}`);
|
||||
} else {
|
||||
await fs.unlink(downloadPath);
|
||||
logger.info(`Removed source file: ${downloadPath}`);
|
||||
// No file filter — delete entire source path (original behavior)
|
||||
const stats = await fs.stat(downloadPath);
|
||||
if (stats.isDirectory()) {
|
||||
await fs.rm(downloadPath, { recursive: true, force: true });
|
||||
logger.info(`Removed source directory: ${downloadPath}`);
|
||||
} else {
|
||||
await fs.unlink(downloadPath);
|
||||
logger.info(`Removed source file: ${downloadPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Determine boundary path based on download path prefix
|
||||
|
||||
@@ -106,7 +106,7 @@ export async function processPlexRecentlyAddedCheck(payload: PlexRecentlyAddedPa
|
||||
author: item.author || 'Unknown Author',
|
||||
narrator: item.narrator,
|
||||
summary: item.description,
|
||||
duration: item.duration ? item.duration * 1000 : null, // Convert seconds to milliseconds
|
||||
duration: item.duration ? BigInt(Math.round(item.duration * 1000)) : null, // Convert seconds to milliseconds
|
||||
year: item.year,
|
||||
asin: item.asin, // Store ASIN from library backend
|
||||
isbn: item.isbn, // Store ISBN from library backend
|
||||
@@ -146,7 +146,7 @@ export async function processPlexRecentlyAddedCheck(payload: PlexRecentlyAddedPa
|
||||
author: item.author || existing.author,
|
||||
narrator: item.narrator || existing.narrator,
|
||||
summary: item.description || existing.summary,
|
||||
duration: item.duration ? item.duration * 1000 : existing.duration,
|
||||
duration: item.duration ? BigInt(Math.round(item.duration * 1000)) : existing.duration,
|
||||
year: item.year || existing.year,
|
||||
asin: item.asin || existing.asin, // Update ASIN if available
|
||||
isbn: item.isbn || existing.isbn, // Update ISBN if available
|
||||
@@ -265,7 +265,7 @@ export async function processPlexRecentlyAddedCheck(payload: PlexRecentlyAddedPa
|
||||
},
|
||||
},
|
||||
},
|
||||
take: 100,
|
||||
|
||||
});
|
||||
|
||||
if (matchableRequests.length > 0) {
|
||||
|
||||
@@ -90,7 +90,7 @@ export async function processScanPlex(payload: ScanPlexPayload): Promise<any> {
|
||||
author: item.author || existing.author,
|
||||
narrator: item.narrator || existing.narrator,
|
||||
summary: item.description || existing.summary,
|
||||
duration: item.duration ? item.duration * 1000 : existing.duration, // Convert seconds to milliseconds
|
||||
duration: item.duration ? BigInt(Math.round(item.duration * 1000)) : existing.duration, // Convert seconds to milliseconds
|
||||
year: item.year || existing.year,
|
||||
asin: item.asin || existing.asin, // Store ASIN from library backend
|
||||
isbn: item.isbn || existing.isbn, // Store ISBN from library backend
|
||||
@@ -132,7 +132,7 @@ export async function processScanPlex(payload: ScanPlexPayload): Promise<any> {
|
||||
author: item.author || 'Unknown Author',
|
||||
narrator: item.narrator,
|
||||
summary: item.description,
|
||||
duration: item.duration ? item.duration * 1000 : null, // Convert seconds to milliseconds
|
||||
duration: item.duration ? BigInt(Math.round(item.duration * 1000)) : null, // Convert seconds to milliseconds
|
||||
year: item.year,
|
||||
asin: item.asin, // Store ASIN from library backend (Plex or Audiobookshelf)
|
||||
isbn: item.isbn, // Store ISBN from library backend
|
||||
@@ -450,7 +450,7 @@ export async function processScanPlex(payload: ScanPlexPayload): Promise<any> {
|
||||
},
|
||||
},
|
||||
},
|
||||
take: 100, // Increased from 50 to handle more eligible requests
|
||||
|
||||
});
|
||||
|
||||
logger.info(`Found ${matchableRequests.length} matchable requests (all non-terminal statuses)`);
|
||||
|
||||
@@ -15,6 +15,8 @@ export interface SyncShelvesPayload {
|
||||
shelfId?: string;
|
||||
/** The type of shelf, if shelfId is specified */
|
||||
shelfType?: 'goodreads' | 'hardcover';
|
||||
/** If set, only process shelves for this user */
|
||||
userId?: string;
|
||||
/** Max Audible lookups per shelf. 0 = unlimited. */
|
||||
maxLookupsPerShelf?: number;
|
||||
}
|
||||
@@ -22,7 +24,7 @@ export interface SyncShelvesPayload {
|
||||
export async function processSyncShelves(
|
||||
payload: SyncShelvesPayload,
|
||||
): Promise<any> {
|
||||
const { jobId, shelfId, shelfType, maxLookupsPerShelf } = payload;
|
||||
const { jobId, shelfId, shelfType, userId, maxLookupsPerShelf } = payload;
|
||||
const logger = RMABLogger.forJob(jobId, 'SyncShelves');
|
||||
|
||||
const stats = {
|
||||
@@ -48,6 +50,7 @@ export async function processSyncShelves(
|
||||
await import('../services/goodreads-sync.service');
|
||||
const grStats = await processGoodreadsShelves(logger, {
|
||||
shelfId: shelfType === 'goodreads' ? shelfId : undefined,
|
||||
userId,
|
||||
maxLookupsPerShelf: maxLookupsPerShelf ?? (shelfId ? 0 : undefined),
|
||||
});
|
||||
|
||||
@@ -70,6 +73,7 @@ export async function processSyncShelves(
|
||||
await import('../services/hardcover-sync.service');
|
||||
const hcStats = await processHardcoverShelves(logger, {
|
||||
shelfId: shelfType === 'hardcover' ? shelfId : undefined,
|
||||
userId,
|
||||
maxLookupsPerShelf: maxLookupsPerShelf ?? (shelfId ? 0 : undefined),
|
||||
});
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user