mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 12:50:09 +00:00
Compare commits
44 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 | |||
| c9392c49c9 | |||
| 7b01cda955 | |||
| 9a6062d860 | |||
| ad1ab3af05 | |||
| 35cb318389 | |||
| e9d7a2359a | |||
| 1abaff1677 |
@@ -99,6 +99,29 @@ if [ "$READY" = "false" ]; then
|
|||||||
echo "[App] The scheduler will not be initialized - scheduled jobs may be missing"
|
echo "[App] The scheduler will not be initialized - scheduled jobs may be missing"
|
||||||
echo "[App] Check server logs above for errors (database connection, port conflict, etc.)"
|
echo "[App] Check server logs above for errors (database connection, port conflict, etc.)"
|
||||||
else
|
else
|
||||||
|
# =========================================================================
|
||||||
|
# WAIT FOR REDIS TO FINISH LOADING (internal Redis only)
|
||||||
|
# =========================================================================
|
||||||
|
# Redis returns "LOADING Redis is loading the dataset in memory" while it
|
||||||
|
# replays its AOF/RDB on startup. /api/health only checks Postgres, so it
|
||||||
|
# passes before Redis is actually ready to accept commands. Without this
|
||||||
|
# wait, /api/init kicks off Bull queues that flood the log with LOADING
|
||||||
|
# errors until the retry loop catches up.
|
||||||
|
if [ "$USE_EXTERNAL_REDIS" != "true" ]; then
|
||||||
|
REDIS_READY_TIMEOUT=${REDIS_READY_TIMEOUT:-60}
|
||||||
|
echo "[App] Waiting for Redis to finish loading (timeout: ${REDIS_READY_TIMEOUT}s)..."
|
||||||
|
for i in $(seq 1 "$REDIS_READY_TIMEOUT"); do
|
||||||
|
if redis-cli -h 127.0.0.1 -p 6379 ping 2>/dev/null | grep -q '^PONG$'; then
|
||||||
|
echo "[App] Redis is ready (took ${i}s)"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
if [ "$i" -eq "$REDIS_READY_TIMEOUT" ]; then
|
||||||
|
echo "[App] WARNING: Redis did not become ready within ${REDIS_READY_TIMEOUT}s - proceeding anyway"
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# INITIALIZE APPLICATION SERVICES
|
# INITIALIZE APPLICATION SERVICES
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
- **Admin-generated login token per user (URL-login)** → [backend/services/auth.md](backend/services/auth.md)
|
- **Admin-generated login token per user (URL-login)** → [backend/services/auth.md](backend/services/auth.md)
|
||||||
- **Route protection, auth guards** → [frontend/routing-auth.md](frontend/routing-auth.md)
|
- **Route protection, auth guards** → [frontend/routing-auth.md](frontend/routing-auth.md)
|
||||||
- **Login page UI/UX** → [frontend/pages/login.md](frontend/pages/login.md)
|
- **Login page UI/UX** → [frontend/pages/login.md](frontend/pages/login.md)
|
||||||
|
- **Credential recovery (lost CONFIG_ENCRYPTION_KEY, locked-out admin)** → [admin-features/credential-recovery.md](admin-features/credential-recovery.md)
|
||||||
|
|
||||||
## Configuration & Setup
|
## Configuration & Setup
|
||||||
- **First-time setup wizard** → [setup-wizard.md](setup-wizard.md)
|
- **First-time setup wizard** → [setup-wizard.md](setup-wizard.md)
|
||||||
@@ -45,6 +46,8 @@
|
|||||||
- **Web scraping (popular, new releases)** → [integrations/audible.md](integrations/audible.md)
|
- **Web scraping (popular, new releases)** → [integrations/audible.md](integrations/audible.md)
|
||||||
- **Database caching, real-time matching** → [integrations/audible.md](integrations/audible.md)
|
- **Database caching, real-time matching** → [integrations/audible.md](integrations/audible.md)
|
||||||
- **Book covers API for login page** → [frontend/pages/login.md](frontend/pages/login.md)
|
- **Book covers API for login page** → [frontend/pages/login.md](frontend/pages/login.md)
|
||||||
|
- **Dedup & works table (cross-ASIN identity)** → [integrations/audible.md](integrations/audible.md#dedup--works-table)
|
||||||
|
- **Multi-narrator capture in HTML scrapers** → [integrations/audible.md](integrations/audible.md#narrator-capture-in-html-scrapers)
|
||||||
|
|
||||||
## E-book Support (First-Class)
|
## E-book Support (First-Class)
|
||||||
- **First-class ebook requests, separate tracking** → [integrations/ebook-sidecar.md](integrations/ebook-sidecar.md)
|
- **First-class ebook requests, separate tracking** → [integrations/ebook-sidecar.md](integrations/ebook-sidecar.md)
|
||||||
@@ -141,9 +144,12 @@
|
|||||||
**"What's the database schema?"** → [backend/database.md](backend/database.md)
|
**"What's the database schema?"** → [backend/database.md](backend/database.md)
|
||||||
**"How does authentication work?"** → [backend/services/auth.md](backend/services/auth.md)
|
**"How does authentication work?"** → [backend/services/auth.md](backend/services/auth.md)
|
||||||
**"How do I change my password?"** → [backend/services/auth.md](backend/services/auth.md) (local users only - accessed via user menu in header)
|
**"How do I change my password?"** → [backend/services/auth.md](backend/services/auth.md) (local users only - accessed via user menu in header)
|
||||||
|
**"Local admin can't log in / 'Invalid username or password' with correct credentials"** → [admin-features/credential-recovery.md](admin-features/credential-recovery.md)
|
||||||
|
**"How do I recover from a lost CONFIG_ENCRYPTION_KEY?"** → [admin-features/credential-recovery.md](admin-features/credential-recovery.md)
|
||||||
**"How do I delete requests?"** → [admin-features/request-deletion.md](admin-features/request-deletion.md)
|
**"How do I delete requests?"** → [admin-features/request-deletion.md](admin-features/request-deletion.md)
|
||||||
**"How do I approve/deny user requests?"** → [admin-features/request-approval.md](admin-features/request-approval.md)
|
**"How do I approve/deny user requests?"** → [admin-features/request-approval.md](admin-features/request-approval.md)
|
||||||
**"How do I enable auto-approve for requests?"** → [admin-features/request-approval.md](admin-features/request-approval.md)
|
**"How do I enable auto-approve for requests?"** → [admin-features/request-approval.md](admin-features/request-approval.md)
|
||||||
|
**"How does the admin book info modal work?"** → [admin-features/request-approval.md](admin-features/request-approval.md#ui-features), [frontend/components.md](frontend/components.md#component-apis)
|
||||||
**"How do I customize audiobook folder organization?"** → [settings-pages.md](settings-pages.md#audiobook-organization-template), [phase3/file-organization.md](phase3/file-organization.md#target-structure)
|
**"How do I customize audiobook folder organization?"** → [settings-pages.md](settings-pages.md#audiobook-organization-template), [phase3/file-organization.md](phase3/file-organization.md#target-structure)
|
||||||
**"How do I deploy?"** → [deployment/docker.md](deployment/docker.md) (multi-container), [deployment/unified.md](deployment/unified.md) (all-in-one)
|
**"How do I deploy?"** → [deployment/docker.md](deployment/docker.md) (multi-container), [deployment/unified.md](deployment/unified.md) (all-in-one)
|
||||||
**"How do I use the unified container?"** → [deployment/unified.md](deployment/unified.md)
|
**"How do I use the unified container?"** → [deployment/unified.md](deployment/unified.md)
|
||||||
|
|||||||
@@ -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
|
- Title and author
|
||||||
- User avatar and username
|
- User avatar and username
|
||||||
- Request timestamp (relative: "2 hours ago")
|
- Request timestamp (relative: "2 hours ago")
|
||||||
|
- Info button (ⓘ, top-right corner) — opens AudiobookDetailsModal for full book details
|
||||||
- Approve button (green, checkmark icon)
|
- Approve button (green, checkmark icon)
|
||||||
|
- Search button (blue, magnifier icon) — opens InteractiveTorrentSearchModal
|
||||||
- Deny button (red, X icon)
|
- Deny button (red, X icon)
|
||||||
|
- **Info modal:** `AudiobookDetailsModal` rendered with `adminActions` prop containing Approve/Search/Deny buttons, allowing admin to review full book details (cover, description, series, genres, narrator, etc.) without leaving the approval workflow
|
||||||
- Auto-refreshes every 10 seconds (SWR)
|
- Auto-refreshes every 10 seconds (SWR)
|
||||||
- Loading states on buttons during approval/denial
|
- Loading states on buttons during approval/denial
|
||||||
- Success/error toast notifications
|
- Success/error toast notifications
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ PostgreSQL database storing users, audiobooks, requests, downloads, configuratio
|
|||||||
|
|
||||||
### Plex_Library (Library Cache)
|
### Plex_Library (Library Cache)
|
||||||
- `id` (UUID PK), `plex_guid` (unique, external ID from Plex or Audiobookshelf), `plex_rating_key`
|
- `id` (UUID PK), `plex_guid` (unique, external ID from Plex or Audiobookshelf), `plex_rating_key`
|
||||||
- `title`, `author`, `narrator`, `summary`, `duration` (milliseconds), `year`, `user_rating` (0-10 scale)
|
- `title`, `author`, `narrator`, `summary`, `duration` (BigInt, milliseconds), `year`, `user_rating` (0-10 scale)
|
||||||
- **Universal identifiers:** `asin` (Audible ASIN), `isbn` (ISBN-10 or ISBN-13)
|
- **Universal identifiers:** `asin` (Audible ASIN), `isbn` (ISBN-10 or ISBN-13)
|
||||||
- `file_path`, `thumb_url`, `cached_library_cover_path` (local cached cover path), `plex_library_id`, `added_at`
|
- `file_path`, `thumb_url`, `cached_library_cover_path` (local cached cover path), `plex_library_id`, `added_at`
|
||||||
- `last_scanned_at`, `created_at`, `updated_at`
|
- `last_scanned_at`, `created_at`, `updated_at`
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ Sends notifications for audiobook request events (pending approval, approved, av
|
|||||||
|
|
||||||
## Key Details
|
## Key Details
|
||||||
- **Backends:** Apprise (API), Discord (webhooks), ntfy (API), Pushover (API)
|
- **Backends:** Apprise (API), Discord (webhooks), ntfy (API), Pushover (API)
|
||||||
- **Events:** request_pending_approval, request_approved, request_available, request_error, issue_reported
|
- **Events:** request_pending_approval, request_approved, request_grabbed, request_available, request_error, issue_reported
|
||||||
- **Encryption:** AES-256-GCM for sensitive config (webhook URLs, API keys, notification URLs)
|
- **Encryption:** AES-256-GCM for sensitive config (webhook URLs, API keys, notification URLs)
|
||||||
- **Delivery:** Async via Bull job queue (priority 5)
|
- **Delivery:** Async via Bull job queue (priority 5)
|
||||||
- **Failure Handling:** Non-blocking, Promise.allSettled (one backend fails, others succeed)
|
- **Failure Handling:** Non-blocking, Promise.allSettled (one backend fails, others succeed)
|
||||||
@@ -33,11 +33,14 @@ model NotificationBackend {
|
|||||||
|-------|---------|------------------------|
|
|-------|---------|------------------------|
|
||||||
| request_pending_approval | User creates request | Request needs admin approval |
|
| request_pending_approval | User creates request | Request needs admin approval |
|
||||||
| request_approved | Admin approves OR auto-approval | Request approved (manual or auto) |
|
| request_approved | Admin approves OR auto-approval | Request approved (manual or auto) |
|
||||||
|
| request_grabbed | Torrent/NZB added to download client | Download handed off to configured download client (title resolves by type) — **opt-in: existing backends do not auto-subscribe; enable in Settings** |
|
||||||
| request_available | Plex/ABS scan or ebook download completes | Request available (title resolves by type) |
|
| request_available | Plex/ABS scan or ebook download completes | Request available (title resolves by type) |
|
||||||
| request_error | Download/import fails | Request failed at any stage |
|
| request_error | Download/import fails | Request failed at any stage |
|
||||||
| issue_reported | User reports issue | User reports problem with available audiobook |
|
| issue_reported | User reports issue | User reports problem with available audiobook |
|
||||||
|
|
||||||
**Dynamic Titles:** Events can define `titleByRequestType` in `notification-events.ts` for type-specific titles.
|
**Dynamic Titles:** Events can define `titleByRequestType` in `notification-events.ts` for type-specific titles.
|
||||||
|
- `request_grabbed` + `requestType: 'audiobook'` → "Audiobook Grabbed"
|
||||||
|
- `request_grabbed` + `requestType: 'ebook'` → "Ebook Grabbed"
|
||||||
- `request_available` + `requestType: 'audiobook'` → "Audiobook Available"
|
- `request_available` + `requestType: 'audiobook'` → "Audiobook Available"
|
||||||
- `request_available` + `requestType: 'ebook'` → "Ebook Available"
|
- `request_available` + `requestType: 'ebook'` → "Ebook Available"
|
||||||
- `request_available` + no requestType → "Request Available" (fallback)
|
- `request_available` + no requestType → "Request Available" (fallback)
|
||||||
@@ -66,6 +69,11 @@ model NotificationBackend {
|
|||||||
- Approve (with or without pre-selected torrent): After job triggered → request_approved
|
- Approve (with or without pre-selected torrent): After job triggered → request_approved
|
||||||
- Deny: No notification
|
- Deny: No notification
|
||||||
|
|
||||||
|
**Download Grabbed (processor: download-torrent)**
|
||||||
|
- After `client.addDownload()` succeeds and `DownloadHistory` record created → request_grabbed
|
||||||
|
- `message` field: `"${torrent.title} via ${indexer} (${clientType})"`
|
||||||
|
- `requestType`: from `request.type` (audiobook/ebook)
|
||||||
|
|
||||||
**Audiobook Available (processors: scan-plex, plex-recently-added)**
|
**Audiobook Available (processors: scan-plex, plex-recently-added)**
|
||||||
- After `status: 'available'` update → request_available (requestType: 'audiobook')
|
- After `status: 'available'` update → request_available (requestType: 'audiobook')
|
||||||
- Includes user info in query (plexUsername)
|
- Includes user info in query (plexUsername)
|
||||||
|
|||||||
@@ -13,9 +13,13 @@ Lets admins scan a server folder recursively, discover audiobook subfolders, mat
|
|||||||
## Key Details
|
## Key Details
|
||||||
- **Access:** Admin-only, modal opened from admin dashboard Quick Actions
|
- **Access:** Admin-only, modal opened from admin dashboard Quick Actions
|
||||||
- **Audio detection:** Uses `AUDIO_EXTENSIONS` from `src/lib/constants/audio-formats.ts`
|
- **Audio detection:** Uses `AUDIO_EXTENSIONS` from `src/lib/constants/audio-formats.ts`
|
||||||
- **Audiobook boundary:** A folder containing audio files = one audiobook; subfolders not scanned further
|
- **Audiobook boundary:** A folder containing audio files = one audiobook. Files with matching metadata tags are grouped by title+author+narrator. Files with no metadata title tag are all grouped together per folder (one entry, not one per file).
|
||||||
- **Metadata extraction:** ffprobe reads `album` (title), `album_artist` (author), `composer` (narrator) from first audio file
|
- **Metadata extraction:** ffprobe reads `album` (title), `album_artist` (author), `composer` (narrator) from all audio files in folder
|
||||||
- **Fallback:** If metadata tags are empty, folder name used as search term; "Low Confidence" badge shown
|
- **Search term fallback chain** (when no `album` tag):
|
||||||
|
1. **ASIN in folder name** — scans folder name for pattern `B[A-Z0-9]{9}` bounded by bracket/paren/space; if found, uses direct ASIN lookup instead of text search; no badge shown
|
||||||
|
2. **Folder name** — cleaned (strips bracketed ASIN/year, underscores→spaces); skipped if generic (CD1, Disc 2, Part 3, Vol 1, etc.); shows "Low Confidence" badge
|
||||||
|
3. **First file name** — last resort; shows "Low Confidence" badge
|
||||||
|
- **Generic folder detection:** `/^(cd|disc|disk|part|vol(ume)?)\s*\d+$/i` — these names are skipped as search terms
|
||||||
- **Author/narrator dedup:** Splits on `,;& ` delimiters, removes names appearing in both fields
|
- **Author/narrator dedup:** Splits on `,;& ` delimiters, removes names appearing in both fields
|
||||||
- **Scan depth:** Max 10 levels recursion
|
- **Scan depth:** Max 10 levels recursion
|
||||||
- **Rate limiting:** 1.5s delay between Audible searches (same as existing scraping rate limit)
|
- **Rate limiting:** 1.5s delay between Audible searches (same as existing scraping rate limit)
|
||||||
@@ -56,7 +60,8 @@ Lets admins scan a server folder recursively, discover audiobook subfolders, mat
|
|||||||
| Already in library | 40% opacity, green "In Library" badge, toggle disabled |
|
| Already in library | 40% opacity, green "In Library" badge, toggle disabled |
|
||||||
| Active request exists | 40% opacity, purple "Requested" badge, toggle disabled |
|
| Active request exists | 40% opacity, purple "Requested" badge, toggle disabled |
|
||||||
| No Audible match | Red "No Match" badge, folder name shown, pre-skipped |
|
| No Audible match | Red "No Match" badge, folder name shown, pre-skipped |
|
||||||
| Low confidence (folder name fallback) | Amber "Low Confidence" badge |
|
| ASIN extracted from folder name | No badge (high confidence — direct ASIN lookup) |
|
||||||
|
| Low confidence (folder name or file name fallback, no ASIN) | Amber "Low Confidence" badge |
|
||||||
|
|
||||||
## Files
|
## Files
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ src/components/
|
|||||||
**Audiobooks**
|
**Audiobooks**
|
||||||
- **AudiobookCard** ✅ - Cover, title, author, narrator, duration, request button, clickable to open details modal. Shows "Requested by [username]" when someone else has requested the book, "Requested" when current user has requested it
|
- **AudiobookCard** ✅ - Cover, title, author, narrator, duration, request button, clickable to open details modal. Shows "Requested by [username]" when someone else has requested the book, "Requested" when current user has requested it
|
||||||
- **AudiobookGrid** - Responsive grid (1/2/3/4 cols)
|
- **AudiobookGrid** - Responsive grid (1/2/3/4 cols)
|
||||||
- **AudiobookDetailsModal** ✅ - Full-screen modal with comprehensive metadata (description, genres, rating, release date, narrator, request functionality). Shows requesting user's name when applicable
|
- **AudiobookDetailsModal** ✅ - Full-screen modal with comprehensive metadata (description, genres, rating, release date, narrator, language, format, publisher, request functionality). Shows requesting user's name when applicable
|
||||||
|
|
||||||
**Requests**
|
**Requests**
|
||||||
- **RequestCard** ✅ - Cover, title, author, status badge, progress bar, timestamps, action buttons (cancel, manual search, interactive search)
|
- **RequestCard** ✅ - Cover, title, author, status badge, progress bar, timestamps, action buttons (cancel, manual search, interactive search)
|
||||||
@@ -113,6 +113,7 @@ interface AudiobookDetailsModalProps {
|
|||||||
requestStatus?: string | null;
|
requestStatus?: string | null;
|
||||||
isAvailable?: boolean;
|
isAvailable?: boolean;
|
||||||
requestedByUsername?: string | null;
|
requestedByUsername?: string | null;
|
||||||
|
adminActions?: React.ReactNode; // Optional admin buttons (Approve/Search/Deny) rendered as second row in action bar
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RequestCardProps {
|
interface RequestCardProps {
|
||||||
|
|||||||
@@ -1,29 +1,40 @@
|
|||||||
# Audible Integration
|
# Audible Integration
|
||||||
|
|
||||||
**Status:** Implemented | Unauthenticated Audible JSON catalog API (primary) + Audnexus API (per-ASIN details)
|
**Status:** Implemented | Hybrid — curated HTML for discovery refresh + Audible JSON catalog API for user-facing real-time + Audnexus for per-ASIN details
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
Audiobook metadata for discovery, search, and detail pages. All catalog operations (search, popular, new releases, categories, category books, author books, single-product details) now call Audible's unauthenticated public JSON catalog API (`api.audible.<tld>/1.0/catalog/*`). Per-ASIN detail lookups prefer Audnexus; the catalog API is used as fallback.
|
Audiobook metadata for discovery, search, and detail pages. Split by access pattern:
|
||||||
|
|
||||||
|
- **Nightly discovery refresh** (popular / new releases / category lists) — scraped from Audible's **curated HTML storefronts** (`www.audible.<tld>/adblbestsellers`, `/newreleases`, `/search?node=<id>`). The HTML pages reflect Audible's own editorial picks.
|
||||||
|
- **User-facing real-time** (search, author books, categories listing, per-ASIN details) — Audible's unauthenticated public **JSON catalog API** (`api.audible.<tld>/1.0/catalog/*`).
|
||||||
|
- **Per-ASIN detail lookups** — Audnexus (`api.audnex.us/books/{asin}`) primary; catalog API used as fallback when Audnexus returns 404.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
- **Primary data source:** Audible JSON catalog API, same endpoint used by the official Audible mobile apps. No authentication, no API key, no user credentials, no special headers.
|
- **Curated HTML (refresh job only):** the three methods called solely by `audible-refresh.processor.ts` (`getPopularAudiobooks`, `getNewReleases`, `getCategoryBooks`) scrape Audible's storefront HTML to inherit editorial curation. Beefed-up retry/backoff knobs (12 retries, 3-min jittered cap) handle 503 storms patiently on the nightly job without slowing healthy users.
|
||||||
- **Per-ASIN details:** Audnexus (`api.audnex.us/books/{asin}`) remains primary; catalog API (`/1.0/catalog/products/{asin}`) is the fallback when Audnexus returns 404.
|
- **JSON catalog API (real-time):** `search`, `searchByAuthorAsin`, `getCategories` (categories listing), and `fetchAudibleDetailsFromApi` (per-ASIN fallback). Same endpoint used by the official Audible mobile apps. No authentication, no API key, no user credentials, no special headers.
|
||||||
- **HTML scraping:** Removed from `audible.service.ts`. The only remaining HTML path is `audible-series.ts` (series-page scraping, out of scope).
|
- **Audnexus (per-ASIN):** `getAudiobookDetails` and `getRuntime` prefer Audnexus, with catalog API fallback for `getAudiobookDetails`.
|
||||||
- **`www.audible.<tld>`:** Still used by `audible-series.ts` and by `getBaseUrl()` for "View on Audible" link generation. Not used for any catalog operation.
|
- **`www.audible.<tld>`:** Used by HTML refresh scraping, by `audible-series.ts`, and by `getBaseUrl()` for "View on Audible" link generation.
|
||||||
|
|
||||||
## Data Sources
|
## Data Sources
|
||||||
|
|
||||||
All catalog operations are HTTP GET against `{apiBaseUrl}` (region-dependent, e.g. `https://api.audible.com`):
|
### Nightly refresh (HTML — `htmlClient`, baseURL `www.audible.<tld>`)
|
||||||
|
|
||||||
|
| Operation | Endpoint | Key params |
|
||||||
|
|---|---|---|
|
||||||
|
| Popular | `/adblbestsellers` | `pageSize=50`, `page=<n>` (omitted on first page) |
|
||||||
|
| New releases | `/newreleases` | `pageSize=50`, `page=<n>` (omitted on first page) |
|
||||||
|
| Category books | `/search` | `node=<categoryId>&pageSize=50&sort=popularity-rank&page=<n>` |
|
||||||
|
|
||||||
|
Parsed via cheerio. Selectors: `.productListItem` (popular/new releases), `.s-result-item, .productListItem` (categories).
|
||||||
|
|
||||||
|
### Real-time (JSON catalog API — `apiClient`, baseURL `api.audible.<tld>`)
|
||||||
|
|
||||||
| Operation | Endpoint | Key params |
|
| Operation | Endpoint | Key params |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Search | `/1.0/catalog/products` | `keywords=<q>` |
|
| Search | `/1.0/catalog/products` | `keywords=<q>` |
|
||||||
| Author books | `/1.0/catalog/products` | `author=<name>` (name, NOT ASIN) |
|
| Author books | `/1.0/catalog/products` | `author=<name>` (name, NOT ASIN) |
|
||||||
| Popular | `/1.0/catalog/products` | `products_sort_by=BestSellers` |
|
|
||||||
| New releases | `/1.0/catalog/products` | `products_sort_by=-ReleaseDate` |
|
|
||||||
| Category books | `/1.0/catalog/products` | `category_id=<id>&products_sort_by=BestSellers` |
|
|
||||||
| Categories listing | `/1.0/catalog/categories` | (none) |
|
| Categories listing | `/1.0/catalog/categories` | (none) |
|
||||||
| Single product | `/1.0/catalog/products/{asin}` | — |
|
| Single product | `/1.0/catalog/products/{asin}` | — |
|
||||||
| Audnexus (per-ASIN) | `https://api.audnex.us/books/{asin}` | `region={audnexusParam}` |
|
| Audnexus (per-ASIN) | `https://api.audnex.us/books/{asin}` | `region={audnexusParam}` |
|
||||||
@@ -48,20 +59,20 @@ Populates every `AudibleAudiobook` field. Covered:
|
|||||||
|
|
||||||
## Gotchas
|
## Gotchas
|
||||||
|
|
||||||
|
- **Catalog API cannot filter preorders or surface curated bestsellers.** The API's `BestSellers` sort is a right-now velocity rank that spikes on launch-day promos and preorder windows; the `-ReleaseDate` sort returns 100% future preorders. There is no server-side `release_time`, `released-only`, `customer_rights`, or alternate sort (`Reviewed`, `MostListened`, etc.) — every plausible variant was tested and silently ignored. This is why the nightly refresh job uses the curated HTML storefront pages instead.
|
||||||
- **`author=` takes a name, not an ASIN.** The catalog API has no ASIN-based author param. `searchByAuthorAsin()` queries by name, then filters client-side: keeps only products where `products[].authors[].asin === authorAsin`. Preserves ASIN-authoritative author identity. Also filters by `product.language` via `isAcceptedLanguage()` for the configured region.
|
- **`author=` takes a name, not an ASIN.** The catalog API has no ASIN-based author param. `searchByAuthorAsin()` queries by name, then filters client-side: keeps only products where `products[].authors[].asin === authorAsin`. Preserves ASIN-authoritative author identity. Also filters by `product.language` via `isAcceptedLanguage()` for the configured region.
|
||||||
- **Invalid ASIN returns HTTP 200 with stub body.** `/1.0/catalog/products/{asin}` responds 200 with `{product: {asin: INPUT}}` and no other fields. `fetchAudibleDetailsFromApi()` detects this via missing `product.title` and returns `null`.
|
- **Invalid ASIN returns HTTP 200 with stub body.** `/1.0/catalog/products/{asin}` responds 200 with `{product: {asin: INPUT}}` and no other fields. `fetchAudibleDetailsFromApi()` detects this via missing `product.title` and returns `null`.
|
||||||
- **`publisher_summary` is HTML.** Service strips tags via inline `stripHtml()` helper (regex-based, no cheerio) before populating `description`. Falls back to `merchandising_summary` (plain text) if `publisher_summary` missing.
|
- **`publisher_summary` is HTML.** Service strips tags via inline `stripHtml()` helper (regex-based, no cheerio) before populating `description`. Falls back to `merchandising_summary` (plain text) if `publisher_summary` missing.
|
||||||
- **Series is an array.** `products[].series[]` — a book may belong to multiple series. Service picks the first entry with non-empty `sequence`, else the first entry. `sequence` is cleaned by extracting first `/\d+(?:\.\d+)?/` match for numeric ordering.
|
- **Series is an array.** `products[].series[]` — a book may belong to multiple series. Service picks the first entry with non-empty `sequence`, else the first entry. `sequence` is cleaned by extracting first `/\d+(?:\.\d+)?/` match for numeric ordering.
|
||||||
- **Stub `product_images`:** cover URL reads from `product_images['500']`; missing keys fall back to `undefined`.
|
- **Stub `product_images`:** cover URL reads from `product_images['500']`; missing keys fall back to `undefined`.
|
||||||
- **`page` is 0-indexed.** Despite the default value appearing to be 1, the API returns items `(page * num_results)` through `((page + 1) * num_results - 1)`. So `page=1` fetches items 51–100, not 1–50. All service methods accept a 1-indexed `page` and subtract 1 at the axios call. The symptom of getting this wrong is silent: queries whose `total_results ≤ num_results` return an empty `products` array while `total_results` is populated (e.g. author searches for small catalogues).
|
- **`page` is 0-indexed (catalog API only).** Despite the default value appearing to be 1, the API returns items `(page * num_results)` through `((page + 1) * num_results - 1)`. So `page=1` fetches items 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
|
## Rate Limiting & Resilience
|
||||||
|
|
||||||
- 503s still possible but dramatically less frequent than the HTML surface.
|
- **Real-time JSON API paths:** 503s are uncommon. `fetchWithRetry()` uses jittered exponential backoff, 5 retries, retries on 503/429/5xx. API responses include `Cache-Control: private, max-age=1800`.
|
||||||
- `fetchWithRetry()` — jittered exponential backoff, 5 retries, retries on 503/429/5xx.
|
- **Nightly HTML refresh paths:** 503s are more likely (HTML storefront is more rate-sensitive). Same `fetchWithRetry()`, but with `HTML_MAX_RETRIES=12` and `HTML_MAX_BACKOFF_MS=180_000` (3-minute cap on jittered backoff). Healthy refreshes still complete fast (per-page success on attempt 0); users hit by sustained 503 storms grind through patiently rather than abandoning the refresh.
|
||||||
- `AdaptivePacer` circuit-breaker preserved.
|
- **`AdaptivePacer`** — inter-page delay 2–4 s baseline, scales up multiplicatively under retry pressure, with a 45–60 s circuit-breaker cooldown after 3 consecutive retry-pages.
|
||||||
- Inter-page base delay on API paths: **500–1500ms** (down from 2000–4000ms for HTML).
|
- **Per-batch cooldowns** in `audible-refresh.processor.ts` — 15–30 s between popular/new-releases, 10–20 s between categories.
|
||||||
- API responses include `Cache-Control: private, max-age=1800`.
|
|
||||||
|
|
||||||
## Region Configuration
|
## Region Configuration
|
||||||
|
|
||||||
@@ -101,8 +112,8 @@ Configurable Audible region for accurate metadata matching across international
|
|||||||
- Automatic refresh: Region change triggers `audible_refresh` job.
|
- Automatic refresh: Region change triggers `audible_refresh` job.
|
||||||
|
|
||||||
**Per-region HTTP clients (on init):**
|
**Per-region HTTP clients (on init):**
|
||||||
- `apiClient` — `baseURL=apiBaseUrl`, `Accept: application/json`, `User-Agent: ReadMeABook/1.0`, no language/ipRedirect params.
|
- `apiClient` — `baseURL=apiBaseUrl`, `Accept: application/json`, `User-Agent: ReadMeABook/1.0`, no language/ipRedirect params. Used for the real-time JSON catalog operations (search, author books, categories listing, per-ASIN details fallback).
|
||||||
- `htmlClient` — `baseURL=baseUrl`, browser headers, default params `ipRedirectOverride=true` + `language=<audibleLocaleParam>`. Used only by `audible-series.ts` and `getBaseUrl()`-based link generation.
|
- `htmlClient` — `baseURL=baseUrl`, rotating browser headers (`pickUserAgent` + `getBrowserHeaders`), default params `ipRedirectOverride=true` + `language=<audibleLocaleParam>`. Used by the nightly discovery refresh (`/adblbestsellers`, `/newreleases`, `/search?node=...`), by `audible-series.ts`, and by `getBaseUrl()`-based link generation.
|
||||||
- Audnexus calls include `region=<audnexusParam>`.
|
- Audnexus calls include `region=<audnexusParam>`.
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
@@ -130,6 +141,44 @@ Single matching algorithm used everywhere (search, popular, new-releases, jobs).
|
|||||||
|
|
||||||
**Note:** Fuzzy matching (70% threshold) is preserved in `ranking-algorithm.ts` for Prowlarr torrent ranking. Library availability checks require exact ASIN matches only.
|
**Note:** Fuzzy matching (70% threshold) is preserved in `ranking-algorithm.ts` for Prowlarr torrent ranking. Library availability checks require exact ASIN matches only.
|
||||||
|
|
||||||
|
## Dedup & Works Table
|
||||||
|
|
||||||
|
**Status:** ✅ Implemented | Two-pass dedup on every discovery view + cross-batch identity via works table
|
||||||
|
|
||||||
|
Discovery views (search, author books, series detail) collapse duplicate Audible listings for the same recording (publisher re-listings, regional re-issues, full-cast vs single-narrator productions) into a single card. Two passes run in sequence:
|
||||||
|
|
||||||
|
1. **Local pass — `deduplicateAndCollectGroups()`** (`src/lib/utils/deduplicate-audiobooks.ts`)
|
||||||
|
- Stateless, in-memory. Keys books by normalized title + sorted narrator set + duration (±max(5%, 10 min) tolerance), with subtitle compatibility to keep distinct series entries separate.
|
||||||
|
- Picks a canonical representative per group by `metadataScore()` (cover + rating + duration + description + narrator + release date + genres).
|
||||||
|
- Emits `DedupGroup[]` describing every multi-ASIN collapse → handed to `persistDedupGroups()` for the works table.
|
||||||
|
|
||||||
|
2. **Works pass — `collapseByExistingWorks()`** (`src/lib/services/works.service.ts`)
|
||||||
|
- Async DB lookup. Reads `work_asins` for every ASIN in the local-passed list and collapses any books sharing a `workId` to one representative (same `metadataScore()` ranking).
|
||||||
|
- Catches duplicates the local pass misses: source-metadata divergence (e.g. HTML scraper captured different narrators), cross-page splits (paginated series), or non-matching field shapes.
|
||||||
|
- Degrades gracefully — returns the input unchanged on DB failure (view still renders).
|
||||||
|
|
||||||
|
### Works Table Schema
|
||||||
|
- `Work { id, title, author }` — one row per logical book
|
||||||
|
- `WorkAsin { id, workId, asin, narrator?, durationMinutes?, isCanonical, source, createdAt }` — many ASINs per Work
|
||||||
|
|
||||||
|
### Population Layers
|
||||||
|
- **Layer 1 (auto):** `persistDedupGroups()` writes whenever the local pass finds a duplicate. Merges across pre-existing works when a new group spans them.
|
||||||
|
- **Layer 2 (seed):** `seedAsin()` writes a single-ASIN work at request creation time, ensuring every requested ASIN has an entry to grow from.
|
||||||
|
|
||||||
|
### Read Paths
|
||||||
|
- **`collapseByExistingWorks()`** — view-level collapse (this section).
|
||||||
|
- **`getSiblingAsins()`** — library availability matching (`audiobook-matcher.ts`), request-creation duplicate prevention (`request-creator.service.ts`), ignored-audiobook expansion. Returns sibling ASINs grouped by input ASIN.
|
||||||
|
|
||||||
|
### Narrator Capture in HTML Scrapers
|
||||||
|
- HTML scrapers (`audible-series.ts`, the two `parse*Items` parsers in `audible.service.ts`) capture **all** narrator anchors via `extractAllNarrators()` (`src/lib/utils/extract-narrator.ts`). Multi-narrator productions render each name as its own `<a href="?searchNarrator=...">` link; capturing only the first (prior bug) made co-narrated audiobooks fail to dedup. Order is not significant — `normalizeNarrator()` sorts before comparison.
|
||||||
|
|
||||||
|
### Wired Routes
|
||||||
|
- `src/app/api/audiobooks/search/route.ts`
|
||||||
|
- `src/app/api/authors/[asin]/books/route.ts`
|
||||||
|
- `src/app/api/series/[asin]/route.ts`
|
||||||
|
|
||||||
|
Watched-list background jobs (`watched-lists.service.ts`) run the local pass only — they don't render a view, and the downstream `request-creator.service.ts` already does sibling-aware dedup at request creation time.
|
||||||
|
|
||||||
## Database-First Approach
|
## Database-First Approach
|
||||||
|
|
||||||
**Status:** Implemented
|
**Status:** Implemented
|
||||||
@@ -137,12 +186,12 @@ Single matching algorithm used everywhere (search, popular, new-releases, jobs).
|
|||||||
Discovery APIs serve cached data from DB with real-time matching.
|
Discovery APIs serve cached data from DB with real-time matching.
|
||||||
|
|
||||||
**Flow:**
|
**Flow:**
|
||||||
1. `audible_refresh` cron runs daily → fetches 200 popular + 200 new releases + user-configured categories via catalog API.
|
1. `audible_refresh` cron runs daily → fetches 200 popular + 200 new releases + user-configured categories by scraping Audible's curated HTML storefronts (`/adblbestsellers`, `/newreleases`, `/search?node=<id>&sort=popularity-rank`).
|
||||||
2. Downloads and caches cover thumbnails locally.
|
2. Downloads and caches cover thumbnails locally.
|
||||||
3. Stores metadata in `audible_cache`, ranked entries in `audible_cache_categories` with reserved IDs (`__popular__`, `__new_releases__`) and user category IDs.
|
3. Stores metadata in `audible_cache`, ranked entries in `audible_cache_categories` with reserved IDs (`__popular__`, `__new_releases__`) and user category IDs.
|
||||||
4. Cleans up unused thumbnails after sync.
|
4. Cleans up unused thumbnails after sync.
|
||||||
5. API routes query `AudibleCacheCategory` by categoryId → join with `AudibleCache` metadata → apply real-time matching → return enriched results.
|
5. API routes query `AudibleCacheCategory` by categoryId → join with `AudibleCache` metadata → apply real-time matching → return enriched results.
|
||||||
6. Homepage loads instantly (no Audible API hits).
|
6. Homepage loads instantly (no Audible HTTP hits at request time).
|
||||||
|
|
||||||
## Thumbnail Caching
|
## Thumbnail Caching
|
||||||
|
|
||||||
@@ -201,6 +250,9 @@ interface AudibleAudiobook {
|
|||||||
series?: string;
|
series?: string;
|
||||||
seriesPart?: string;
|
seriesPart?: string;
|
||||||
seriesAsin?: string;
|
seriesAsin?: string;
|
||||||
|
language?: string;
|
||||||
|
formatType?: string;
|
||||||
|
publisherName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface EnrichedAudibleAudiobook extends AudibleAudiobook {
|
interface EnrichedAudibleAudiobook extends AudibleAudiobook {
|
||||||
@@ -228,12 +280,25 @@ interface AuthorBooksResult {
|
|||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
- `axios` (HTTP, two clients: `apiClient` for JSON catalog, `htmlClient` for series-page scraping only)
|
- `axios` (HTTP, two clients: `apiClient` for JSON catalog API, `htmlClient` for HTML refresh + series scraping)
|
||||||
|
- `cheerio` (HTML parsing for refresh job and `audible-series.ts`)
|
||||||
- Audnexus API (per-ASIN details, primary)
|
- Audnexus API (per-ASIN details, primary)
|
||||||
- PostgreSQL (`audible_cache`, `audible_cache_categories`)
|
- PostgreSQL (`audible_cache`, `audible_cache_categories`)
|
||||||
|
|
||||||
## Fixed Issues
|
## Fixed Issues
|
||||||
|
|
||||||
|
**Series-page duplicates not collapsing across user views (2026-05-14)**
|
||||||
|
- **Problem:** Two re-listings of the same audiobook (same title, same narrator set, same duration, different ASINs) showed as two cards on series detail pages, even after the works table had already linked them via search-page dedup.
|
||||||
|
- **Root cause (two-part):** (1) HTML scrapers used `$el.find('a[href*="searchNarrator="]').first()` for multi-narrator productions, capturing only the first co-narrator. So two listings of the same recording landed in `deduplicateAndCollectGroups` with mismatched single-narrator strings and never merged. (2) `deduplicateAndCollectGroups` was stateless — it wrote to the works table but never read it back, so even when one path (e.g. search) successfully merged two ASINs and persisted the Work, every other path (series, author books) re-derived the dedup decision from scratch and split them again.
|
||||||
|
- **Fix:** (1) New `extractAllNarrators()` helper (`src/lib/utils/extract-narrator.ts`) captures every `searchNarrator=` anchor and joins them; all three HTML scrapers route through it. (2) New `collapseByExistingWorks()` consults the works table after the local pass and collapses any remaining books sharing a `workId`. Wired into the three user-facing discovery routes (search / author books / series detail). Skipped for watched-list background jobs — those feed `request-creator.service.ts` which already does sibling-aware dedup.
|
||||||
|
- **Location:** `src/lib/utils/extract-narrator.ts` (new); `src/lib/integrations/audible-series.ts` (parseSeriesBooks); `src/lib/integrations/audible.service.ts` (parseProductListItems + parseSearchResultItems); `src/lib/utils/deduplicate-audiobooks.ts` (`metadataScore` exported); `src/lib/services/works.service.ts` (`collapseByExistingWorks` added); three API routes updated.
|
||||||
|
|
||||||
|
**Discovery refresh reverted to curated HTML scraping (2026-05-14)**
|
||||||
|
- **Problem:** After switching all catalog ops to the JSON catalog API in `f564d0a`, the nightly discovery refresh (Popular / New Releases / user-configured Categories) started serving junk: New Releases became 100% preorders out to 2027, and Popular was dominated by launch-day no-name shovelware.
|
||||||
|
- **Root cause:** `products_sort_by=BestSellers` is a right-now sales velocity rank that spikes on launch promos and preorder windows; `-ReleaseDate` returns all catalog items in date order with no released-only filter. The catalog API exposes no server-side filter to exclude preorders or sort by established popularity (verified by exhaustively testing `release_time`, `availability_status`, `customer_rights`, `Reviewed`/`MostListened`/`SalesRank` sorts — all silently ignored or rejected). Doing the curation client-side would have made RMAB the editorial curator, which Audible's storefront pages already do well.
|
||||||
|
- **Fix:** Hybrid architecture — the three refresh-only methods (`getPopularAudiobooks`, `getNewReleases`, `getCategoryBooks`) went back to scraping Audible's curated HTML storefronts (`/adblbestsellers`, `/newreleases`, `/search?node=<id>&sort=popularity-rank`). All user-facing real-time paths (search, author books, categories listing, per-ASIN details) stayed on the JSON catalog API. To keep the higher-503-risk HTML traffic resilient on the unattended nightly job, `fetchWithRetry()` accepts an optional `maxBackoffMs` cap and HTML callers use `HTML_MAX_RETRIES=12` + `HTML_MAX_BACKOFF_MS=180_000` (3-min cap). Healthy users finish quickly; 503-blocked users grind through patiently.
|
||||||
|
- **Location:** `src/lib/integrations/audible.service.ts` (three methods + two private parsers `parseProductListItems` / `parseSearchResultItems`); `src/lib/utils/scrape-resilience.ts` (`jitteredBackoff` cap parameter).
|
||||||
|
|
||||||
**Audiobookshelf metadata matching not respecting configured region (2026-01-28)**
|
**Audiobookshelf metadata matching not respecting configured region (2026-01-28)**
|
||||||
- **Problem:** `triggerABSItemMatch()` hardcoded `'audible'` provider (audible.com) instead of respecting user's configured Audible region.
|
- **Problem:** `triggerABSItemMatch()` hardcoded `'audible'` provider (audible.com) instead of respecting user's configured Audible region.
|
||||||
- **Impact:** Users with non-US regions (CA, UK, AU, IN) had incorrect metadata matching in Audiobookshelf, causing wrong ASINs.
|
- **Impact:** Users with non-US regions (CA, UK, AU, IN) had incorrect metadata matching in Audiobookshelf, causing wrong ASINs.
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "readmeabook",
|
"name": "readmeabook",
|
||||||
"version": "1.0.15",
|
"version": "1.2.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "readmeabook",
|
"name": "readmeabook",
|
||||||
"version": "1.0.15",
|
"version": "1.2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@heroicons/react": "^2.2.0",
|
"@heroicons/react": "^2.2.0",
|
||||||
"@prisma/client": "^6.19.0",
|
"@prisma/client": "^6.19.0",
|
||||||
|
|||||||
+3
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "readmeabook",
|
"name": "readmeabook",
|
||||||
"version": "1.1.8",
|
"version": "1.2.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
@@ -13,7 +13,8 @@
|
|||||||
"prisma:generate": "prisma generate",
|
"prisma:generate": "prisma generate",
|
||||||
"prisma:migrate": "prisma migrate dev",
|
"prisma:migrate": "prisma migrate dev",
|
||||||
"prisma:studio": "prisma studio",
|
"prisma:studio": "prisma studio",
|
||||||
"db:push": "prisma db push"
|
"db:push": "prisma db push",
|
||||||
|
"rmab:recover": "node scripts/recover-credentials.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@heroicons/react": "^2.2.0",
|
"@heroicons/react": "^2.2.0",
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ model PlexLibrary {
|
|||||||
author String
|
author String
|
||||||
narrator String?
|
narrator String?
|
||||||
summary String? @db.Text
|
summary String? @db.Text
|
||||||
duration Int? // Duration in milliseconds (Plex format)
|
duration BigInt? // Duration in milliseconds (Plex format)
|
||||||
year Int?
|
year Int?
|
||||||
userRating Decimal? @map("user_rating") @db.Decimal(3, 1) // User's rating (0-10 scale from Plex)
|
userRating Decimal? @map("user_rating") @db.Decimal(3, 1) // User's rating (0-10 scale from Plex)
|
||||||
|
|
||||||
|
|||||||
@@ -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 { InteractiveTorrentSearchModal } from '@/components/requests/InteractiveTorrentSearchModal';
|
||||||
import { AdjustSearchTermsModal } from './AdjustSearchTermsModal';
|
import { AdjustSearchTermsModal } from './AdjustSearchTermsModal';
|
||||||
import { useSmartDropdownPosition } from '@/hooks/useSmartDropdownPosition';
|
import { useSmartDropdownPosition } from '@/hooks/useSmartDropdownPosition';
|
||||||
|
import { ConfirmModal } from '@/components/ui/ConfirmModal';
|
||||||
|
import { CANCELLABLE_STATUSES } from '@/lib/constants/request-statuses';
|
||||||
|
|
||||||
export interface RequestActionsDropdownProps {
|
export interface RequestActionsDropdownProps {
|
||||||
request: {
|
request: {
|
||||||
@@ -54,8 +56,12 @@ export function RequestActionsDropdown({
|
|||||||
const [showInteractiveSearch, setShowInteractiveSearch] = useState(false);
|
const [showInteractiveSearch, setShowInteractiveSearch] = useState(false);
|
||||||
const [showInteractiveSearchEbook, setShowInteractiveSearchEbook] = useState(false);
|
const [showInteractiveSearchEbook, setShowInteractiveSearchEbook] = useState(false);
|
||||||
const [showAdjustSearchTerms, setShowAdjustSearchTerms] = useState(false);
|
const [showAdjustSearchTerms, setShowAdjustSearchTerms] = useState(false);
|
||||||
|
const [confirmCancelOpen, setConfirmCancelOpen] = useState(false);
|
||||||
|
const [isCancelling, setIsCancelling] = useState(false);
|
||||||
const { containerRef, dropdownRef, positionAbove, style } = useSmartDropdownPosition(isOpen);
|
const { containerRef, dropdownRef, positionAbove, style } = useSmartDropdownPosition(isOpen);
|
||||||
|
|
||||||
|
const isAwaitingApproval = request.status === 'awaiting_approval';
|
||||||
|
|
||||||
// Determine request type
|
// Determine request type
|
||||||
const isEbook = request.type === 'ebook';
|
const isEbook = request.type === 'ebook';
|
||||||
|
|
||||||
@@ -66,7 +72,7 @@ export function RequestActionsDropdown({
|
|||||||
const canSearch = ['pending', 'failed', 'awaiting_search'].includes(request.status);
|
const canSearch = ['pending', 'failed', 'awaiting_search'].includes(request.status);
|
||||||
const canAdjustSearchTerms = ['pending', 'failed', 'awaiting_search', 'searching'].includes(request.status);
|
const canAdjustSearchTerms = ['pending', 'failed', 'awaiting_search', 'searching'].includes(request.status);
|
||||||
const canRetryDownload = request.status === 'failed' && (request.downloadAttempts ?? 0) > 0 && !!onRetryDownload;
|
const canRetryDownload = request.status === 'failed' && (request.downloadAttempts ?? 0) > 0 && !!onRetryDownload;
|
||||||
const canCancel = ['pending', 'searching', 'downloading', 'awaiting_search'].includes(request.status);
|
const canCancel = (CANCELLABLE_STATUSES as readonly string[]).includes(request.status);
|
||||||
const canDelete = true; // Admins can always delete
|
const canDelete = true; // Admins can always delete
|
||||||
|
|
||||||
// View Source: For ebooks, extract MD5 from slow download URL and link to Anna's Archive
|
// View Source: For ebooks, extract MD5 from slow download URL and link to Anna's Archive
|
||||||
@@ -157,14 +163,21 @@ export function RequestActionsDropdown({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCancel = async () => {
|
const handleCancel = () => {
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
if (window.confirm(`Are you sure you want to cancel the request for "${request.title}"?`)) {
|
setConfirmCancelOpen(true);
|
||||||
try {
|
};
|
||||||
await onCancel(request.requestId);
|
|
||||||
} catch (error) {
|
const handleConfirmCancel = async () => {
|
||||||
console.error('Failed to cancel request:', error);
|
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}
|
currentSearchTerms={request.customSearchTerms}
|
||||||
onSuccess={onSearchTermsUpdated}
|
onSuccess={onSearchTermsUpdated}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ConfirmModal
|
||||||
|
isOpen={confirmCancelOpen}
|
||||||
|
onClose={() => !isCancelling && setConfirmCancelOpen(false)}
|
||||||
|
onConfirm={handleConfirmCancel}
|
||||||
|
title={isAwaitingApproval ? 'Withdraw request' : 'Cancel request'}
|
||||||
|
message={
|
||||||
|
isAwaitingApproval
|
||||||
|
? `"${request.title}" is pending admin approval and will be withdrawn. The user can request it again later.`
|
||||||
|
: `"${request.title}" has already been approved and is actively being processed. Cancelling will stop the download.`
|
||||||
|
}
|
||||||
|
confirmText={isAwaitingApproval ? 'Withdraw request' : 'Cancel request'}
|
||||||
|
cancelText="Keep request"
|
||||||
|
variant="danger"
|
||||||
|
isLoading={isCancelling}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+118
-44
@@ -14,8 +14,10 @@ import { RecentRequestsTable } from './components/RecentRequestsTable';
|
|||||||
import { ToastProvider, useToast } from '@/components/ui/Toast';
|
import { ToastProvider, useToast } from '@/components/ui/Toast';
|
||||||
import { ReportedIssuesSection } from './components/ReportedIssuesSection';
|
import { ReportedIssuesSection } from './components/ReportedIssuesSection';
|
||||||
import { InteractiveTorrentSearchModal } from '@/components/requests/InteractiveTorrentSearchModal';
|
import { InteractiveTorrentSearchModal } from '@/components/requests/InteractiveTorrentSearchModal';
|
||||||
|
import { AudiobookDetailsModal } from '@/components/audiobooks/AudiobookDetailsModal';
|
||||||
import { BulkImportWizard } from '@/components/admin/BulkImportWizard';
|
import { BulkImportWizard } from '@/components/admin/BulkImportWizard';
|
||||||
import { TorrentResult } from '@/lib/utils/ranking-algorithm';
|
import { TorrentResult } from '@/lib/utils/ranking-algorithm';
|
||||||
|
import { InformationCircleIcon } from '@heroicons/react/24/outline';
|
||||||
import { formatDistanceToNow } from 'date-fns';
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
@@ -56,15 +58,78 @@ function formatTorrentSize(bytes: number): string {
|
|||||||
return gb >= 1 ? `${gb.toFixed(1)} GB` : `${mb.toFixed(0)} MB`;
|
return gb >= 1 ? `${gb.toFixed(1)} GB` : `${mb.toFixed(0)} MB`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function LoadingSpinner() {
|
||||||
|
return (
|
||||||
|
<svg className="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ApprovalActionButtonsProps {
|
||||||
|
isLoading: boolean;
|
||||||
|
onApprove: () => void;
|
||||||
|
onSearch: () => void;
|
||||||
|
onDeny: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ApprovalActionButtons({ isLoading, onApprove, onSearch, onDeny }: ApprovalActionButtonsProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={onApprove}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="flex-1 inline-flex items-center justify-center gap-1.5 px-3 py-2 bg-green-600 hover:bg-green-700 disabled:bg-green-400 disabled:cursor-not-allowed text-white text-sm font-medium rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
{isLoading ? <LoadingSpinner /> : (
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
<span>Approve</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onSearch}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="flex-1 inline-flex items-center justify-center gap-1.5 px-3 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 disabled:cursor-not-allowed text-white text-sm font-medium rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||||
|
</svg>
|
||||||
|
<span>Search</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onDeny}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="flex-1 inline-flex items-center justify-center gap-1.5 px-3 py-2 bg-red-600 hover:bg-red-700 disabled:bg-red-400 disabled:cursor-not-allowed text-white text-sm font-medium rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
{isLoading ? <LoadingSpinner /> : (
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
<span>Deny</span>
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function PendingApprovalSection({ requests }: { requests: PendingApprovalRequest[] }) {
|
function PendingApprovalSection({ requests }: { requests: PendingApprovalRequest[] }) {
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const [loadingStates, setLoadingStates] = useState<Record<string, boolean>>({});
|
const [loadingStates, setLoadingStates] = useState<Record<string, boolean>>({});
|
||||||
const [searchModalRequestId, setSearchModalRequestId] = useState<string | null>(null);
|
const [searchModalRequestId, setSearchModalRequestId] = useState<string | null>(null);
|
||||||
|
const [detailsAsin, setDetailsAsin] = useState<string | null>(null);
|
||||||
|
const [detailsRequestId, setDetailsRequestId] = useState<string | null>(null);
|
||||||
|
|
||||||
const searchModalRequest = searchModalRequestId
|
const searchModalRequest = searchModalRequestId
|
||||||
? requests.find((r) => r.id === searchModalRequestId)
|
? requests.find((r) => r.id === searchModalRequestId)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
const detailsRequest = detailsRequestId
|
||||||
|
? requests.find((r) => r.id === detailsRequestId)
|
||||||
|
: null;
|
||||||
|
|
||||||
const handleApproveRequest = async (requestId: string) => {
|
const handleApproveRequest = async (requestId: string) => {
|
||||||
setLoadingStates((prev) => ({ ...prev, [requestId]: true }));
|
setLoadingStates((prev) => ({ ...prev, [requestId]: true }));
|
||||||
|
|
||||||
@@ -125,13 +190,6 @@ function PendingApprovalSection({ requests }: { requests: PendingApprovalRequest
|
|||||||
await mutate('/api/admin/metrics');
|
await mutate('/api/admin/metrics');
|
||||||
};
|
};
|
||||||
|
|
||||||
const LoadingSpinner = () => (
|
|
||||||
<svg className="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
|
|
||||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
|
||||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
{/* Section Header */}
|
{/* Section Header */}
|
||||||
@@ -170,8 +228,23 @@ function PendingApprovalSection({ requests }: { requests: PendingApprovalRequest
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={request.id}
|
key={request.id}
|
||||||
className="bg-white dark:bg-gray-800 border-2 border-amber-200 dark:border-amber-800 rounded-lg shadow-sm hover:shadow-md transition-shadow overflow-hidden"
|
className="relative bg-white dark:bg-gray-800 border-2 border-amber-200 dark:border-amber-800 rounded-lg shadow-sm hover:shadow-md transition-shadow overflow-hidden"
|
||||||
>
|
>
|
||||||
|
{/* Info Button — opens AudiobookDetailsModal */}
|
||||||
|
{request.audiobook.audibleAsin && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setDetailsAsin(request.audiobook.audibleAsin);
|
||||||
|
setDetailsRequestId(request.id);
|
||||||
|
}}
|
||||||
|
className="absolute top-2 right-2 z-10 p-1 text-gray-400 hover:text-blue-500 dark:hover:text-blue-400 transition-colors rounded-full hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||||
|
title="View book details"
|
||||||
|
aria-label="View book details"
|
||||||
|
>
|
||||||
|
<InformationCircleIcon className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Card Content */}
|
{/* Card Content */}
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
@@ -314,42 +387,12 @@ function PendingApprovalSection({ requests }: { requests: PendingApprovalRequest
|
|||||||
|
|
||||||
{/* Action Buttons */}
|
{/* Action Buttons */}
|
||||||
<div className="border-t border-amber-200 dark:border-amber-800 bg-gray-50 dark:bg-gray-900/50 px-4 py-3 flex gap-2">
|
<div className="border-t border-amber-200 dark:border-amber-800 bg-gray-50 dark:bg-gray-900/50 px-4 py-3 flex gap-2">
|
||||||
<button
|
<ApprovalActionButtons
|
||||||
onClick={() => handleApproveRequest(request.id)}
|
isLoading={isLoading}
|
||||||
disabled={isLoading}
|
onApprove={() => handleApproveRequest(request.id)}
|
||||||
className="flex-1 inline-flex items-center justify-center gap-1.5 px-3 py-2 bg-green-600 hover:bg-green-700 disabled:bg-green-400 disabled:cursor-not-allowed text-white text-sm font-medium rounded-lg transition-colors"
|
onSearch={() => setSearchModalRequestId(request.id)}
|
||||||
>
|
onDeny={() => handleDenyRequest(request.id)}
|
||||||
{isLoading ? <LoadingSpinner /> : (
|
/>
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
<span>Approve</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => setSearchModalRequestId(request.id)}
|
|
||||||
disabled={isLoading}
|
|
||||||
className="flex-1 inline-flex items-center justify-center gap-1.5 px-3 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 disabled:cursor-not-allowed text-white text-sm font-medium rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
|
||||||
</svg>
|
|
||||||
<span>Search</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => handleDenyRequest(request.id)}
|
|
||||||
disabled={isLoading}
|
|
||||||
className="flex-1 inline-flex items-center justify-center gap-1.5 px-3 py-2 bg-red-600 hover:bg-red-700 disabled:bg-red-400 disabled:cursor-not-allowed text-white text-sm font-medium rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
{isLoading ? <LoadingSpinner /> : (
|
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
<span>Deny</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -375,6 +418,37 @@ function PendingApprovalSection({ requests }: { requests: PendingApprovalRequest
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Book Details Modal — opened via info button on each approval card */}
|
||||||
|
{detailsAsin && detailsRequestId && (
|
||||||
|
<AudiobookDetailsModal
|
||||||
|
asin={detailsAsin}
|
||||||
|
isOpen={true}
|
||||||
|
onClose={() => { setDetailsAsin(null); setDetailsRequestId(null); }}
|
||||||
|
requestStatus="awaiting_approval"
|
||||||
|
requestedByUsername={detailsRequest?.user.plexUsername ?? null}
|
||||||
|
adminActions={
|
||||||
|
<ApprovalActionButtons
|
||||||
|
isLoading={loadingStates[detailsRequestId] || false}
|
||||||
|
onApprove={async () => {
|
||||||
|
await handleApproveRequest(detailsRequestId);
|
||||||
|
setDetailsAsin(null);
|
||||||
|
setDetailsRequestId(null);
|
||||||
|
}}
|
||||||
|
onSearch={() => {
|
||||||
|
setSearchModalRequestId(detailsRequestId);
|
||||||
|
setDetailsAsin(null);
|
||||||
|
setDetailsRequestId(null);
|
||||||
|
}}
|
||||||
|
onDeny={async () => {
|
||||||
|
await handleDenyRequest(detailsRequestId);
|
||||||
|
setDetailsAsin(null);
|
||||||
|
setDetailsRequestId(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||||
import { prisma } from '@/lib/db';
|
import { prisma } from '@/lib/db';
|
||||||
import { RMABLogger } from '@/lib/utils/logger';
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
import { discoverAudiobooks } from '@/lib/utils/bulk-import-scanner';
|
import { discoverAudiobooks, cleanSearchString } from '@/lib/utils/bulk-import-scanner';
|
||||||
import { getAudibleService } from '@/lib/integrations/audible.service';
|
import { getAudibleService } from '@/lib/integrations/audible.service';
|
||||||
import { findPlexMatch } from '@/lib/utils/audiobook-matcher';
|
import { findPlexMatch } from '@/lib/utils/audiobook-matcher';
|
||||||
|
|
||||||
@@ -159,10 +159,37 @@ export async function POST(request: NextRequest) {
|
|||||||
let hasActiveRequest = false;
|
let hasActiveRequest = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const searchResult = await audibleService.search(book.searchTerm);
|
// If the scanner extracted an ASIN directly from the folder name,
|
||||||
|
// use a direct ASIN lookup (Audnexus API) — more reliable than a
|
||||||
|
// keyword text search. Fall back to text search if the lookup fails.
|
||||||
|
if (book.extractedAsin) {
|
||||||
|
try {
|
||||||
|
const asinResult = await audibleService.getAudiobookDetails(book.extractedAsin);
|
||||||
|
if (asinResult) {
|
||||||
|
match = asinResult;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ASIN lookup failed — fall through to text search */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (searchResult.results.length > 0) {
|
if (!match) {
|
||||||
match = searchResult.results[0];
|
// 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
|
// Check library availability
|
||||||
const plexMatch = await findPlexMatch({
|
const plexMatch = await findPlexMatch({
|
||||||
@@ -208,6 +235,7 @@ export async function POST(request: NextRequest) {
|
|||||||
audioFileCount: book.audioFileCount,
|
audioFileCount: book.audioFileCount,
|
||||||
totalSizeBytes: book.totalSizeBytes,
|
totalSizeBytes: book.totalSizeBytes,
|
||||||
metadataSource: book.metadataSource,
|
metadataSource: book.metadataSource,
|
||||||
|
extractedAsin: book.extractedAsin,
|
||||||
searchTerm: book.searchTerm,
|
searchTerm: book.searchTerm,
|
||||||
audioFiles: book.audioFiles,
|
audioFiles: book.audioFiles,
|
||||||
match: match
|
match: match
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
import { getAudibleService } from '@/lib/integrations/audible.service';
|
import { getAudibleService } from '@/lib/integrations/audible.service';
|
||||||
import { enrichAudiobooksWithMatches } from '@/lib/utils/audiobook-matcher';
|
import { enrichAudiobooksWithMatches } from '@/lib/utils/audiobook-matcher';
|
||||||
import { deduplicateAndCollectGroups } from '@/lib/utils/deduplicate-audiobooks';
|
import { deduplicateAndCollectGroups } from '@/lib/utils/deduplicate-audiobooks';
|
||||||
import { persistDedupGroups } from '@/lib/services/works.service';
|
import { persistDedupGroups, collapseByExistingWorks } from '@/lib/services/works.service';
|
||||||
import { getCurrentUser } from '@/lib/middleware/auth';
|
import { getCurrentUser } from '@/lib/middleware/auth';
|
||||||
import { RMABLogger } from '@/lib/utils/logger';
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
import { annotateWithIgnoreStatus } from '@/lib/utils/ignored-audiobooks';
|
import { annotateWithIgnoreStatus } from '@/lib/utils/ignored-audiobooks';
|
||||||
@@ -41,16 +41,19 @@ export async function GET(request: NextRequest) {
|
|||||||
const currentUser = getCurrentUser(request);
|
const currentUser = getCurrentUser(request);
|
||||||
const userId = currentUser?.sub || undefined;
|
const userId = currentUser?.sub || undefined;
|
||||||
|
|
||||||
// Deduplicate before enrichment to avoid wasted DB queries on duplicate entries
|
// Two-pass dedup: local title/narrator/duration matching first, then collapse
|
||||||
|
// any remaining duplicates that the works table already knows are the same book
|
||||||
|
// (handles cases where source metadata diverges across paths or pages).
|
||||||
const { books: dedupedResults, groups } = deduplicateAndCollectGroups(results.results);
|
const { books: dedupedResults, groups } = deduplicateAndCollectGroups(results.results);
|
||||||
|
|
||||||
// Fire-and-forget: persist dedup groups to works table for cross-ASIN matching
|
|
||||||
if (groups.length > 0) {
|
if (groups.length > 0) {
|
||||||
persistDedupGroups(groups).catch(() => {});
|
persistDedupGroups(groups).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const collapsedResults = await collapseByExistingWorks(dedupedResults);
|
||||||
|
|
||||||
// Enrich search results with availability and request status information
|
// Enrich search results with availability and request status information
|
||||||
const enrichedResults = await enrichAudiobooksWithMatches(dedupedResults, userId);
|
const enrichedResults = await enrichAudiobooksWithMatches(collapsedResults, userId);
|
||||||
|
|
||||||
// Annotate with per-user ignore status
|
// Annotate with per-user ignore status
|
||||||
const annotatedResults = await annotateWithIgnoreStatus(enrichedResults, userId);
|
const annotatedResults = await annotateWithIgnoreStatus(enrichedResults, userId);
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
import { getAudibleService } from '@/lib/integrations/audible.service';
|
import { getAudibleService } from '@/lib/integrations/audible.service';
|
||||||
import { enrichAudiobooksWithMatches } from '@/lib/utils/audiobook-matcher';
|
import { enrichAudiobooksWithMatches } from '@/lib/utils/audiobook-matcher';
|
||||||
import { deduplicateAndCollectGroups } from '@/lib/utils/deduplicate-audiobooks';
|
import { deduplicateAndCollectGroups } from '@/lib/utils/deduplicate-audiobooks';
|
||||||
import { persistDedupGroups } from '@/lib/services/works.service';
|
import { persistDedupGroups, collapseByExistingWorks } from '@/lib/services/works.service';
|
||||||
import { getCurrentUser } from '@/lib/middleware/auth';
|
import { getCurrentUser } from '@/lib/middleware/auth';
|
||||||
import { RMABLogger } from '@/lib/utils/logger';
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
import { annotateWithIgnoreStatus } from '@/lib/utils/ignored-audiobooks';
|
import { annotateWithIgnoreStatus } from '@/lib/utils/ignored-audiobooks';
|
||||||
@@ -56,17 +56,20 @@ export async function GET(
|
|||||||
const audibleService = getAudibleService();
|
const audibleService = getAudibleService();
|
||||||
const result = await audibleService.searchByAuthorAsin(authorName.trim(), asin, page);
|
const result = await audibleService.searchByAuthorAsin(authorName.trim(), asin, page);
|
||||||
|
|
||||||
// Deduplicate before enrichment to avoid wasted DB queries on duplicate entries
|
// Two-pass dedup: local title/narrator/duration matching first, then collapse
|
||||||
|
// any remaining duplicates that the works table already knows are the same book
|
||||||
|
// (handles cases where source metadata diverges across paths or pages).
|
||||||
const { books: dedupedBooks, groups } = deduplicateAndCollectGroups(result.books);
|
const { books: dedupedBooks, groups } = deduplicateAndCollectGroups(result.books);
|
||||||
|
|
||||||
// Fire-and-forget: persist dedup groups to works table for cross-ASIN matching
|
|
||||||
if (groups.length > 0) {
|
if (groups.length > 0) {
|
||||||
persistDedupGroups(groups).catch(() => {});
|
persistDedupGroups(groups).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const collapsedBooks = await collapseByExistingWorks(dedupedBooks);
|
||||||
|
|
||||||
// Enrich with library availability and request status
|
// Enrich with library availability and request status
|
||||||
const userId = currentUser.sub || undefined;
|
const userId = currentUser.sub || undefined;
|
||||||
const enrichedBooks = await enrichAudiobooksWithMatches(dedupedBooks, userId);
|
const enrichedBooks = await enrichAudiobooksWithMatches(collapsedBooks, userId);
|
||||||
|
|
||||||
// Annotate with per-user ignore status
|
// Annotate with per-user ignore status
|
||||||
const annotatedBooks = await annotateWithIgnoreStatus(enrichedBooks, userId);
|
const annotatedBooks = await annotateWithIgnoreStatus(enrichedBooks, userId);
|
||||||
|
|||||||
@@ -4,10 +4,12 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { Prisma } from '@/generated/prisma/client';
|
||||||
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
|
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||||
import { prisma } from '@/lib/db';
|
import { prisma } from '@/lib/db';
|
||||||
import { RMABLogger } from '@/lib/utils/logger';
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
import { CLIENT_PROTOCOL_MAP, DownloadClientType } from '@/lib/interfaces/download-client.interface';
|
import { CLIENT_PROTOCOL_MAP, DownloadClientType } from '@/lib/interfaces/download-client.interface';
|
||||||
|
import { CANCELLABLE_STATUSES } from '@/lib/constants/request-statuses';
|
||||||
|
|
||||||
const logger = RMABLogger.create('API.RequestById');
|
const logger = RMABLogger.create('API.RequestById');
|
||||||
|
|
||||||
@@ -112,6 +114,10 @@ export async function PATCH(
|
|||||||
id,
|
id,
|
||||||
deletedAt: null, // Only allow updates to active requests
|
deletedAt: null, // Only allow updates to active requests
|
||||||
},
|
},
|
||||||
|
include: {
|
||||||
|
audiobook: true,
|
||||||
|
user: { select: { plexUsername: true } },
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!requestRecord) {
|
if (!requestRecord) {
|
||||||
@@ -130,18 +136,44 @@ export async function PATCH(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (action === 'cancel') {
|
if (action === 'cancel') {
|
||||||
// Cancel the request
|
if (!(CANCELLABLE_STATUSES as readonly string[]).includes(requestRecord.status)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: 'ValidationError',
|
||||||
|
message: `Cannot cancel request with status: ${requestRecord.status}`,
|
||||||
|
},
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAwaitingApproval = requestRecord.status === 'awaiting_approval';
|
||||||
|
|
||||||
const updated = await prisma.request.update({
|
const updated = await prisma.request.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: {
|
data: {
|
||||||
status: 'cancelled',
|
status: 'cancelled',
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
|
...(isAwaitingApproval && { selectedTorrent: Prisma.DbNull }),
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
audiobook: true,
|
audiobook: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { getJobQueueService } = await import('@/lib/services/job-queue.service');
|
||||||
|
const jobQueue = getJobQueueService();
|
||||||
|
await jobQueue.addNotificationJob(
|
||||||
|
'request_cancelled',
|
||||||
|
updated.id,
|
||||||
|
updated.audiobook.title,
|
||||||
|
updated.audiobook.author,
|
||||||
|
requestRecord.user.plexUsername || 'Unknown User'
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to queue cancellation notification', { error });
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
request: updated,
|
request: updated,
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { RMABLogger } from '@/lib/utils/logger';
|
|||||||
import { scrapeSeriesPage } from '@/lib/integrations/audible-series';
|
import { scrapeSeriesPage } from '@/lib/integrations/audible-series';
|
||||||
import { enrichAudiobooksWithMatches } from '@/lib/utils/audiobook-matcher';
|
import { enrichAudiobooksWithMatches } from '@/lib/utils/audiobook-matcher';
|
||||||
import { deduplicateAndCollectGroups } from '@/lib/utils/deduplicate-audiobooks';
|
import { deduplicateAndCollectGroups } from '@/lib/utils/deduplicate-audiobooks';
|
||||||
import { persistDedupGroups } from '@/lib/services/works.service';
|
import { persistDedupGroups, collapseByExistingWorks } from '@/lib/services/works.service';
|
||||||
import { annotateWithIgnoreStatus } from '@/lib/utils/ignored-audiobooks';
|
import { annotateWithIgnoreStatus } from '@/lib/utils/ignored-audiobooks';
|
||||||
|
|
||||||
const logger = RMABLogger.create('API.Series.Detail');
|
const logger = RMABLogger.create('API.Series.Detail');
|
||||||
@@ -52,17 +52,20 @@ export async function GET(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deduplicate before enrichment to avoid wasted DB queries on duplicate entries
|
// Two-pass dedup: local title/narrator/duration matching first, then collapse
|
||||||
|
// any remaining duplicates that the works table already knows are the same book
|
||||||
|
// (handles cases where source metadata diverges across paths or pages).
|
||||||
const { books: dedupedBooks, groups } = deduplicateAndCollectGroups(detail.books);
|
const { books: dedupedBooks, groups } = deduplicateAndCollectGroups(detail.books);
|
||||||
|
|
||||||
// Fire-and-forget: persist dedup groups to works table for cross-ASIN matching
|
|
||||||
if (groups.length > 0) {
|
if (groups.length > 0) {
|
||||||
persistDedupGroups(groups).catch(() => {});
|
persistDedupGroups(groups).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const collapsedBooks = await collapseByExistingWorks(dedupedBooks);
|
||||||
|
|
||||||
// Enrich books with library availability and request status
|
// Enrich books with library availability and request status
|
||||||
const userId = currentUser.sub || undefined;
|
const userId = currentUser.sub || undefined;
|
||||||
const enrichedBooks = await enrichAudiobooksWithMatches(dedupedBooks, userId);
|
const enrichedBooks = await enrichAudiobooksWithMatches(collapsedBooks, userId);
|
||||||
|
|
||||||
// Annotate with per-user ignore status
|
// Annotate with per-user ignore status
|
||||||
const annotatedBooks = await annotateWithIgnoreStatus(enrichedBooks, userId);
|
const annotatedBooks = await annotateWithIgnoreStatus(enrichedBooks, userId);
|
||||||
|
|||||||
@@ -265,11 +265,15 @@ function LoginContent() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Poll for authorization
|
// Poll for authorization
|
||||||
await login(pinId);
|
const loginResult = await login(pinId);
|
||||||
|
|
||||||
// Close popup
|
// Close popup
|
||||||
authWindow.close();
|
authWindow.close();
|
||||||
|
|
||||||
|
if (loginResult === 'profile-selection-required') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Redirect to intended page or homepage
|
// Redirect to intended page or homepage
|
||||||
const redirect = searchParams.get('redirect') || '/';
|
const redirect = searchParams.get('redirect') || '/';
|
||||||
router.push(redirect);
|
router.push(redirect);
|
||||||
|
|||||||
@@ -39,7 +39,12 @@ function BookRow({
|
|||||||
const isDisabled = book.inLibrary || book.hasActiveRequest;
|
const isDisabled = book.inLibrary || book.hasActiveRequest;
|
||||||
const isSkipped = book.skipped;
|
const isSkipped = book.skipped;
|
||||||
const hasMatch = book.match !== null;
|
const hasMatch = book.match !== null;
|
||||||
const isLowConfidence = book.metadataSource === 'file_name';
|
// Low confidence when search term came from a filename or folder name fallback,
|
||||||
|
// BUT not when an ASIN was extracted directly from the folder name (that's a
|
||||||
|
// direct lookup and is as reliable as embedded metadata tags).
|
||||||
|
const isLowConfidence =
|
||||||
|
(book.metadataSource === 'file_name' || book.metadataSource === 'folder_name') &&
|
||||||
|
!book.extractedAsin;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -34,7 +34,9 @@ export interface ScannedBook {
|
|||||||
relativePath: string;
|
relativePath: string;
|
||||||
audioFileCount: number;
|
audioFileCount: number;
|
||||||
totalSizeBytes: number;
|
totalSizeBytes: number;
|
||||||
metadataSource: 'tags' | 'file_name';
|
metadataSource: 'tags' | 'folder_name' | 'file_name';
|
||||||
|
/** ASIN extracted directly from the folder name, if present. */
|
||||||
|
extractedAsin?: string;
|
||||||
searchTerm: string;
|
searchTerm: string;
|
||||||
audioFiles: string[];
|
audioFiles: string[];
|
||||||
match: AudibleMatch | null;
|
match: AudibleMatch | null;
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ interface AudiobookDetailsModalProps {
|
|||||||
hideRequestActions?: boolean;
|
hideRequestActions?: boolean;
|
||||||
hasReportedIssue?: boolean;
|
hasReportedIssue?: boolean;
|
||||||
aiReason?: string | null;
|
aiReason?: string | null;
|
||||||
|
/** Optional admin action buttons (Approve / Search / Deny) rendered as a second row in the action bar */
|
||||||
|
adminActions?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Status helper
|
// Status helper
|
||||||
@@ -80,6 +82,7 @@ export function AudiobookDetailsModal({
|
|||||||
hideRequestActions = false,
|
hideRequestActions = false,
|
||||||
hasReportedIssue = false,
|
hasReportedIssue = false,
|
||||||
aiReason = null,
|
aiReason = null,
|
||||||
|
adminActions,
|
||||||
}: AudiobookDetailsModalProps) {
|
}: AudiobookDetailsModalProps) {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { squareCovers } = usePreferences();
|
const { squareCovers } = usePreferences();
|
||||||
@@ -548,6 +551,30 @@ export function AudiobookDetailsModal({
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Language */}
|
||||||
|
{audiobook.language && (
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-500 dark:text-gray-400">Language</p>
|
||||||
|
<p className="text-gray-900 dark:text-gray-100 capitalize">{audiobook.language}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Format */}
|
||||||
|
{audiobook.formatType && (
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-500 dark:text-gray-400">Format</p>
|
||||||
|
<p className="text-gray-900 dark:text-gray-100 capitalize">{audiobook.formatType}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Publisher */}
|
||||||
|
{audiobook.publisherName && (
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-500 dark:text-gray-400">Publisher</p>
|
||||||
|
<p className="text-gray-900 dark:text-gray-100">{audiobook.publisherName}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Download Link - subtle utility, visible from any context */}
|
{/* Download Link - subtle utility, visible from any context */}
|
||||||
{isAvailable && downloadAvailable && requestId && user?.permissions?.download !== false && (
|
{isAvailable && downloadAvailable && requestId && user?.permissions?.download !== false && (
|
||||||
<div>
|
<div>
|
||||||
@@ -739,6 +766,13 @@ export function AudiobookDetailsModal({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Admin Actions Row (Approve / Search / Deny) — injected by admin pages */}
|
||||||
|
{adminActions && (
|
||||||
|
<div className="flex items-center gap-2 mt-3 pt-3 border-t border-amber-200 dark:border-amber-700/50">
|
||||||
|
{adminActions}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ import { useCancelRequest } from '@/lib/hooks/useRequests';
|
|||||||
import { cn } from '@/lib/utils/cn';
|
import { cn } from '@/lib/utils/cn';
|
||||||
import { usePreferences } from '@/contexts/PreferencesContext';
|
import { usePreferences } from '@/contexts/PreferencesContext';
|
||||||
import { AudiobookDetailsModal } from '@/components/audiobooks/AudiobookDetailsModal';
|
import { AudiobookDetailsModal } from '@/components/audiobooks/AudiobookDetailsModal';
|
||||||
import { COMPLETED_STATUSES } from '@/lib/constants/request-statuses';
|
import { ConfirmModal } from '@/components/ui/ConfirmModal';
|
||||||
|
import { COMPLETED_STATUSES, CANCELLABLE_STATUSES } from '@/lib/constants/request-statuses';
|
||||||
|
|
||||||
interface RequestCardProps {
|
interface RequestCardProps {
|
||||||
request: {
|
request: {
|
||||||
@@ -45,22 +46,25 @@ export function RequestCard({ request, showActions = true }: RequestCardProps) {
|
|||||||
const [showError, setShowError] = React.useState(false);
|
const [showError, setShowError] = React.useState(false);
|
||||||
const [showDetailsModal, setShowDetailsModal] = React.useState(false);
|
const [showDetailsModal, setShowDetailsModal] = React.useState(false);
|
||||||
const [coverError, setCoverError] = React.useState(false);
|
const [coverError, setCoverError] = React.useState(false);
|
||||||
|
const [confirmCancelOpen, setConfirmCancelOpen] = React.useState(false);
|
||||||
|
|
||||||
|
const isAwaitingApproval = request.status === 'awaiting_approval';
|
||||||
|
|
||||||
const requestType = request.type || 'audiobook';
|
const requestType = request.type || 'audiobook';
|
||||||
const isEbook = requestType === 'ebook';
|
const isEbook = requestType === 'ebook';
|
||||||
|
|
||||||
const isCompleted = COMPLETED_STATUSES.includes(request.status as typeof COMPLETED_STATUSES[number]);
|
const isCompleted = COMPLETED_STATUSES.includes(request.status as typeof COMPLETED_STATUSES[number]);
|
||||||
const canCancel = ['pending', 'searching', 'downloading', 'awaiting_search'].includes(request.status);
|
const canCancel = (CANCELLABLE_STATUSES as readonly string[]).includes(request.status);
|
||||||
const isActive = ['searching', 'downloading', 'processing'].includes(request.status);
|
const isActive = ['searching', 'downloading', 'processing'].includes(request.status);
|
||||||
const isFailed = request.status === 'failed';
|
const isFailed = request.status === 'failed';
|
||||||
|
|
||||||
const handleCancel = async () => {
|
const handleConfirmCancel = async () => {
|
||||||
if (window.confirm('Are you sure you want to cancel this request?')) {
|
try {
|
||||||
try {
|
await cancelRequest(request.id);
|
||||||
await cancelRequest(request.id);
|
setConfirmCancelOpen(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to cancel request:', error);
|
console.error('Failed to cancel request:', error);
|
||||||
}
|
setConfirmCancelOpen(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -228,13 +232,13 @@ export function RequestCard({ request, showActions = true }: RequestCardProps) {
|
|||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{canCancel && (
|
{canCancel && (
|
||||||
<Button
|
<Button
|
||||||
onClick={handleCancel}
|
onClick={() => setConfirmCancelOpen(true)}
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="text-xs sm:text-sm text-red-600 border-red-600 hover:bg-red-50 dark:hover:bg-red-900/20"
|
className="text-xs sm:text-sm text-red-600 border-red-600 hover:bg-red-50 dark:hover:bg-red-900/20"
|
||||||
>
|
>
|
||||||
Cancel
|
{isAwaitingApproval ? 'Withdraw' : 'Cancel'}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -254,6 +258,22 @@ export function RequestCard({ request, showActions = true }: RequestCardProps) {
|
|||||||
hideRequestActions
|
hideRequestActions
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<ConfirmModal
|
||||||
|
isOpen={confirmCancelOpen}
|
||||||
|
onClose={() => !isLoading && setConfirmCancelOpen(false)}
|
||||||
|
onConfirm={handleConfirmCancel}
|
||||||
|
title={isAwaitingApproval ? 'Withdraw request' : 'Cancel request'}
|
||||||
|
message={
|
||||||
|
isAwaitingApproval
|
||||||
|
? 'This request is pending admin approval and will be withdrawn. You can request it again later.'
|
||||||
|
: 'This request has already been approved and is actively being processed. Cancelling will stop the download.'
|
||||||
|
}
|
||||||
|
confirmText={isAwaitingApproval ? 'Withdraw request' : 'Cancel request'}
|
||||||
|
cancelText="Keep request"
|
||||||
|
variant="danger"
|
||||||
|
isLoading={isLoading}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,11 +24,13 @@ interface User {
|
|||||||
permissions?: UserPermissions;
|
permissions?: UserPermissions;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type LoginResult = 'authenticated' | 'profile-selection-required';
|
||||||
|
|
||||||
interface AuthContextType {
|
interface AuthContextType {
|
||||||
user: User | null;
|
user: User | null;
|
||||||
accessToken: string | null;
|
accessToken: string | null;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
login: (pinId: number) => Promise<void>;
|
login: (pinId: number) => Promise<LoginResult>;
|
||||||
logout: () => void;
|
logout: () => void;
|
||||||
refreshToken: () => Promise<void>;
|
refreshToken: () => Promise<void>;
|
||||||
setAuthData: (user: User, accessToken: string) => void;
|
setAuthData: (user: User, accessToken: string) => void;
|
||||||
@@ -182,7 +184,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Poll Plex OAuth callback during login
|
// Poll Plex OAuth callback during login
|
||||||
const login = async (pinId: number) => {
|
const login = async (pinId: number): Promise<LoginResult> => {
|
||||||
const maxAttempts = 60; // 2 minutes total
|
const maxAttempts = 60; // 2 minutes total
|
||||||
let attempts = 0;
|
let attempts = 0;
|
||||||
|
|
||||||
@@ -211,7 +213,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
// Redirect to profile selection page
|
// Redirect to profile selection page
|
||||||
// Note: Plex token is stored server-side for security, not in sessionStorage
|
// Note: Plex token is stored server-side for security, not in sessionStorage
|
||||||
window.location.href = data.redirectUrl;
|
window.location.href = data.redirectUrl;
|
||||||
return;
|
return 'profile-selection-required';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Login successful (no profile selection needed)
|
// Login successful (no profile selection needed)
|
||||||
@@ -226,7 +228,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
// Schedule auto-refresh
|
// Schedule auto-refresh
|
||||||
scheduleTokenRefresh(data.accessToken);
|
scheduleTokenRefresh(data.accessToken);
|
||||||
|
|
||||||
return;
|
return 'authenticated';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Still waiting for authorization
|
// Still waiting for authorization
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
/**
|
/**
|
||||||
* Component: Notification Event Constants
|
* Component: Notification Event Constants
|
||||||
* Documentation: documentation/backend/services/notifications.md
|
* Documentation: documentation/backend/services/notifications.md
|
||||||
*
|
*
|
||||||
@@ -10,16 +10,28 @@ export type NotificationSeverity = 'info' | 'success' | 'error' | 'warning';
|
|||||||
export type NotificationPriority = 'normal' | 'high';
|
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
|
* - `label`: Human-readable name shown in the UI
|
||||||
* - `title`: Default title used in notification messages
|
* - `title`: Default title used in notification messages
|
||||||
* - `titleByRequestType`: Optional map of request-type-specific titles (e.g. audiobook → "Audiobook Available")
|
* - `titleByRequestType`: Optional map of request-type-specific titles (e.g. audiobook → "Audiobook Available")
|
||||||
* - `emoji`: Emoji prefix for notification titles
|
* - `emoji`: Emoji prefix for notification titles
|
||||||
* - `severity`: Drives provider formatting (colors, Apprise types, ntfy tags)
|
* - `severity`: Drives provider formatting (colors, Apprise types, ntfy tags)
|
||||||
* - `priority`: Drives notification urgency (Pushover/ntfy priority levels)
|
* - `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 = {
|
export const NOTIFICATION_EVENTS = {
|
||||||
request_pending_approval: {
|
request_pending_approval: {
|
||||||
label: 'Request Pending Approval',
|
label: 'Request Pending Approval',
|
||||||
@@ -31,17 +43,29 @@ export const NOTIFICATION_EVENTS = {
|
|||||||
request_approved: {
|
request_approved: {
|
||||||
label: 'Request Approved',
|
label: 'Request Approved',
|
||||||
title: 'Request Approved',
|
title: 'Request Approved',
|
||||||
emoji: '\u2705',
|
emoji: '✅',
|
||||||
severity: 'success' as const,
|
severity: 'success' as const,
|
||||||
priority: 'normal' 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: {
|
request_available: {
|
||||||
label: 'Request Available',
|
label: 'Request Available',
|
||||||
title: 'Request Available',
|
title: 'Request Available',
|
||||||
titleByRequestType: {
|
titleByRequestType: {
|
||||||
audiobook: 'Audiobook Available',
|
audiobook: 'Audiobook Available',
|
||||||
ebook: 'Ebook Available',
|
ebook: 'Ebook Available',
|
||||||
} as Record<string, string>,
|
},
|
||||||
emoji: '\u{1F389}',
|
emoji: '\u{1F389}',
|
||||||
severity: 'success' as const,
|
severity: 'success' as const,
|
||||||
priority: 'high' as const,
|
priority: 'high' as const,
|
||||||
@@ -49,18 +73,26 @@ export const NOTIFICATION_EVENTS = {
|
|||||||
request_error: {
|
request_error: {
|
||||||
label: 'Request Error',
|
label: 'Request Error',
|
||||||
title: 'Request Error',
|
title: 'Request Error',
|
||||||
emoji: '\u274C',
|
emoji: '❌',
|
||||||
severity: 'error' as const,
|
severity: 'error' as const,
|
||||||
priority: 'high' 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: {
|
issue_reported: {
|
||||||
label: 'Issue Reported',
|
label: 'Issue Reported',
|
||||||
title: 'Issue Reported',
|
title: 'Issue Reported',
|
||||||
emoji: '\u{1F6A9}',
|
emoji: '\u{1F6A9}',
|
||||||
severity: 'warning' as const,
|
severity: 'warning' as const,
|
||||||
priority: 'high' as const,
|
priority: 'high' as const,
|
||||||
|
messageLabel: 'Reason',
|
||||||
},
|
},
|
||||||
} as const;
|
} satisfies Record<string, NotificationEventConfig>;
|
||||||
|
|
||||||
/** Union type of all valid notification event keys */
|
/** Union type of all valid notification event keys */
|
||||||
export type NotificationEvent = keyof typeof NOTIFICATION_EVENTS;
|
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];
|
export type NotificationEventMeta = (typeof NOTIFICATION_EVENTS)[NotificationEvent];
|
||||||
|
|
||||||
/** Helper: get event metadata by key */
|
/** Helper: get event metadata by key */
|
||||||
export function getEventMeta(event: NotificationEvent) {
|
export function getEventMeta(event: NotificationEvent): NotificationEventConfig {
|
||||||
return NOTIFICATION_EVENTS[event];
|
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`.
|
* returns the type-specific title. Otherwise falls back to the default `title`.
|
||||||
*/
|
*/
|
||||||
export function getEventTitle(event: NotificationEvent, requestType?: string): string {
|
export function getEventTitle(event: NotificationEvent, requestType?: string): string {
|
||||||
const meta = NOTIFICATION_EVENTS[event];
|
const meta = getEventMeta(event);
|
||||||
if (requestType && 'titleByRequestType' in meta) {
|
if (requestType && meta.titleByRequestType) {
|
||||||
const typeTitle = (meta as typeof meta & { titleByRequestType: Record<string, string> }).titleByRequestType[requestType];
|
const typeTitle = meta.titleByRequestType[requestType];
|
||||||
if (typeTitle) return typeTitle;
|
if (typeTitle) return typeTitle;
|
||||||
}
|
}
|
||||||
return meta.title;
|
return meta.title;
|
||||||
|
|||||||
@@ -5,3 +5,12 @@
|
|||||||
|
|
||||||
/** Terminal statuses indicating a request has been fulfilled and files are ready */
|
/** Terminal statuses indicating a request has been fulfilled and files are ready */
|
||||||
export const COMPLETED_STATUSES = ['available', 'downloaded'] as const;
|
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;
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ export interface Audiobook {
|
|||||||
requestedByUsername?: string | null; // Username who requested (only if not current user)
|
requestedByUsername?: string | null; // Username who requested (only if not current user)
|
||||||
hasReportedIssue?: boolean; // True if an open issue exists for this audiobook
|
hasReportedIssue?: boolean; // True if an open issue exists for this audiobook
|
||||||
isIgnored?: boolean; // True if this user has ignored this audiobook from auto-requests
|
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) {
|
export function useAudiobooks(type: 'popular' | 'new-releases', limit: number = 20, page: number = 1, hideAvailable: boolean = false) {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
import { RMABLogger } from '../utils/logger';
|
import { RMABLogger } from '../utils/logger';
|
||||||
import { parseRuntime } from '../utils/parse-runtime';
|
import { parseRuntime } from '../utils/parse-runtime';
|
||||||
import { randomDelay } from '../utils/scrape-resilience';
|
import { randomDelay } from '../utils/scrape-resilience';
|
||||||
|
import { extractAllNarrators } from '../utils/extract-narrator';
|
||||||
|
|
||||||
const logger = RMABLogger.create('Audible.Series');
|
const logger = RMABLogger.create('Audible.Series');
|
||||||
|
|
||||||
@@ -442,10 +443,8 @@ function parseSeriesBooks(
|
|||||||
const authorHref = authorLink.attr('href') || '';
|
const authorHref = authorLink.attr('href') || '';
|
||||||
const authorAsinMatch = authorHref.match(/\/author\/[^/]+\/([A-Z0-9]{10})/);
|
const authorAsinMatch = authorHref.match(/\/author\/[^/]+\/([A-Z0-9]{10})/);
|
||||||
|
|
||||||
// Narrator
|
// Narrator — capture all narrator links (multi-narrator productions are common)
|
||||||
const narratorText = $el.find('a[href*="searchNarrator="]').first().text().trim() ||
|
const narratorText = extractAllNarrators($, $el);
|
||||||
$el.find('.narratorLabel').text().trim() ||
|
|
||||||
'';
|
|
||||||
|
|
||||||
// Cover art
|
// Cover art
|
||||||
const coverArtUrl = $el.find('img').first().attr('src')?.replace(/\._.*_\./, '._SL500_.') || '';
|
const coverArtUrl = $el.find('img').first().attr('src')?.replace(/\._.*_\./, '._SL500_.') || '';
|
||||||
|
|||||||
@@ -4,21 +4,26 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import axios, { AxiosInstance } from 'axios';
|
import axios, { AxiosInstance } from 'axios';
|
||||||
|
import * as cheerio from 'cheerio';
|
||||||
import { RMABLogger } from '../utils/logger';
|
import { RMABLogger } from '../utils/logger';
|
||||||
import { getConfigService } from '../services/config.service';
|
import { getConfigService } from '../services/config.service';
|
||||||
import { AudibleRegion, AUDIBLE_REGIONS, DEFAULT_AUDIBLE_REGION } from '../types/audible';
|
import { AudibleRegion, AUDIBLE_REGIONS, DEFAULT_AUDIBLE_REGION } from '../types/audible';
|
||||||
import {
|
import {
|
||||||
getLanguageForRegion,
|
getLanguageForRegion,
|
||||||
isAcceptedLanguage,
|
isAcceptedLanguage,
|
||||||
|
stripPrefixes,
|
||||||
|
buildContainsSelector,
|
||||||
|
type LanguageConfig,
|
||||||
} from '../constants/language-config';
|
} from '../constants/language-config';
|
||||||
import {
|
import {
|
||||||
pickUserAgent,
|
pickUserAgent,
|
||||||
getBrowserHeaders,
|
getBrowserHeaders,
|
||||||
jitteredBackoff,
|
jitteredBackoff,
|
||||||
randomDelay,
|
|
||||||
AdaptivePacer,
|
AdaptivePacer,
|
||||||
FetchResultMeta,
|
FetchResultMeta,
|
||||||
} from '../utils/scrape-resilience';
|
} from '../utils/scrape-resilience';
|
||||||
|
import { parseRuntime as parseRuntimeUtil } from '../utils/parse-runtime';
|
||||||
|
import { extractAllNarrators } from '../utils/extract-narrator';
|
||||||
|
|
||||||
const logger = RMABLogger.create('Audible');
|
const logger = RMABLogger.create('Audible');
|
||||||
|
|
||||||
@@ -27,6 +32,13 @@ const AUDIBLE_PAGE_SIZE = 50;
|
|||||||
const CATALOG_RESPONSE_GROUPS =
|
const CATALOG_RESPONSE_GROUPS =
|
||||||
'contributors,product_desc,product_attrs,product_extended_attrs,media,rating,series,category_ladders,product_details';
|
'contributors,product_desc,product_attrs,product_extended_attrs,media,rating,series,category_ladders,product_details';
|
||||||
|
|
||||||
|
// Retry/backoff knobs for HTML scraping (nightly refresh job only).
|
||||||
|
// Healthy users still finish quickly — per-page success returns on attempt 0
|
||||||
|
// with a 2-4s inter-page delay. Struggling users grind through 503 storms
|
||||||
|
// patiently: up to ~12 retries per request, with each backoff capped at 3 min.
|
||||||
|
const HTML_MAX_RETRIES = 12;
|
||||||
|
const HTML_MAX_BACKOFF_MS = 180_000;
|
||||||
|
|
||||||
export interface AudibleAudiobook {
|
export interface AudibleAudiobook {
|
||||||
asin: string;
|
asin: string;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -42,6 +54,9 @@ export interface AudibleAudiobook {
|
|||||||
series?: string;
|
series?: string;
|
||||||
seriesPart?: string;
|
seriesPart?: string;
|
||||||
seriesAsin?: string;
|
seriesAsin?: string;
|
||||||
|
language?: string;
|
||||||
|
formatType?: string;
|
||||||
|
publisherName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AudibleSearchResult {
|
export interface AudibleSearchResult {
|
||||||
@@ -93,6 +108,8 @@ interface CatalogProduct {
|
|||||||
runtime_length_min?: number;
|
runtime_length_min?: number;
|
||||||
release_date?: string;
|
release_date?: string;
|
||||||
language?: string;
|
language?: string;
|
||||||
|
format_type?: string;
|
||||||
|
publisher_name?: string;
|
||||||
rating?: {
|
rating?: {
|
||||||
overall_distribution?: {
|
overall_distribution?: {
|
||||||
display_stars?: number;
|
display_stars?: number;
|
||||||
@@ -183,6 +200,9 @@ function mapCatalogProduct(product: CatalogProduct): AudibleAudiobook {
|
|||||||
series,
|
series,
|
||||||
seriesPart,
|
seriesPart,
|
||||||
seriesAsin,
|
seriesAsin,
|
||||||
|
language: product.language ?? undefined,
|
||||||
|
formatType: product.format_type ?? undefined,
|
||||||
|
publisherName: product.publisher_name ?? undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -298,6 +318,7 @@ export class AudibleService {
|
|||||||
config: any = {},
|
config: any = {},
|
||||||
maxRetries: number = 5,
|
maxRetries: number = 5,
|
||||||
client: AxiosInstance = this.htmlClient,
|
client: AxiosInstance = this.htmlClient,
|
||||||
|
maxBackoffMs: number = Number.POSITIVE_INFINITY,
|
||||||
): Promise<{ data: any; meta: FetchResultMeta }> {
|
): Promise<{ data: any; meta: FetchResultMeta }> {
|
||||||
let lastError: Error | null = null;
|
let lastError: Error | null = null;
|
||||||
let retriesUsed = 0;
|
let retriesUsed = 0;
|
||||||
@@ -324,7 +345,7 @@ export class AudibleService {
|
|||||||
|
|
||||||
retriesUsed++;
|
retriesUsed++;
|
||||||
|
|
||||||
const backoffMs = jitteredBackoff(attempt);
|
const backoffMs = jitteredBackoff(attempt, 1000, maxBackoffMs);
|
||||||
logger.info(
|
logger.info(
|
||||||
` Request failed (${status || 'network error'}), retrying in ${backoffMs}ms (attempt ${attempt + 1}/${maxRetries})...`,
|
` Request failed (${status || 'network error'}), retrying in ${backoffMs}ms (attempt ${attempt + 1}/${maxRetries})...`,
|
||||||
);
|
);
|
||||||
@@ -379,6 +400,12 @@ export class AudibleService {
|
|||||||
throw lastError || new Error('External API request failed after retries');
|
throw lastError || new Error('External API request failed after retries');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Popular audiobooks from Audible's curated /adblbestsellers HTML page.
|
||||||
|
* Uses HTML scraping (not the catalog API) because the API's BestSellers sort
|
||||||
|
* is a right-now velocity rank that surfaces launch-day shovelware and preorders;
|
||||||
|
* the HTML page reflects Audible's editorial curation.
|
||||||
|
*/
|
||||||
async getPopularAudiobooks(limit: number = 20): Promise<AudibleAudiobook[]> {
|
async getPopularAudiobooks(limit: number = 20): Promise<AudibleAudiobook[]> {
|
||||||
await this.initialize();
|
await this.initialize();
|
||||||
|
|
||||||
@@ -395,42 +422,36 @@ export class AudibleService {
|
|||||||
logger.info(` Fetching page ${page}/${maxPages}...`);
|
logger.info(` Fetching page ${page}/${maxPages}...`);
|
||||||
|
|
||||||
const { data: response, meta } = await this.fetchWithRetry(
|
const { data: response, meta } = await this.fetchWithRetry(
|
||||||
'/1.0/catalog/products',
|
'/adblbestsellers',
|
||||||
{
|
{
|
||||||
params: {
|
params: {
|
||||||
products_sort_by: 'BestSellers',
|
ipRedirectOverride: 'true',
|
||||||
num_results: AUDIBLE_PAGE_SIZE,
|
pageSize: AUDIBLE_PAGE_SIZE,
|
||||||
page: page - 1,
|
...(page > 1 ? { page } : {}),
|
||||||
response_groups: CATALOG_RESPONSE_GROUPS,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
5,
|
HTML_MAX_RETRIES,
|
||||||
this.apiClient,
|
this.htmlClient,
|
||||||
|
HTML_MAX_BACKOFF_MS,
|
||||||
);
|
);
|
||||||
|
|
||||||
const envelope: CatalogProductsResponse = response.data;
|
const foundOnPage = this.parseProductListItems(
|
||||||
const products = envelope.products ?? [];
|
response.data,
|
||||||
const totalResults = envelope.total_results ?? 0;
|
audiobooks,
|
||||||
|
limit,
|
||||||
|
);
|
||||||
|
|
||||||
for (const product of products) {
|
logger.info(` Found ${foundOnPage} audiobooks on page ${page}`);
|
||||||
if (audiobooks.length >= limit) break;
|
|
||||||
if (audiobooks.some((b) => b.asin === product.asin)) continue;
|
if (foundOnPage < AUDIBLE_PAGE_SIZE / 2) {
|
||||||
audiobooks.push(mapCatalogProduct(product));
|
logger.info(` Reached end of available pages`);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(` Found ${products.length} audiobooks on page ${page}`);
|
|
||||||
|
|
||||||
const hasMore =
|
|
||||||
totalResults > 0
|
|
||||||
? totalResults > page * AUDIBLE_PAGE_SIZE
|
|
||||||
: products.length >= AUDIBLE_PAGE_SIZE;
|
|
||||||
|
|
||||||
if (!hasMore) break;
|
|
||||||
|
|
||||||
page++;
|
page++;
|
||||||
|
|
||||||
if (page <= maxPages && audiobooks.length < limit) {
|
if (page <= maxPages && audiobooks.length < limit) {
|
||||||
await this.delay(this.apiPageDelay(meta));
|
await this.delay(this.pacer.reportPageResult(meta));
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Failed to fetch page ${page} of popular audiobooks`, {
|
logger.error(`Failed to fetch page ${page} of popular audiobooks`, {
|
||||||
@@ -445,6 +466,11 @@ export class AudibleService {
|
|||||||
return audiobooks;
|
return audiobooks;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* New release audiobooks from Audible's curated /newreleases HTML page.
|
||||||
|
* Uses HTML scraping (not the catalog API) because the API's -ReleaseDate sort
|
||||||
|
* returns 100% future preorders with no released-only filter available.
|
||||||
|
*/
|
||||||
async getNewReleases(limit: number = 20): Promise<AudibleAudiobook[]> {
|
async getNewReleases(limit: number = 20): Promise<AudibleAudiobook[]> {
|
||||||
await this.initialize();
|
await this.initialize();
|
||||||
|
|
||||||
@@ -461,42 +487,36 @@ export class AudibleService {
|
|||||||
logger.info(` Fetching page ${page}/${maxPages}...`);
|
logger.info(` Fetching page ${page}/${maxPages}...`);
|
||||||
|
|
||||||
const { data: response, meta } = await this.fetchWithRetry(
|
const { data: response, meta } = await this.fetchWithRetry(
|
||||||
'/1.0/catalog/products',
|
'/newreleases',
|
||||||
{
|
{
|
||||||
params: {
|
params: {
|
||||||
products_sort_by: '-ReleaseDate',
|
ipRedirectOverride: 'true',
|
||||||
num_results: AUDIBLE_PAGE_SIZE,
|
pageSize: AUDIBLE_PAGE_SIZE,
|
||||||
page: page - 1,
|
...(page > 1 ? { page } : {}),
|
||||||
response_groups: CATALOG_RESPONSE_GROUPS,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
5,
|
HTML_MAX_RETRIES,
|
||||||
this.apiClient,
|
this.htmlClient,
|
||||||
|
HTML_MAX_BACKOFF_MS,
|
||||||
);
|
);
|
||||||
|
|
||||||
const envelope: CatalogProductsResponse = response.data;
|
const foundOnPage = this.parseProductListItems(
|
||||||
const products = envelope.products ?? [];
|
response.data,
|
||||||
const totalResults = envelope.total_results ?? 0;
|
audiobooks,
|
||||||
|
limit,
|
||||||
|
);
|
||||||
|
|
||||||
for (const product of products) {
|
logger.info(` Found ${foundOnPage} audiobooks on page ${page}`);
|
||||||
if (audiobooks.length >= limit) break;
|
|
||||||
if (audiobooks.some((b) => b.asin === product.asin)) continue;
|
if (foundOnPage < AUDIBLE_PAGE_SIZE / 2) {
|
||||||
audiobooks.push(mapCatalogProduct(product));
|
logger.info(` Reached end of available pages`);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(` Found ${products.length} audiobooks on page ${page}`);
|
|
||||||
|
|
||||||
const hasMore =
|
|
||||||
totalResults > 0
|
|
||||||
? totalResults > page * AUDIBLE_PAGE_SIZE
|
|
||||||
: products.length >= AUDIBLE_PAGE_SIZE;
|
|
||||||
|
|
||||||
if (!hasMore) break;
|
|
||||||
|
|
||||||
page++;
|
page++;
|
||||||
|
|
||||||
if (page <= maxPages && audiobooks.length < limit) {
|
if (page <= maxPages && audiobooks.length < limit) {
|
||||||
await this.delay(this.apiPageDelay(meta));
|
await this.delay(this.pacer.reportPageResult(meta));
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Failed to fetch page ${page} of new releases`, {
|
logger.error(`Failed to fetch page ${page} of new releases`, {
|
||||||
@@ -677,6 +697,9 @@ export class AudibleService {
|
|||||||
series: data.seriesPrimary?.name || undefined,
|
series: data.seriesPrimary?.name || undefined,
|
||||||
seriesPart: data.seriesPrimary?.position || undefined,
|
seriesPart: data.seriesPrimary?.position || undefined,
|
||||||
seriesAsin: data.seriesPrimary?.asin || undefined,
|
seriesAsin: data.seriesPrimary?.asin || undefined,
|
||||||
|
language: data.language || undefined,
|
||||||
|
formatType: data.formatType || undefined,
|
||||||
|
publisherName: data.publisherName || undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (result.coverArtUrl && !result.coverArtUrl.includes('_SL500_')) {
|
if (result.coverArtUrl && !result.coverArtUrl.includes('_SL500_')) {
|
||||||
@@ -791,6 +814,11 @@ export class AudibleService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Category audiobooks from Audible's HTML /search?node=<categoryId> page,
|
||||||
|
* sorted by popularity-rank. Uses HTML scraping (not the catalog API) so
|
||||||
|
* results match Audible's curated category-storefront ordering.
|
||||||
|
*/
|
||||||
async getCategoryBooks(categoryId: string, limit: number = 200): Promise<AudibleAudiobook[]> {
|
async getCategoryBooks(categoryId: string, limit: number = 200): Promise<AudibleAudiobook[]> {
|
||||||
await this.initialize();
|
await this.initialize();
|
||||||
|
|
||||||
@@ -805,43 +833,35 @@ export class AudibleService {
|
|||||||
while (audiobooks.length < limit && page <= maxPages) {
|
while (audiobooks.length < limit && page <= maxPages) {
|
||||||
try {
|
try {
|
||||||
const { data: response, meta } = await this.fetchWithRetry(
|
const { data: response, meta } = await this.fetchWithRetry(
|
||||||
'/1.0/catalog/products',
|
'/search',
|
||||||
{
|
{
|
||||||
params: {
|
params: {
|
||||||
category_id: categoryId,
|
ipRedirectOverride: 'true',
|
||||||
products_sort_by: 'BestSellers',
|
node: categoryId,
|
||||||
num_results: AUDIBLE_PAGE_SIZE,
|
pageSize: AUDIBLE_PAGE_SIZE,
|
||||||
page: page - 1,
|
sort: 'popularity-rank',
|
||||||
response_groups: CATALOG_RESPONSE_GROUPS,
|
...(page > 1 ? { page } : {}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
5,
|
HTML_MAX_RETRIES,
|
||||||
this.apiClient,
|
this.htmlClient,
|
||||||
|
HTML_MAX_BACKOFF_MS,
|
||||||
);
|
);
|
||||||
|
|
||||||
const envelope: CatalogProductsResponse = response.data;
|
const foundOnPage = this.parseSearchResultItems(
|
||||||
const products = envelope.products ?? [];
|
response.data,
|
||||||
const totalResults = envelope.total_results ?? 0;
|
audiobooks,
|
||||||
|
limit,
|
||||||
|
);
|
||||||
|
|
||||||
for (const product of products) {
|
logger.info(`Category ${categoryId}: found ${foundOnPage} books on page ${page}`);
|
||||||
if (audiobooks.length >= limit) break;
|
|
||||||
if (audiobooks.some((b) => b.asin === product.asin)) continue;
|
|
||||||
audiobooks.push(mapCatalogProduct(product));
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(`Category ${categoryId}: found ${products.length} books on page ${page}`);
|
if (foundOnPage < AUDIBLE_PAGE_SIZE / 2) break;
|
||||||
|
|
||||||
const hasMore =
|
|
||||||
totalResults > 0
|
|
||||||
? totalResults > page * AUDIBLE_PAGE_SIZE
|
|
||||||
: products.length >= AUDIBLE_PAGE_SIZE;
|
|
||||||
|
|
||||||
if (!hasMore) break;
|
|
||||||
|
|
||||||
page++;
|
page++;
|
||||||
|
|
||||||
if (page <= maxPages && audiobooks.length < limit) {
|
if (page <= maxPages && audiobooks.length < limit) {
|
||||||
await this.delay(this.apiPageDelay(meta));
|
await this.delay(this.pacer.reportPageResult(meta));
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Failed to fetch category ${categoryId} page ${page}`, {
|
logger.error(`Failed to fetch category ${categoryId} page ${page}`, {
|
||||||
@@ -858,12 +878,148 @@ export class AudibleService {
|
|||||||
return audiobooks;
|
return audiobooks;
|
||||||
}
|
}
|
||||||
|
|
||||||
private apiPageDelay(meta: FetchResultMeta): number {
|
private getLangConfig(): LanguageConfig {
|
||||||
if (meta.retriesUsed > 0) {
|
return getLanguageForRegion(this.region);
|
||||||
return this.pacer.reportPageResult(meta);
|
}
|
||||||
}
|
|
||||||
this.pacer.reportPageResult(meta);
|
private parseRuntime(runtimeText: string): number | undefined {
|
||||||
return randomDelay(500, 1500);
|
return parseRuntimeUtil(runtimeText, this.getLangConfig());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse the `.productListItem` blocks used by /adblbestsellers and /newreleases.
|
||||||
|
* Pushes matched books into `audiobooks` (skipping duplicates and respecting `limit`)
|
||||||
|
* and returns the count parsed from this page.
|
||||||
|
*/
|
||||||
|
private parseProductListItems(
|
||||||
|
html: string,
|
||||||
|
audiobooks: AudibleAudiobook[],
|
||||||
|
limit: number,
|
||||||
|
): number {
|
||||||
|
const $ = cheerio.load(html);
|
||||||
|
const langConfig = this.getLangConfig();
|
||||||
|
let foundOnPage = 0;
|
||||||
|
|
||||||
|
$('.productListItem').each((_index, element) => {
|
||||||
|
if (audiobooks.length >= limit) return false;
|
||||||
|
|
||||||
|
const $el = $(element);
|
||||||
|
|
||||||
|
const asin =
|
||||||
|
$el.find('li').attr('data-asin') ||
|
||||||
|
$el.find('a').attr('href')?.match(/\/(?:pd|ac)\/[^\/]+\/([A-Z0-9]{10})/)?.[1] ||
|
||||||
|
'';
|
||||||
|
if (!asin) return;
|
||||||
|
if (audiobooks.some((book) => book.asin === asin)) return;
|
||||||
|
|
||||||
|
const title =
|
||||||
|
$el.find('h3 a').text().trim() ||
|
||||||
|
$el.find('.bc-heading a').text().trim();
|
||||||
|
|
||||||
|
const authorText =
|
||||||
|
$el.find('.authorLabel').text().trim() ||
|
||||||
|
$el.find('.bc-size-small .bc-text-bold').first().text().trim();
|
||||||
|
|
||||||
|
const authorHref = $el.find('a[href*="/author/"]').first().attr('href') || '';
|
||||||
|
const authorAsinMatch = authorHref.match(/\/author\/[^\/]+\/([A-Z0-9]{10})/);
|
||||||
|
|
||||||
|
// Narrator — capture all narrator links (multi-narrator productions are common);
|
||||||
|
// fall back to .narratorLabel text, then to the bc-text-bold sibling for layouts
|
||||||
|
// that omit both anchor links and the .narratorLabel span.
|
||||||
|
const narratorText =
|
||||||
|
extractAllNarrators($, $el) ||
|
||||||
|
$el.find('.bc-size-small .bc-text-bold').eq(1).text().trim();
|
||||||
|
|
||||||
|
const coverArtUrl = $el.find('img').attr('src') || '';
|
||||||
|
|
||||||
|
const ratingText = $el.find('.ratingsLabel').text().trim();
|
||||||
|
const rating = ratingText ? parseFloat(ratingText.split(' ')[0]) : undefined;
|
||||||
|
|
||||||
|
audiobooks.push({
|
||||||
|
asin,
|
||||||
|
title,
|
||||||
|
author: stripPrefixes(authorText, langConfig.scraping.authorPrefixes),
|
||||||
|
authorAsin: authorAsinMatch?.[1] || undefined,
|
||||||
|
narrator: stripPrefixes(narratorText, langConfig.scraping.narratorPrefixes),
|
||||||
|
coverArtUrl: coverArtUrl.replace(/\._.*_\./, '._SL500_.'),
|
||||||
|
rating,
|
||||||
|
});
|
||||||
|
|
||||||
|
foundOnPage++;
|
||||||
|
});
|
||||||
|
|
||||||
|
return foundOnPage;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse the `.s-result-item` / `.productListItem` blocks used by
|
||||||
|
* /search?node=<categoryId>. Pushes matched books into `audiobooks`
|
||||||
|
* (skipping duplicates and respecting `limit`) and returns the count parsed
|
||||||
|
* from this page.
|
||||||
|
*/
|
||||||
|
private parseSearchResultItems(
|
||||||
|
html: string,
|
||||||
|
audiobooks: AudibleAudiobook[],
|
||||||
|
limit: number,
|
||||||
|
): number {
|
||||||
|
const $ = cheerio.load(html);
|
||||||
|
const langConfig = this.getLangConfig();
|
||||||
|
let foundOnPage = 0;
|
||||||
|
|
||||||
|
$('.s-result-item, .productListItem').each((_index, element) => {
|
||||||
|
if (audiobooks.length >= limit) return false;
|
||||||
|
|
||||||
|
const $el = $(element);
|
||||||
|
|
||||||
|
const asin =
|
||||||
|
$el.find('li').attr('data-asin') ||
|
||||||
|
$el.find('a').attr('href')?.match(/\/(?:pd|ac)\/[^\/]+\/([A-Z0-9]{10})/)?.[1] ||
|
||||||
|
'';
|
||||||
|
if (!asin) return;
|
||||||
|
if (audiobooks.some((b) => b.asin === asin)) return;
|
||||||
|
|
||||||
|
const title =
|
||||||
|
$el.find('h2').first().text().trim() ||
|
||||||
|
$el.find('h3 a').text().trim() ||
|
||||||
|
$el.find('.bc-heading a').text().trim();
|
||||||
|
|
||||||
|
const authorLink = $el.find('a[href*="/author/"]').first();
|
||||||
|
const authorText =
|
||||||
|
authorLink.text().trim() ||
|
||||||
|
$el.find('.authorLabel').text().trim();
|
||||||
|
const authorHref = authorLink.attr('href') || '';
|
||||||
|
const authorAsinMatch = authorHref.match(/\/author\/[^\/]+\/([A-Z0-9]{10})/);
|
||||||
|
|
||||||
|
// Narrator — capture all narrator links (multi-narrator productions are common)
|
||||||
|
const narratorText = extractAllNarrators($, $el);
|
||||||
|
|
||||||
|
const coverArtUrl = $el.find('img').attr('src') || '';
|
||||||
|
|
||||||
|
const runtimeText =
|
||||||
|
$el.find('.runtimeLabel').text().trim() ||
|
||||||
|
$el.find(buildContainsSelector('span', langConfig.scraping.lengthLabels)).text().trim();
|
||||||
|
const durationMinutes = this.parseRuntime(runtimeText);
|
||||||
|
|
||||||
|
const ratingText =
|
||||||
|
$el.find('.ratingsLabel').text().trim() ||
|
||||||
|
$el.find('.a-icon-star span').first().text().trim();
|
||||||
|
const rating = ratingText ? parseFloat(ratingText.split(' ')[0]) : undefined;
|
||||||
|
|
||||||
|
audiobooks.push({
|
||||||
|
asin,
|
||||||
|
title,
|
||||||
|
author: stripPrefixes(authorText, langConfig.scraping.authorPrefixes),
|
||||||
|
authorAsin: authorAsinMatch?.[1] || undefined,
|
||||||
|
narrator: stripPrefixes(narratorText, langConfig.scraping.narratorPrefixes),
|
||||||
|
coverArtUrl: coverArtUrl.replace(/\._.*_\./, '._SL500_.'),
|
||||||
|
durationMinutes,
|
||||||
|
rating,
|
||||||
|
});
|
||||||
|
|
||||||
|
foundOnPage++;
|
||||||
|
});
|
||||||
|
|
||||||
|
return foundOnPage;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async delay(ms: number): Promise<void> {
|
private async delay(ms: number): Promise<void> {
|
||||||
|
|||||||
@@ -315,6 +315,9 @@ export class ProwlarrService {
|
|||||||
limit: 100,
|
limit: 100,
|
||||||
extended: 1,
|
extended: 1,
|
||||||
},
|
},
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'ReadMeABook',
|
||||||
|
},
|
||||||
timeout: DOWNLOAD_CLIENT_TIMEOUT,
|
timeout: DOWNLOAD_CLIENT_TIMEOUT,
|
||||||
responseType: 'text', // Get XML as text
|
responseType: 'text', // Get XML as text
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ export class QBittorrentService implements IDownloadClient {
|
|||||||
private username: string;
|
private username: string;
|
||||||
private password: string;
|
private password: string;
|
||||||
private cookie?: string;
|
private cookie?: string;
|
||||||
|
private authOptional: boolean;
|
||||||
private defaultSavePath: string;
|
private defaultSavePath: string;
|
||||||
private defaultCategory: string;
|
private defaultCategory: string;
|
||||||
private disableSSLVerify: boolean;
|
private disableSSLVerify: boolean;
|
||||||
@@ -126,11 +127,16 @@ export class QBittorrentService implements IDownloadClient {
|
|||||||
this.baseUrl = baseUrl.replace(/\/$/, '');
|
this.baseUrl = baseUrl.replace(/\/$/, '');
|
||||||
this.username = username;
|
this.username = username;
|
||||||
this.password = password;
|
this.password = password;
|
||||||
|
this.authOptional = !username && !password;
|
||||||
this.defaultSavePath = defaultSavePath;
|
this.defaultSavePath = defaultSavePath;
|
||||||
this.defaultCategory = defaultCategory;
|
this.defaultCategory = defaultCategory;
|
||||||
this.disableSSLVerify = disableSSLVerify;
|
this.disableSSLVerify = disableSSLVerify;
|
||||||
this.pathMappingConfig = pathMappingConfig || { enabled: false, remotePath: '', localPath: '' };
|
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
|
// Create HTTPS agent if SSL verification is disabled
|
||||||
if (disableSSLVerify && this.baseUrl.startsWith('https')) {
|
if (disableSSLVerify && this.baseUrl.startsWith('https')) {
|
||||||
this.httpsAgent = new https.Agent({
|
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> {
|
async login(): Promise<void> {
|
||||||
|
if (this.authOptional) {
|
||||||
|
logger.debug('[QBittorrent] Skipping login — auth-optional mode');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const loginUrl = `${this.baseUrl}/api/v2/auth/login`;
|
const loginUrl = `${this.baseUrl}/api/v2/auth/login`;
|
||||||
|
|
||||||
logger.debug('[QBittorrent] Attempting login', {
|
logger.debug('[QBittorrent] Attempting login', {
|
||||||
@@ -241,7 +261,7 @@ export class QBittorrentService implements IDownloadClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Ensure we're authenticated
|
// Ensure we're authenticated
|
||||||
if (!this.cookie) {
|
if (!this.cookie && !this.authOptional) {
|
||||||
await this.login();
|
await this.login();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,8 +280,10 @@ export class QBittorrentService implements IDownloadClient {
|
|||||||
return await this.addTorrentFile(url, category, options);
|
return await this.addTorrentFile(url, category, options);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Try re-authenticating once if we get a 403
|
// Try re-authenticating once if we get a 403 — only meaningful when credentials are configured.
|
||||||
if (!retried && axios.isAxiosError(error) && error.response?.status === 403) {
|
// 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...');
|
logger.info('[QBittorrent] Session expired, re-authenticating...');
|
||||||
await this.login();
|
await this.login();
|
||||||
return this.addTorrent(url, options, true);
|
return this.addTorrent(url, options, true);
|
||||||
@@ -322,7 +344,7 @@ export class QBittorrentService implements IDownloadClient {
|
|||||||
|
|
||||||
const response = await this.client.post('/torrents/add', form, {
|
const response = await this.client.post('/torrents/add', form, {
|
||||||
headers: {
|
headers: {
|
||||||
Cookie: this.cookie,
|
...this.authHeaders(),
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
'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, {
|
const response = await this.client.post('/torrents/add', formData, {
|
||||||
headers: {
|
headers: {
|
||||||
Cookie: this.cookie,
|
...this.authHeaders(),
|
||||||
...formData.getHeaders(),
|
...formData.getHeaders(),
|
||||||
},
|
},
|
||||||
maxBodyLength: Infinity,
|
maxBodyLength: Infinity,
|
||||||
@@ -491,7 +513,7 @@ export class QBittorrentService implements IDownloadClient {
|
|||||||
* Applies reverse path mapping (local → remote) for remote seedbox scenarios
|
* Applies reverse path mapping (local → remote) for remote seedbox scenarios
|
||||||
*/
|
*/
|
||||||
protected async ensureCategory(category: string): Promise<void> {
|
protected async ensureCategory(category: string): Promise<void> {
|
||||||
if (!this.cookie) {
|
if (!this.cookie && !this.authOptional) {
|
||||||
await this.login();
|
await this.login();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -501,7 +523,7 @@ export class QBittorrentService implements IDownloadClient {
|
|||||||
try {
|
try {
|
||||||
// First, get all categories to check if it exists and what save path it has
|
// First, get all categories to check if it exists and what save path it has
|
||||||
const categoriesResponse = await this.client.get('/torrents/categories', {
|
const categoriesResponse = await this.client.get('/torrents/categories', {
|
||||||
headers: { Cookie: this.cookie },
|
headers: this.authHeaders(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const categories = categoriesResponse.data;
|
const categories = categoriesResponse.data;
|
||||||
@@ -519,7 +541,7 @@ export class QBittorrentService implements IDownloadClient {
|
|||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
Cookie: this.cookie,
|
...this.authHeaders(),
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -541,7 +563,7 @@ export class QBittorrentService implements IDownloadClient {
|
|||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
Cookie: this.cookie,
|
...this.authHeaders(),
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -572,13 +594,13 @@ export class QBittorrentService implements IDownloadClient {
|
|||||||
* Get torrent status and progress
|
* Get torrent status and progress
|
||||||
*/
|
*/
|
||||||
async getTorrent(hash: string): Promise<TorrentInfo> {
|
async getTorrent(hash: string): Promise<TorrentInfo> {
|
||||||
if (!this.cookie) {
|
if (!this.cookie && !this.authOptional) {
|
||||||
await this.login();
|
await this.login();
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await this.client.get('/torrents/info', {
|
const response = await this.client.get('/torrents/info', {
|
||||||
headers: { Cookie: this.cookie },
|
headers: this.authHeaders(),
|
||||||
params: { hashes: hash },
|
params: { hashes: hash },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -610,7 +632,7 @@ export class QBittorrentService implements IDownloadClient {
|
|||||||
* Get all torrents (optionally filtered by category)
|
* Get all torrents (optionally filtered by category)
|
||||||
*/
|
*/
|
||||||
async getTorrents(category?: string): Promise<TorrentInfo[]> {
|
async getTorrents(category?: string): Promise<TorrentInfo[]> {
|
||||||
if (!this.cookie) {
|
if (!this.cookie && !this.authOptional) {
|
||||||
await this.login();
|
await this.login();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -621,7 +643,7 @@ export class QBittorrentService implements IDownloadClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const response = await this.client.get('/torrents/info', {
|
const response = await this.client.get('/torrents/info', {
|
||||||
headers: { Cookie: this.cookie },
|
headers: this.authHeaders(),
|
||||||
params,
|
params,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -636,7 +658,7 @@ export class QBittorrentService implements IDownloadClient {
|
|||||||
* Pause torrent
|
* Pause torrent
|
||||||
*/
|
*/
|
||||||
async pauseTorrent(hash: string): Promise<void> {
|
async pauseTorrent(hash: string): Promise<void> {
|
||||||
if (!this.cookie) {
|
if (!this.cookie && !this.authOptional) {
|
||||||
await this.login();
|
await this.login();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -646,7 +668,7 @@ export class QBittorrentService implements IDownloadClient {
|
|||||||
new URLSearchParams({ hashes: hash }),
|
new URLSearchParams({ hashes: hash }),
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
Cookie: this.cookie,
|
...this.authHeaders(),
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -663,7 +685,7 @@ export class QBittorrentService implements IDownloadClient {
|
|||||||
* Resume torrent
|
* Resume torrent
|
||||||
*/
|
*/
|
||||||
async resumeTorrent(hash: string): Promise<void> {
|
async resumeTorrent(hash: string): Promise<void> {
|
||||||
if (!this.cookie) {
|
if (!this.cookie && !this.authOptional) {
|
||||||
await this.login();
|
await this.login();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -673,7 +695,7 @@ export class QBittorrentService implements IDownloadClient {
|
|||||||
new URLSearchParams({ hashes: hash }),
|
new URLSearchParams({ hashes: hash }),
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
Cookie: this.cookie,
|
...this.authHeaders(),
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -690,7 +712,7 @@ export class QBittorrentService implements IDownloadClient {
|
|||||||
* Delete torrent
|
* Delete torrent
|
||||||
*/
|
*/
|
||||||
async deleteTorrent(hash: string, deleteFiles: boolean = false): Promise<void> {
|
async deleteTorrent(hash: string, deleteFiles: boolean = false): Promise<void> {
|
||||||
if (!this.cookie) {
|
if (!this.cookie && !this.authOptional) {
|
||||||
await this.login();
|
await this.login();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -703,7 +725,7 @@ export class QBittorrentService implements IDownloadClient {
|
|||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
Cookie: this.cookie,
|
...this.authHeaders(),
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -720,13 +742,13 @@ export class QBittorrentService implements IDownloadClient {
|
|||||||
* Get files in torrent
|
* Get files in torrent
|
||||||
*/
|
*/
|
||||||
async getFiles(hash: string): Promise<TorrentFile[]> {
|
async getFiles(hash: string): Promise<TorrentFile[]> {
|
||||||
if (!this.cookie) {
|
if (!this.cookie && !this.authOptional) {
|
||||||
await this.login();
|
await this.login();
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await this.client.get('/torrents/files', {
|
const response = await this.client.get('/torrents/files', {
|
||||||
headers: { Cookie: this.cookie },
|
headers: this.authHeaders(),
|
||||||
params: { hash },
|
params: { hash },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -741,13 +763,13 @@ export class QBittorrentService implements IDownloadClient {
|
|||||||
* Get all configured categories from qBittorrent
|
* Get all configured categories from qBittorrent
|
||||||
*/
|
*/
|
||||||
async getCategories(): Promise<string[]> {
|
async getCategories(): Promise<string[]> {
|
||||||
if (!this.cookie) {
|
if (!this.cookie && !this.authOptional) {
|
||||||
await this.login();
|
await this.login();
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await this.client.get('/torrents/categories', {
|
const response = await this.client.get('/torrents/categories', {
|
||||||
headers: { Cookie: this.cookie },
|
headers: this.authHeaders(),
|
||||||
});
|
});
|
||||||
|
|
||||||
return Object.keys(response.data || {});
|
return Object.keys(response.data || {});
|
||||||
@@ -761,7 +783,7 @@ export class QBittorrentService implements IDownloadClient {
|
|||||||
* Set category for torrent
|
* Set category for torrent
|
||||||
*/
|
*/
|
||||||
async setCategory(hash: string, category: string): Promise<void> {
|
async setCategory(hash: string, category: string): Promise<void> {
|
||||||
if (!this.cookie) {
|
if (!this.cookie && !this.authOptional) {
|
||||||
await this.login();
|
await this.login();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -774,7 +796,7 @@ export class QBittorrentService implements IDownloadClient {
|
|||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
Cookie: this.cookie,
|
...this.authHeaders(),
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
'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> {
|
async testConnection(): Promise<ConnectionTestResult> {
|
||||||
try {
|
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 {
|
try {
|
||||||
const versionResponse = await this.client.get('/app/version', {
|
const versionResponse = await this.client.get('/app/version', {
|
||||||
headers: { Cookie: this.cookie },
|
headers: this.authHeaders(),
|
||||||
});
|
});
|
||||||
const raw = versionResponse.data || '';
|
const raw = versionResponse.data || '';
|
||||||
version = typeof raw === 'string' ? raw.replace(/^v/i, '') : undefined;
|
const version = typeof raw === 'string' ? raw.replace(/^v/i, '') : undefined;
|
||||||
} catch {
|
return { success: true, version, message: `Connected to qBittorrent${version ? ` ${version}` : ''}` };
|
||||||
// Version fetch is non-critical - connection is still valid
|
} 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');
|
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) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : 'Connection failed';
|
const message = error instanceof Error ? error.message : 'Connection failed';
|
||||||
logger.error('Connection test failed', { error: message });
|
logger.error('Connection test failed', { error: message });
|
||||||
@@ -826,6 +858,7 @@ export class QBittorrentService implements IDownloadClient {
|
|||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const baseUrl = url.replace(/\/$/, '');
|
const baseUrl = url.replace(/\/$/, '');
|
||||||
const loginUrl = `${baseUrl}/api/v2/auth/login`;
|
const loginUrl = `${baseUrl}/api/v2/auth/login`;
|
||||||
|
const authOptional = !username && !password;
|
||||||
|
|
||||||
// Create HTTPS agent if SSL verification is disabled
|
// Create HTTPS agent if SSL verification is disabled
|
||||||
let httpsAgent: https.Agent | undefined;
|
let httpsAgent: https.Agent | undefined;
|
||||||
@@ -844,9 +877,25 @@ export class QBittorrentService implements IDownloadClient {
|
|||||||
passwordLength: password?.length,
|
passwordLength: password?.length,
|
||||||
sslVerifyDisabled: disableSSLVerify,
|
sslVerifyDisabled: disableSSLVerify,
|
||||||
hasHttpsAgent: !!httpsAgent,
|
hasHttpsAgent: !!httpsAgent,
|
||||||
|
authOptional,
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
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 requestBody = new URLSearchParams({ username, password });
|
||||||
const requestHeaders = {
|
const requestHeaders = {
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
@@ -980,6 +1029,11 @@ export class QBittorrentService implements IDownloadClient {
|
|||||||
|
|
||||||
// HTTP status errors
|
// HTTP status errors
|
||||||
if (status === 401 || status === 403) {
|
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(
|
throw new Error(
|
||||||
`Authentication failed (HTTP ${status}). Check your username and password.`
|
`Authentication failed (HTTP ${status}). Check your username and password.`
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -138,16 +138,37 @@ async function persistSectionBooks(
|
|||||||
logger: ReturnType<typeof RMABLogger.forJob>,
|
logger: ReturnType<typeof RMABLogger.forJob>,
|
||||||
labelForErrors: string,
|
labelForErrors: string,
|
||||||
): Promise<number> {
|
): 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
|
// Wipe previous entries for this section
|
||||||
logger.info(`Clearing previous data for ${categoryId}...`);
|
logger.info(`Clearing previous data for ${categoryId}...`);
|
||||||
await prisma.audibleCacheCategory.deleteMany({
|
await prisma.audibleCacheCategory.deleteMany({
|
||||||
where: { categoryId },
|
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;
|
let saved = 0;
|
||||||
for (let i = 0; i < books.length; i++) {
|
for (let i = 0; i < dedupedBooks.length; i++) {
|
||||||
const book = books[i];
|
const book = dedupedBooks[i];
|
||||||
try {
|
try {
|
||||||
// Cache thumbnail if coverArtUrl exists
|
// Cache thumbnail if coverArtUrl exists
|
||||||
let cachedCoverPath: string | null = null;
|
let cachedCoverPath: string | null = null;
|
||||||
|
|||||||
@@ -31,13 +31,16 @@ export async function processDownloadTorrent(payload: DownloadTorrentPayload): P
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Update request status to downloading
|
// Update request status to downloading
|
||||||
await prisma.request.update({
|
const request = await prisma.request.update({
|
||||||
where: { id: requestId },
|
where: { id: requestId },
|
||||||
data: {
|
data: {
|
||||||
status: 'downloading',
|
status: 'downloading',
|
||||||
progress: 0,
|
progress: 0,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
},
|
},
|
||||||
|
include: {
|
||||||
|
user: { select: { plexUsername: true } },
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Detect protocol from result and get appropriate client
|
// Detect protocol from result and get appropriate client
|
||||||
@@ -103,8 +106,22 @@ export async function processDownloadTorrent(payload: DownloadTorrentPayload): P
|
|||||||
|
|
||||||
logger.info(`Created download history record: ${downloadHistory.id}`);
|
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 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(
|
await jobQueue.addMonitorJob(
|
||||||
requestId,
|
requestId,
|
||||||
downloadHistory.id,
|
downloadHistory.id,
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ export async function processPlexRecentlyAddedCheck(payload: PlexRecentlyAddedPa
|
|||||||
author: item.author || 'Unknown Author',
|
author: item.author || 'Unknown Author',
|
||||||
narrator: item.narrator,
|
narrator: item.narrator,
|
||||||
summary: item.description,
|
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,
|
year: item.year,
|
||||||
asin: item.asin, // Store ASIN from library backend
|
asin: item.asin, // Store ASIN from library backend
|
||||||
isbn: item.isbn, // Store ISBN 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,
|
author: item.author || existing.author,
|
||||||
narrator: item.narrator || existing.narrator,
|
narrator: item.narrator || existing.narrator,
|
||||||
summary: item.description || existing.summary,
|
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,
|
year: item.year || existing.year,
|
||||||
asin: item.asin || existing.asin, // Update ASIN if available
|
asin: item.asin || existing.asin, // Update ASIN if available
|
||||||
isbn: item.isbn || existing.isbn, // Update ISBN if available
|
isbn: item.isbn || existing.isbn, // Update ISBN if available
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ export async function processScanPlex(payload: ScanPlexPayload): Promise<any> {
|
|||||||
author: item.author || existing.author,
|
author: item.author || existing.author,
|
||||||
narrator: item.narrator || existing.narrator,
|
narrator: item.narrator || existing.narrator,
|
||||||
summary: item.description || existing.summary,
|
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,
|
year: item.year || existing.year,
|
||||||
asin: item.asin || existing.asin, // Store ASIN from library backend
|
asin: item.asin || existing.asin, // Store ASIN from library backend
|
||||||
isbn: item.isbn || existing.isbn, // Store ISBN 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',
|
author: item.author || 'Unknown Author',
|
||||||
narrator: item.narrator,
|
narrator: item.narrator,
|
||||||
summary: item.description,
|
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,
|
year: item.year,
|
||||||
asin: item.asin, // Store ASIN from library backend (Plex or Audiobookshelf)
|
asin: item.asin, // Store ASIN from library backend (Plex or Audiobookshelf)
|
||||||
isbn: item.isbn, // Store ISBN from library backend
|
isbn: item.isbn, // Store ISBN from library backend
|
||||||
|
|||||||
@@ -127,6 +127,7 @@ export class AppriseProvider implements INotificationProvider {
|
|||||||
|
|
||||||
private formatMessage(payload: NotificationPayload): { title: string; body: string } {
|
private formatMessage(payload: NotificationPayload): { title: string; body: string } {
|
||||||
const { event, title, author, userName, message, requestType } = payload;
|
const { event, title, author, userName, message, requestType } = payload;
|
||||||
|
const meta = getEventMeta(event);
|
||||||
|
|
||||||
const isIssue = event === 'issue_reported';
|
const isIssue = event === 'issue_reported';
|
||||||
const messageLines = [
|
const messageLines = [
|
||||||
@@ -136,7 +137,9 @@ export class AppriseProvider implements INotificationProvider {
|
|||||||
];
|
];
|
||||||
|
|
||||||
if (message) {
|
if (message) {
|
||||||
messageLines.push(isIssue ? `\u{1F4DD} Reason: ${message}` : `\u26A0\uFE0F Error: ${message}`);
|
const messageLabel = meta.messageLabel ?? 'Error';
|
||||||
|
const msgEmoji = meta.severity === 'error' ? '\u26A0\uFE0F' : '\u{1F4DD}';
|
||||||
|
messageLines.push(`${msgEmoji} ${messageLabel}: ${message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ export class DiscordProvider implements INotificationProvider {
|
|||||||
];
|
];
|
||||||
|
|
||||||
if (message) {
|
if (message) {
|
||||||
fields.push({ name: isIssue ? 'Reason' : 'Error', value: message, inline: false });
|
fields.push({ name: meta.messageLabel ?? 'Error', value: message, inline: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ export class NtfyProvider implements INotificationProvider {
|
|||||||
|
|
||||||
private formatMessage(payload: NotificationPayload): { title: string; message: string } {
|
private formatMessage(payload: NotificationPayload): { title: string; message: string } {
|
||||||
const { event, title, author, userName, message, requestType } = payload;
|
const { event, title, author, userName, message, requestType } = payload;
|
||||||
|
const meta = getEventMeta(event);
|
||||||
|
|
||||||
const isIssue = event === 'issue_reported';
|
const isIssue = event === 'issue_reported';
|
||||||
const messageLines = [
|
const messageLines = [
|
||||||
@@ -93,7 +94,9 @@ export class NtfyProvider implements INotificationProvider {
|
|||||||
];
|
];
|
||||||
|
|
||||||
if (message) {
|
if (message) {
|
||||||
messageLines.push(isIssue ? `\u{1F4DD} Reason: ${message}` : `\u26A0\uFE0F Error: ${message}`);
|
const messageLabel = meta.messageLabel ?? 'Error';
|
||||||
|
const msgEmoji = meta.severity === 'error' ? '\u26A0\uFE0F' : '\u{1F4DD}';
|
||||||
|
messageLines.push(`${msgEmoji} ${messageLabel}: ${message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -91,7 +91,9 @@ export class PushoverProvider implements INotificationProvider {
|
|||||||
];
|
];
|
||||||
|
|
||||||
if (message) {
|
if (message) {
|
||||||
messageLines.push('', isIssue ? `\u{1F4DD} Reason: ${message}` : `\u26A0\uFE0F Error: ${message}`);
|
const messageLabel = meta.messageLabel ?? 'Error';
|
||||||
|
const msgEmoji = meta.severity === 'error' ? '\u26A0\uFE0F' : '\u{1F4DD}';
|
||||||
|
messageLines.push('', `${msgEmoji} ${messageLabel}: ${message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -9,7 +9,8 @@
|
|||||||
|
|
||||||
import { prisma } from '@/lib/db';
|
import { prisma } from '@/lib/db';
|
||||||
import { RMABLogger } from '@/lib/utils/logger';
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
import type { DedupGroup } from '@/lib/utils/deduplicate-audiobooks';
|
import { metadataScore, type DedupGroup } from '@/lib/utils/deduplicate-audiobooks';
|
||||||
|
import type { AudibleAudiobook } from '@/lib/integrations/audible.service';
|
||||||
|
|
||||||
const logger = RMABLogger.create('WorksService');
|
const logger = RMABLogger.create('WorksService');
|
||||||
|
|
||||||
@@ -182,6 +183,96 @@ export async function seedAsin(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// View-level collapse (consult the works table after local dedup)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collapse books that already share a Work record according to the works table.
|
||||||
|
*
|
||||||
|
* The local `deduplicateAndCollectGroups()` pass is title/narrator/duration-based
|
||||||
|
* and stateless — it can fail to merge ASINs whose source metadata diverges (e.g.
|
||||||
|
* a series-page scrape captures different "first narrators" for two ASINs of the
|
||||||
|
* same recording, or two paginated pages each contain one ASIN and never compare
|
||||||
|
* them). The works table is the durable source of truth for "same book" identity,
|
||||||
|
* populated by every prior dedup pass and by request-time seeding. This pass
|
||||||
|
* applies that knowledge to the current view.
|
||||||
|
*
|
||||||
|
* Behavior:
|
||||||
|
* - Books whose ASINs map to a shared workId collapse to a single representative
|
||||||
|
* chosen by `metadataScore()` (same ranking as local dedup).
|
||||||
|
* - Books not present in any work, or in single-ASIN works, pass through untouched.
|
||||||
|
* - Original ordering is preserved (the kept representative sits at the position
|
||||||
|
* of the first occurrence of its work in the input list).
|
||||||
|
* - DB failure is non-fatal: the input list is returned unchanged so the view
|
||||||
|
* still renders (degrades to local-dedup-only behavior).
|
||||||
|
*/
|
||||||
|
export async function collapseByExistingWorks(
|
||||||
|
books: AudibleAudiobook[],
|
||||||
|
): Promise<AudibleAudiobook[]> {
|
||||||
|
if (books.length <= 1) return books;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const asins = books.map(b => b.asin);
|
||||||
|
const entries = await prisma.workAsin.findMany({
|
||||||
|
where: { asin: { in: asins } },
|
||||||
|
select: { asin: true, workId: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (entries.length === 0) return books;
|
||||||
|
|
||||||
|
// Map ASIN → workId for fast lookup in the loop below
|
||||||
|
const asinToWorkId = new Map<string, string>();
|
||||||
|
for (const entry of entries) {
|
||||||
|
asinToWorkId.set(entry.asin, entry.workId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Walk the input once, preserving position. For each work seen, keep a
|
||||||
|
// running "best" book; for books not in any work, emit immediately.
|
||||||
|
const result: AudibleAudiobook[] = [];
|
||||||
|
const workIdToResultIndex = new Map<string, number>();
|
||||||
|
|
||||||
|
for (const book of books) {
|
||||||
|
const workId = asinToWorkId.get(book.asin);
|
||||||
|
if (!workId) {
|
||||||
|
result.push(book);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingIndex = workIdToResultIndex.get(workId);
|
||||||
|
if (existingIndex === undefined) {
|
||||||
|
workIdToResultIndex.set(workId, result.length);
|
||||||
|
result.push(book);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A sibling from this work is already in the result. Keep whichever
|
||||||
|
// has the richer metadata; on tie, keep the earlier entry (already there).
|
||||||
|
const existing = result[existingIndex];
|
||||||
|
if (metadataScore(book) > metadataScore(existing)) {
|
||||||
|
result[existingIndex] = book;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const collapsed = books.length - result.length;
|
||||||
|
if (collapsed > 0) {
|
||||||
|
logger.debug('Collapsed books via works table', {
|
||||||
|
inputCount: books.length,
|
||||||
|
outputCount: result.length,
|
||||||
|
collapsed,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('collapseByExistingWorks failed; returning input unchanged', {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
bookCount: books.length,
|
||||||
|
});
|
||||||
|
return books;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Sibling ASIN lookup (for library matching expansion)
|
// Sibling ASIN lookup (for library matching expansion)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -21,6 +21,12 @@ export const MAX_SCAN_DEPTH = 10;
|
|||||||
/** Maximum concurrent ffprobe calls for metadata reads. */
|
/** Maximum concurrent ffprobe calls for metadata reads. */
|
||||||
const METADATA_CONCURRENCY = 10;
|
const METADATA_CONCURRENCY = 10;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Folder names matching this pattern are considered generic and should not be
|
||||||
|
* used as Audible search terms (e.g. "CD1", "Disc 2", "Part 3", "Volume 1").
|
||||||
|
*/
|
||||||
|
const GENERIC_FOLDER_NAME_RE = /^(cd|disc|disk|part|vol(ume)?)\s*\d+$/i;
|
||||||
|
|
||||||
/** Metadata extracted from an audio file via ffprobe. */
|
/** Metadata extracted from an audio file via ffprobe. */
|
||||||
export interface AudioFileMetadata {
|
export interface AudioFileMetadata {
|
||||||
title?: string; // From 'album' tag (book title)
|
title?: string; // From 'album' tag (book title)
|
||||||
@@ -39,7 +45,8 @@ export interface DiscoveredAudiobook {
|
|||||||
totalSizeBytes: number;
|
totalSizeBytes: number;
|
||||||
metadata: AudioFileMetadata;
|
metadata: AudioFileMetadata;
|
||||||
searchTerm: string; // Constructed search query for Audible
|
searchTerm: string; // Constructed search query for Audible
|
||||||
metadataSource: 'tags' | 'file_name'; // Where the search term came from
|
metadataSource: 'tags' | 'folder_name' | 'file_name'; // Where the search term came from
|
||||||
|
extractedAsin?: string; // ASIN extracted directly from folder name, if present
|
||||||
audioFiles: string[]; // File names (relative to folderPath) belonging to this book
|
audioFiles: string[]; // File names (relative to folderPath) belonging to this book
|
||||||
groupingKey: string; // Normalized key for cross-folder deduplication
|
groupingKey: string; // Normalized key for cross-folder deduplication
|
||||||
}
|
}
|
||||||
@@ -60,6 +67,18 @@ function isAudioFile(filename: string): boolean {
|
|||||||
return (AUDIO_EXTENSIONS as readonly string[]).includes(ext);
|
return (AUDIO_EXTENSIONS as readonly string[]).includes(ext);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract an Audible ASIN from a string (typically a folder name).
|
||||||
|
* Audible ASINs start with 'B' and are exactly 10 alphanumeric characters.
|
||||||
|
* The ASIN must be bounded by a bracket, parenthesis, whitespace, or string
|
||||||
|
* boundary to avoid false positives from random alphanumeric sequences.
|
||||||
|
* Returns the ASIN string or null if not found.
|
||||||
|
*/
|
||||||
|
export function extractAsinFromString(str: string): string | null {
|
||||||
|
const match = str.match(/(?:^|[^A-Z0-9])(B[A-Z0-9]{9})(?:$|[^A-Z0-9])/i);
|
||||||
|
return match ? match[1].toUpperCase() : null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read audio metadata from a file using ffprobe.
|
* Read audio metadata from a file using ffprobe.
|
||||||
* Extracts album, album_artist, composer, and title tags.
|
* Extracts album, album_artist, composer, and title tags.
|
||||||
@@ -140,15 +159,36 @@ export function deduplicateNames(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build a search term from metadata or file name.
|
* Clean a raw string (folder name or file name) for use as an Audible search term.
|
||||||
|
* Strips file extension, bracketed ASINs, bracketed years, leading track numbers,
|
||||||
|
* underscores, and collapses whitespace.
|
||||||
|
*/
|
||||||
|
export function cleanSearchString(raw: string): string {
|
||||||
|
return raw
|
||||||
|
.replace(/\.[^.]+$/, '') // Remove file extension
|
||||||
|
.replace(/[\[\(][A-Z0-9]{10}[\]\)]/g, '') // Remove ASIN in brackets
|
||||||
|
.replace(/[\[\(]\d{4}[\]\)]/g, '') // Remove year in brackets
|
||||||
|
.replace(/^\d+[\s._-]+/, '') // Remove leading track numbers
|
||||||
|
.replace(/[_]/g, ' ') // Underscores to spaces
|
||||||
|
.replace(/\s+/g, ' ') // Collapse whitespace
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a search term from metadata or folder/file name.
|
||||||
* Returns the search term and the source it was derived from.
|
* Returns the search term and the source it was derived from.
|
||||||
|
*
|
||||||
|
* Fallback chain (when no album metadata tag is present):
|
||||||
|
* 1. Folder name — if provided and not a generic name (CD1, Disc 2, Part 3, etc.)
|
||||||
|
* 2. First audio file name — last resort, always available
|
||||||
|
*
|
||||||
* When metadata tags are present, constructs "Title Author Narrator ContributingArtists".
|
* When metadata tags are present, constructs "Title Author Narrator ContributingArtists".
|
||||||
* When tags are empty, falls back to the first audio file's name (cleaned).
|
|
||||||
*/
|
*/
|
||||||
export function buildSearchTerm(
|
export function buildSearchTerm(
|
||||||
metadata: AudioFileMetadata,
|
metadata: AudioFileMetadata,
|
||||||
firstFileName: string
|
firstFileName: string,
|
||||||
): { searchTerm: string; source: 'tags' | 'file_name' } {
|
folderName?: string
|
||||||
|
): { searchTerm: string; source: 'tags' | 'folder_name' | 'file_name' } {
|
||||||
const { author, narrator, contributingArtists } = deduplicateNames(
|
const { author, narrator, contributingArtists } = deduplicateNames(
|
||||||
metadata.author,
|
metadata.author,
|
||||||
metadata.narrator,
|
metadata.narrator,
|
||||||
@@ -165,23 +205,23 @@ export function buildSearchTerm(
|
|||||||
return { searchTerm: parts.join(' '), source: 'tags' };
|
return { searchTerm: parts.join(' '), source: 'tags' };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: clean up the first audio file name and use it as search term
|
// Fallback 1: folder name (if provided and not generic)
|
||||||
const cleaned = firstFileName
|
if (folderName && !GENERIC_FOLDER_NAME_RE.test(folderName.trim())) {
|
||||||
.replace(/\.[^.]+$/, '') // Remove file extension
|
const cleaned = cleanSearchString(folderName);
|
||||||
.replace(/[\[\(][A-Z0-9]{10}[\]\)]/g, '') // Remove ASIN in brackets
|
if (cleaned) {
|
||||||
.replace(/[\[\(]\d{4}[\]\)]/g, '') // Remove year in brackets
|
return { searchTerm: cleaned, source: 'folder_name' };
|
||||||
.replace(/^\d+[\s._-]+/, '') // Remove leading track numbers
|
}
|
||||||
.replace(/[_]/g, ' ') // Underscores to spaces
|
}
|
||||||
.replace(/\s+/g, ' ') // Collapse whitespace
|
|
||||||
.trim();
|
|
||||||
|
|
||||||
|
// Fallback 2: first audio file name
|
||||||
|
const cleaned = cleanSearchString(firstFileName);
|
||||||
return { searchTerm: cleaned || firstFileName, source: 'file_name' };
|
return { searchTerm: cleaned || firstFileName, source: 'file_name' };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build a normalized grouping key from metadata.
|
* Build a normalized grouping key from metadata.
|
||||||
* Used to determine which files belong to the same book.
|
* Used to determine which files belong to the same book.
|
||||||
* Returns null if metadata has no title (ungroupable).
|
* Returns null if metadata has no title (ungroupable by metadata).
|
||||||
*/
|
*/
|
||||||
function buildGroupingKey(metadata: AudioFileMetadata): string | null {
|
function buildGroupingKey(metadata: AudioFileMetadata): string | null {
|
||||||
if (!metadata.title) return null;
|
if (!metadata.title) return null;
|
||||||
@@ -259,17 +299,23 @@ async function asyncPool<T, R>(
|
|||||||
* Group audio files in a directory by their metadata.
|
* Group audio files in a directory by their metadata.
|
||||||
* Reads metadata from all files using a concurrency pool, then groups them
|
* Reads metadata from all files using a concurrency pool, then groups them
|
||||||
* by a normalized key of title + author + narrator.
|
* by a normalized key of title + author + narrator.
|
||||||
* Files with no metadata title each become their own group.
|
*
|
||||||
|
* Files with a metadata title are grouped by their shared key. Files with no
|
||||||
|
* metadata title are all grouped together under a single '__ungrouped_folder'
|
||||||
|
* key (rather than one entry per file), treating the folder as one book.
|
||||||
|
* If a folder contains both tagged and untagged files, the untagged files form
|
||||||
|
* one extra group alongside the tagged groups.
|
||||||
*/
|
*/
|
||||||
async function groupAudioFilesByMetadata(
|
async function groupAudioFilesByMetadata(
|
||||||
dirPath: string,
|
dirPath: string,
|
||||||
audioFiles: string[],
|
audioFiles: string[],
|
||||||
audioSizes: Map<string, number>
|
audioSizes: Map<string, number>,
|
||||||
|
folderName: string
|
||||||
): Promise<Array<{
|
): Promise<Array<{
|
||||||
files: string[];
|
files: string[];
|
||||||
totalSize: number;
|
totalSize: number;
|
||||||
metadata: AudioFileMetadata;
|
metadata: AudioFileMetadata;
|
||||||
metadataSource: 'tags' | 'file_name';
|
metadataSource: 'tags' | 'folder_name' | 'file_name';
|
||||||
searchTerm: string;
|
searchTerm: string;
|
||||||
groupingKey: string;
|
groupingKey: string;
|
||||||
}>> {
|
}>> {
|
||||||
@@ -291,14 +337,12 @@ async function groupAudioFilesByMetadata(
|
|||||||
metadata: AudioFileMetadata;
|
metadata: AudioFileMetadata;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
let ungroupedCounter = 0;
|
|
||||||
|
|
||||||
for (const { fileName, metadata } of metadataResults) {
|
for (const { fileName, metadata } of metadataResults) {
|
||||||
const key = buildGroupingKey(metadata);
|
const key = buildGroupingKey(metadata);
|
||||||
const fileSize = audioSizes.get(fileName) || 0;
|
const fileSize = audioSizes.get(fileName) || 0;
|
||||||
|
|
||||||
if (key) {
|
if (key) {
|
||||||
// Has metadata — group with others sharing the same key
|
// Has metadata title — group with others sharing the same key
|
||||||
const existing = groups.get(key);
|
const existing = groups.get(key);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
existing.files.push(fileName);
|
existing.files.push(fileName);
|
||||||
@@ -311,20 +355,45 @@ async function groupAudioFilesByMetadata(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// No title metadata — treat as individual book
|
// No title metadata — collect all such files under one folder-level group.
|
||||||
const uniqueKey = `__ungrouped_${ungroupedCounter++}`;
|
// Key must start with '__ungrouped_' so deduplicateDiscoveries treats it
|
||||||
groups.set(uniqueKey, {
|
// as unique per folder (prefixes it with folderPath before deduplication).
|
||||||
files: [fileName],
|
const ungroupedKey = '__ungrouped_folder';
|
||||||
totalSize: fileSize,
|
const existing = groups.get(ungroupedKey);
|
||||||
metadata,
|
if (existing) {
|
||||||
});
|
existing.files.push(fileName);
|
||||||
|
existing.totalSize += fileSize;
|
||||||
|
} else {
|
||||||
|
groups.set(ungroupedKey, {
|
||||||
|
files: [fileName],
|
||||||
|
totalSize: fileSize,
|
||||||
|
metadata,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If there is exactly one tagged group alongside an ungrouped group, absorb
|
||||||
|
// the untagged files into the tagged group. Untagged files in the same folder
|
||||||
|
// almost certainly belong to the same book (e.g. one chapter was ripped
|
||||||
|
// without tags, or a cover/intro file carries different metadata).
|
||||||
|
// Only do this when there is a single tagged group — multiple tagged groups
|
||||||
|
// mean genuinely different books are mixed in the folder, so keep them separate.
|
||||||
|
const ungrouped = groups.get('__ungrouped_folder');
|
||||||
|
if (ungrouped) {
|
||||||
|
const taggedKeys = Array.from(groups.keys()).filter((k) => k !== '__ungrouped_folder');
|
||||||
|
if (taggedKeys.length === 1) {
|
||||||
|
const taggedGroup = groups.get(taggedKeys[0])!;
|
||||||
|
taggedGroup.files.push(...ungrouped.files);
|
||||||
|
taggedGroup.totalSize += ungrouped.totalSize;
|
||||||
|
groups.delete('__ungrouped_folder');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build result with search terms
|
// Build result with search terms
|
||||||
return Array.from(groups.entries()).map(([groupingKey, group]) => {
|
return Array.from(groups.entries()).map(([groupingKey, group]) => {
|
||||||
group.files.sort((a, b) => a.localeCompare(b));
|
group.files.sort((a, b) => a.localeCompare(b));
|
||||||
const { searchTerm, source } = buildSearchTerm(group.metadata, group.files[0]);
|
const { searchTerm, source } = buildSearchTerm(group.metadata, group.files[0], folderName);
|
||||||
return {
|
return {
|
||||||
files: group.files,
|
files: group.files,
|
||||||
totalSize: group.totalSize,
|
totalSize: group.totalSize,
|
||||||
@@ -389,15 +458,17 @@ function deduplicateDiscoveries(
|
|||||||
combinedCount += disc.audioFileCount;
|
combinedCount += disc.audioFileCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const mergedFolderName = path.basename(commonParent);
|
||||||
merged.push({
|
merged.push({
|
||||||
folderPath: commonParent,
|
folderPath: commonParent,
|
||||||
folderName: path.basename(commonParent),
|
folderName: mergedFolderName,
|
||||||
relativePath: first.relativePath.split('/').slice(0, -1).join('/') || path.basename(commonParent),
|
relativePath: first.relativePath.split('/').slice(0, -1).join('/') || mergedFolderName,
|
||||||
audioFileCount: combinedCount,
|
audioFileCount: combinedCount,
|
||||||
totalSizeBytes: combinedSize,
|
totalSizeBytes: combinedSize,
|
||||||
metadata: first.metadata,
|
metadata: first.metadata,
|
||||||
searchTerm: first.searchTerm,
|
searchTerm: first.searchTerm,
|
||||||
metadataSource: first.metadataSource,
|
metadataSource: first.metadataSource,
|
||||||
|
extractedAsin: extractAsinFromString(mergedFolderName) ?? first.extractedAsin,
|
||||||
audioFiles: combinedFiles,
|
audioFiles: combinedFiles,
|
||||||
groupingKey: first.groupingKey,
|
groupingKey: first.groupingKey,
|
||||||
});
|
});
|
||||||
@@ -434,9 +505,10 @@ function findCommonParent(paths: string[]): string {
|
|||||||
*
|
*
|
||||||
* Scans every folder for audio files. When audio files are found, they are
|
* Scans every folder for audio files. When audio files are found, they are
|
||||||
* grouped by metadata (title + author + narrator) — each group becomes a
|
* grouped by metadata (title + author + narrator) — each group becomes a
|
||||||
* separate discovered audiobook. Files with no metadata are treated as
|
* separate discovered audiobook. Files with no metadata are all grouped
|
||||||
* individual books. Scanning ALWAYS recurses into subfolders regardless of
|
* together per folder (treated as one book) rather than one entry per file.
|
||||||
* whether the current folder has audio files.
|
* Scanning ALWAYS recurses into subfolders regardless of whether the current
|
||||||
|
* folder has audio files.
|
||||||
*
|
*
|
||||||
* After the full walk, discoveries sharing the same grouping key across
|
* After the full walk, discoveries sharing the same grouping key across
|
||||||
* different folders (e.g., CD1/ and CD2/) are merged.
|
* different folders (e.g., CD1/ and CD2/) are merged.
|
||||||
@@ -460,11 +532,13 @@ export async function discoverAudiobooks(
|
|||||||
|
|
||||||
foldersScanned++;
|
foldersScanned++;
|
||||||
|
|
||||||
|
const folderName = path.basename(currentPath);
|
||||||
|
|
||||||
onProgress?.({
|
onProgress?.({
|
||||||
phase: 'discovering',
|
phase: 'discovering',
|
||||||
foldersScanned,
|
foldersScanned,
|
||||||
audiobooksFound: results.length,
|
audiobooksFound: results.length,
|
||||||
currentFolder: path.basename(currentPath),
|
currentFolder: folderName,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Check if this folder contains audio files
|
// Check if this folder contains audio files
|
||||||
@@ -486,19 +560,22 @@ export async function discoverAudiobooks(
|
|||||||
phase: 'grouping',
|
phase: 'grouping',
|
||||||
foldersScanned,
|
foldersScanned,
|
||||||
audiobooksFound: results.length,
|
audiobooksFound: results.length,
|
||||||
currentFolder: path.basename(currentPath),
|
currentFolder: folderName,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Group audio files by metadata
|
// Group audio files by metadata, passing folder name for fallback search terms
|
||||||
const groups = await groupAudioFilesByMetadata(
|
const groups = await groupAudioFilesByMetadata(
|
||||||
currentPath,
|
currentPath,
|
||||||
audioResult.audioFiles,
|
audioResult.audioFiles,
|
||||||
audioSizes
|
audioSizes,
|
||||||
|
folderName
|
||||||
);
|
);
|
||||||
|
|
||||||
const folderName = path.basename(currentPath);
|
|
||||||
const relativePath = path.relative(rootPath, currentPath).replace(/\\/g, '/');
|
const relativePath = path.relative(rootPath, currentPath).replace(/\\/g, '/');
|
||||||
|
|
||||||
|
// Extract ASIN from folder name once for all groups in this folder
|
||||||
|
const extractedAsin = extractAsinFromString(folderName) ?? undefined;
|
||||||
|
|
||||||
for (const group of groups) {
|
for (const group of groups) {
|
||||||
results.push({
|
results.push({
|
||||||
folderPath: currentPath.replace(/\\/g, '/'),
|
folderPath: currentPath.replace(/\\/g, '/'),
|
||||||
@@ -509,6 +586,7 @@ export async function discoverAudiobooks(
|
|||||||
metadata: group.metadata,
|
metadata: group.metadata,
|
||||||
searchTerm: group.searchTerm,
|
searchTerm: group.searchTerm,
|
||||||
metadataSource: group.metadataSource,
|
metadataSource: group.metadataSource,
|
||||||
|
extractedAsin,
|
||||||
audioFiles: group.files,
|
audioFiles: group.files,
|
||||||
groupingKey: group.groupingKey,
|
groupingKey: group.groupingKey,
|
||||||
});
|
});
|
||||||
@@ -518,7 +596,7 @@ export async function discoverAudiobooks(
|
|||||||
phase: 'reading_metadata',
|
phase: 'reading_metadata',
|
||||||
foldersScanned,
|
foldersScanned,
|
||||||
audiobooksFound: results.length,
|
audiobooksFound: results.length,
|
||||||
currentFolder: path.basename(currentPath),
|
currentFolder: folderName,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -109,7 +109,12 @@ export function areDurationsCompatible(a?: number, b?: number): boolean {
|
|||||||
// Metadata scoring (for picking best representative)
|
// Metadata scoring (for picking best representative)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
function metadataScore(book: AudibleAudiobook): number {
|
/**
|
||||||
|
* Score a book by how much metadata it carries. Used as the tie-breaker when
|
||||||
|
* collapsing duplicates — the entry with the richest metadata wins. Exported
|
||||||
|
* so the works-table collapse pass can apply the same ranking.
|
||||||
|
*/
|
||||||
|
export function metadataScore(book: AudibleAudiobook): number {
|
||||||
let score = 0;
|
let score = 0;
|
||||||
if (book.coverArtUrl) score++;
|
if (book.coverArtUrl) score++;
|
||||||
if (book.rating != null) score++;
|
if (book.rating != null) score++;
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
/**
|
||||||
|
* Component: Narrator Extraction Utility
|
||||||
|
* Documentation: documentation/integrations/audible.md
|
||||||
|
*
|
||||||
|
* Shared helper for Audible HTML scrapers. Audible product listings render
|
||||||
|
* each narrator as a separate `<a href="?searchNarrator=...">` link; using
|
||||||
|
* `.first()` on that selector silently drops co-narrators and breaks dedup
|
||||||
|
* for multi-narrator productions (e.g. full-cast audiobooks). This helper
|
||||||
|
* captures every narrator link and joins them, falling back to the
|
||||||
|
* `.narratorLabel` span when no anchor links are present.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type * as cheerio from 'cheerio';
|
||||||
|
import type { AnyNode } from 'domhandler';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract a comma-joined narrator string from an Audible product list item.
|
||||||
|
*
|
||||||
|
* Order is not semantically significant — downstream `normalizeNarrator()`
|
||||||
|
* sorts before comparison — but document-order preserves a stable, legible
|
||||||
|
* value for caching and logging.
|
||||||
|
*/
|
||||||
|
export function extractAllNarrators(
|
||||||
|
$: cheerio.CheerioAPI,
|
||||||
|
$el: cheerio.Cheerio<AnyNode>,
|
||||||
|
): string {
|
||||||
|
const links = $el.find('a[href*="searchNarrator="]');
|
||||||
|
if (links.length > 0) {
|
||||||
|
const names: string[] = [];
|
||||||
|
links.each((_, link) => {
|
||||||
|
const name = $(link).text().trim();
|
||||||
|
if (name) names.push(name);
|
||||||
|
});
|
||||||
|
if (names.length > 0) return names.join(', ');
|
||||||
|
}
|
||||||
|
return $el.find('.narratorLabel').text().trim();
|
||||||
|
}
|
||||||
@@ -252,6 +252,8 @@ export class FileOrganizer {
|
|||||||
narrator: audiobook.narrator,
|
narrator: audiobook.narrator,
|
||||||
year: audiobook.year,
|
year: audiobook.year,
|
||||||
asin: audiobook.asin,
|
asin: audiobook.asin,
|
||||||
|
series: audiobook.series,
|
||||||
|
seriesPart: audiobook.seriesPart,
|
||||||
});
|
});
|
||||||
|
|
||||||
const successCount = taggingResults.filter((r) => r.success).length;
|
const successCount = taggingResults.filter((r) => r.success).length;
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ export interface MetadataTaggingOptions {
|
|||||||
narrator?: string;
|
narrator?: string;
|
||||||
year?: number;
|
year?: number;
|
||||||
asin?: string;
|
asin?: string;
|
||||||
|
series?: string;
|
||||||
|
seriesPart?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TaggingResult {
|
export interface TaggingResult {
|
||||||
@@ -83,6 +85,14 @@ export async function tagAudioFileMetadata(
|
|||||||
args.push('-metadata', `----:com.apple.iTunes:ASIN="${escapeMetadata(metadata.asin)}"`);
|
args.push('-metadata', `----:com.apple.iTunes:ASIN="${escapeMetadata(metadata.asin)}"`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (metadata.series) {
|
||||||
|
args.push('-metadata', `show="${escapeMetadata(metadata.series)}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (metadata.seriesPart) {
|
||||||
|
args.push('-metadata', `episode_id="${escapeMetadata(metadata.seriesPart)}"`);
|
||||||
|
}
|
||||||
|
|
||||||
// Explicitly specify output format (fixes .tmp extension issue)
|
// Explicitly specify output format (fixes .tmp extension issue)
|
||||||
args.push('-f', 'mp4');
|
args.push('-f', 'mp4');
|
||||||
}
|
}
|
||||||
@@ -108,6 +118,14 @@ export async function tagAudioFileMetadata(
|
|||||||
args.push('-metadata', `ASIN="${escapeMetadata(metadata.asin)}"`);
|
args.push('-metadata', `ASIN="${escapeMetadata(metadata.asin)}"`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (metadata.series) {
|
||||||
|
args.push('-metadata', `SERIES="${escapeMetadata(metadata.series)}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (metadata.seriesPart) {
|
||||||
|
args.push('-metadata', `SERIES-PART="${escapeMetadata(metadata.seriesPart)}"`);
|
||||||
|
}
|
||||||
|
|
||||||
// Explicitly specify output format
|
// Explicitly specify output format
|
||||||
args.push('-f', 'flac');
|
args.push('-f', 'flac');
|
||||||
}
|
}
|
||||||
@@ -134,6 +152,14 @@ export async function tagAudioFileMetadata(
|
|||||||
args.push('-metadata', `ASIN="${escapeMetadata(metadata.asin)}"`);
|
args.push('-metadata', `ASIN="${escapeMetadata(metadata.asin)}"`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (metadata.series) {
|
||||||
|
args.push('-metadata', `SERIES="${escapeMetadata(metadata.series)}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (metadata.seriesPart) {
|
||||||
|
args.push('-metadata', `SERIES-PART="${escapeMetadata(metadata.seriesPart)}"`);
|
||||||
|
}
|
||||||
|
|
||||||
// Explicitly specify output format (fixes .tmp extension issue)
|
// Explicitly specify output format (fixes .tmp extension issue)
|
||||||
args.push('-f', 'mp3');
|
args.push('-f', 'mp3');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,12 +38,18 @@ export function getBrowserHeaders(userAgent: string): Record<string, string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Jittered exponential backoff: 2^attempt * baseMs * random(0.5, 1.5)
|
* Jittered exponential backoff: 2^attempt * baseMs * random(0.5, 1.5),
|
||||||
|
* optionally capped so high attempt counts don't produce absurd waits.
|
||||||
* Avoids predictable retry timing that is trivially fingerprinted.
|
* Avoids predictable retry timing that is trivially fingerprinted.
|
||||||
*/
|
*/
|
||||||
export function jitteredBackoff(attempt: number, baseMs: number = 1000): number {
|
export function jitteredBackoff(
|
||||||
|
attempt: number,
|
||||||
|
baseMs: number = 1000,
|
||||||
|
maxBackoffMs: number = Number.POSITIVE_INFINITY,
|
||||||
|
): number {
|
||||||
const jitter = 0.5 + Math.random(); // 0.5 – 1.5
|
const jitter = 0.5 + Math.random(); // 0.5 – 1.5
|
||||||
return Math.round(Math.pow(2, attempt) * baseMs * jitter);
|
const raw = Math.pow(2, attempt) * baseMs * jitter;
|
||||||
|
return Math.round(Math.min(raw, maxBackoffMs));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Random integer in [minMs, maxMs] */
|
/** Random integer in [minMs, maxMs] */
|
||||||
|
|||||||
@@ -4,12 +4,13 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { Prisma } from '@/generated/prisma/client';
|
||||||
import { createPrismaMock } from '../helpers/prisma';
|
import { createPrismaMock } from '../helpers/prisma';
|
||||||
|
|
||||||
let authRequest: any;
|
let authRequest: any;
|
||||||
|
|
||||||
const prismaMock = createPrismaMock();
|
const prismaMock = createPrismaMock();
|
||||||
const jobQueueMock = vi.hoisted(() => ({ addSearchJob: vi.fn(), addOrganizeJob: vi.fn() }));
|
const jobQueueMock = vi.hoisted(() => ({ addSearchJob: vi.fn(), addOrganizeJob: vi.fn(), addNotificationJob: vi.fn().mockResolvedValue(undefined) }));
|
||||||
const requireAuthMock = vi.hoisted(() => vi.fn());
|
const requireAuthMock = vi.hoisted(() => vi.fn());
|
||||||
const qbtMock = vi.hoisted(() => ({ getTorrent: vi.fn() }));
|
const qbtMock = vi.hoisted(() => ({ getTorrent: vi.fn() }));
|
||||||
const sabnzbdMock = vi.hoisted(() => ({ getNZB: vi.fn() }));
|
const sabnzbdMock = vi.hoisted(() => ({ getNZB: vi.fn() }));
|
||||||
@@ -115,11 +116,13 @@ describe('Request by ID API routes', () => {
|
|||||||
id: 'req-2',
|
id: 'req-2',
|
||||||
userId: 'user-1',
|
userId: 'user-1',
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
|
user: { plexUsername: 'testuser' },
|
||||||
|
audiobook: { id: 'ab-1', title: 'Test Book', author: 'Test Author' },
|
||||||
});
|
});
|
||||||
prismaMock.request.update.mockResolvedValueOnce({
|
prismaMock.request.update.mockResolvedValueOnce({
|
||||||
id: 'req-2',
|
id: 'req-2',
|
||||||
status: 'cancelled',
|
status: 'cancelled',
|
||||||
audiobook: { id: 'ab-1' },
|
audiobook: { id: 'ab-1', title: 'Test Book', author: 'Test Author' },
|
||||||
});
|
});
|
||||||
|
|
||||||
const { PATCH } = await import('@/app/api/requests/[id]/route');
|
const { PATCH } = await import('@/app/api/requests/[id]/route');
|
||||||
@@ -128,6 +131,66 @@ describe('Request by ID API routes', () => {
|
|||||||
|
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
expect(payload.request.status).toBe('cancelled');
|
expect(payload.request.status).toBe('cancelled');
|
||||||
|
expect(jobQueueMock.addNotificationJob).toHaveBeenCalledWith(
|
||||||
|
'request_cancelled',
|
||||||
|
'req-2',
|
||||||
|
'Test Book',
|
||||||
|
'Test Author',
|
||||||
|
'testuser'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cancels an awaiting_approval request and clears selectedTorrent', async () => {
|
||||||
|
authRequest.json.mockResolvedValue({ action: 'cancel' });
|
||||||
|
prismaMock.request.findFirst.mockResolvedValueOnce({
|
||||||
|
id: 'req-ap',
|
||||||
|
userId: 'user-1',
|
||||||
|
status: 'awaiting_approval',
|
||||||
|
user: { plexUsername: 'testuser' },
|
||||||
|
audiobook: { id: 'ab-ap', title: 'Approval Book', author: 'Some Author' },
|
||||||
|
});
|
||||||
|
prismaMock.request.update.mockResolvedValueOnce({
|
||||||
|
id: 'req-ap',
|
||||||
|
status: 'cancelled',
|
||||||
|
audiobook: { id: 'ab-ap', title: 'Approval Book', author: 'Some Author' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const { PATCH } = await import('@/app/api/requests/[id]/route');
|
||||||
|
const response = await PATCH({} as any, { params: Promise.resolve({ id: 'req-ap' }) });
|
||||||
|
const payload = await response.json();
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(payload.request.status).toBe('cancelled');
|
||||||
|
expect(prismaMock.request.update).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
data: expect.objectContaining({ selectedTorrent: Prisma.DbNull }),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(jobQueueMock.addNotificationJob).toHaveBeenCalledWith(
|
||||||
|
'request_cancelled',
|
||||||
|
'req-ap',
|
||||||
|
'Approval Book',
|
||||||
|
'Some Author',
|
||||||
|
'testuser'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 400 when cancelling a request in a non-cancellable status', async () => {
|
||||||
|
authRequest.json.mockResolvedValue({ action: 'cancel' });
|
||||||
|
prismaMock.request.findFirst.mockResolvedValueOnce({
|
||||||
|
id: 'req-2',
|
||||||
|
userId: 'user-1',
|
||||||
|
status: 'available',
|
||||||
|
user: { plexUsername: 'testuser' },
|
||||||
|
audiobook: { id: 'ab-1', title: 'Test Book', author: 'Test Author' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const { PATCH } = await import('@/app/api/requests/[id]/route');
|
||||||
|
const response = await PATCH({} as any, { params: Promise.resolve({ id: 'req-2' }) });
|
||||||
|
const payload = await response.json();
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(payload.error).toBe('ValidationError');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns 400 for invalid actions', async () => {
|
it('returns 400 for invalid actions', async () => {
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ describe('RequestActionsDropdown', () => {
|
|||||||
|
|
||||||
fireEvent.click(screen.getByTitle('Actions'));
|
fireEvent.click(screen.getByTitle('Actions'));
|
||||||
fireEvent.click(screen.getByText('Cancel Request'));
|
fireEvent.click(screen.getByText('Cancel Request'));
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Cancel request' }));
|
||||||
await waitFor(() => expect(onCancel).toHaveBeenCalledWith('req-1'));
|
await waitFor(() => expect(onCancel).toHaveBeenCalledWith('req-1'));
|
||||||
|
|
||||||
fireEvent.click(screen.getByTitle('Actions'));
|
fireEvent.click(screen.getByTitle('Actions'));
|
||||||
|
|||||||
@@ -103,6 +103,7 @@ describe('RequestCard', () => {
|
|||||||
render(<RequestCard request={baseRequest} />);
|
render(<RequestCard request={baseRequest} />);
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
|
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Cancel request' }));
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(cancelRequestMock).toHaveBeenCalledWith('req-1');
|
expect(cancelRequestMock).toHaveBeenCalledWith('req-1');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -20,13 +20,15 @@ vi.mock('@/lib/utils/jwt-client', () => ({
|
|||||||
|
|
||||||
function TestConsumer() {
|
function TestConsumer() {
|
||||||
const { user, accessToken, isLoading, login, logout, refreshToken, setAuthData } = useAuth();
|
const { user, accessToken, isLoading, login, logout, refreshToken, setAuthData } = useAuth();
|
||||||
|
const [loginResult, setLoginResult] = React.useState('none');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div data-testid="loading">{String(isLoading)}</div>
|
<div data-testid="loading">{String(isLoading)}</div>
|
||||||
<div data-testid="user">{user?.username ?? 'none'}</div>
|
<div data-testid="user">{user?.username ?? 'none'}</div>
|
||||||
<div data-testid="token">{accessToken ?? 'none'}</div>
|
<div data-testid="token">{accessToken ?? 'none'}</div>
|
||||||
<button type="button" onClick={() => void login(123)}>
|
<div data-testid="login-result">{loginResult}</div>
|
||||||
|
<button type="button" onClick={() => void login(123).then(setLoginResult)}>
|
||||||
login
|
login
|
||||||
</button>
|
</button>
|
||||||
<button type="button" onClick={logout}>
|
<button type="button" onClick={logout}>
|
||||||
@@ -188,6 +190,34 @@ describe('AuthProvider', () => {
|
|||||||
expect(screen.getByTestId('token')).toHaveTextContent('login-access');
|
expect(screen.getByTestId('token')).toHaveTextContent('login-access');
|
||||||
expect(localStorage.getItem('accessToken')).toBe('login-access');
|
expect(localStorage.getItem('accessToken')).toBe('login-access');
|
||||||
expect(localStorage.getItem('refreshToken')).toBe('login-refresh');
|
expect(localStorage.getItem('refreshToken')).toBe('login-refresh');
|
||||||
|
expect(screen.getByTestId('login-result')).toHaveTextContent('authenticated');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns profile selection result without storing auth data for Plex Home users', async () => {
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({
|
||||||
|
success: true,
|
||||||
|
authorized: true,
|
||||||
|
requiresProfileSelection: true,
|
||||||
|
redirectUrl: '/auth/select-profile?pinId=123',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
|
renderAuthProvider();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'login' }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByTestId('login-result')).toHaveTextContent('profile-selection-required'));
|
||||||
|
|
||||||
|
expect(locationStub.href).toBe('/auth/select-profile?pinId=123');
|
||||||
|
expect(screen.getByTestId('user')).toHaveTextContent('none');
|
||||||
|
expect(screen.getByTestId('token')).toHaveTextContent('none');
|
||||||
|
expect(localStorage.getItem('accessToken')).toBeNull();
|
||||||
|
expect(localStorage.getItem('refreshToken')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('logs out by clearing storage and redirecting to the login page', () => {
|
it('logs out by clearing storage and redirecting to the login page', () => {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ type RenderWithProvidersOptions = Omit<RenderOptions, 'wrapper'> & {
|
|||||||
user: MockUser | null;
|
user: MockUser | null;
|
||||||
accessToken: string | null;
|
accessToken: string | null;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
login: (pinId: number) => Promise<void>;
|
login: (pinId: number) => Promise<'authenticated' | 'profile-selection-required'>;
|
||||||
logout: () => void;
|
logout: () => void;
|
||||||
refreshToken: () => Promise<void>;
|
refreshToken: () => Promise<void>;
|
||||||
setAuthData: (user: MockUser, accessToken: string) => void;
|
setAuthData: (user: MockUser, accessToken: string) => void;
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ interface ProductOverrides {
|
|||||||
runtime_length_min?: number;
|
runtime_length_min?: number;
|
||||||
release_date?: string;
|
release_date?: string;
|
||||||
language?: string;
|
language?: string;
|
||||||
|
format_type?: string;
|
||||||
|
publisher_name?: string;
|
||||||
rating?: { overall_distribution?: { display_stars?: number } };
|
rating?: { overall_distribution?: { display_stars?: number } };
|
||||||
category_ladders?: Array<{ ladder: Array<{ name: string }> }>;
|
category_ladders?: Array<{ ladder: Array<{ name: string }> }>;
|
||||||
series?: Array<{ asin?: string; title?: string; sequence?: string }>;
|
series?: Array<{ asin?: string; title?: string; sequence?: string }>;
|
||||||
@@ -81,6 +83,122 @@ function apiResponse(envelope: object) {
|
|||||||
return { data: envelope };
|
return { data: envelope };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// HTML fixture helpers (for getPopularAudiobooks / getNewReleases / getCategoryBooks,
|
||||||
|
// which scrape Audible's curated HTML pages)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface HtmlBookOverrides {
|
||||||
|
asin?: string;
|
||||||
|
title?: string;
|
||||||
|
author?: string;
|
||||||
|
authorAsin?: string;
|
||||||
|
/** Single-narrator shorthand; mutually exclusive with `narrators`. */
|
||||||
|
narrator?: string;
|
||||||
|
/** Multi-narrator productions render each name as its own searchNarrator anchor. */
|
||||||
|
narrators?: string[];
|
||||||
|
coverArtUrl?: string;
|
||||||
|
rating?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Render one or more narrator anchor links suitable for embedding in .narratorLabel. */
|
||||||
|
function renderNarratorLinks(names: string[]): string {
|
||||||
|
return names
|
||||||
|
.map(
|
||||||
|
(name) =>
|
||||||
|
`<a href="/search?searchNarrator=${encodeURIComponent(name)}">${name}</a>`,
|
||||||
|
)
|
||||||
|
.join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Produces a single .productListItem block matching the selectors parsed by
|
||||||
|
* parseProductListItems(). The parser looks for an `<li data-asin>` descendant,
|
||||||
|
* with an `<a href="/pd/...">` fallback — using a real `<li>` here both
|
||||||
|
* exercises the primary path and keeps the markup well-formed.
|
||||||
|
*/
|
||||||
|
function makeProductListItemHtml(overrides: HtmlBookOverrides = {}): string {
|
||||||
|
const {
|
||||||
|
asin = 'B000000001',
|
||||||
|
title = 'Test Book',
|
||||||
|
author = 'Test Author',
|
||||||
|
authorAsin = 'A000000001',
|
||||||
|
narrator = 'Test Narrator',
|
||||||
|
narrators,
|
||||||
|
coverArtUrl = 'https://images.example.com/cover._SL500_.jpg',
|
||||||
|
rating = 4.5,
|
||||||
|
} = overrides;
|
||||||
|
|
||||||
|
// Real Audible storefront markup embeds each narrator as its own anchor inside
|
||||||
|
// .narratorLabel for multi-narrator productions. The single-narrator case keeps
|
||||||
|
// the original plain-text span for backward compatibility with existing tests.
|
||||||
|
const narratorMarkup = narrators && narrators.length > 0
|
||||||
|
? `<span class="narratorLabel">Narrated by: ${renderNarratorLinks(narrators)}</span>`
|
||||||
|
: `<span class="narratorLabel">${narrator}</span>`;
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="productListItem">
|
||||||
|
<ul>
|
||||||
|
<li data-asin="${asin}">
|
||||||
|
<img src="${coverArtUrl}" />
|
||||||
|
<h3><a href="/pd/test/${asin}">${title}</a></h3>
|
||||||
|
<a class="authorLabel" href="/author/test/${authorAsin}">${author}</a>
|
||||||
|
${narratorMarkup}
|
||||||
|
<span class="ratingsLabel">${rating} out of 5</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Produces a single .s-result-item block matching the selectors parsed by
|
||||||
|
* parseSearchResultItems(). Used for /search?node=<categoryId> category pages.
|
||||||
|
*/
|
||||||
|
function makeSearchResultItemHtml(overrides: HtmlBookOverrides = {}): string {
|
||||||
|
const {
|
||||||
|
asin = 'B000000001',
|
||||||
|
title = 'Test Book',
|
||||||
|
author = 'Test Author',
|
||||||
|
authorAsin = 'A000000001',
|
||||||
|
narrator = 'Test Narrator',
|
||||||
|
narrators,
|
||||||
|
coverArtUrl = 'https://images.example.com/cover._SL500_.jpg',
|
||||||
|
rating = 4.5,
|
||||||
|
} = overrides;
|
||||||
|
|
||||||
|
const narratorLinks = narrators && narrators.length > 0
|
||||||
|
? renderNarratorLinks(narrators)
|
||||||
|
: `<a href="/search?searchNarrator=${encodeURIComponent(narrator)}">${narrator}</a>`;
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="s-result-item">
|
||||||
|
<ul>
|
||||||
|
<li data-asin="${asin}">
|
||||||
|
<img src="${coverArtUrl}" />
|
||||||
|
<h2><a href="/pd/test/${asin}">${title}</a></h2>
|
||||||
|
<a href="/author/test/${authorAsin}">${author}</a>
|
||||||
|
${narratorLinks}
|
||||||
|
<span class="ratingsLabel">${rating} out of 5</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wrap one or more item-HTML strings in a minimal page document. */
|
||||||
|
function makeHtmlPage(items: string[]): string {
|
||||||
|
return `<html><body>${items.join('')}</body></html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Produces the value that client.get() should resolve to for HTML responses.
|
||||||
|
* cheerio.load() is called on response.data, so .data must be the raw HTML string.
|
||||||
|
*/
|
||||||
|
function htmlResponse(html: string) {
|
||||||
|
return { data: html };
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Test setup
|
// Test setup
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -499,6 +617,47 @@ describe('AudibleService', () => {
|
|||||||
const genreSet = new Set(results[0].genres);
|
const genreSet = new Set(results[0].genres);
|
||||||
expect(genreSet.size).toBe(5);
|
expect(genreSet.size).toBe(5);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('maps language from catalog product', async () => {
|
||||||
|
const products = [makeProduct({ language: 'english' })];
|
||||||
|
apiClientMock.get.mockResolvedValue(apiResponse(makeProductsResponse(products)));
|
||||||
|
|
||||||
|
const service = new AudibleService();
|
||||||
|
const { results } = await service.search('test', 1);
|
||||||
|
|
||||||
|
expect(results[0].language).toBe('english');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps format_type to formatType from catalog product', async () => {
|
||||||
|
const products = [makeProduct({ format_type: 'unabridged' })];
|
||||||
|
apiClientMock.get.mockResolvedValue(apiResponse(makeProductsResponse(products)));
|
||||||
|
|
||||||
|
const service = new AudibleService();
|
||||||
|
const { results } = await service.search('test', 1);
|
||||||
|
|
||||||
|
expect(results[0].formatType).toBe('unabridged');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps publisher_name to publisherName from catalog product', async () => {
|
||||||
|
const products = [makeProduct({ publisher_name: 'Penguin Random House Audio' })];
|
||||||
|
apiClientMock.get.mockResolvedValue(apiResponse(makeProductsResponse(products)));
|
||||||
|
|
||||||
|
const service = new AudibleService();
|
||||||
|
const { results } = await service.search('test', 1);
|
||||||
|
|
||||||
|
expect(results[0].publisherName).toBe('Penguin Random House Audio');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves formatType and publisherName undefined when catalog product omits them', async () => {
|
||||||
|
const products = [makeProduct()];
|
||||||
|
apiClientMock.get.mockResolvedValue(apiResponse(makeProductsResponse(products)));
|
||||||
|
|
||||||
|
const service = new AudibleService();
|
||||||
|
const { results } = await service.search('test', 1);
|
||||||
|
|
||||||
|
expect(results[0].formatType).toBeUndefined();
|
||||||
|
expect(results[0].publisherName).toBeUndefined();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
@@ -683,61 +842,66 @@ describe('AudibleService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// getPopularAudiobooks()
|
// getPopularAudiobooks() — HTML scraping of /adblbestsellers
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
describe('getPopularAudiobooks()', () => {
|
describe('getPopularAudiobooks()', () => {
|
||||||
it('uses products_sort_by: BestSellers', async () => {
|
it('hits /adblbestsellers on the htmlClient with pageSize=50', async () => {
|
||||||
apiClientMock.get.mockResolvedValue(apiResponse(makeProductsResponse([])));
|
htmlClientMock.get.mockResolvedValue(htmlResponse(makeHtmlPage([makeProductListItemHtml()])));
|
||||||
|
|
||||||
const service = new AudibleService();
|
const service = new AudibleService();
|
||||||
await service.getPopularAudiobooks(1);
|
await service.getPopularAudiobooks(1);
|
||||||
|
|
||||||
expect(apiClientMock.get.mock.calls[0][1].params.products_sort_by).toBe('BestSellers');
|
expect(htmlClientMock.get).toHaveBeenCalledWith(
|
||||||
|
'/adblbestsellers',
|
||||||
|
expect.objectContaining({
|
||||||
|
params: expect.objectContaining({ pageSize: 50 }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('subtracts 1 from public page=1 before calling the API', async () => {
|
it('does not include a page param on the first request (only from page 2 onward)', async () => {
|
||||||
apiClientMock.get.mockResolvedValue(apiResponse(makeProductsResponse([])));
|
htmlClientMock.get.mockResolvedValue(htmlResponse(makeHtmlPage([makeProductListItemHtml()])));
|
||||||
const service = new AudibleService();
|
const service = new AudibleService();
|
||||||
const delaySpy = vi.spyOn(service as any, 'delay').mockResolvedValue(undefined);
|
const delaySpy = vi.spyOn(service as any, 'delay').mockResolvedValue(undefined);
|
||||||
|
|
||||||
await service.getPopularAudiobooks(1);
|
await service.getPopularAudiobooks(1);
|
||||||
expect(apiClientMock.get.mock.calls[0][1].params.page).toBe(0);
|
expect(htmlClientMock.get.mock.calls[0][1].params.page).toBeUndefined();
|
||||||
delaySpy.mockRestore();
|
delaySpy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('makes a second call with page=1 when paginating to page 2', async () => {
|
it('includes page=2 on the second request when paginating', async () => {
|
||||||
const page1Products = Array.from({ length: 50 }, (_, i) =>
|
const page1Items = Array.from({ length: 50 }, (_, i) =>
|
||||||
makeProduct({ asin: `B${String(i).padStart(9, '0')}`, title: `Book ${i}` }),
|
makeProductListItemHtml({ asin: `B${String(i).padStart(9, '0')}`, title: `Book ${i}` }),
|
||||||
);
|
);
|
||||||
const page2Products = Array.from({ length: 25 }, (_, i) =>
|
const page2Items = Array.from({ length: 25 }, (_, i) =>
|
||||||
makeProduct({ asin: `B${String(i + 50).padStart(9, '0')}`, title: `Book ${i + 50}` }),
|
makeProductListItemHtml({ asin: `B${String(i + 50).padStart(9, '0')}`, title: `Book ${i + 50}` }),
|
||||||
);
|
);
|
||||||
|
|
||||||
apiClientMock.get
|
htmlClientMock.get
|
||||||
.mockResolvedValueOnce(apiResponse(makeProductsResponse(page1Products, 75)))
|
.mockResolvedValueOnce(htmlResponse(makeHtmlPage(page1Items)))
|
||||||
.mockResolvedValueOnce(apiResponse(makeProductsResponse(page2Products, 75)));
|
.mockResolvedValueOnce(htmlResponse(makeHtmlPage(page2Items)));
|
||||||
|
|
||||||
const service = new AudibleService();
|
const service = new AudibleService();
|
||||||
const delaySpy = vi.spyOn(service as any, 'delay').mockResolvedValue(undefined);
|
const delaySpy = vi.spyOn(service as any, 'delay').mockResolvedValue(undefined);
|
||||||
|
|
||||||
await service.getPopularAudiobooks(75);
|
await service.getPopularAudiobooks(75);
|
||||||
|
|
||||||
expect(apiClientMock.get.mock.calls[1][1].params.page).toBe(1);
|
expect(htmlClientMock.get.mock.calls[1][1].params.page).toBe(2);
|
||||||
delaySpy.mockRestore();
|
delaySpy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('paginates and returns up to the requested limit', async () => {
|
it('paginates across pages and returns up to the requested limit', async () => {
|
||||||
const page1Products = Array.from({ length: 50 }, (_, i) =>
|
const page1Items = Array.from({ length: 50 }, (_, i) =>
|
||||||
makeProduct({ asin: `B${String(i).padStart(9, '0')}`, title: `Book ${i}` }),
|
makeProductListItemHtml({ asin: `B${String(i).padStart(9, '0')}`, title: `Book ${i}` }),
|
||||||
);
|
);
|
||||||
const page2Products = Array.from({ length: 25 }, (_, i) =>
|
const page2Items = Array.from({ length: 25 }, (_, i) =>
|
||||||
makeProduct({ asin: `B${String(i + 50).padStart(9, '0')}`, title: `Book ${i + 50}` }),
|
makeProductListItemHtml({ asin: `B${String(i + 50).padStart(9, '0')}`, title: `Book ${i + 50}` }),
|
||||||
);
|
);
|
||||||
|
|
||||||
apiClientMock.get
|
htmlClientMock.get
|
||||||
.mockResolvedValueOnce(apiResponse(makeProductsResponse(page1Products, 75)))
|
.mockResolvedValueOnce(htmlResponse(makeHtmlPage(page1Items)))
|
||||||
.mockResolvedValueOnce(apiResponse(makeProductsResponse(page2Products, 75)));
|
.mockResolvedValueOnce(htmlResponse(makeHtmlPage(page2Items)));
|
||||||
|
|
||||||
const service = new AudibleService();
|
const service = new AudibleService();
|
||||||
const delaySpy = vi.spyOn(service as any, 'delay').mockResolvedValue(undefined);
|
const delaySpy = vi.spyOn(service as any, 'delay').mockResolvedValue(undefined);
|
||||||
@@ -747,176 +911,338 @@ describe('AudibleService', () => {
|
|||||||
delaySpy.mockRestore();
|
delaySpy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('stops early when a page returns fewer than the page size', async () => {
|
it('stops early when a page returns fewer than half the page size', async () => {
|
||||||
const products = [makeProduct()];
|
htmlClientMock.get.mockResolvedValueOnce(
|
||||||
apiClientMock.get.mockResolvedValueOnce(apiResponse(makeProductsResponse(products, 1)));
|
htmlResponse(makeHtmlPage([makeProductListItemHtml()])),
|
||||||
|
);
|
||||||
|
|
||||||
const service = new AudibleService();
|
const service = new AudibleService();
|
||||||
const results = await service.getPopularAudiobooks(50);
|
const results = await service.getPopularAudiobooks(50);
|
||||||
|
|
||||||
expect(results).toHaveLength(1);
|
expect(results).toHaveLength(1);
|
||||||
expect(apiClientMock.get).toHaveBeenCalledTimes(1);
|
expect(htmlClientMock.get).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('deduplicates by ASIN across pages', async () => {
|
it('deduplicates by ASIN across pages', async () => {
|
||||||
const sharedProduct = makeProduct({ asin: 'BDUP000001', title: 'Duplicated Book' });
|
const sharedAsin = 'BDUP000001';
|
||||||
const uniqueProduct = makeProduct({ asin: 'BUNIQ000001', title: 'Unique Book' });
|
const uniqueAsin = 'BUNIQ000001';
|
||||||
|
|
||||||
apiClientMock.get
|
// Build a "full" first page (50 items, all with the shared ASIN duplicated as filler)
|
||||||
.mockResolvedValueOnce(
|
// so the parser proceeds to page 2.
|
||||||
apiResponse(makeProductsResponse([sharedProduct], 51)),
|
const page1Items = [
|
||||||
)
|
makeProductListItemHtml({ asin: sharedAsin, title: 'Duplicated Book' }),
|
||||||
.mockResolvedValueOnce(
|
...Array.from({ length: 49 }, (_, i) =>
|
||||||
// page 2 returns the same ASIN plus a new one
|
makeProductListItemHtml({ asin: `BFILL${String(i).padStart(5, '0')}`, title: `Filler ${i}` }),
|
||||||
apiResponse(makeProductsResponse([sharedProduct, uniqueProduct], 51)),
|
),
|
||||||
);
|
];
|
||||||
|
const page2Items = [
|
||||||
|
makeProductListItemHtml({ asin: sharedAsin, title: 'Duplicated Book' }),
|
||||||
|
makeProductListItemHtml({ asin: uniqueAsin, title: 'Unique Book' }),
|
||||||
|
...Array.from({ length: 48 }, (_, i) =>
|
||||||
|
makeProductListItemHtml({ asin: `BFILL2${String(i).padStart(4, '0')}`, title: `Filler2 ${i}` }),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
htmlClientMock.get
|
||||||
|
.mockResolvedValueOnce(htmlResponse(makeHtmlPage(page1Items)))
|
||||||
|
.mockResolvedValueOnce(htmlResponse(makeHtmlPage(page2Items)));
|
||||||
|
|
||||||
const service = new AudibleService();
|
const service = new AudibleService();
|
||||||
const delaySpy = vi.spyOn(service as any, 'delay').mockResolvedValue(undefined);
|
const delaySpy = vi.spyOn(service as any, 'delay').mockResolvedValue(undefined);
|
||||||
const results = await service.getPopularAudiobooks(100);
|
const results = await service.getPopularAudiobooks(150);
|
||||||
|
|
||||||
const asins = results.map((r) => r.asin);
|
const asins = results.map((r) => r.asin);
|
||||||
expect(asins.filter((a) => a === 'BDUP000001')).toHaveLength(1);
|
expect(asins.filter((a) => a === sharedAsin)).toHaveLength(1);
|
||||||
|
expect(asins).toContain(uniqueAsin);
|
||||||
delaySpy.mockRestore();
|
delaySpy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns empty array on error without throwing', async () => {
|
it('returns empty array on error without throwing', async () => {
|
||||||
const error: Error & { response?: { status: number } } = new Error('Not Found');
|
const error: Error & { response?: { status: number } } = new Error('Not Found');
|
||||||
error.response = { status: 404 };
|
error.response = { status: 404 };
|
||||||
apiClientMock.get.mockRejectedValue(error);
|
htmlClientMock.get.mockRejectedValue(error);
|
||||||
|
|
||||||
const service = new AudibleService();
|
const service = new AudibleService();
|
||||||
const results = await service.getPopularAudiobooks(5);
|
const results = await service.getPopularAudiobooks(5);
|
||||||
|
|
||||||
expect(results).toEqual([]);
|
expect(results).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses htmlClient (not apiClient) for the request', async () => {
|
||||||
|
htmlClientMock.get.mockResolvedValue(htmlResponse(makeHtmlPage([makeProductListItemHtml()])));
|
||||||
|
|
||||||
|
const service = new AudibleService();
|
||||||
|
await service.getPopularAudiobooks(1);
|
||||||
|
|
||||||
|
expect(htmlClientMock.get).toHaveBeenCalled();
|
||||||
|
expect(apiClientMock.get).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps title, author, narrator, and rating from the parsed item', async () => {
|
||||||
|
htmlClientMock.get.mockResolvedValue(
|
||||||
|
htmlResponse(
|
||||||
|
makeHtmlPage([
|
||||||
|
makeProductListItemHtml({
|
||||||
|
asin: 'B0HTMLMAP1',
|
||||||
|
title: 'Mapped Title',
|
||||||
|
author: 'Mapped Author',
|
||||||
|
authorAsin: 'A00MAPAUTH',
|
||||||
|
narrator: 'Mapped Narrator',
|
||||||
|
rating: 4.7,
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const service = new AudibleService();
|
||||||
|
const [book] = await service.getPopularAudiobooks(1);
|
||||||
|
|
||||||
|
expect(book.asin).toBe('B0HTMLMAP1');
|
||||||
|
expect(book.title).toBe('Mapped Title');
|
||||||
|
expect(book.author).toBe('Mapped Author');
|
||||||
|
expect(book.authorAsin).toBe('A00MAPAUTH');
|
||||||
|
expect(book.narrator).toBe('Mapped Narrator');
|
||||||
|
expect(book.rating).toBeCloseTo(4.7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('captures every co-narrator on multi-narrator productions (regression: prior code took only the first link)', async () => {
|
||||||
|
htmlClientMock.get.mockResolvedValue(
|
||||||
|
htmlResponse(
|
||||||
|
makeHtmlPage([
|
||||||
|
makeProductListItemHtml({
|
||||||
|
asin: 'B0FULLCAST',
|
||||||
|
narrators: [
|
||||||
|
'Kristin Atherton',
|
||||||
|
'Roy McMillan',
|
||||||
|
'Clare Corbett',
|
||||||
|
'Tom Bateman',
|
||||||
|
'Patience Tomlinson',
|
||||||
|
'Shaheen Khan',
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const service = new AudibleService();
|
||||||
|
const [book] = await service.getPopularAudiobooks(1);
|
||||||
|
|
||||||
|
// Every narrator must round-trip — order is not significant downstream,
|
||||||
|
// but document order should be preserved for stable cache values.
|
||||||
|
expect(book.narrator).toBe(
|
||||||
|
'Kristin Atherton, Roy McMillan, Clare Corbett, Tom Bateman, Patience Tomlinson, Shaheen Khan',
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// getNewReleases()
|
// getNewReleases() — HTML scraping of /newreleases
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
describe('getNewReleases()', () => {
|
describe('getNewReleases()', () => {
|
||||||
it('uses products_sort_by: -ReleaseDate', async () => {
|
it('hits /newreleases on the htmlClient with pageSize=50', async () => {
|
||||||
apiClientMock.get.mockResolvedValue(apiResponse(makeProductsResponse([])));
|
htmlClientMock.get.mockResolvedValue(htmlResponse(makeHtmlPage([makeProductListItemHtml()])));
|
||||||
|
|
||||||
const service = new AudibleService();
|
const service = new AudibleService();
|
||||||
await service.getNewReleases(1);
|
await service.getNewReleases(1);
|
||||||
|
|
||||||
expect(apiClientMock.get.mock.calls[0][1].params.products_sort_by).toBe('-ReleaseDate');
|
expect(htmlClientMock.get).toHaveBeenCalledWith(
|
||||||
|
'/newreleases',
|
||||||
|
expect.objectContaining({
|
||||||
|
params: expect.objectContaining({ pageSize: 50 }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('subtracts 1 from public page=1 before calling the API', async () => {
|
it('does not include a page param on the first request', async () => {
|
||||||
apiClientMock.get.mockResolvedValue(apiResponse(makeProductsResponse([])));
|
htmlClientMock.get.mockResolvedValue(htmlResponse(makeHtmlPage([makeProductListItemHtml()])));
|
||||||
const service = new AudibleService();
|
const service = new AudibleService();
|
||||||
const delaySpy = vi.spyOn(service as any, 'delay').mockResolvedValue(undefined);
|
const delaySpy = vi.spyOn(service as any, 'delay').mockResolvedValue(undefined);
|
||||||
|
|
||||||
await service.getNewReleases(1);
|
await service.getNewReleases(1);
|
||||||
expect(apiClientMock.get.mock.calls[0][1].params.page).toBe(0);
|
expect(htmlClientMock.get.mock.calls[0][1].params.page).toBeUndefined();
|
||||||
delaySpy.mockRestore();
|
delaySpy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('subtracts 1 from public page=2 when paginating to the second page', async () => {
|
it('includes page=2 on the second request when paginating', async () => {
|
||||||
const page1Products = Array.from({ length: 50 }, (_, i) =>
|
const page1Items = Array.from({ length: 50 }, (_, i) =>
|
||||||
makeProduct({ asin: `B${String(i).padStart(9, '0')}` }),
|
makeProductListItemHtml({ asin: `B${String(i).padStart(9, '0')}` }),
|
||||||
|
);
|
||||||
|
const page2Items = Array.from({ length: 50 }, (_, i) =>
|
||||||
|
makeProductListItemHtml({ asin: `B${String(i + 50).padStart(9, '0')}` }),
|
||||||
);
|
);
|
||||||
const page2Products = [makeProduct({ asin: 'BNEW000099' })];
|
|
||||||
|
|
||||||
apiClientMock.get
|
htmlClientMock.get
|
||||||
.mockResolvedValueOnce(apiResponse(makeProductsResponse(page1Products, 51)))
|
.mockResolvedValueOnce(htmlResponse(makeHtmlPage(page1Items)))
|
||||||
.mockResolvedValueOnce(apiResponse(makeProductsResponse(page2Products, 51)));
|
.mockResolvedValueOnce(htmlResponse(makeHtmlPage(page2Items)));
|
||||||
|
|
||||||
const service = new AudibleService();
|
const service = new AudibleService();
|
||||||
const delaySpy = vi.spyOn(service as any, 'delay').mockResolvedValue(undefined);
|
const delaySpy = vi.spyOn(service as any, 'delay').mockResolvedValue(undefined);
|
||||||
|
|
||||||
await service.getNewReleases(51);
|
await service.getNewReleases(100);
|
||||||
expect(apiClientMock.get.mock.calls[1][1].params.page).toBe(1);
|
expect(htmlClientMock.get.mock.calls[1][1].params.page).toBe(2);
|
||||||
delaySpy.mockRestore();
|
delaySpy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('deduplicates by ASIN across pages', async () => {
|
it('deduplicates by ASIN across pages', async () => {
|
||||||
const sharedProduct = makeProduct({ asin: 'BDUP000002' });
|
const sharedAsin = 'BDUP000002';
|
||||||
apiClientMock.get
|
|
||||||
.mockResolvedValueOnce(apiResponse(makeProductsResponse([sharedProduct], 51)))
|
const page1Items = [
|
||||||
.mockResolvedValueOnce(apiResponse(makeProductsResponse([sharedProduct], 51)));
|
makeProductListItemHtml({ asin: sharedAsin }),
|
||||||
|
...Array.from({ length: 49 }, (_, i) =>
|
||||||
|
makeProductListItemHtml({ asin: `BNEW${String(i).padStart(6, '0')}` }),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
const page2Items = [
|
||||||
|
makeProductListItemHtml({ asin: sharedAsin }),
|
||||||
|
...Array.from({ length: 49 }, (_, i) =>
|
||||||
|
makeProductListItemHtml({ asin: `BNEW2${String(i).padStart(5, '0')}` }),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
htmlClientMock.get
|
||||||
|
.mockResolvedValueOnce(htmlResponse(makeHtmlPage(page1Items)))
|
||||||
|
.mockResolvedValueOnce(htmlResponse(makeHtmlPage(page2Items)));
|
||||||
|
|
||||||
const service = new AudibleService();
|
const service = new AudibleService();
|
||||||
const delaySpy = vi.spyOn(service as any, 'delay').mockResolvedValue(undefined);
|
const delaySpy = vi.spyOn(service as any, 'delay').mockResolvedValue(undefined);
|
||||||
const results = await service.getNewReleases(100);
|
const results = await service.getNewReleases(150);
|
||||||
|
|
||||||
expect(results.filter((r) => r.asin === 'BDUP000002')).toHaveLength(1);
|
expect(results.filter((r) => r.asin === sharedAsin)).toHaveLength(1);
|
||||||
delaySpy.mockRestore();
|
delaySpy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns empty array on error without throwing', async () => {
|
it('returns empty array on error without throwing', async () => {
|
||||||
const error: Error & { response?: { status: number } } = new Error('Not Found');
|
const error: Error & { response?: { status: number } } = new Error('Not Found');
|
||||||
error.response = { status: 404 };
|
error.response = { status: 404 };
|
||||||
apiClientMock.get.mockRejectedValue(error);
|
htmlClientMock.get.mockRejectedValue(error);
|
||||||
|
|
||||||
const service = new AudibleService();
|
const service = new AudibleService();
|
||||||
const results = await service.getNewReleases(5);
|
const results = await service.getNewReleases(5);
|
||||||
|
|
||||||
expect(results).toEqual([]);
|
expect(results).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses htmlClient (not apiClient) for the request', async () => {
|
||||||
|
htmlClientMock.get.mockResolvedValue(htmlResponse(makeHtmlPage([makeProductListItemHtml()])));
|
||||||
|
|
||||||
|
const service = new AudibleService();
|
||||||
|
await service.getNewReleases(1);
|
||||||
|
|
||||||
|
expect(htmlClientMock.get).toHaveBeenCalled();
|
||||||
|
expect(apiClientMock.get).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// getCategoryBooks()
|
// getCategoryBooks() — HTML scraping of /search?node=<categoryId>
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
describe('getCategoryBooks()', () => {
|
describe('getCategoryBooks()', () => {
|
||||||
it('sends category_id and BestSellers sort param', async () => {
|
it('hits /search on the htmlClient with node, pageSize, and popularity-rank sort', async () => {
|
||||||
apiClientMock.get.mockResolvedValue(apiResponse(makeProductsResponse([])));
|
htmlClientMock.get.mockResolvedValue(
|
||||||
|
htmlResponse(makeHtmlPage([makeSearchResultItemHtml()])),
|
||||||
|
);
|
||||||
|
|
||||||
const service = new AudibleService();
|
const service = new AudibleService();
|
||||||
await service.getCategoryBooks('18685580011', 1);
|
await service.getCategoryBooks('18685580011', 1);
|
||||||
|
|
||||||
const params = apiClientMock.get.mock.calls[0][1].params;
|
const params = htmlClientMock.get.mock.calls[0][1].params;
|
||||||
expect(params.category_id).toBe('18685580011');
|
expect(htmlClientMock.get.mock.calls[0][0]).toBe('/search');
|
||||||
expect(params.products_sort_by).toBe('BestSellers');
|
expect(params.node).toBe('18685580011');
|
||||||
|
expect(params.pageSize).toBe(50);
|
||||||
|
expect(params.sort).toBe('popularity-rank');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('subtracts 1 from public page=1 before calling the API', async () => {
|
it('does not include a page param on the first request', async () => {
|
||||||
apiClientMock.get.mockResolvedValue(apiResponse(makeProductsResponse([])));
|
htmlClientMock.get.mockResolvedValue(
|
||||||
|
htmlResponse(makeHtmlPage([makeSearchResultItemHtml()])),
|
||||||
|
);
|
||||||
const service = new AudibleService();
|
const service = new AudibleService();
|
||||||
const delaySpy = vi.spyOn(service as any, 'delay').mockResolvedValue(undefined);
|
const delaySpy = vi.spyOn(service as any, 'delay').mockResolvedValue(undefined);
|
||||||
|
|
||||||
await service.getCategoryBooks('CAT001', 1);
|
await service.getCategoryBooks('CAT001', 1);
|
||||||
expect(apiClientMock.get.mock.calls[0][1].params.page).toBe(0);
|
expect(htmlClientMock.get.mock.calls[0][1].params.page).toBeUndefined();
|
||||||
delaySpy.mockRestore();
|
delaySpy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('subtracts 1 from public page=2 when paginating to the second page', async () => {
|
it('includes page=2 on the second request when paginating', async () => {
|
||||||
const page1Products = Array.from({ length: 50 }, (_, i) =>
|
const page1Items = Array.from({ length: 50 }, (_, i) =>
|
||||||
makeProduct({ asin: `B${String(i).padStart(9, '0')}` }),
|
makeSearchResultItemHtml({ asin: `B${String(i).padStart(9, '0')}` }),
|
||||||
|
);
|
||||||
|
const page2Items = Array.from({ length: 50 }, (_, i) =>
|
||||||
|
makeSearchResultItemHtml({ asin: `B${String(i + 50).padStart(9, '0')}` }),
|
||||||
);
|
);
|
||||||
const page2Products = [makeProduct({ asin: 'BCAT000099' })];
|
|
||||||
|
|
||||||
apiClientMock.get
|
htmlClientMock.get
|
||||||
.mockResolvedValueOnce(apiResponse(makeProductsResponse(page1Products, 51)))
|
.mockResolvedValueOnce(htmlResponse(makeHtmlPage(page1Items)))
|
||||||
.mockResolvedValueOnce(apiResponse(makeProductsResponse(page2Products, 51)));
|
.mockResolvedValueOnce(htmlResponse(makeHtmlPage(page2Items)));
|
||||||
|
|
||||||
const service = new AudibleService();
|
const service = new AudibleService();
|
||||||
const delaySpy = vi.spyOn(service as any, 'delay').mockResolvedValue(undefined);
|
const delaySpy = vi.spyOn(service as any, 'delay').mockResolvedValue(undefined);
|
||||||
|
|
||||||
await service.getCategoryBooks('CAT001', 51);
|
await service.getCategoryBooks('CAT001', 100);
|
||||||
expect(apiClientMock.get.mock.calls[1][1].params.page).toBe(1);
|
expect(htmlClientMock.get.mock.calls[1][1].params.page).toBe(2);
|
||||||
delaySpy.mockRestore();
|
delaySpy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('deduplicates by ASIN across pages', async () => {
|
it('deduplicates by ASIN across pages', async () => {
|
||||||
const sharedProduct = makeProduct({ asin: 'BDUP000003' });
|
const sharedAsin = 'BDUP000003';
|
||||||
apiClientMock.get
|
|
||||||
.mockResolvedValueOnce(apiResponse(makeProductsResponse([sharedProduct], 51)))
|
const page1Items = [
|
||||||
.mockResolvedValueOnce(apiResponse(makeProductsResponse([sharedProduct], 51)));
|
makeSearchResultItemHtml({ asin: sharedAsin }),
|
||||||
|
...Array.from({ length: 49 }, (_, i) =>
|
||||||
|
makeSearchResultItemHtml({ asin: `BCAT${String(i).padStart(6, '0')}` }),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
const page2Items = [
|
||||||
|
makeSearchResultItemHtml({ asin: sharedAsin }),
|
||||||
|
...Array.from({ length: 49 }, (_, i) =>
|
||||||
|
makeSearchResultItemHtml({ asin: `BCAT2${String(i).padStart(5, '0')}` }),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
htmlClientMock.get
|
||||||
|
.mockResolvedValueOnce(htmlResponse(makeHtmlPage(page1Items)))
|
||||||
|
.mockResolvedValueOnce(htmlResponse(makeHtmlPage(page2Items)));
|
||||||
|
|
||||||
const service = new AudibleService();
|
const service = new AudibleService();
|
||||||
const delaySpy = vi.spyOn(service as any, 'delay').mockResolvedValue(undefined);
|
const delaySpy = vi.spyOn(service as any, 'delay').mockResolvedValue(undefined);
|
||||||
const results = await service.getCategoryBooks('CAT001', 100);
|
const results = await service.getCategoryBooks('CAT001', 150);
|
||||||
|
|
||||||
expect(results.filter((r) => r.asin === 'BDUP000003')).toHaveLength(1);
|
expect(results.filter((r) => r.asin === sharedAsin)).toHaveLength(1);
|
||||||
delaySpy.mockRestore();
|
delaySpy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses htmlClient (not apiClient) for the request', async () => {
|
||||||
|
htmlClientMock.get.mockResolvedValue(
|
||||||
|
htmlResponse(makeHtmlPage([makeSearchResultItemHtml()])),
|
||||||
|
);
|
||||||
|
|
||||||
|
const service = new AudibleService();
|
||||||
|
await service.getCategoryBooks('CAT001', 1);
|
||||||
|
|
||||||
|
expect(htmlClientMock.get).toHaveBeenCalled();
|
||||||
|
expect(apiClientMock.get).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('captures every co-narrator on multi-narrator productions (regression: prior code took only the first link)', async () => {
|
||||||
|
htmlClientMock.get.mockResolvedValue(
|
||||||
|
htmlResponse(
|
||||||
|
makeHtmlPage([
|
||||||
|
makeSearchResultItemHtml({
|
||||||
|
asin: 'B0FULLCAST',
|
||||||
|
narrators: ['Alice', 'Bob', 'Carol', 'Dan'],
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const service = new AudibleService();
|
||||||
|
const [book] = await service.getCategoryBooks('CAT001', 1);
|
||||||
|
|
||||||
|
expect(book.narrator).toBe('Alice, Bob, Carol, Dan');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
@@ -979,6 +1305,9 @@ describe('AudibleService', () => {
|
|||||||
runtimeLengthMin: '300',
|
runtimeLengthMin: '300',
|
||||||
genres: ['Fiction'],
|
genres: ['Fiction'],
|
||||||
rating: '4.7',
|
rating: '4.7',
|
||||||
|
language: 'english',
|
||||||
|
formatType: 'unabridged',
|
||||||
|
publisherName: 'Test Publisher',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -988,6 +1317,9 @@ describe('AudibleService', () => {
|
|||||||
expect(details?.title).toBe('Audnexus Book');
|
expect(details?.title).toBe('Audnexus Book');
|
||||||
expect(details?.author).toBe('Author A');
|
expect(details?.author).toBe('Author A');
|
||||||
expect(details?.durationMinutes).toBe(300);
|
expect(details?.durationMinutes).toBe(300);
|
||||||
|
expect(details?.language).toBe('english');
|
||||||
|
expect(details?.formatType).toBe('unabridged');
|
||||||
|
expect(details?.publisherName).toBe('Test Publisher');
|
||||||
// Catalog API should NOT be called when Audnexus succeeds.
|
// Catalog API should NOT be called when Audnexus succeeds.
|
||||||
expect(apiClientMock.get).not.toHaveBeenCalled();
|
expect(apiClientMock.get).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1217,4 +1217,124 @@ describe('QBittorrentService', () => {
|
|||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(loginSpy).toHaveBeenCalled();
|
expect(loginSpy).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('auth-optional mode (blank credentials)', () => {
|
||||||
|
it('flags service as auth-optional when both credentials are blank', () => {
|
||||||
|
const service = new QBittorrentService('http://qb', '', '');
|
||||||
|
expect((service as any).authOptional).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flags service as credentialed when any credential is provided', () => {
|
||||||
|
const withUser = new QBittorrentService('http://qb', 'user', '');
|
||||||
|
const withPass = new QBittorrentService('http://qb', '', 'pass');
|
||||||
|
expect((withUser as any).authOptional).toBe(false);
|
||||||
|
expect((withPass as any).authOptional).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('login() is a no-op when auth-optional', async () => {
|
||||||
|
const service = new QBittorrentService('http://qb', '', '');
|
||||||
|
await service.login();
|
||||||
|
expect(axiosMock.post).not.toHaveBeenCalled();
|
||||||
|
expect((service as any).cookie).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('testConnection() succeeds when /app/version returns a version (auth-optional)', async () => {
|
||||||
|
const service = new QBittorrentService('http://qb', '', '');
|
||||||
|
clientMock.get.mockResolvedValueOnce({ data: 'v4.6.0' });
|
||||||
|
|
||||||
|
const result = await service.testConnection();
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.version).toBe('4.6.0');
|
||||||
|
expect(axiosMock.post).not.toHaveBeenCalled();
|
||||||
|
expect(clientMock.get).toHaveBeenCalledWith('/app/version', expect.objectContaining({
|
||||||
|
headers: {},
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('testConnection() returns failure when /app/version returns 401 (auth-optional)', async () => {
|
||||||
|
const service = new QBittorrentService('http://qb', '', '');
|
||||||
|
clientMock.get.mockRejectedValueOnce({
|
||||||
|
isAxiosError: true,
|
||||||
|
response: { status: 401 },
|
||||||
|
message: 'Unauthorized',
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.testConnection();
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.message).toMatch(/requires authentication/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('testConnection() returns failure when /app/version is unreachable (auth-optional)', async () => {
|
||||||
|
const service = new QBittorrentService('http://qb', '', '');
|
||||||
|
clientMock.get.mockRejectedValueOnce({
|
||||||
|
isAxiosError: true,
|
||||||
|
code: 'ECONNREFUSED',
|
||||||
|
message: 'connect ECONNREFUSED',
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.testConnection();
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.message).toMatch(/Failed to reach qBittorrent/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('testConnectionWithCredentials() probes /app/version directly when both creds blank', async () => {
|
||||||
|
axiosMock.get.mockResolvedValueOnce({ data: 'v4.6.0' });
|
||||||
|
|
||||||
|
const version = await QBittorrentService.testConnectionWithCredentials('http://qb', '', '');
|
||||||
|
|
||||||
|
expect(version).toBe('4.6.0');
|
||||||
|
expect(axiosMock.post).not.toHaveBeenCalled();
|
||||||
|
expect(axiosMock.get).toHaveBeenCalledWith(
|
||||||
|
'http://qb/api/v2/app/version',
|
||||||
|
expect.objectContaining({ httpsAgent: undefined })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('testConnectionWithCredentials() reports auth-required when blank creds get 401', async () => {
|
||||||
|
axiosMock.get.mockRejectedValueOnce({
|
||||||
|
isAxiosError: true,
|
||||||
|
response: { status: 401 },
|
||||||
|
message: 'Unauthorized',
|
||||||
|
config: { url: 'http://qb/api/v2/app/version' },
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
QBittorrentService.testConnectionWithCredentials('http://qb', '', '')
|
||||||
|
).rejects.toThrow(/requires authentication/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('addTorrent does not attempt re-login on 403 when auth-optional', async () => {
|
||||||
|
const service = new QBittorrentService('http://qb', '', '');
|
||||||
|
vi.spyOn(service as any, 'ensureCategory').mockResolvedValue(undefined);
|
||||||
|
const loginSpy = vi.spyOn(service, 'login');
|
||||||
|
vi.spyOn(service as any, 'addMagnetLink').mockRejectedValueOnce({
|
||||||
|
isAxiosError: true,
|
||||||
|
response: { status: 403 },
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.addTorrent('magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567')
|
||||||
|
).rejects.toThrow('Failed to add torrent');
|
||||||
|
|
||||||
|
expect(loginSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits Cookie header on requests when auth-optional', async () => {
|
||||||
|
const service = new QBittorrentService('http://qb', '', '');
|
||||||
|
vi.spyOn(service as any, 'getTorrent').mockRejectedValue(new Error('not found'));
|
||||||
|
clientMock.post.mockResolvedValue({ data: 'Ok.' });
|
||||||
|
|
||||||
|
await (service as any).addMagnetLink(
|
||||||
|
'magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567',
|
||||||
|
'readmeabook'
|
||||||
|
);
|
||||||
|
|
||||||
|
const headers = clientMock.post.mock.calls[0][2].headers;
|
||||||
|
expect(headers.Cookie).toBeUndefined();
|
||||||
|
expect(headers['Content-Type']).toBe('application/x-www-form-urlencoded');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -198,4 +198,69 @@ describe('processAudibleRefresh', () => {
|
|||||||
const { processAudibleRefresh } = await import('@/lib/processors/audible-refresh.processor');
|
const { processAudibleRefresh } = await import('@/lib/processors/audible-refresh.processor');
|
||||||
await expect(processAudibleRefresh({ jobId: 'job-2' })).rejects.toThrow('DB down');
|
await expect(processAudibleRefresh({ jobId: 'job-2' })).rejects.toThrow('DB down');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('deduplicates ASINs in the input list before persisting, preserving order', async () => {
|
||||||
|
// Two `A` entries should collapse to one. Final ranks must be contiguous
|
||||||
|
// (1, 2, 3) and follow Audible's editorial ordering (A, B, C).
|
||||||
|
const popular = [
|
||||||
|
{ asin: 'A', title: 'Book A', author: 'X', coverArtUrl: null },
|
||||||
|
{ asin: 'B', title: 'Book B', author: 'X', coverArtUrl: null },
|
||||||
|
{ asin: 'A', title: 'Book A (duplicate)', author: 'X', coverArtUrl: null },
|
||||||
|
{ asin: 'C', title: 'Book C', author: 'X', coverArtUrl: null },
|
||||||
|
];
|
||||||
|
|
||||||
|
audibleServiceMock.getPopularAudiobooks.mockResolvedValue(popular);
|
||||||
|
audibleServiceMock.getNewReleases.mockResolvedValue([]);
|
||||||
|
thumbnailCacheMock.cleanupUnusedThumbnails.mockResolvedValue(0);
|
||||||
|
prismaMock.audibleCache.upsert.mockResolvedValue({});
|
||||||
|
prismaMock.audibleCacheCategory.deleteMany.mockResolvedValue({ count: 0 });
|
||||||
|
prismaMock.audibleCacheCategory.create.mockResolvedValue({});
|
||||||
|
prismaMock.userHomeSection.findMany.mockResolvedValue([]);
|
||||||
|
prismaMock.audibleCache.findMany.mockResolvedValue([]);
|
||||||
|
|
||||||
|
const { processAudibleRefresh } = await import('@/lib/processors/audible-refresh.processor');
|
||||||
|
const result = await processAudibleRefresh({ jobId: 'job-dedup' });
|
||||||
|
|
||||||
|
expect(result.popularSaved).toBe(3);
|
||||||
|
|
||||||
|
// Only 3 category entries created — the duplicate `A` was dropped.
|
||||||
|
const popularCreates = (prismaMock.audibleCacheCategory.create.mock.calls as Array<[{ data: { asin: string; categoryId: string; rank: number } }]>)
|
||||||
|
.map((c) => c[0].data)
|
||||||
|
.filter((d) => d.categoryId === '__popular__');
|
||||||
|
expect(popularCreates).toHaveLength(3);
|
||||||
|
expect(popularCreates.map((d) => d.asin)).toEqual(['A', 'B', 'C']);
|
||||||
|
expect(popularCreates.map((d) => d.rank)).toEqual([1, 2, 3]);
|
||||||
|
|
||||||
|
// upsert called once per unique ASIN, not per input row.
|
||||||
|
expect(prismaMock.audibleCache.upsert).toHaveBeenCalledTimes(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops entries with missing ASINs as part of dedup', async () => {
|
||||||
|
const popular = [
|
||||||
|
{ asin: 'A', title: 'Book A', author: 'X', coverArtUrl: null },
|
||||||
|
{ asin: '', title: 'Book with empty asin', author: 'X', coverArtUrl: null },
|
||||||
|
{ asin: null, title: 'Book with null asin', author: 'X', coverArtUrl: null },
|
||||||
|
{ asin: 'B', title: 'Book B', author: 'X', coverArtUrl: null },
|
||||||
|
];
|
||||||
|
|
||||||
|
audibleServiceMock.getPopularAudiobooks.mockResolvedValue(popular as any);
|
||||||
|
audibleServiceMock.getNewReleases.mockResolvedValue([]);
|
||||||
|
thumbnailCacheMock.cleanupUnusedThumbnails.mockResolvedValue(0);
|
||||||
|
prismaMock.audibleCache.upsert.mockResolvedValue({});
|
||||||
|
prismaMock.audibleCacheCategory.deleteMany.mockResolvedValue({ count: 0 });
|
||||||
|
prismaMock.audibleCacheCategory.create.mockResolvedValue({});
|
||||||
|
prismaMock.userHomeSection.findMany.mockResolvedValue([]);
|
||||||
|
prismaMock.audibleCache.findMany.mockResolvedValue([]);
|
||||||
|
|
||||||
|
const { processAudibleRefresh } = await import('@/lib/processors/audible-refresh.processor');
|
||||||
|
const result = await processAudibleRefresh({ jobId: 'job-empty-asin' });
|
||||||
|
|
||||||
|
expect(result.popularSaved).toBe(2);
|
||||||
|
|
||||||
|
const popularCreates = (prismaMock.audibleCacheCategory.create.mock.calls as Array<[{ data: { asin: string; categoryId: string; rank: number } }]>)
|
||||||
|
.map((c) => c[0].data)
|
||||||
|
.filter((d) => d.categoryId === '__popular__');
|
||||||
|
expect(popularCreates.map((d) => d.asin)).toEqual(['A', 'B']);
|
||||||
|
expect(popularCreates.map((d) => d.rank)).toEqual([1, 2]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ describe('processDownloadTorrent', () => {
|
|||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
// Restore default implementations cleared by clearAllMocks
|
// Restore default implementations cleared by clearAllMocks
|
||||||
configMock.getMany.mockResolvedValue({ prowlarr_api_key: null });
|
configMock.getMany.mockResolvedValue({ prowlarr_api_key: null });
|
||||||
|
jobQueueMock.addNotificationJob.mockResolvedValue(undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
const torrentPayload = {
|
const torrentPayload = {
|
||||||
@@ -110,7 +111,7 @@ describe('processDownloadTorrent', () => {
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
category: 'readmeabook',
|
category: 'readmeabook',
|
||||||
});
|
});
|
||||||
prismaMock.request.update.mockResolvedValue({});
|
prismaMock.request.update.mockResolvedValue({ type: 'audiobook', user: { plexUsername: 'testuser' } });
|
||||||
prismaMock.downloadHistory.create.mockResolvedValue({ id: 'dh-1' });
|
prismaMock.downloadHistory.create.mockResolvedValue({ id: 'dh-1' });
|
||||||
|
|
||||||
const { processDownloadTorrent } = await import('@/lib/processors/download-torrent.processor');
|
const { processDownloadTorrent } = await import('@/lib/processors/download-torrent.processor');
|
||||||
@@ -141,7 +142,7 @@ describe('processDownloadTorrent', () => {
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
category: 'readmeabook',
|
category: 'readmeabook',
|
||||||
});
|
});
|
||||||
prismaMock.request.update.mockResolvedValue({});
|
prismaMock.request.update.mockResolvedValue({ type: 'audiobook', user: { plexUsername: 'testuser' } });
|
||||||
prismaMock.downloadHistory.create.mockResolvedValue({ id: 'dh-2' });
|
prismaMock.downloadHistory.create.mockResolvedValue({ id: 'dh-2' });
|
||||||
|
|
||||||
const { processDownloadTorrent } = await import('@/lib/processors/download-torrent.processor');
|
const { processDownloadTorrent } = await import('@/lib/processors/download-torrent.processor');
|
||||||
@@ -186,7 +187,7 @@ describe('processDownloadTorrent', () => {
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
category: 'readmeabook',
|
category: 'readmeabook',
|
||||||
});
|
});
|
||||||
prismaMock.request.update.mockResolvedValue({});
|
prismaMock.request.update.mockResolvedValue({ type: 'audiobook', user: { plexUsername: 'testuser' } });
|
||||||
prismaMock.downloadHistory.create.mockResolvedValue({ id: 'dh-1' });
|
prismaMock.downloadHistory.create.mockResolvedValue({ id: 'dh-1' });
|
||||||
|
|
||||||
const { processDownloadTorrent } = await import('@/lib/processors/download-torrent.processor');
|
const { processDownloadTorrent } = await import('@/lib/processors/download-torrent.processor');
|
||||||
|
|||||||
@@ -125,6 +125,72 @@ describe('processPlexRecentlyAddedCheck', () => {
|
|||||||
expect(prismaMock.plexLibrary.update).toHaveBeenCalled();
|
expect(prismaMock.plexLibrary.update).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('persists durations exceeding INT4 max as BigInt on both create and update paths (regression for #193)', async () => {
|
||||||
|
configMock.getBackendMode.mockResolvedValue('plex');
|
||||||
|
configMock.getMany.mockResolvedValue({
|
||||||
|
plex_url: 'http://plex',
|
||||||
|
plex_token: 'token',
|
||||||
|
plex_audiobook_library_id: 'lib-1',
|
||||||
|
});
|
||||||
|
configMock.get.mockResolvedValue('lib-1');
|
||||||
|
|
||||||
|
libraryServiceMock.getCoverCachingParams.mockResolvedValue({
|
||||||
|
backendBaseUrl: 'http://plex',
|
||||||
|
authToken: 'token',
|
||||||
|
backendMode: 'plex',
|
||||||
|
});
|
||||||
|
|
||||||
|
thumbnailCacheServiceMock.cacheLibraryThumbnail.mockResolvedValue(null);
|
||||||
|
|
||||||
|
// Production-observed overflow value: ~4_082_750 seconds → 4_082_750_000 ms (> INT4 max 2_147_483_647)
|
||||||
|
const overflowSeconds = 4_082_750;
|
||||||
|
const overflowMs = BigInt(overflowSeconds * 1000);
|
||||||
|
|
||||||
|
libraryServiceMock.getRecentlyAdded.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 'rating-new',
|
||||||
|
externalId: 'guid-new',
|
||||||
|
title: 'Long Audiobook (new)',
|
||||||
|
author: 'Author',
|
||||||
|
duration: overflowSeconds,
|
||||||
|
addedAt: new Date(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'rating-existing',
|
||||||
|
externalId: 'guid-existing',
|
||||||
|
title: 'Long Audiobook (existing)',
|
||||||
|
author: 'Author',
|
||||||
|
duration: overflowSeconds,
|
||||||
|
addedAt: new Date(),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
prismaMock.plexLibrary.findUnique.mockImplementation(async (query: any) => {
|
||||||
|
if (query.where.plexGuid === 'guid-existing') {
|
||||||
|
return { id: 'existing-id', plexGuid: 'guid-existing', author: 'Author', duration: null };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
prismaMock.plexLibrary.create.mockResolvedValue({});
|
||||||
|
prismaMock.plexLibrary.update.mockResolvedValue({});
|
||||||
|
prismaMock.request.findMany.mockResolvedValue([]);
|
||||||
|
|
||||||
|
const { processPlexRecentlyAddedCheck } = await import('@/lib/processors/plex-recently-added.processor');
|
||||||
|
await processPlexRecentlyAddedCheck({ jobId: 'job-overflow' });
|
||||||
|
|
||||||
|
expect(prismaMock.plexLibrary.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
data: expect.objectContaining({ duration: overflowMs }),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(prismaMock.plexLibrary.update).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
where: { plexGuid: 'guid-existing' },
|
||||||
|
data: expect.objectContaining({ duration: overflowMs }),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('matches requests without re-triggering ABS metadata match for audiobookshelf', async () => {
|
it('matches requests without re-triggering ABS metadata match for audiobookshelf', async () => {
|
||||||
const matcher = await import('@/lib/utils/audiobook-matcher');
|
const matcher = await import('@/lib/utils/audiobook-matcher');
|
||||||
const absApi = await import('@/lib/services/audiobookshelf/api');
|
const absApi = await import('@/lib/services/audiobookshelf/api');
|
||||||
|
|||||||
@@ -140,6 +140,79 @@ describe('processScanPlex', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('persists durations exceeding INT4 max as BigInt on both create and update paths (regression for #193)', async () => {
|
||||||
|
configMock.getBackendMode.mockResolvedValue('plex');
|
||||||
|
configMock.getPlexConfig.mockResolvedValue({
|
||||||
|
serverUrl: 'http://plex',
|
||||||
|
authToken: 'token',
|
||||||
|
libraryId: 'lib-1',
|
||||||
|
machineIdentifier: 'machine',
|
||||||
|
});
|
||||||
|
|
||||||
|
libraryServiceMock.getCoverCachingParams.mockResolvedValue({
|
||||||
|
backendBaseUrl: 'http://plex',
|
||||||
|
authToken: 'token',
|
||||||
|
backendMode: 'plex',
|
||||||
|
});
|
||||||
|
|
||||||
|
thumbnailCacheServiceMock.cacheLibraryThumbnail.mockResolvedValue(null);
|
||||||
|
|
||||||
|
// Production-observed overflow value: ~4_082_750 seconds → 4_082_750_000 ms (> INT4 max 2_147_483_647)
|
||||||
|
const overflowSeconds = 4_082_750;
|
||||||
|
const overflowMs = BigInt(overflowSeconds * 1000);
|
||||||
|
|
||||||
|
libraryServiceMock.getLibraryItems.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 'rating-new',
|
||||||
|
externalId: 'guid-new',
|
||||||
|
title: 'Long Audiobook (new)',
|
||||||
|
author: 'Author',
|
||||||
|
duration: overflowSeconds,
|
||||||
|
addedAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'rating-existing',
|
||||||
|
externalId: 'guid-existing',
|
||||||
|
title: 'Long Audiobook (existing)',
|
||||||
|
author: 'Author',
|
||||||
|
duration: overflowSeconds,
|
||||||
|
addedAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
prismaMock.plexLibrary.findFirst.mockImplementation(async (query: any) => {
|
||||||
|
if (query.where.plexGuid === 'guid-existing') {
|
||||||
|
return { id: 'existing-id', plexGuid: 'guid-existing', author: 'Author', duration: null };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
prismaMock.plexLibrary.create.mockResolvedValue({ id: 'new-id', plexGuid: 'guid-new' });
|
||||||
|
prismaMock.plexLibrary.update.mockResolvedValue({});
|
||||||
|
prismaMock.plexLibrary.findMany.mockResolvedValue([]);
|
||||||
|
prismaMock.audiobook.findMany.mockResolvedValue([]);
|
||||||
|
prismaMock.request.findMany.mockResolvedValue([]);
|
||||||
|
|
||||||
|
const matcher = await import('@/lib/utils/audiobook-matcher');
|
||||||
|
(matcher.findPlexMatch as ReturnType<typeof vi.fn>).mockResolvedValue(null);
|
||||||
|
|
||||||
|
const { processScanPlex } = await import('@/lib/processors/scan-plex.processor');
|
||||||
|
await processScanPlex({ jobId: 'job-overflow' });
|
||||||
|
|
||||||
|
expect(prismaMock.plexLibrary.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
data: expect.objectContaining({ duration: overflowMs }),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(prismaMock.plexLibrary.update).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
where: { id: 'existing-id' },
|
||||||
|
data: expect.objectContaining({ duration: overflowMs }),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('throws when audiobookshelf library is not configured', async () => {
|
it('throws when audiobookshelf library is not configured', async () => {
|
||||||
configMock.getBackendMode.mockResolvedValue('audiobookshelf');
|
configMock.getBackendMode.mockResolvedValue('audiobookshelf');
|
||||||
configMock.get.mockResolvedValue(null);
|
configMock.get.mockResolvedValue(null);
|
||||||
|
|||||||
@@ -458,6 +458,64 @@ describe('AppriseProvider', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('messageLabel rendering by event', () => {
|
||||||
|
const basePayload = {
|
||||||
|
requestId: 'req-1',
|
||||||
|
title: 'Test Book',
|
||||||
|
author: 'Test Author',
|
||||||
|
userName: 'Test User',
|
||||||
|
timestamp: new Date('2024-01-01T00:00:00Z'),
|
||||||
|
};
|
||||||
|
|
||||||
|
it('renders "⚠️ Error:" with error emoji for request_error', async () => {
|
||||||
|
fetchMock.mockResolvedValue({ ok: true, text: async () => 'ok' });
|
||||||
|
const { AppriseProvider } = await import('@/lib/services/notification');
|
||||||
|
const provider = new AppriseProvider();
|
||||||
|
|
||||||
|
await provider.send(
|
||||||
|
{ serverUrl: 'http://apprise:8000', urls: 'slack://token' },
|
||||||
|
{ ...basePayload, event: 'request_error', message: 'Boom' }
|
||||||
|
);
|
||||||
|
|
||||||
|
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
||||||
|
expect(body.body).toContain('⚠️ Error: Boom');
|
||||||
|
expect(body.body).not.toContain('📝');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders "📝 Reason:" with note emoji for issue_reported', async () => {
|
||||||
|
fetchMock.mockResolvedValue({ ok: true, text: async () => 'ok' });
|
||||||
|
const { AppriseProvider } = await import('@/lib/services/notification');
|
||||||
|
const provider = new AppriseProvider();
|
||||||
|
|
||||||
|
await provider.send(
|
||||||
|
{ serverUrl: 'http://apprise:8000', urls: 'slack://token' },
|
||||||
|
{ ...basePayload, event: 'issue_reported', issueId: 'iss-1', message: 'Chapter 3 cuts off' }
|
||||||
|
);
|
||||||
|
|
||||||
|
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
||||||
|
expect(body.body).toContain('📝 Reason: Chapter 3 cuts off');
|
||||||
|
expect(body.body).not.toContain('⚠️');
|
||||||
|
expect(body.body).not.toContain('Error:');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders "📝 Details:" with note emoji for request_grabbed', async () => {
|
||||||
|
fetchMock.mockResolvedValue({ ok: true, text: async () => 'ok' });
|
||||||
|
const { AppriseProvider } = await import('@/lib/services/notification');
|
||||||
|
const provider = new AppriseProvider();
|
||||||
|
|
||||||
|
await provider.send(
|
||||||
|
{ serverUrl: 'http://apprise:8000', urls: 'slack://token' },
|
||||||
|
{ ...basePayload, event: 'request_grabbed', message: 'Test Book [M4B] via NZBGeek (SABnzbd)', requestType: 'audiobook' }
|
||||||
|
);
|
||||||
|
|
||||||
|
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
||||||
|
expect(body.body).toContain('📝 Details: Test Book [M4B] via NZBGeek (SABnzbd)');
|
||||||
|
expect(body.body).not.toContain('⚠️');
|
||||||
|
expect(body.body).not.toContain('Error:');
|
||||||
|
expect(body.title).toBe('Audiobook Grabbed');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('integration with NotificationService.sendToBackend', () => {
|
describe('integration with NotificationService.sendToBackend', () => {
|
||||||
it('decrypts sensitive fields and sends to Apprise', async () => {
|
it('decrypts sensitive fields and sends to Apprise', async () => {
|
||||||
fetchMock.mockResolvedValue({
|
fetchMock.mockResolvedValue({
|
||||||
|
|||||||
@@ -267,6 +267,64 @@ describe('NtfyProvider', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('messageLabel rendering by event', () => {
|
||||||
|
const basePayload = {
|
||||||
|
requestId: 'req-1',
|
||||||
|
title: 'Test Book',
|
||||||
|
author: 'Test Author',
|
||||||
|
userName: 'Test User',
|
||||||
|
timestamp: new Date('2024-01-01T00:00:00Z'),
|
||||||
|
};
|
||||||
|
|
||||||
|
it('renders "⚠️ Error:" with error emoji for request_error', async () => {
|
||||||
|
fetchMock.mockResolvedValue({ ok: true, json: async () => ({ id: 'msg' }) });
|
||||||
|
const { NtfyProvider } = await import('@/lib/services/notification');
|
||||||
|
const provider = new NtfyProvider();
|
||||||
|
|
||||||
|
await provider.send(
|
||||||
|
{ topic: 'audiobooks' },
|
||||||
|
{ ...basePayload, event: 'request_error', message: 'Boom' }
|
||||||
|
);
|
||||||
|
|
||||||
|
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
||||||
|
expect(body.message).toContain('⚠️ Error: Boom');
|
||||||
|
expect(body.message).not.toContain('📝');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders "📝 Reason:" with note emoji for issue_reported', async () => {
|
||||||
|
fetchMock.mockResolvedValue({ ok: true, json: async () => ({ id: 'msg' }) });
|
||||||
|
const { NtfyProvider } = await import('@/lib/services/notification');
|
||||||
|
const provider = new NtfyProvider();
|
||||||
|
|
||||||
|
await provider.send(
|
||||||
|
{ topic: 'audiobooks' },
|
||||||
|
{ ...basePayload, event: 'issue_reported', issueId: 'iss-1', message: 'Chapter 3 cuts off' }
|
||||||
|
);
|
||||||
|
|
||||||
|
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
||||||
|
expect(body.message).toContain('📝 Reason: Chapter 3 cuts off');
|
||||||
|
expect(body.message).not.toContain('⚠️');
|
||||||
|
expect(body.message).not.toContain('Error:');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders "📝 Details:" with note emoji for request_grabbed', async () => {
|
||||||
|
fetchMock.mockResolvedValue({ ok: true, json: async () => ({ id: 'msg' }) });
|
||||||
|
const { NtfyProvider } = await import('@/lib/services/notification');
|
||||||
|
const provider = new NtfyProvider();
|
||||||
|
|
||||||
|
await provider.send(
|
||||||
|
{ topic: 'audiobooks' },
|
||||||
|
{ ...basePayload, event: 'request_grabbed', message: 'Test Book [M4B] via NZBGeek (SABnzbd)', requestType: 'audiobook' }
|
||||||
|
);
|
||||||
|
|
||||||
|
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
||||||
|
expect(body.message).toContain('📝 Details: Test Book [M4B] via NZBGeek (SABnzbd)');
|
||||||
|
expect(body.message).not.toContain('⚠️');
|
||||||
|
expect(body.message).not.toContain('Error:');
|
||||||
|
expect(body.title).toBe('Audiobook Grabbed');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('integration with NotificationService.sendToBackend', () => {
|
describe('integration with NotificationService.sendToBackend', () => {
|
||||||
it('decrypts accessToken and sends to ntfy', async () => {
|
it('decrypts accessToken and sends to ntfy', async () => {
|
||||||
fetchMock.mockResolvedValue({
|
fetchMock.mockResolvedValue({
|
||||||
|
|||||||
@@ -6,6 +6,15 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { createPrismaMock } from '../helpers/prisma';
|
import { createPrismaMock } from '../helpers/prisma';
|
||||||
import type { DedupGroup } from '@/lib/utils/deduplicate-audiobooks';
|
import type { DedupGroup } from '@/lib/utils/deduplicate-audiobooks';
|
||||||
|
import type { AudibleAudiobook } from '@/lib/integrations/audible.service';
|
||||||
|
|
||||||
|
function makeBook(overrides: Partial<AudibleAudiobook> & { asin: string }): AudibleAudiobook {
|
||||||
|
return {
|
||||||
|
title: 'Test Book',
|
||||||
|
author: 'Test Author',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const prismaMock = createPrismaMock();
|
const prismaMock = createPrismaMock();
|
||||||
|
|
||||||
@@ -304,3 +313,183 @@ describe('getSiblingAsins', () => {
|
|||||||
expect(result.has('ASIN_LONELY')).toBe(false);
|
expect(result.has('ASIN_LONELY')).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('collapseByExistingWorks', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.resetModules();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns input unchanged when the list is empty or has one entry', async () => {
|
||||||
|
const { collapseByExistingWorks } = await import('@/lib/services/works.service');
|
||||||
|
|
||||||
|
expect(await collapseByExistingWorks([])).toEqual([]);
|
||||||
|
expect(prismaMock.workAsin.findMany).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
const single = [makeBook({ asin: 'A1' })];
|
||||||
|
expect(await collapseByExistingWorks(single)).toEqual(single);
|
||||||
|
expect(prismaMock.workAsin.findMany).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns input unchanged when none of the ASINs are in any work', async () => {
|
||||||
|
prismaMock.workAsin.findMany.mockResolvedValue([]);
|
||||||
|
|
||||||
|
const { collapseByExistingWorks } = await import('@/lib/services/works.service');
|
||||||
|
|
||||||
|
const books = [
|
||||||
|
makeBook({ asin: 'A1', title: 'Alpha' }),
|
||||||
|
makeBook({ asin: 'A2', title: 'Beta' }),
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = await collapseByExistingWorks(books);
|
||||||
|
expect(result).toEqual(books);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('collapses two ASINs that share a work to a single representative', async () => {
|
||||||
|
prismaMock.workAsin.findMany.mockResolvedValue([
|
||||||
|
{ asin: 'A1', workId: 'work-1' },
|
||||||
|
{ asin: 'A2', workId: 'work-1' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const { collapseByExistingWorks } = await import('@/lib/services/works.service');
|
||||||
|
|
||||||
|
const books = [
|
||||||
|
makeBook({ asin: 'A1', title: 'The Passengers', coverArtUrl: 'cover.jpg' }),
|
||||||
|
makeBook({ asin: 'A2', title: 'The Passengers' }),
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = await collapseByExistingWorks(books);
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
// A1 wins — it has the cover URL (higher metadata score)
|
||||||
|
expect(result[0].asin).toBe('A1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the richest-metadata entry when collapsing, regardless of input order', async () => {
|
||||||
|
prismaMock.workAsin.findMany.mockResolvedValue([
|
||||||
|
{ asin: 'A1', workId: 'work-1' },
|
||||||
|
{ asin: 'A2', workId: 'work-1' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const { collapseByExistingWorks } = await import('@/lib/services/works.service');
|
||||||
|
|
||||||
|
// A1 first (sparse), A2 second (rich) — A2 should win on score
|
||||||
|
const books = [
|
||||||
|
makeBook({ asin: 'A1', title: 'Book' }),
|
||||||
|
makeBook({
|
||||||
|
asin: 'A2',
|
||||||
|
title: 'Book',
|
||||||
|
coverArtUrl: 'cover.jpg',
|
||||||
|
rating: 4.5,
|
||||||
|
durationMinutes: 600,
|
||||||
|
narrator: 'Full Cast',
|
||||||
|
description: 'Rich book',
|
||||||
|
releaseDate: '2024-01-01',
|
||||||
|
genres: ['Fiction'],
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = await collapseByExistingWorks(books);
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].asin).toBe('A2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves position of the work in the input order', async () => {
|
||||||
|
prismaMock.workAsin.findMany.mockResolvedValue([
|
||||||
|
{ asin: 'A2', workId: 'work-1' },
|
||||||
|
{ asin: 'A4', workId: 'work-1' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const { collapseByExistingWorks } = await import('@/lib/services/works.service');
|
||||||
|
|
||||||
|
const books = [
|
||||||
|
makeBook({ asin: 'A1', title: 'Alpha' }),
|
||||||
|
makeBook({ asin: 'A2', title: 'Beta' }),
|
||||||
|
makeBook({ asin: 'A3', title: 'Gamma' }),
|
||||||
|
makeBook({ asin: 'A4', title: 'Beta' }),
|
||||||
|
makeBook({ asin: 'A5', title: 'Delta' }),
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = await collapseByExistingWorks(books);
|
||||||
|
// A2 and A4 collapse to one entry at position 1 (the first occurrence)
|
||||||
|
expect(result.map(b => b.asin)).toEqual(['A1', 'A2', 'A3', 'A5']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles multiple independent works in the same batch', async () => {
|
||||||
|
prismaMock.workAsin.findMany.mockResolvedValue([
|
||||||
|
{ asin: 'A1', workId: 'work-1' },
|
||||||
|
{ asin: 'A2', workId: 'work-1' },
|
||||||
|
{ asin: 'B1', workId: 'work-2' },
|
||||||
|
{ asin: 'B2', workId: 'work-2' },
|
||||||
|
{ asin: 'B3', workId: 'work-2' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const { collapseByExistingWorks } = await import('@/lib/services/works.service');
|
||||||
|
|
||||||
|
const books = [
|
||||||
|
makeBook({ asin: 'A1' }),
|
||||||
|
makeBook({ asin: 'B1' }),
|
||||||
|
makeBook({ asin: 'A2' }),
|
||||||
|
makeBook({ asin: 'B2' }),
|
||||||
|
makeBook({ asin: 'B3' }),
|
||||||
|
makeBook({ asin: 'C1' }),
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = await collapseByExistingWorks(books);
|
||||||
|
expect(result.map(b => b.asin)).toEqual(['A1', 'B1', 'C1']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes through books that are not in any work alongside collapsed ones', async () => {
|
||||||
|
prismaMock.workAsin.findMany.mockResolvedValue([
|
||||||
|
{ asin: 'A1', workId: 'work-1' },
|
||||||
|
{ asin: 'A2', workId: 'work-1' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const { collapseByExistingWorks } = await import('@/lib/services/works.service');
|
||||||
|
|
||||||
|
const books = [
|
||||||
|
makeBook({ asin: 'STANDALONE_1', title: 'Standalone 1' }),
|
||||||
|
makeBook({ asin: 'A1', title: 'Same Book' }),
|
||||||
|
makeBook({ asin: 'STANDALONE_2', title: 'Standalone 2' }),
|
||||||
|
makeBook({ asin: 'A2', title: 'Same Book' }),
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = await collapseByExistingWorks(books);
|
||||||
|
expect(result).toHaveLength(3);
|
||||||
|
expect(result.map(b => b.asin)).toEqual(['STANDALONE_1', 'A1', 'STANDALONE_2']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns input unchanged on DB failure (does not throw)', async () => {
|
||||||
|
prismaMock.workAsin.findMany.mockRejectedValue(new Error('DB exploded'));
|
||||||
|
|
||||||
|
const { collapseByExistingWorks } = await import('@/lib/services/works.service');
|
||||||
|
|
||||||
|
const books = [
|
||||||
|
makeBook({ asin: 'A1' }),
|
||||||
|
makeBook({ asin: 'A2' }),
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = await collapseByExistingWorks(books);
|
||||||
|
expect(result).toEqual(books);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('only queries the workAsin table once per call', async () => {
|
||||||
|
prismaMock.workAsin.findMany.mockResolvedValue([
|
||||||
|
{ asin: 'A1', workId: 'work-1' },
|
||||||
|
{ asin: 'A2', workId: 'work-1' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const { collapseByExistingWorks } = await import('@/lib/services/works.service');
|
||||||
|
|
||||||
|
await collapseByExistingWorks([
|
||||||
|
makeBook({ asin: 'A1' }),
|
||||||
|
makeBook({ asin: 'A2' }),
|
||||||
|
makeBook({ asin: 'A3' }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(prismaMock.workAsin.findMany).toHaveBeenCalledTimes(1);
|
||||||
|
expect(prismaMock.workAsin.findMany).toHaveBeenCalledWith({
|
||||||
|
where: { asin: { in: ['A1', 'A2', 'A3'] } },
|
||||||
|
select: { asin: true, workId: true },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,316 @@
|
|||||||
|
/**
|
||||||
|
* Component: Bulk Import Scanner Tests
|
||||||
|
* Documentation: documentation/features/bulk-import.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import path from 'path';
|
||||||
|
import os from 'os';
|
||||||
|
|
||||||
|
const execMock = vi.hoisted(() => {
|
||||||
|
const mockFn = vi.fn();
|
||||||
|
// util.promisify on child_process.exec resolves to { stdout, stderr }
|
||||||
|
// (via the [util.promisify.custom] symbol). Attach the same shape here so
|
||||||
|
// code that destructures `{ stdout } = await execPromise(...)` works.
|
||||||
|
const customSymbol = Symbol.for('nodejs.util.promisify.custom');
|
||||||
|
(mockFn as unknown as Record<symbol, unknown>)[customSymbol] = (
|
||||||
|
...args: unknown[]
|
||||||
|
) =>
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
mockFn(
|
||||||
|
...args,
|
||||||
|
(err: Error | null, stdout: string, stderr: string) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve({ stdout, stderr });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return mockFn;
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('child_process', () => ({
|
||||||
|
exec: execMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import fs from 'fs/promises';
|
||||||
|
import {
|
||||||
|
buildSearchTerm,
|
||||||
|
cleanSearchString,
|
||||||
|
discoverAudiobooks,
|
||||||
|
extractAsinFromString,
|
||||||
|
} from '@/lib/utils/bulk-import-scanner';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configure the ffprobe mock so each invocation returns canned tags
|
||||||
|
* keyed by the file path embedded in the command string.
|
||||||
|
*/
|
||||||
|
function mockFfprobeByFile(tagsByFile: Record<string, Record<string, string>>) {
|
||||||
|
execMock.mockImplementation(
|
||||||
|
(command: string, options: unknown, callback?: unknown) => {
|
||||||
|
const cb = (typeof options === 'function' ? options : callback) as (
|
||||||
|
err: Error | null,
|
||||||
|
stdout: string,
|
||||||
|
stderr: string,
|
||||||
|
) => void;
|
||||||
|
const match = command.match(/"([^"]+)"\s*$/);
|
||||||
|
const filePath = match ? match[1].replace(/\\/g, '/') : '';
|
||||||
|
const tags = tagsByFile[filePath] ?? {};
|
||||||
|
const payload = JSON.stringify({ format: { tags } });
|
||||||
|
cb(null, payload, '');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('extractAsinFromString', () => {
|
||||||
|
it.each([
|
||||||
|
['parenthesized', 'Stephen King - The Gunslinger (B019NOKST6)', 'B019NOKST6'],
|
||||||
|
['bracketed', 'Some Book [B019NOKST6]', 'B019NOKST6'],
|
||||||
|
['whitespace-separated', 'Some Book B019NOKST6 extra', 'B019NOKST6'],
|
||||||
|
['at start of string', 'B019NOKST6 some title', 'B019NOKST6'],
|
||||||
|
['at end of string', 'some title B019NOKST6', 'B019NOKST6'],
|
||||||
|
['hyphen-delimited', 'Some Book-B019NOKST6-end', 'B019NOKST6'],
|
||||||
|
['lowercase folder name', 'some book (b019nokst6)', 'B019NOKST6'],
|
||||||
|
['mixed case', 'Some Book (b019nOkSt6)', 'B019NOKST6'],
|
||||||
|
])('extracts ASIN from %s', (_label, input, expected) => {
|
||||||
|
expect(extractAsinFromString(input)).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['no ASIN at all', 'Stephen King - The Gunslinger'],
|
||||||
|
['does not start with B', 'Some Book (A019NOKST6)'],
|
||||||
|
['too short', 'Some Book (B019NOKST)'],
|
||||||
|
['too long is rejected by boundary', 'Some Book (B019NOKST6A)'],
|
||||||
|
['embedded in longer alphanumeric word', 'fooB019NOKST6bar'],
|
||||||
|
['not starting with B at all', '0019NOKST6'],
|
||||||
|
])('returns null when %s', (_label, input) => {
|
||||||
|
expect(extractAsinFromString(input)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('cleanSearchString', () => {
|
||||||
|
it('strips a file extension', () => {
|
||||||
|
expect(cleanSearchString('The Gunslinger.m4b')).toBe('The Gunslinger');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips a bracketed ASIN', () => {
|
||||||
|
expect(cleanSearchString('The Gunslinger [B019NOKST6]')).toBe('The Gunslinger');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips a parenthesized ASIN', () => {
|
||||||
|
expect(cleanSearchString('The Gunslinger (B019NOKST6)')).toBe('The Gunslinger');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips a bracketed year', () => {
|
||||||
|
expect(cleanSearchString('The Gunslinger (1982)')).toBe('The Gunslinger');
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['01 - The Gunslinger', 'The Gunslinger'],
|
||||||
|
['001_The Gunslinger', 'The Gunslinger'],
|
||||||
|
['12 The Gunslinger.m4b', 'The Gunslinger'],
|
||||||
|
])('strips leading track number from "%s"', (input, expected) => {
|
||||||
|
expect(cleanSearchString(input)).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts underscores to spaces', () => {
|
||||||
|
expect(cleanSearchString('The_Gunslinger')).toBe('The Gunslinger');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('collapses internal whitespace', () => {
|
||||||
|
expect(cleanSearchString('The Gunslinger Book')).toBe('The Gunslinger Book');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('combines multiple transformations', () => {
|
||||||
|
expect(
|
||||||
|
cleanSearchString('01_The_Gunslinger_[B019NOKST6]_(1982).m4b'),
|
||||||
|
).toBe('The Gunslinger');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildSearchTerm', () => {
|
||||||
|
it('uses tags when title is present (title + author + narrator)', () => {
|
||||||
|
expect(
|
||||||
|
buildSearchTerm(
|
||||||
|
{ title: 'The Gunslinger', author: 'Stephen King', narrator: 'George Guidall' },
|
||||||
|
'whatever.m4b',
|
||||||
|
),
|
||||||
|
).toEqual({
|
||||||
|
searchTerm: 'The Gunslinger Stephen King George Guidall',
|
||||||
|
source: 'tags',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses title alone when no other metadata fields are present', () => {
|
||||||
|
expect(buildSearchTerm({ title: 'The Gunslinger' }, 'whatever.m4b')).toEqual({
|
||||||
|
searchTerm: 'The Gunslinger',
|
||||||
|
source: 'tags',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to folder name when no title and folder is non-generic', () => {
|
||||||
|
expect(
|
||||||
|
buildSearchTerm({}, 'track01.m4b', 'The Gunslinger (B019NOKST6)'),
|
||||||
|
).toEqual({ searchTerm: 'The Gunslinger', source: 'folder_name' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to file name when folder name is generic', () => {
|
||||||
|
expect(buildSearchTerm({}, 'The Gunslinger Chapter 1.m4b', 'CD1')).toEqual({
|
||||||
|
searchTerm: 'The Gunslinger Chapter 1',
|
||||||
|
source: 'file_name',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
'CD1',
|
||||||
|
'CD 1',
|
||||||
|
'cd2',
|
||||||
|
'Disc 2',
|
||||||
|
'disc3',
|
||||||
|
'Disk 4',
|
||||||
|
'DISK 5',
|
||||||
|
'Part 1',
|
||||||
|
'part2',
|
||||||
|
'Vol 1',
|
||||||
|
'vol2',
|
||||||
|
'Volume 3',
|
||||||
|
'VOLUME 99',
|
||||||
|
])('treats "%s" as a generic folder name', (folderName) => {
|
||||||
|
const result = buildSearchTerm({}, 'whatever.m4b', folderName);
|
||||||
|
expect(result.source).toBe('file_name');
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['CD Player', 'Discworld', 'Particle Physics', 'Volumetric Sound'])(
|
||||||
|
'does not treat "%s" as a generic folder name',
|
||||||
|
(folderName) => {
|
||||||
|
const result = buildSearchTerm({}, 'whatever.m4b', folderName);
|
||||||
|
expect(result.source).toBe('folder_name');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it('falls back to file name when no title and no folder is provided', () => {
|
||||||
|
expect(buildSearchTerm({}, '01 - The Gunslinger.m4b')).toEqual({
|
||||||
|
searchTerm: 'The Gunslinger',
|
||||||
|
source: 'file_name',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('discoverAudiobooks integration', () => {
|
||||||
|
let tmpDir: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'rmab-bulk-import-'));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
async function createAudioFiles(dir: string, names: string[]): Promise<void> {
|
||||||
|
await fs.mkdir(dir, { recursive: true });
|
||||||
|
for (const name of names) {
|
||||||
|
await fs.writeFile(path.join(dir, name), '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fwd(p: string): string {
|
||||||
|
return p.replace(/\\/g, '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
it('absorbs untagged files into the single tagged group in the same folder', async () => {
|
||||||
|
const bookDir = path.join(tmpDir, 'The Gunslinger');
|
||||||
|
await createAudioFiles(bookDir, ['01.m4b', '02.m4b', '03.m4b']);
|
||||||
|
|
||||||
|
mockFfprobeByFile({
|
||||||
|
[fwd(path.join(bookDir, '01.m4b'))]: {
|
||||||
|
album: 'The Gunslinger',
|
||||||
|
album_artist: 'Stephen King',
|
||||||
|
},
|
||||||
|
[fwd(path.join(bookDir, '02.m4b'))]: {
|
||||||
|
album: 'The Gunslinger',
|
||||||
|
album_artist: 'Stephen King',
|
||||||
|
},
|
||||||
|
// 03.m4b returns empty tags -> ungrouped, then absorbed
|
||||||
|
});
|
||||||
|
|
||||||
|
const results = await discoverAudiobooks(tmpDir);
|
||||||
|
|
||||||
|
expect(results).toHaveLength(1);
|
||||||
|
expect(results[0].audioFileCount).toBe(3);
|
||||||
|
expect(results[0].audioFiles).toEqual(['01.m4b', '02.m4b', '03.m4b']);
|
||||||
|
expect(results[0].metadata.title).toBe('The Gunslinger');
|
||||||
|
expect(results[0].metadataSource).toBe('tags');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps untagged group separate when multiple tagged groups exist in the same folder', async () => {
|
||||||
|
const mixedDir = path.join(tmpDir, 'Mixed');
|
||||||
|
await createAudioFiles(mixedDir, ['a1.m4b', 'b1.m4b', 'untagged.m4b']);
|
||||||
|
|
||||||
|
mockFfprobeByFile({
|
||||||
|
[fwd(path.join(mixedDir, 'a1.m4b'))]: {
|
||||||
|
album: 'Book A',
|
||||||
|
album_artist: 'Author A',
|
||||||
|
},
|
||||||
|
[fwd(path.join(mixedDir, 'b1.m4b'))]: {
|
||||||
|
album: 'Book B',
|
||||||
|
album_artist: 'Author B',
|
||||||
|
},
|
||||||
|
// untagged.m4b empty
|
||||||
|
});
|
||||||
|
|
||||||
|
const results = await discoverAudiobooks(tmpDir);
|
||||||
|
|
||||||
|
expect(results).toHaveLength(3);
|
||||||
|
const titles = results.map((r) => r.metadata.title).sort();
|
||||||
|
expect(titles).toEqual(['Book A', 'Book B', undefined]);
|
||||||
|
|
||||||
|
const untagged = results.find((r) => !r.metadata.title);
|
||||||
|
expect(untagged?.audioFiles).toEqual(['untagged.m4b']);
|
||||||
|
expect(untagged?.metadataSource).toBe('folder_name');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('re-derives extractedAsin from the common parent on cross-folder merge', async () => {
|
||||||
|
const parentDir = path.join(tmpDir, 'Some Book (B019NOKST6)');
|
||||||
|
const cd1Dir = path.join(parentDir, 'CD1');
|
||||||
|
const cd2Dir = path.join(parentDir, 'CD2');
|
||||||
|
await createAudioFiles(cd1Dir, ['01.m4b']);
|
||||||
|
await createAudioFiles(cd2Dir, ['02.m4b']);
|
||||||
|
|
||||||
|
mockFfprobeByFile({
|
||||||
|
[fwd(path.join(cd1Dir, '01.m4b'))]: {
|
||||||
|
album: 'Some Book',
|
||||||
|
album_artist: 'Some Author',
|
||||||
|
},
|
||||||
|
[fwd(path.join(cd2Dir, '02.m4b'))]: {
|
||||||
|
album: 'Some Book',
|
||||||
|
album_artist: 'Some Author',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const results = await discoverAudiobooks(tmpDir);
|
||||||
|
|
||||||
|
expect(results).toHaveLength(1);
|
||||||
|
const merged = results[0];
|
||||||
|
expect(merged.folderName).toBe('Some Book (B019NOKST6)');
|
||||||
|
expect(merged.extractedAsin).toBe('B019NOKST6');
|
||||||
|
expect(merged.audioFileCount).toBe(2);
|
||||||
|
expect(merged.audioFiles.sort()).toEqual(['CD1/01.m4b', 'CD2/02.m4b']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts ASIN from a single-folder book', async () => {
|
||||||
|
const bookDir = path.join(tmpDir, 'The Gunslinger (B019NOKST6)');
|
||||||
|
await createAudioFiles(bookDir, ['01.m4b']);
|
||||||
|
|
||||||
|
mockFfprobeByFile({
|
||||||
|
[fwd(path.join(bookDir, '01.m4b'))]: {
|
||||||
|
album: 'The Gunslinger',
|
||||||
|
album_artist: 'Stephen King',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const results = await discoverAudiobooks(tmpDir);
|
||||||
|
|
||||||
|
expect(results).toHaveLength(1);
|
||||||
|
expect(results[0].extractedAsin).toBe('B019NOKST6');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
/**
|
||||||
|
* Component: Narrator Extraction Utility Tests
|
||||||
|
* Documentation: documentation/integrations/audible.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import * as cheerio from 'cheerio';
|
||||||
|
import { extractAllNarrators } from '@/lib/utils/extract-narrator';
|
||||||
|
|
||||||
|
function load(html: string) {
|
||||||
|
const $ = cheerio.load(`<div id="item">${html}</div>`);
|
||||||
|
return { $, $el: $('#item') };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('extractAllNarrators', () => {
|
||||||
|
it('returns the single narrator name when only one searchNarrator link is present', () => {
|
||||||
|
const { $, $el } = load(
|
||||||
|
`<a href="/search?searchNarrator=Andy%20Serkis">Andy Serkis</a>`,
|
||||||
|
);
|
||||||
|
expect(extractAllNarrators($, $el)).toBe('Andy Serkis');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('joins multiple narrator names from separate searchNarrator links', () => {
|
||||||
|
const { $, $el } = load(`
|
||||||
|
<a href="/search?searchNarrator=Kristin%20Atherton">Kristin Atherton</a>,
|
||||||
|
<a href="/search?searchNarrator=Roy%20McMillan">Roy McMillan</a>,
|
||||||
|
<a href="/search?searchNarrator=Clare%20Corbett">Clare Corbett</a>,
|
||||||
|
<a href="/search?searchNarrator=Tom%20Bateman">Tom Bateman</a>,
|
||||||
|
<a href="/search?searchNarrator=Patience%20Tomlinson">Patience Tomlinson</a>,
|
||||||
|
<a href="/search?searchNarrator=Shaheen%20Khan">Shaheen Khan</a>
|
||||||
|
`);
|
||||||
|
expect(extractAllNarrators($, $el)).toBe(
|
||||||
|
'Kristin Atherton, Roy McMillan, Clare Corbett, Tom Bateman, Patience Tomlinson, Shaheen Khan',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves document order (downstream sorts before comparing, but order should be stable)', () => {
|
||||||
|
const { $, $el } = load(`
|
||||||
|
<a href="/search?searchNarrator=Z">Zelda</a>
|
||||||
|
<a href="/search?searchNarrator=A">Alice</a>
|
||||||
|
<a href="/search?searchNarrator=M">Mallory</a>
|
||||||
|
`);
|
||||||
|
expect(extractAllNarrators($, $el)).toBe('Zelda, Alice, Mallory');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to .narratorLabel text when no searchNarrator links exist', () => {
|
||||||
|
const { $, $el } = load(
|
||||||
|
`<span class="narratorLabel">Narrated by: Single Narrator</span>`,
|
||||||
|
);
|
||||||
|
expect(extractAllNarrators($, $el)).toBe('Narrated by: Single Narrator');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefers searchNarrator links over .narratorLabel when both are present', () => {
|
||||||
|
const { $, $el } = load(`
|
||||||
|
<span class="narratorLabel">Narrated by: ONLY ONE</span>
|
||||||
|
<a href="/search?searchNarrator=First">First</a>
|
||||||
|
<a href="/search?searchNarrator=Second">Second</a>
|
||||||
|
`);
|
||||||
|
expect(extractAllNarrators($, $el)).toBe('First, Second');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty string when neither links nor .narratorLabel exist', () => {
|
||||||
|
const { $, $el } = load(`<span>some other content</span>`);
|
||||||
|
expect(extractAllNarrators($, $el)).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips empty link text and joins only non-empty names', () => {
|
||||||
|
const { $, $el } = load(`
|
||||||
|
<a href="/search?searchNarrator=A"></a>
|
||||||
|
<a href="/search?searchNarrator=B">Bob</a>
|
||||||
|
<a href="/search?searchNarrator=C"> </a>
|
||||||
|
<a href="/search?searchNarrator=D">Diana</a>
|
||||||
|
`);
|
||||||
|
expect(extractAllNarrators($, $el)).toBe('Bob, Diana');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('trims whitespace from each captured name', () => {
|
||||||
|
const { $, $el } = load(`
|
||||||
|
<a href="/search?searchNarrator=A"> Alice </a>
|
||||||
|
<a href="/search?searchNarrator=B">
|
||||||
|
Bob
|
||||||
|
</a>
|
||||||
|
`);
|
||||||
|
expect(extractAllNarrators($, $el)).toBe('Alice, Bob');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to .narratorLabel when all searchNarrator links are empty', () => {
|
||||||
|
const { $, $el } = load(`
|
||||||
|
<a href="/search?searchNarrator=A"></a>
|
||||||
|
<a href="/search?searchNarrator=B"> </a>
|
||||||
|
<span class="narratorLabel">Fallback Narrator</span>
|
||||||
|
`);
|
||||||
|
expect(extractAllNarrators($, $el)).toBe('Fallback Narrator');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -114,6 +114,72 @@ describe('metadata tagger', () => {
|
|||||||
await expect(checkFfmpegAvailable()).resolves.toBe(false);
|
await expect(checkFfmpegAvailable()).resolves.toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('series metadata', () => {
|
||||||
|
it('writes show/episode_id for m4b when series/seriesPart provided', async () => {
|
||||||
|
fsMock.access.mockResolvedValue(undefined);
|
||||||
|
mockExecSuccess('done');
|
||||||
|
|
||||||
|
await tagAudioFileMetadata('/tmp/book.m4b', {
|
||||||
|
title: 'Book',
|
||||||
|
author: 'Author',
|
||||||
|
series: 'The Mistborn Saga',
|
||||||
|
seriesPart: '1',
|
||||||
|
});
|
||||||
|
|
||||||
|
const command = execMock.mock.calls[0][0] as string;
|
||||||
|
expect(command).toContain('-metadata show="The Mistborn Saga"');
|
||||||
|
expect(command).toContain('-metadata episode_id="1"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes SERIES/SERIES-PART for mp3 when series/seriesPart provided', async () => {
|
||||||
|
fsMock.access.mockResolvedValue(undefined);
|
||||||
|
mockExecSuccess('done');
|
||||||
|
|
||||||
|
await tagAudioFileMetadata('/tmp/book.mp3', {
|
||||||
|
title: 'Book',
|
||||||
|
author: 'Author',
|
||||||
|
series: 'The Mistborn Saga',
|
||||||
|
seriesPart: '1.5',
|
||||||
|
});
|
||||||
|
|
||||||
|
const command = execMock.mock.calls[0][0] as string;
|
||||||
|
expect(command).toContain('-metadata SERIES="The Mistborn Saga"');
|
||||||
|
expect(command).toContain('-metadata SERIES-PART="1.5"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes SERIES/SERIES-PART for flac when series/seriesPart provided', async () => {
|
||||||
|
fsMock.access.mockResolvedValue(undefined);
|
||||||
|
mockExecSuccess('done');
|
||||||
|
|
||||||
|
await tagAudioFileMetadata('/tmp/book.flac', {
|
||||||
|
title: 'Book',
|
||||||
|
author: 'Author',
|
||||||
|
series: 'The Mistborn Saga',
|
||||||
|
seriesPart: '2',
|
||||||
|
});
|
||||||
|
|
||||||
|
const command = execMock.mock.calls[0][0] as string;
|
||||||
|
expect(command).toContain('-metadata SERIES="The Mistborn Saga"');
|
||||||
|
expect(command).toContain('-metadata SERIES-PART="2"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits series tags when fields are absent', async () => {
|
||||||
|
fsMock.access.mockResolvedValue(undefined);
|
||||||
|
mockExecSuccess('done');
|
||||||
|
|
||||||
|
await tagAudioFileMetadata('/tmp/book.m4b', {
|
||||||
|
title: 'Book',
|
||||||
|
author: 'Author',
|
||||||
|
});
|
||||||
|
|
||||||
|
const command = execMock.mock.calls[0][0] as string;
|
||||||
|
expect(command).not.toContain('show=');
|
||||||
|
expect(command).not.toContain('episode_id=');
|
||||||
|
expect(command).not.toContain('SERIES=');
|
||||||
|
expect(command).not.toContain('SERIES-PART=');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('metadata escaping', () => {
|
describe('metadata escaping', () => {
|
||||||
it('does NOT escape single quotes (they are literal in double-quoted shell strings)', async () => {
|
it('does NOT escape single quotes (they are literal in double-quoted shell strings)', async () => {
|
||||||
fsMock.access.mockResolvedValue(undefined);
|
fsMock.access.mockResolvedValue(undefined);
|
||||||
|
|||||||
@@ -67,6 +67,24 @@ describe('jitteredBackoff', () => {
|
|||||||
expect(value).toBeGreaterThanOrEqual(250);
|
expect(value).toBeGreaterThanOrEqual(250);
|
||||||
expect(value).toBeLessThanOrEqual(750);
|
expect(value).toBeLessThanOrEqual(750);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('caps the result at maxBackoffMs when the raw backoff would exceed it', () => {
|
||||||
|
// attempt=10 with base=1000 produces 2^10 * 1000 * [0.5..1.5] = 512_000..1_536_000,
|
||||||
|
// all of which exceed a 60_000ms cap.
|
||||||
|
for (let i = 0; i < 50; i++) {
|
||||||
|
const value = jitteredBackoff(10, 1000, 60_000);
|
||||||
|
expect(value).toBeLessThanOrEqual(60_000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the un-capped jittered value when below the cap', () => {
|
||||||
|
// attempt=0 with base=1000 produces 500..1500, all below a 60_000ms cap.
|
||||||
|
for (let i = 0; i < 50; i++) {
|
||||||
|
const value = jitteredBackoff(0, 1000, 60_000);
|
||||||
|
expect(value).toBeGreaterThanOrEqual(500);
|
||||||
|
expect(value).toBeLessThanOrEqual(1500);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('randomDelay', () => {
|
describe('randomDelay', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user