Compare commits

..

23 Commits

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

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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 21:19:46 -04:00
TylerNorris214 edecda9e64 Add series and seriesPart to metadata tagging 2026-05-05 21:00:38 -05:00
TylerNorris214 6b76932a0a Add series and seriesPart to audiobook metadata 2026-05-05 20:59:12 -05:00
Chris Busillo 02b636e5b8 fix plex home profile login redirect 2026-05-04 13:41:53 -04:00
H0tChicken 37f063229c fix: use BigInt for PlexLibrary.duration to prevent INT4 overflow
The duration column (Int/int4, max ~2.15B) overflows when storing
millisecond values for items with large durations from Audiobookshelf
or Plex backends. Change to BigInt (int8) and wrap duration calculations
in BigInt() at the Prisma write boundary.

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

Fixes #193

Co-Authored-By: Oz <oz-agent@warp.dev>
2026-05-04 00:32:09 +00:00
33 changed files with 1882 additions and 88 deletions
+23
View File
@@ -99,6 +99,29 @@ if [ "$READY" = "false" ]; then
echo "[App] The scheduler will not be initialized - scheduled jobs may be missing"
echo "[App] Check server logs above for errors (database connection, port conflict, etc.)"
else
# =========================================================================
# WAIT FOR REDIS TO FINISH LOADING (internal Redis only)
# =========================================================================
# Redis returns "LOADING Redis is loading the dataset in memory" while it
# replays its AOF/RDB on startup. /api/health only checks Postgres, so it
# passes before Redis is actually ready to accept commands. Without this
# wait, /api/init kicks off Bull queues that flood the log with LOADING
# errors until the retry loop catches up.
if [ "$USE_EXTERNAL_REDIS" != "true" ]; then
REDIS_READY_TIMEOUT=${REDIS_READY_TIMEOUT:-60}
echo "[App] Waiting for Redis to finish loading (timeout: ${REDIS_READY_TIMEOUT}s)..."
for i in $(seq 1 "$REDIS_READY_TIMEOUT"); do
if redis-cli -h 127.0.0.1 -p 6379 ping 2>/dev/null | grep -q '^PONG$'; then
echo "[App] Redis is ready (took ${i}s)"
break
fi
if [ "$i" -eq "$REDIS_READY_TIMEOUT" ]; then
echo "[App] WARNING: Redis did not become ready within ${REDIS_READY_TIMEOUT}s - proceeding anyway"
fi
sleep 1
done
fi
# =========================================================================
# INITIALIZE APPLICATION SERVICES
# =========================================================================
+3
View File
@@ -8,6 +8,7 @@
- **Admin-generated login token per user (URL-login)** → [backend/services/auth.md](backend/services/auth.md)
- **Route protection, auth guards** → [frontend/routing-auth.md](frontend/routing-auth.md)
- **Login page UI/UX** → [frontend/pages/login.md](frontend/pages/login.md)
- **Credential recovery (lost CONFIG_ENCRYPTION_KEY, locked-out admin)** → [admin-features/credential-recovery.md](admin-features/credential-recovery.md)
## Configuration & Setup
- **First-time setup wizard** → [setup-wizard.md](setup-wizard.md)
@@ -143,6 +144,8 @@
**"What's the database schema?"** → [backend/database.md](backend/database.md)
**"How does authentication work?"** → [backend/services/auth.md](backend/services/auth.md)
**"How do I change my password?"** → [backend/services/auth.md](backend/services/auth.md) (local users only - accessed via user menu in header)
**"Local admin can't log in / 'Invalid username or password' with correct credentials"** → [admin-features/credential-recovery.md](admin-features/credential-recovery.md)
**"How do I recover from a lost CONFIG_ENCRYPTION_KEY?"** → [admin-features/credential-recovery.md](admin-features/credential-recovery.md)
**"How do I delete requests?"** → [admin-features/request-deletion.md](admin-features/request-deletion.md)
**"How do I approve/deny user requests?"** → [admin-features/request-approval.md](admin-features/request-approval.md)
**"How do I enable auto-approve for requests?"** → [admin-features/request-approval.md](admin-features/request-approval.md)
@@ -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
+1 -1
View File
@@ -35,7 +35,7 @@ PostgreSQL database storing users, audiobooks, requests, downloads, configuratio
### Plex_Library (Library Cache)
- `id` (UUID PK), `plex_guid` (unique, external ID from Plex or Audiobookshelf), `plex_rating_key`
- `title`, `author`, `narrator`, `summary`, `duration` (milliseconds), `year`, `user_rating` (0-10 scale)
- `title`, `author`, `narrator`, `summary`, `duration` (BigInt, milliseconds), `year`, `user_rating` (0-10 scale)
- **Universal identifiers:** `asin` (Audible ASIN), `isbn` (ISBN-10 or ISBN-13)
- `file_path`, `thumb_url`, `cached_library_cover_path` (local cached cover path), `plex_library_id`, `added_at`
- `last_scanned_at`, `created_at`, `updated_at`
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "readmeabook",
"version": "1.0.15",
"version": "1.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "readmeabook",
"version": "1.0.15",
"version": "1.2.0",
"dependencies": {
"@heroicons/react": "^2.2.0",
"@prisma/client": "^6.19.0",
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "readmeabook",
"version": "1.1.8",
"version": "1.2.0",
"private": true,
"scripts": {
"dev": "next dev",
@@ -13,7 +13,8 @@
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev",
"prisma:studio": "prisma studio",
"db:push": "prisma db push"
"db:push": "prisma db push",
"rmab:recover": "node scripts/recover-credentials.js"
},
"dependencies": {
"@heroicons/react": "^2.2.0",
+1 -1
View File
@@ -132,7 +132,7 @@ model PlexLibrary {
author String
narrator String?
summary String? @db.Text
duration Int? // Duration in milliseconds (Plex format)
duration BigInt? // Duration in milliseconds (Plex format)
year Int?
userRating Decimal? @map("user_rating") @db.Decimal(3, 1) // User's rating (0-10 scale from Plex)
+772
View File
@@ -0,0 +1,772 @@
/**
* Component: Credential Recovery Script
* Documentation: documentation/admin-features/credential-recovery.md
*
* Interactive recovery for lost CONFIG_ENCRYPTION_KEY or forgotten local admin password.
* Run inside the container with: docker exec -it <container> npm run rmab:recover
*
* Hard rules:
* - No CLI arguments accepted. All input via interactive prompts.
* - Never log password or key values.
* - All DB mutations inside a single transaction.
* - File writes happen only after DB commit succeeds.
*/
'use strict';
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const readline = require('readline');
const bcrypt = require('bcrypt');
const SECRETS_FILE = '/app/config/.secrets';
const ENVIRONMENT_FILE = '/etc/environment';
const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 16;
const KEY_LENGTH = 32;
const ENCRYPTED_CONFIG_KEYS_FOR_PROBE = [
'plex_token',
'prowlarr_api_key',
'audiobookshelf.api_token',
'oidc.client_secret',
];
// ---------------------------------------------------------------------------
// Env loading
// ---------------------------------------------------------------------------
// docker exec doesn't inherit runtime-generated env vars, and /etc/environment
// can drift from what the running app process is actually using (e.g. if
// .secrets was regenerated on a restart while the existing pg_user kept its
// original password). The source of truth is the live node process's
// /proc/<pid>/environ — read that first, then fall back to files.
// ---------------------------------------------------------------------------
const WANTED_ENV_KEYS = [
'DATABASE_URL',
'CONFIG_ENCRYPTION_KEY',
'POSTGRES_PASSWORD',
'POSTGRES_USER',
'POSTGRES_DB',
'ALLOW_WEAK_PASSWORD',
];
const envSource = {}; // key -> short label of where it came from
// The dockerfile bakes ENV DATABASE_URL=<this> at build time so prisma generate
// has a valid URL; the entrypoint overrides at runtime. But if the override
// didn't propagate to the child process inheriting via docker exec, we see
// this exact dummy value. Never trust it.
const DUMMY_DB_URL = 'postgresql://dummy:dummy@localhost:5432/dummy?schema=public';
function isUsableValue(key, value) {
if (value == null || value === '') return false;
if (key === 'DATABASE_URL' && value === DUMMY_DB_URL) return false;
if (key === 'DATABASE_URL' && /^postgresql:\/\/dummy:dummy@/.test(value)) return false;
return true;
}
function setIfMissing(key, value, sourceLabel) {
if (!isUsableValue(key, value)) return;
if (!isUsableValue(key, process.env[key])) {
process.env[key] = value;
envSource[key] = sourceLabel;
}
}
// Wipe inherited dummy URL up front so file/proc sources have a clean slate.
if (process.env.DATABASE_URL && !isUsableValue('DATABASE_URL', process.env.DATABASE_URL)) {
delete process.env.DATABASE_URL;
}
function loadEnvFromFile(filePath, sourceLabel) {
if (!fs.existsSync(filePath)) return;
let contents;
try {
contents = fs.readFileSync(filePath, 'utf8');
} catch (_err) {
return;
}
for (const rawLine of contents.split('\n')) {
const line = rawLine.trim();
if (!line || line.startsWith('#')) continue;
const eq = line.indexOf('=');
if (eq === -1) continue;
const key = line.slice(0, eq).trim();
let value = line.slice(eq + 1).trim();
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
setIfMissing(key, value, sourceLabel);
}
}
function loadEnvFromRunningProcess() {
// Walk every readable /proc/<pid>/environ. Pick the first process whose
// environ contains a non-empty DATABASE_URL. Do NOT filter by comm name —
// the app may run under gosu, npm, next-server, etc.
let procDir;
try {
procDir = fs.readdirSync('/proc');
} catch (_err) {
return null;
}
const ownPid = String(process.pid);
for (const entry of procDir) {
if (!/^\d+$/.test(entry)) continue;
if (entry === ownPid) continue;
let environBuf;
try {
environBuf = fs.readFileSync(`/proc/${entry}/environ`);
} catch (_err) {
// environ may be mode 400 owned by another user; skip silently.
continue;
}
if (!environBuf || environBuf.length === 0) continue;
const pairs = environBuf.toString('utf8').split('\u0000');
const collected = {};
for (const p of pairs) {
const eq = p.indexOf('=');
if (eq === -1) continue;
collected[p.slice(0, eq)] = p.slice(eq + 1);
}
if (!collected.DATABASE_URL) continue;
let comm = '';
try {
comm = fs.readFileSync(`/proc/${entry}/comm`, 'utf8').trim();
} catch (_e) {}
const label = `pid ${entry}${comm ? ` (${comm})` : ''}`;
for (const k of WANTED_ENV_KEYS) {
if (collected[k]) setIfMissing(k, collected[k], label);
}
return label;
}
return null;
}
// Priority order: /etc/environment (entrypoint's persisted authoritative state)
// > /app/config/.secrets (persisted keys) > /proc/<pid>/environ (running process).
// The inherited docker-exec env was already wiped of the dummy URL above.
loadEnvFromFile(ENVIRONMENT_FILE, '/etc/environment');
loadEnvFromFile(SECRETS_FILE, '/app/config/.secrets');
const liveProcPid = loadEnvFromRunningProcess();
// Last resort: construct DATABASE_URL from POSTGRES_PASSWORD + sensible defaults,
// mirroring what entrypoint.sh does. Works as long as POSTGRES_PASSWORD was
// recoverable from .secrets or another source.
function urlEncodePassword(s) {
// Match entrypoint.sh urlencode(): everything except [-_.~a-zA-Z0-9] is %xx.
return Array.from(s).map((c) => {
if (/[-_.~a-zA-Z0-9]/.test(c)) return c;
return '%' + c.charCodeAt(0).toString(16).padStart(2, '0');
}).join('');
}
if (!isUsableValue('DATABASE_URL', process.env.DATABASE_URL) && process.env.POSTGRES_PASSWORD) {
const user = process.env.POSTGRES_USER || 'readmeabook';
const db = process.env.POSTGRES_DB || 'readmeabook';
const host = '127.0.0.1';
const port = '5432';
const encoded = urlEncodePassword(process.env.POSTGRES_PASSWORD);
process.env.DATABASE_URL = `postgresql://${user}:${encoded}@${host}:${port}/${db}`;
envSource.DATABASE_URL = 'constructed from POSTGRES_PASSWORD + defaults';
}
// ---------------------------------------------------------------------------
// Encryption helpers (mirrors src/lib/services/encryption.service.ts)
// ---------------------------------------------------------------------------
function deriveKey(rawKey) {
if (!rawKey) {
throw new Error('CONFIG_ENCRYPTION_KEY is not set');
}
if (rawKey.length < KEY_LENGTH) {
const buf = Buffer.alloc(KEY_LENGTH);
Buffer.from(rawKey).copy(buf);
return buf;
}
if (rawKey.length > KEY_LENGTH) {
return Buffer.from(rawKey).subarray(0, KEY_LENGTH);
}
return Buffer.from(rawKey);
}
function decryptWithKey(encryptedData, keyBuffer) {
const parts = String(encryptedData || '').split(':');
if (parts.length !== 3) throw new Error('Invalid encrypted data format');
const iv = Buffer.from(parts[0], 'base64');
const authTag = Buffer.from(parts[1], 'base64');
const decipher = crypto.createDecipheriv(ALGORITHM, keyBuffer, iv);
decipher.setAuthTag(authTag);
let decrypted = decipher.update(parts[2], 'base64', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
}
function encryptWithKey(plaintext, keyBuffer) {
const iv = crypto.randomBytes(IV_LENGTH);
const cipher = crypto.createCipheriv(ALGORITHM, keyBuffer, iv);
let encrypted = cipher.update(plaintext, 'utf8', 'base64');
encrypted += cipher.final('base64');
const authTag = cipher.getAuthTag();
return `${iv.toString('base64')}:${authTag.toString('base64')}:${encrypted}`;
}
function tryDecrypt(encryptedData, keyBuffer) {
try {
return { ok: true, value: decryptWithKey(encryptedData, keyBuffer) };
} catch (err) {
return { ok: false, error: err };
}
}
function generateNewKey() {
return crypto.randomBytes(KEY_LENGTH).toString('base64');
}
// ---------------------------------------------------------------------------
// Prompt helpers
// ---------------------------------------------------------------------------
function ask(rl, question) {
return new Promise((resolve) => rl.question(question, (answer) => resolve(answer)));
}
function askHidden(question) {
return new Promise((resolve, reject) => {
if (!process.stdin.isTTY) {
reject(new Error('Interactive password input requires a TTY. Run with: docker exec -it ...'));
return;
}
process.stdout.write(question);
const stdin = process.stdin;
const wasRaw = stdin.isRaw;
stdin.setRawMode(true);
stdin.resume();
stdin.setEncoding('utf8');
let buffer = '';
const onData = (chunk) => {
for (const ch of chunk) {
if (ch === '\u0003') {
// Ctrl+C
stdin.setRawMode(wasRaw);
stdin.pause();
stdin.removeListener('data', onData);
process.stdout.write('\n');
reject(new Error('Cancelled by user'));
return;
}
if (ch === '\r' || ch === '\n') {
stdin.setRawMode(wasRaw);
stdin.pause();
stdin.removeListener('data', onData);
process.stdout.write('\n');
resolve(buffer);
return;
}
if (ch === '\u007f' || ch === '\b') {
if (buffer.length > 0) {
buffer = buffer.slice(0, -1);
process.stdout.write('\b \b');
}
continue;
}
if (ch < ' ') continue;
buffer += ch;
process.stdout.write('*');
}
};
stdin.on('data', onData);
});
}
// ---------------------------------------------------------------------------
// .secrets / /etc/environment file updates
// ---------------------------------------------------------------------------
function updateKeyInFile(filePath, keyName, newValue, quoted) {
if (!fs.existsSync(filePath)) {
fs.writeFileSync(
filePath,
`${keyName}=${quoted ? `"${newValue}"` : newValue}\n`,
{ mode: 0o600 }
);
return { created: true, replaced: false };
}
const original = fs.readFileSync(filePath, 'utf8');
const lines = original.split('\n');
let replaced = false;
const updated = lines.map((line) => {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) return line;
const eq = trimmed.indexOf('=');
if (eq === -1) return line;
const name = trimmed.slice(0, eq).trim();
if (name !== keyName) return line;
replaced = true;
return `${keyName}=${quoted ? `"${newValue}"` : newValue}`;
});
if (!replaced) {
if (updated[updated.length - 1] === '') {
updated[updated.length - 1] = `${keyName}=${quoted ? `"${newValue}"` : newValue}`;
updated.push('');
} else {
updated.push(`${keyName}=${quoted ? `"${newValue}"` : newValue}`);
}
}
fs.writeFileSync(filePath, updated.join('\n'));
return { created: false, replaced };
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main() {
// Reject any CLI args by design.
if (process.argv.length > 2) {
console.error('This script does not accept CLI arguments. All input is via interactive prompts.');
console.error('Run: docker exec -it <container> npm run rmab:recover');
process.exit(2);
}
console.log('');
console.log('================================================================');
console.log(' ReadMeABook — Credential Recovery');
console.log('================================================================');
console.log('');
console.log('Use when local login fails with "Invalid username or password"');
console.log('despite known-correct credentials. See:');
console.log(' documentation/admin-features/credential-recovery.md');
console.log('');
// Diagnostic: where did we resolve env vars from?
const dbSrc = envSource.DATABASE_URL || (process.env.DATABASE_URL ? 'inherited' : 'NOT FOUND');
const keySrc = envSource.CONFIG_ENCRYPTION_KEY || (process.env.CONFIG_ENCRYPTION_KEY ? 'inherited' : 'NOT FOUND');
console.log('Environment:');
console.log(` Live process w/ DATABASE_URL: ${liveProcPid || 'none found'}`);
console.log(` DATABASE_URL source: ${dbSrc}`);
console.log(` CONFIG_ENCRYPTION_KEY src: ${keySrc}`);
if (process.env.DATABASE_URL) {
const redacted = String(process.env.DATABASE_URL).replace(/(:\/\/[^:]+:)[^@]+(@)/, '$1***$2');
console.log(` DATABASE_URL (redacted): ${redacted}`);
}
console.log('');
if (!process.env.DATABASE_URL) {
console.error('ERROR: DATABASE_URL is not set and could not be loaded from any source.');
console.error(' Tried: /proc/<pid>/environ of running node process,');
console.error(' /etc/environment, /app/config/.secrets');
console.error(' Workaround: docker exec -it -e DATABASE_URL="<your url>" <container> npm run rmab:recover');
process.exit(1);
}
if (!process.env.CONFIG_ENCRYPTION_KEY) {
console.error('ERROR: CONFIG_ENCRYPTION_KEY is not set and could not be loaded from any source.');
console.error(' Tried: /proc/<pid>/environ of running node process,');
console.error(' /etc/environment, /app/config/.secrets');
process.exit(1);
}
const currentKey = deriveKey(process.env.CONFIG_ENCRYPTION_KEY);
// Load Prisma client (generated in container at src/generated/prisma)
let PrismaClient;
try {
({ PrismaClient } = require(path.join(__dirname, '..', 'src', 'generated', 'prisma', 'client')));
} catch (err) {
try {
({ PrismaClient } = require('@prisma/client'));
} catch (innerErr) {
console.error('ERROR: Could not load Prisma client. Tried generated path and @prisma/client.');
console.error(' Generated path error:', err.message);
console.error(' Package error: ', innerErr.message);
process.exit(1);
}
}
const prisma = new PrismaClient();
try {
// -------------------------------------------------------------------------
// Diagnose key health
// -------------------------------------------------------------------------
console.log('Step 1/5 — Diagnosing encryption key health...');
const encryptedRows = await prisma.configuration.findMany({
where: { encrypted: true },
});
let keyWorks = null; // null = unknown (no probe rows)
let probedKey = null;
for (const row of encryptedRows) {
if (!row.value) continue;
const result = tryDecrypt(row.value, currentKey);
if (result.ok) {
keyWorks = true;
probedKey = row.key;
break;
}
if (keyWorks === null) keyWorks = false;
}
if (keyWorks === true) {
console.log(` Key works (verified against Configuration row "${probedKey}").`);
} else if (keyWorks === false) {
console.log(` Key DOES NOT work — none of the ${encryptedRows.length} encrypted Configuration rows decrypt.`);
} else {
console.log(' No encrypted Configuration rows exist yet — defaulting to password-reset-only mode.');
}
// -------------------------------------------------------------------------
// List local users
// -------------------------------------------------------------------------
console.log('');
console.log('Step 2/5 — Selecting local user to reset...');
const localUsers = await prisma.user.findMany({
where: { authProvider: 'local', deletedAt: null },
select: {
id: true,
plexUsername: true,
plexId: true,
role: true,
isSetupAdmin: true,
authToken: true,
},
orderBy: [{ isSetupAdmin: 'desc' }, { plexUsername: 'asc' }],
});
if (localUsers.length === 0) {
console.error('');
console.error('ERROR: No local users exist in the database.');
console.error(' Use the setup wizard / registration page to create one instead.');
process.exit(1);
}
console.log('');
console.log(' Local users:');
for (const u of localUsers) {
const tag = [u.role];
if (u.isSetupAdmin) tag.push('setup-admin');
console.log(` - ${u.plexUsername} [${tag.join(', ')}]`);
}
console.log('');
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
let chosenUser = null;
while (!chosenUser) {
const typed = (await ask(rl, ' Username to reset: ')).trim().toLowerCase();
if (!typed) continue;
chosenUser = localUsers.find((u) => u.plexUsername === typed);
if (!chosenUser) {
console.log(` No local user named "${typed}". Try again, or Ctrl+C to abort.`);
}
}
// -------------------------------------------------------------------------
// New password
// -------------------------------------------------------------------------
console.log('');
console.log('Step 3/5 — New password...');
const allowWeak = process.env.ALLOW_WEAK_PASSWORD === 'true';
const minLen = allowWeak ? 1 : 8;
let newPassword = null;
while (!newPassword) {
rl.pause();
const a = await askHidden(' New password: ');
const b = await askHidden(' Confirm new password: ');
rl.resume();
if (a !== b) {
console.log(' Passwords did not match. Try again.');
continue;
}
if (a.length < minLen) {
console.log(` Password must be at least ${minLen} character(s). Try again.`);
continue;
}
newPassword = a;
}
// -------------------------------------------------------------------------
// Build the plan
// -------------------------------------------------------------------------
console.log('');
console.log('Step 4/5 — Plan...');
console.log('');
const fullRecovery = keyWorks === false;
if (fullRecovery) {
console.log(' MODE: FULL RECOVERY (encryption key is unrecoverable)');
console.log('');
console.log(' The following will happen, atomically:');
console.log(` 1. A new CONFIG_ENCRYPTION_KEY will be generated.`);
console.log(` 2. User "${chosenUser.plexUsername}" will get a new password (bcrypt + new key).`);
console.log(' 3. Every Configuration row with encrypted=true will be tried with the OLD key:');
console.log(' - If it decrypts: re-encrypted with the new key (preserved).');
console.log(' - If it cannot decrypt: DELETED (must be re-entered in Settings).');
console.log(' 4. download_clients JSON: each per-client password tried with OLD key:');
console.log(' - Decryptable: re-encrypted with new key.');
console.log(' - Not decryptable: blanked. URL, host, name, etc. preserved.');
console.log(' 5. User.authToken for every user tried with OLD key:');
console.log(' - Decryptable: re-encrypted with new key.');
console.log(' - Not decryptable: cleared. Plex/OIDC users re-OAuth on next login.');
console.log(' 6. /app/config/.secrets and /etc/environment updated with the new key.');
console.log('');
console.log(' Likely to need re-entering in Settings after this completes:');
console.log(' - Plex auth token (or just re-login with Plex)');
console.log(' - Audiobookshelf API token (if used)');
console.log(' - Prowlarr API key');
console.log(' - OIDC client secret (if used)');
console.log(' - Download client passwords (per client)');
console.log(' - Any AI / Hardcover / Goodreads / notification provider secrets');
console.log('');
console.log(' Survives untouched:');
console.log(' - All requests + request history');
console.log(' - Library mappings, organization templates, schedules');
console.log(' - User accounts (just credentials cleared)');
console.log(' - Non-encrypted config (paths, log level, backend mode, etc.)');
console.log('');
console.log(' Container restart REQUIRED after this completes.');
} else {
console.log(' MODE: PASSWORD RESET ONLY (encryption key is healthy)');
console.log('');
console.log(` Only one change: user "${chosenUser.plexUsername}" gets a new password.`);
console.log(' Everything else (all credentials, all settings) untouched.');
console.log(' No container restart needed.');
}
console.log('');
const confirm = (await ask(rl, " Type 'confirm' to proceed (anything else aborts): ")).trim();
if (confirm !== 'confirm') {
console.log(' Aborted. No changes made.');
rl.close();
await prisma.$disconnect();
process.exit(0);
}
rl.close();
// -------------------------------------------------------------------------
// Execute
// -------------------------------------------------------------------------
console.log('');
console.log('Step 5/5 — Applying changes...');
let summary;
let newKeyBase64 = null;
let newKeyBuffer = currentKey;
if (fullRecovery) {
newKeyBase64 = generateNewKey();
newKeyBuffer = deriveKey(newKeyBase64);
// Plan mutations in memory using OLD key for reads, NEW key for writes.
const configUpdates = [];
const configDeletes = [];
let downloadClientsUpdate = null;
const userUpdates = [];
// Configuration rows
for (const row of encryptedRows) {
if (!row.value) {
configDeletes.push(row.key);
continue;
}
const decrypted = tryDecrypt(row.value, currentKey);
if (decrypted.ok) {
configUpdates.push({ key: row.key, value: encryptWithKey(decrypted.value, newKeyBuffer) });
} else {
configDeletes.push(row.key);
}
}
// download_clients JSON (not marked encrypted=true at row level)
const dcRow = await prisma.configuration.findUnique({ where: { key: 'download_clients' } });
if (dcRow && dcRow.value) {
try {
const clients = JSON.parse(dcRow.value);
let touched = 0;
let cleared = 0;
if (Array.isArray(clients)) {
for (const client of clients) {
if (!client || !client.password) continue;
const decrypted = tryDecrypt(client.password, currentKey);
if (decrypted.ok) {
client.password = encryptWithKey(decrypted.value, newKeyBuffer);
touched++;
} else {
client.password = '';
cleared++;
}
}
downloadClientsUpdate = { value: JSON.stringify(clients), touched, cleared };
}
} catch (err) {
console.log(` WARNING: download_clients JSON unparseable, leaving as-is: ${err.message}`);
}
}
// User auth tokens (except the chosen user, whose token will be overwritten)
const allUsers = await prisma.user.findMany({
where: { deletedAt: null },
select: { id: true, authToken: true, authProvider: true },
});
for (const u of allUsers) {
if (u.id === chosenUser.id) continue;
if (!u.authToken) continue;
const decrypted = tryDecrypt(u.authToken, currentKey);
if (decrypted.ok) {
userUpdates.push({ id: u.id, authToken: encryptWithKey(decrypted.value, newKeyBuffer) });
} else {
userUpdates.push({ id: u.id, authToken: '' });
}
}
// Chosen user — fresh bcrypt encrypted with new key
const newHash = await bcrypt.hash(newPassword, 10);
const encryptedHash = encryptWithKey(newHash, newKeyBuffer);
// Apply atomically
summary = await prisma.$transaction(async (tx) => {
const result = {
configRotated: configUpdates.length,
configDeleted: configDeletes.length,
downloadClients: downloadClientsUpdate
? { touched: downloadClientsUpdate.touched, cleared: downloadClientsUpdate.cleared }
: null,
usersRotated: 0,
usersCleared: 0,
};
for (const u of configUpdates) {
await tx.configuration.update({ where: { key: u.key }, data: { value: u.value } });
}
for (const key of configDeletes) {
await tx.configuration.delete({ where: { key } });
}
if (downloadClientsUpdate) {
await tx.configuration.update({
where: { key: 'download_clients' },
data: { value: downloadClientsUpdate.value },
});
}
for (const u of userUpdates) {
await tx.user.update({ where: { id: u.id }, data: { authToken: u.authToken } });
if (u.authToken === '') result.usersCleared++;
else result.usersRotated++;
}
await tx.user.update({
where: { id: chosenUser.id },
data: { authToken: encryptedHash, lastLoginAt: null },
});
return result;
});
} else {
// Simple password reset, current key preserved
const newHash = await bcrypt.hash(newPassword, 10);
const encryptedHash = encryptWithKey(newHash, currentKey);
await prisma.$transaction(async (tx) => {
await tx.user.update({
where: { id: chosenUser.id },
data: { authToken: encryptedHash, lastLoginAt: null },
});
});
summary = null;
}
// -------------------------------------------------------------------------
// Post-commit: file writes (only on full recovery)
// -------------------------------------------------------------------------
let fileWriteFailed = false;
if (fullRecovery) {
try {
updateKeyInFile(SECRETS_FILE, 'CONFIG_ENCRYPTION_KEY', newKeyBase64, true);
} catch (err) {
fileWriteFailed = true;
console.error(` ERROR writing ${SECRETS_FILE}: ${err.message}`);
}
try {
updateKeyInFile(ENVIRONMENT_FILE, 'CONFIG_ENCRYPTION_KEY', newKeyBase64, false);
} catch (err) {
fileWriteFailed = true;
console.error(` ERROR writing ${ENVIRONMENT_FILE}: ${err.message}`);
}
}
// -------------------------------------------------------------------------
// Summary
// -------------------------------------------------------------------------
console.log('');
console.log('================================================================');
console.log(' Recovery complete.');
console.log('================================================================');
console.log('');
console.log(` User reset: ${chosenUser.plexUsername}`);
if (fullRecovery && summary) {
console.log(` Configuration rows re-encrypted: ${summary.configRotated}`);
console.log(` Configuration rows deleted: ${summary.configDeleted}`);
if (summary.downloadClients) {
console.log(` download_clients passwords re-encrypted: ${summary.downloadClients.touched}`);
console.log(` download_clients passwords cleared: ${summary.downloadClients.cleared}`);
}
console.log(` User tokens re-encrypted: ${summary.usersRotated}`);
console.log(` User tokens cleared: ${summary.usersCleared}`);
console.log('');
if (fileWriteFailed) {
console.log(' ⚠️ Could not persist the new key to .secrets / /etc/environment.');
console.log(' ⚠️ The new key is printed ONCE below. Write it into /app/config/.secrets:');
console.log('');
console.log(` CONFIG_ENCRYPTION_KEY="${newKeyBase64}"`);
console.log('');
console.log(' ⚠️ And into /etc/environment (without quotes):');
console.log('');
console.log(` CONFIG_ENCRYPTION_KEY=${newKeyBase64}`);
console.log('');
} else {
console.log(' New CONFIG_ENCRYPTION_KEY persisted to /app/config/.secrets and /etc/environment.');
}
console.log('');
console.log(' NEXT STEPS:');
console.log(' 1. Restart the container.');
console.log(` 2. Log in as "${chosenUser.plexUsername}" with the new password.`);
console.log(' 3. Re-enter cleared credentials in Settings (Plex, Prowlarr, etc.).');
} else {
console.log(' Encryption key was healthy — only the password was reset.');
console.log(` Log in as "${chosenUser.plexUsername}" with the new password. No restart needed.`);
}
console.log('');
} catch (err) {
console.error('');
console.error('ERROR: Recovery aborted.');
console.error(` ${err.message}`);
console.error('');
const msg = String(err && err.message ? err.message : '');
if (
msg.includes('was denied access') ||
msg.includes('P1010') ||
msg.includes('password authentication')
) {
console.error('Diagnosis: Postgres rejected the credentials in DATABASE_URL.');
console.error('This usually means /etc/environment or .secrets drifted from what the running');
console.error('app process is actually using (common after a container restart where .secrets');
console.error('was regenerated but the existing Postgres user kept its original password).');
console.error('');
console.error('Try one of:');
console.error(' 1. Restart the container so the entrypoint resyncs all env files, then re-run.');
console.error(' 2. Pass DATABASE_URL explicitly:');
console.error(' docker exec -it \\');
console.error(" -e DATABASE_URL=\"$(docker exec <container> cat /proc/1/environ \\");
console.error(" | tr '\\0' '\\n' | grep ^DATABASE_URL= | cut -d= -f2-)\" \\");
console.error(' <container> npm run rmab:recover');
}
console.error('');
console.error('No changes have been committed (or the DB transaction was rolled back).');
process.exitCode = 1;
} finally {
try {
await prisma.$disconnect();
} catch (_e) {
// ignore
}
}
}
main();
@@ -12,6 +12,8 @@ import { createPortal } from 'react-dom';
import { InteractiveTorrentSearchModal } from '@/components/requests/InteractiveTorrentSearchModal';
import { AdjustSearchTermsModal } from './AdjustSearchTermsModal';
import { useSmartDropdownPosition } from '@/hooks/useSmartDropdownPosition';
import { ConfirmModal } from '@/components/ui/ConfirmModal';
import { CANCELLABLE_STATUSES } from '@/lib/constants/request-statuses';
export interface RequestActionsDropdownProps {
request: {
@@ -54,8 +56,12 @@ export function RequestActionsDropdown({
const [showInteractiveSearch, setShowInteractiveSearch] = useState(false);
const [showInteractiveSearchEbook, setShowInteractiveSearchEbook] = useState(false);
const [showAdjustSearchTerms, setShowAdjustSearchTerms] = useState(false);
const [confirmCancelOpen, setConfirmCancelOpen] = useState(false);
const [isCancelling, setIsCancelling] = useState(false);
const { containerRef, dropdownRef, positionAbove, style } = useSmartDropdownPosition(isOpen);
const isAwaitingApproval = request.status === 'awaiting_approval';
// Determine request type
const isEbook = request.type === 'ebook';
@@ -66,7 +72,7 @@ export function RequestActionsDropdown({
const canSearch = ['pending', 'failed', 'awaiting_search'].includes(request.status);
const canAdjustSearchTerms = ['pending', 'failed', 'awaiting_search', 'searching'].includes(request.status);
const canRetryDownload = request.status === 'failed' && (request.downloadAttempts ?? 0) > 0 && !!onRetryDownload;
const canCancel = ['pending', 'searching', 'downloading', 'awaiting_search'].includes(request.status);
const canCancel = (CANCELLABLE_STATUSES as readonly string[]).includes(request.status);
const canDelete = true; // Admins can always delete
// View Source: For ebooks, extract MD5 from slow download URL and link to Anna's Archive
@@ -157,14 +163,21 @@ export function RequestActionsDropdown({
}
};
const handleCancel = async () => {
const handleCancel = () => {
setIsOpen(false);
if (window.confirm(`Are you sure you want to cancel the request for "${request.title}"?`)) {
try {
await onCancel(request.requestId);
} catch (error) {
console.error('Failed to cancel request:', error);
}
setConfirmCancelOpen(true);
};
const handleConfirmCancel = async () => {
setIsCancelling(true);
try {
await onCancel(request.requestId);
setConfirmCancelOpen(false);
} catch (error) {
console.error('Failed to cancel request:', error);
setConfirmCancelOpen(false);
} finally {
setIsCancelling(false);
}
};
@@ -529,6 +542,22 @@ export function RequestActionsDropdown({
currentSearchTerms={request.customSearchTerms}
onSuccess={onSearchTermsUpdated}
/>
<ConfirmModal
isOpen={confirmCancelOpen}
onClose={() => !isCancelling && setConfirmCancelOpen(false)}
onConfirm={handleConfirmCancel}
title={isAwaitingApproval ? 'Withdraw request' : 'Cancel request'}
message={
isAwaitingApproval
? `"${request.title}" is pending admin approval and will be withdrawn. The user can request it again later.`
: `"${request.title}" has already been approved and is actively being processed. Cancelling will stop the download.`
}
confirmText={isAwaitingApproval ? 'Withdraw request' : 'Cancel request'}
cancelText="Keep request"
variant="danger"
isLoading={isCancelling}
/>
</>
);
}
+2 -7
View File
@@ -10,7 +10,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
import { RMABLogger } from '@/lib/utils/logger';
import { discoverAudiobooks } from '@/lib/utils/bulk-import-scanner';
import { discoverAudiobooks, cleanSearchString } from '@/lib/utils/bulk-import-scanner';
import { getAudibleService } from '@/lib/integrations/audible.service';
import { findPlexMatch } from '@/lib/utils/audiobook-matcher';
@@ -181,12 +181,7 @@ export async function POST(request: NextRequest) {
// or intro track), whereas the folder name is the human-assigned
// title and is more likely to be accurate.
const textSearchTerm = book.extractedAsin
? book.folderName
.replace(/[\[\(][A-Z0-9]{10}[\]\)]/g, '') // strip ASIN
.replace(/[\[\(]\d{4}[\]\)]/g, '') // strip year
.replace(/[_]/g, ' ')
.replace(/\s+/g, ' ')
.trim()
? cleanSearchString(book.folderName)
: book.searchTerm;
const searchResult = await audibleService.search(textSearchTerm);
if (searchResult.results.length > 0) {
+33 -1
View File
@@ -4,10 +4,12 @@
*/
import { NextRequest, NextResponse } from 'next/server';
import { Prisma } from '@/generated/prisma/client';
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
import { RMABLogger } from '@/lib/utils/logger';
import { CLIENT_PROTOCOL_MAP, DownloadClientType } from '@/lib/interfaces/download-client.interface';
import { CANCELLABLE_STATUSES } from '@/lib/constants/request-statuses';
const logger = RMABLogger.create('API.RequestById');
@@ -112,6 +114,10 @@ export async function PATCH(
id,
deletedAt: null, // Only allow updates to active requests
},
include: {
audiobook: true,
user: { select: { plexUsername: true } },
},
});
if (!requestRecord) {
@@ -130,18 +136,44 @@ export async function PATCH(
}
if (action === 'cancel') {
// Cancel the request
if (!(CANCELLABLE_STATUSES as readonly string[]).includes(requestRecord.status)) {
return NextResponse.json(
{
error: 'ValidationError',
message: `Cannot cancel request with status: ${requestRecord.status}`,
},
{ status: 400 }
);
}
const isAwaitingApproval = requestRecord.status === 'awaiting_approval';
const updated = await prisma.request.update({
where: { id },
data: {
status: 'cancelled',
updatedAt: new Date(),
...(isAwaitingApproval && { selectedTorrent: Prisma.DbNull }),
},
include: {
audiobook: true,
},
});
try {
const { getJobQueueService } = await import('@/lib/services/job-queue.service');
const jobQueue = getJobQueueService();
await jobQueue.addNotificationJob(
'request_cancelled',
updated.id,
updated.audiobook.title,
updated.audiobook.author,
requestRecord.user.plexUsername || 'Unknown User'
);
} catch (error) {
logger.error('Failed to queue cancellation notification', { error });
}
return NextResponse.json({
success: true,
request: updated,
+5 -1
View File
@@ -265,11 +265,15 @@ function LoginContent() {
}
// Poll for authorization
await login(pinId);
const loginResult = await login(pinId);
// Close popup
authWindow.close();
if (loginResult === 'profile-selection-required') {
return;
}
// Redirect to intended page or homepage
const redirect = searchParams.get('redirect') || '/';
router.push(redirect);
+31 -11
View File
@@ -13,7 +13,8 @@ import { useCancelRequest } from '@/lib/hooks/useRequests';
import { cn } from '@/lib/utils/cn';
import { usePreferences } from '@/contexts/PreferencesContext';
import { AudiobookDetailsModal } from '@/components/audiobooks/AudiobookDetailsModal';
import { COMPLETED_STATUSES } from '@/lib/constants/request-statuses';
import { ConfirmModal } from '@/components/ui/ConfirmModal';
import { COMPLETED_STATUSES, CANCELLABLE_STATUSES } from '@/lib/constants/request-statuses';
interface RequestCardProps {
request: {
@@ -45,22 +46,25 @@ export function RequestCard({ request, showActions = true }: RequestCardProps) {
const [showError, setShowError] = React.useState(false);
const [showDetailsModal, setShowDetailsModal] = React.useState(false);
const [coverError, setCoverError] = React.useState(false);
const [confirmCancelOpen, setConfirmCancelOpen] = React.useState(false);
const isAwaitingApproval = request.status === 'awaiting_approval';
const requestType = request.type || 'audiobook';
const isEbook = requestType === 'ebook';
const isCompleted = COMPLETED_STATUSES.includes(request.status as typeof COMPLETED_STATUSES[number]);
const canCancel = ['pending', 'searching', 'downloading', 'awaiting_search'].includes(request.status);
const canCancel = (CANCELLABLE_STATUSES as readonly string[]).includes(request.status);
const isActive = ['searching', 'downloading', 'processing'].includes(request.status);
const isFailed = request.status === 'failed';
const handleCancel = async () => {
if (window.confirm('Are you sure you want to cancel this request?')) {
try {
await cancelRequest(request.id);
} catch (error) {
console.error('Failed to cancel request:', error);
}
const handleConfirmCancel = async () => {
try {
await cancelRequest(request.id);
setConfirmCancelOpen(false);
} catch (error) {
console.error('Failed to cancel request:', error);
setConfirmCancelOpen(false);
}
};
@@ -228,13 +232,13 @@ export function RequestCard({ request, showActions = true }: RequestCardProps) {
<div className="flex flex-wrap gap-2">
{canCancel && (
<Button
onClick={handleCancel}
onClick={() => setConfirmCancelOpen(true)}
loading={isLoading}
variant="outline"
size="sm"
className="text-xs sm:text-sm text-red-600 border-red-600 hover:bg-red-50 dark:hover:bg-red-900/20"
>
Cancel
{isAwaitingApproval ? 'Withdraw' : 'Cancel'}
</Button>
)}
</div>
@@ -254,6 +258,22 @@ export function RequestCard({ request, showActions = true }: RequestCardProps) {
hideRequestActions
/>
)}
<ConfirmModal
isOpen={confirmCancelOpen}
onClose={() => !isLoading && setConfirmCancelOpen(false)}
onConfirm={handleConfirmCancel}
title={isAwaitingApproval ? 'Withdraw request' : 'Cancel request'}
message={
isAwaitingApproval
? 'This request is pending admin approval and will be withdrawn. You can request it again later.'
: 'This request has already been approved and is actively being processed. Cancelling will stop the download.'
}
confirmText={isAwaitingApproval ? 'Withdraw request' : 'Cancel request'}
cancelText="Keep request"
variant="danger"
isLoading={isLoading}
/>
</div>
);
}
+6 -4
View File
@@ -24,11 +24,13 @@ interface User {
permissions?: UserPermissions;
}
export type LoginResult = 'authenticated' | 'profile-selection-required';
interface AuthContextType {
user: User | null;
accessToken: string | null;
isLoading: boolean;
login: (pinId: number) => Promise<void>;
login: (pinId: number) => Promise<LoginResult>;
logout: () => void;
refreshToken: () => Promise<void>;
setAuthData: (user: User, accessToken: string) => void;
@@ -182,7 +184,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
};
// Poll Plex OAuth callback during login
const login = async (pinId: number) => {
const login = async (pinId: number): Promise<LoginResult> => {
const maxAttempts = 60; // 2 minutes total
let attempts = 0;
@@ -211,7 +213,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
// Redirect to profile selection page
// Note: Plex token is stored server-side for security, not in sessionStorage
window.location.href = data.redirectUrl;
return;
return 'profile-selection-required';
}
// Login successful (no profile selection needed)
@@ -226,7 +228,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
// Schedule auto-refresh
scheduleTokenRefresh(data.accessToken);
return;
return 'authenticated';
}
// Still waiting for authorization
+7
View File
@@ -77,6 +77,13 @@ export const NOTIFICATION_EVENTS = {
severity: 'error' as const,
priority: 'high' as const,
},
request_cancelled: {
label: 'Request Cancelled',
title: 'Request Cancelled',
emoji: '\u{1F6AB}',
severity: 'warning' as const,
priority: 'normal' as const,
},
issue_reported: {
label: 'Issue Reported',
title: 'Issue Reported',
+9
View File
@@ -5,3 +5,12 @@
/** Terminal statuses indicating a request has been fulfilled and files are ready */
export const COMPLETED_STATUSES = ['available', 'downloaded'] as const;
/** Statuses from which a request can be cancelled (server-enforced and UI-gated) */
export const CANCELLABLE_STATUSES = [
'pending',
'searching',
'downloading',
'awaiting_search',
'awaiting_approval',
] as const;
+3
View File
@@ -315,6 +315,9 @@ export class ProwlarrService {
limit: 100,
extended: 1,
},
headers: {
'User-Agent': 'ReadMeABook',
},
timeout: DOWNLOAD_CLIENT_TIMEOUT,
responseType: 'text', // Get XML as text
});
+90 -36
View File
@@ -108,6 +108,7 @@ export class QBittorrentService implements IDownloadClient {
private username: string;
private password: string;
private cookie?: string;
private authOptional: boolean;
private defaultSavePath: string;
private defaultCategory: string;
private disableSSLVerify: boolean;
@@ -126,11 +127,16 @@ export class QBittorrentService implements IDownloadClient {
this.baseUrl = baseUrl.replace(/\/$/, '');
this.username = username;
this.password = password;
this.authOptional = !username && !password;
this.defaultSavePath = defaultSavePath;
this.defaultCategory = defaultCategory;
this.disableSSLVerify = disableSSLVerify;
this.pathMappingConfig = pathMappingConfig || { enabled: false, remotePath: '', localPath: '' };
if (this.authOptional) {
logger.info('[QBittorrent] No credentials configured — running in auth-optional mode (suitable for IP-whitelisted qBittorrent or auth-less proxies like Decypharr)');
}
// Create HTTPS agent if SSL verification is disabled
if (disableSSLVerify && this.baseUrl.startsWith('https')) {
this.httpsAgent = new https.Agent({
@@ -152,9 +158,23 @@ export class QBittorrentService implements IDownloadClient {
}
/**
* Authenticate and establish session
* Build request headers including the session cookie when one exists.
* In auth-optional mode no cookie is set and the Cookie header is omitted.
*/
private authHeaders(): Record<string, string> {
return this.cookie ? { Cookie: this.cookie } : {};
}
/**
* Authenticate and establish session.
* In auth-optional mode (no username/password configured) this is a no-op.
*/
async login(): Promise<void> {
if (this.authOptional) {
logger.debug('[QBittorrent] Skipping login — auth-optional mode');
return;
}
const loginUrl = `${this.baseUrl}/api/v2/auth/login`;
logger.debug('[QBittorrent] Attempting login', {
@@ -241,7 +261,7 @@ export class QBittorrentService implements IDownloadClient {
}
// Ensure we're authenticated
if (!this.cookie) {
if (!this.cookie && !this.authOptional) {
await this.login();
}
@@ -260,8 +280,10 @@ export class QBittorrentService implements IDownloadClient {
return await this.addTorrentFile(url, category, options);
}
} catch (error) {
// Try re-authenticating once if we get a 403
if (!retried && axios.isAxiosError(error) && error.response?.status === 403) {
// Try re-authenticating once if we get a 403 — only meaningful when credentials are configured.
// In auth-optional mode a 403 means the server actually wants auth (e.g. IP no longer whitelisted),
// so retrying login is pointless and would mask the real error.
if (!retried && !this.authOptional && axios.isAxiosError(error) && error.response?.status === 403) {
logger.info('[QBittorrent] Session expired, re-authenticating...');
await this.login();
return this.addTorrent(url, options, true);
@@ -322,7 +344,7 @@ export class QBittorrentService implements IDownloadClient {
const response = await this.client.post('/torrents/add', form, {
headers: {
Cookie: this.cookie,
...this.authHeaders(),
'Content-Type': 'application/x-www-form-urlencoded',
},
});
@@ -470,7 +492,7 @@ export class QBittorrentService implements IDownloadClient {
const response = await this.client.post('/torrents/add', formData, {
headers: {
Cookie: this.cookie,
...this.authHeaders(),
...formData.getHeaders(),
},
maxBodyLength: Infinity,
@@ -491,7 +513,7 @@ export class QBittorrentService implements IDownloadClient {
* Applies reverse path mapping (local → remote) for remote seedbox scenarios
*/
protected async ensureCategory(category: string): Promise<void> {
if (!this.cookie) {
if (!this.cookie && !this.authOptional) {
await this.login();
}
@@ -501,7 +523,7 @@ export class QBittorrentService implements IDownloadClient {
try {
// First, get all categories to check if it exists and what save path it has
const categoriesResponse = await this.client.get('/torrents/categories', {
headers: { Cookie: this.cookie },
headers: this.authHeaders(),
});
const categories = categoriesResponse.data;
@@ -519,7 +541,7 @@ export class QBittorrentService implements IDownloadClient {
}),
{
headers: {
Cookie: this.cookie,
...this.authHeaders(),
'Content-Type': 'application/x-www-form-urlencoded',
},
}
@@ -541,7 +563,7 @@ export class QBittorrentService implements IDownloadClient {
}),
{
headers: {
Cookie: this.cookie,
...this.authHeaders(),
'Content-Type': 'application/x-www-form-urlencoded',
},
}
@@ -572,13 +594,13 @@ export class QBittorrentService implements IDownloadClient {
* Get torrent status and progress
*/
async getTorrent(hash: string): Promise<TorrentInfo> {
if (!this.cookie) {
if (!this.cookie && !this.authOptional) {
await this.login();
}
try {
const response = await this.client.get('/torrents/info', {
headers: { Cookie: this.cookie },
headers: this.authHeaders(),
params: { hashes: hash },
});
@@ -610,7 +632,7 @@ export class QBittorrentService implements IDownloadClient {
* Get all torrents (optionally filtered by category)
*/
async getTorrents(category?: string): Promise<TorrentInfo[]> {
if (!this.cookie) {
if (!this.cookie && !this.authOptional) {
await this.login();
}
@@ -621,7 +643,7 @@ export class QBittorrentService implements IDownloadClient {
}
const response = await this.client.get('/torrents/info', {
headers: { Cookie: this.cookie },
headers: this.authHeaders(),
params,
});
@@ -636,7 +658,7 @@ export class QBittorrentService implements IDownloadClient {
* Pause torrent
*/
async pauseTorrent(hash: string): Promise<void> {
if (!this.cookie) {
if (!this.cookie && !this.authOptional) {
await this.login();
}
@@ -646,7 +668,7 @@ export class QBittorrentService implements IDownloadClient {
new URLSearchParams({ hashes: hash }),
{
headers: {
Cookie: this.cookie,
...this.authHeaders(),
'Content-Type': 'application/x-www-form-urlencoded',
},
}
@@ -663,7 +685,7 @@ export class QBittorrentService implements IDownloadClient {
* Resume torrent
*/
async resumeTorrent(hash: string): Promise<void> {
if (!this.cookie) {
if (!this.cookie && !this.authOptional) {
await this.login();
}
@@ -673,7 +695,7 @@ export class QBittorrentService implements IDownloadClient {
new URLSearchParams({ hashes: hash }),
{
headers: {
Cookie: this.cookie,
...this.authHeaders(),
'Content-Type': 'application/x-www-form-urlencoded',
},
}
@@ -690,7 +712,7 @@ export class QBittorrentService implements IDownloadClient {
* Delete torrent
*/
async deleteTorrent(hash: string, deleteFiles: boolean = false): Promise<void> {
if (!this.cookie) {
if (!this.cookie && !this.authOptional) {
await this.login();
}
@@ -703,7 +725,7 @@ export class QBittorrentService implements IDownloadClient {
}),
{
headers: {
Cookie: this.cookie,
...this.authHeaders(),
'Content-Type': 'application/x-www-form-urlencoded',
},
}
@@ -720,13 +742,13 @@ export class QBittorrentService implements IDownloadClient {
* Get files in torrent
*/
async getFiles(hash: string): Promise<TorrentFile[]> {
if (!this.cookie) {
if (!this.cookie && !this.authOptional) {
await this.login();
}
try {
const response = await this.client.get('/torrents/files', {
headers: { Cookie: this.cookie },
headers: this.authHeaders(),
params: { hash },
});
@@ -741,13 +763,13 @@ export class QBittorrentService implements IDownloadClient {
* Get all configured categories from qBittorrent
*/
async getCategories(): Promise<string[]> {
if (!this.cookie) {
if (!this.cookie && !this.authOptional) {
await this.login();
}
try {
const response = await this.client.get('/torrents/categories', {
headers: { Cookie: this.cookie },
headers: this.authHeaders(),
});
return Object.keys(response.data || {});
@@ -761,7 +783,7 @@ export class QBittorrentService implements IDownloadClient {
* Set category for torrent
*/
async setCategory(hash: string, category: string): Promise<void> {
if (!this.cookie) {
if (!this.cookie && !this.authOptional) {
await this.login();
}
@@ -774,7 +796,7 @@ export class QBittorrentService implements IDownloadClient {
}),
{
headers: {
Cookie: this.cookie,
...this.authHeaders(),
'Content-Type': 'application/x-www-form-urlencoded',
},
}
@@ -788,26 +810,36 @@ export class QBittorrentService implements IDownloadClient {
}
/**
* Test connection to qBittorrent
* Test connection to qBittorrent.
* In auth-optional mode the /app/version probe IS the connectivity check, so it must succeed.
* In credentialed mode login() is the connectivity check and version is best-effort.
*/
async testConnection(): Promise<ConnectionTestResult> {
try {
await this.login();
await this.login(); // no-op when authOptional; throws on real auth failure
// Fetch version after successful login
let version: string | undefined;
try {
const versionResponse = await this.client.get('/app/version', {
headers: { Cookie: this.cookie },
headers: this.authHeaders(),
});
const raw = versionResponse.data || '';
version = typeof raw === 'string' ? raw.replace(/^v/i, '') : undefined;
} catch {
// Version fetch is non-critical - connection is still valid
const version = typeof raw === 'string' ? raw.replace(/^v/i, '') : undefined;
return { success: true, version, message: `Connected to qBittorrent${version ? ` ${version}` : ''}` };
} catch (versionError) {
if (this.authOptional) {
// No login happened — version probe was our only connectivity signal.
const status = axios.isAxiosError(versionError) ? versionError.response?.status : undefined;
const baseMessage = versionError instanceof Error ? versionError.message : 'Connection failed';
const message = status === 401 || status === 403
? `qBittorrent requires authentication (HTTP ${status}). Provide username/password or whitelist this app's IP in qBittorrent.`
: `Failed to reach qBittorrent: ${baseMessage}`;
logger.error('[QBittorrent] Auth-optional connection probe failed', { status, message: baseMessage });
return { success: false, message };
}
// Credentialed path: login already succeeded, version is nice-to-have.
logger.debug('Could not fetch qBittorrent version');
return { success: true, message: 'Connected to qBittorrent' };
}
return { success: true, version, message: `Connected to qBittorrent${version ? ` ${version}` : ''}` };
} catch (error) {
const message = error instanceof Error ? error.message : 'Connection failed';
logger.error('Connection test failed', { error: message });
@@ -826,6 +858,7 @@ export class QBittorrentService implements IDownloadClient {
): Promise<string> {
const baseUrl = url.replace(/\/$/, '');
const loginUrl = `${baseUrl}/api/v2/auth/login`;
const authOptional = !username && !password;
// Create HTTPS agent if SSL verification is disabled
let httpsAgent: https.Agent | undefined;
@@ -844,9 +877,25 @@ export class QBittorrentService implements IDownloadClient {
passwordLength: password?.length,
sslVerifyDisabled: disableSSLVerify,
hasHttpsAgent: !!httpsAgent,
authOptional,
});
try {
if (authOptional) {
// No credentials provided — skip /auth/login and probe /app/version directly.
// Works for IP-whitelisted qBittorrent and auth-less qBit-compatible proxies (e.g. Decypharr).
logger.info('[QBittorrent] No credentials provided, probing /app/version directly');
const versionResponse = await axios.get(`${baseUrl}/api/v2/app/version`, {
httpsAgent,
timeout: DOWNLOAD_CLIENT_TIMEOUT,
});
logger.info('[QBittorrent] Auth-optional version check successful', {
version: versionResponse.data,
});
const rawVersion = versionResponse.data || '';
return typeof rawVersion === 'string' ? rawVersion.replace(/^v/i, '') || 'Connected' : 'Connected';
}
const requestBody = new URLSearchParams({ username, password });
const requestHeaders = {
'Content-Type': 'application/x-www-form-urlencoded',
@@ -980,6 +1029,11 @@ export class QBittorrentService implements IDownloadClient {
// HTTP status errors
if (status === 401 || status === 403) {
if (authOptional) {
throw new Error(
`qBittorrent requires authentication (HTTP ${status}). Provide username/password, or whitelist this app's IP in qBittorrent's Web UI settings.`
);
}
throw new Error(
`Authentication failed (HTTP ${status}). Check your username and password.`
);
@@ -106,7 +106,7 @@ export async function processPlexRecentlyAddedCheck(payload: PlexRecentlyAddedPa
author: item.author || 'Unknown Author',
narrator: item.narrator,
summary: item.description,
duration: item.duration ? item.duration * 1000 : null, // Convert seconds to milliseconds
duration: item.duration ? BigInt(Math.round(item.duration * 1000)) : null, // Convert seconds to milliseconds
year: item.year,
asin: item.asin, // Store ASIN from library backend
isbn: item.isbn, // Store ISBN from library backend
@@ -146,7 +146,7 @@ export async function processPlexRecentlyAddedCheck(payload: PlexRecentlyAddedPa
author: item.author || existing.author,
narrator: item.narrator || existing.narrator,
summary: item.description || existing.summary,
duration: item.duration ? item.duration * 1000 : existing.duration,
duration: item.duration ? BigInt(Math.round(item.duration * 1000)) : existing.duration,
year: item.year || existing.year,
asin: item.asin || existing.asin, // Update ASIN if available
isbn: item.isbn || existing.isbn, // Update ISBN if available
+2 -2
View File
@@ -90,7 +90,7 @@ export async function processScanPlex(payload: ScanPlexPayload): Promise<any> {
author: item.author || existing.author,
narrator: item.narrator || existing.narrator,
summary: item.description || existing.summary,
duration: item.duration ? item.duration * 1000 : existing.duration, // Convert seconds to milliseconds
duration: item.duration ? BigInt(Math.round(item.duration * 1000)) : existing.duration, // Convert seconds to milliseconds
year: item.year || existing.year,
asin: item.asin || existing.asin, // Store ASIN from library backend
isbn: item.isbn || existing.isbn, // Store ISBN from library backend
@@ -132,7 +132,7 @@ export async function processScanPlex(payload: ScanPlexPayload): Promise<any> {
author: item.author || 'Unknown Author',
narrator: item.narrator,
summary: item.description,
duration: item.duration ? item.duration * 1000 : null, // Convert seconds to milliseconds
duration: item.duration ? BigInt(Math.round(item.duration * 1000)) : null, // Convert seconds to milliseconds
year: item.year,
asin: item.asin, // Store ASIN from library backend (Plex or Audiobookshelf)
isbn: item.isbn, // Store ISBN from library backend
+7 -6
View File
@@ -75,8 +75,8 @@ function isAudioFile(filename: string): boolean {
* Returns the ASIN string or null if not found.
*/
export function extractAsinFromString(str: string): string | null {
const match = str.match(/(?:^|[\s\[\(])([B][A-Z0-9]{9})(?:$|[\s\]\)])/);
return match ? match[1] : null;
const match = str.match(/(?:^|[^A-Z0-9])(B[A-Z0-9]{9})(?:$|[^A-Z0-9])/i);
return match ? match[1].toUpperCase() : null;
}
/**
@@ -163,7 +163,7 @@ export function deduplicateNames(
* Strips file extension, bracketed ASINs, bracketed years, leading track numbers,
* underscores, and collapses whitespace.
*/
function cleanSearchString(raw: string): string {
export function cleanSearchString(raw: string): string {
return raw
.replace(/\.[^.]+$/, '') // Remove file extension
.replace(/[\[\(][A-Z0-9]{10}[\]\)]/g, '') // Remove ASIN in brackets
@@ -458,16 +458,17 @@ function deduplicateDiscoveries(
combinedCount += disc.audioFileCount;
}
const mergedFolderName = path.basename(commonParent);
merged.push({
folderPath: commonParent,
folderName: path.basename(commonParent),
relativePath: first.relativePath.split('/').slice(0, -1).join('/') || path.basename(commonParent),
folderName: mergedFolderName,
relativePath: first.relativePath.split('/').slice(0, -1).join('/') || mergedFolderName,
audioFileCount: combinedCount,
totalSizeBytes: combinedSize,
metadata: first.metadata,
searchTerm: first.searchTerm,
metadataSource: first.metadataSource,
extractedAsin: first.extractedAsin,
extractedAsin: extractAsinFromString(mergedFolderName) ?? first.extractedAsin,
audioFiles: combinedFiles,
groupingKey: first.groupingKey,
});
+2
View File
@@ -252,6 +252,8 @@ export class FileOrganizer {
narrator: audiobook.narrator,
year: audiobook.year,
asin: audiobook.asin,
series: audiobook.series,
seriesPart: audiobook.seriesPart,
});
const successCount = taggingResults.filter((r) => r.success).length;
+26
View File
@@ -17,6 +17,8 @@ export interface MetadataTaggingOptions {
narrator?: string;
year?: number;
asin?: string;
series?: string;
seriesPart?: string;
}
export interface TaggingResult {
@@ -83,6 +85,14 @@ export async function tagAudioFileMetadata(
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)
args.push('-f', 'mp4');
}
@@ -108,6 +118,14 @@ export async function tagAudioFileMetadata(
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
args.push('-f', 'flac');
}
@@ -134,6 +152,14 @@ export async function tagAudioFileMetadata(
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)
args.push('-f', 'mp3');
}
+65 -2
View File
@@ -4,12 +4,13 @@
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { Prisma } from '@/generated/prisma/client';
import { createPrismaMock } from '../helpers/prisma';
let authRequest: any;
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 qbtMock = vi.hoisted(() => ({ getTorrent: vi.fn() }));
const sabnzbdMock = vi.hoisted(() => ({ getNZB: vi.fn() }));
@@ -115,11 +116,13 @@ describe('Request by ID API routes', () => {
id: 'req-2',
userId: 'user-1',
status: 'pending',
user: { plexUsername: 'testuser' },
audiobook: { id: 'ab-1', title: 'Test Book', author: 'Test Author' },
});
prismaMock.request.update.mockResolvedValueOnce({
id: 'req-2',
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');
@@ -128,6 +131,66 @@ describe('Request by ID API routes', () => {
expect(response.status).toBe(200);
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 () => {
@@ -71,6 +71,7 @@ describe('RequestActionsDropdown', () => {
fireEvent.click(screen.getByTitle('Actions'));
fireEvent.click(screen.getByText('Cancel Request'));
fireEvent.click(screen.getByRole('button', { name: 'Cancel request' }));
await waitFor(() => expect(onCancel).toHaveBeenCalledWith('req-1'));
fireEvent.click(screen.getByTitle('Actions'));
@@ -103,6 +103,7 @@ describe('RequestCard', () => {
render(<RequestCard request={baseRequest} />);
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
fireEvent.click(screen.getByRole('button', { name: 'Cancel request' }));
await waitFor(() => {
expect(cancelRequestMock).toHaveBeenCalledWith('req-1');
});
+31 -1
View File
@@ -20,13 +20,15 @@ vi.mock('@/lib/utils/jwt-client', () => ({
function TestConsumer() {
const { user, accessToken, isLoading, login, logout, refreshToken, setAuthData } = useAuth();
const [loginResult, setLoginResult] = React.useState('none');
return (
<div>
<div data-testid="loading">{String(isLoading)}</div>
<div data-testid="user">{user?.username ?? '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
</button>
<button type="button" onClick={logout}>
@@ -188,6 +190,34 @@ describe('AuthProvider', () => {
expect(screen.getByTestId('token')).toHaveTextContent('login-access');
expect(localStorage.getItem('accessToken')).toBe('login-access');
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', () => {
+1 -1
View File
@@ -14,7 +14,7 @@ type RenderWithProvidersOptions = Omit<RenderOptions, 'wrapper'> & {
user: MockUser | null;
accessToken: string | null;
isLoading: boolean;
login: (pinId: number) => Promise<void>;
login: (pinId: number) => Promise<'authenticated' | 'profile-selection-required'>;
logout: () => void;
refreshToken: () => Promise<void>;
setAuthData: (user: MockUser, accessToken: string) => void;
@@ -1217,4 +1217,124 @@ describe('QBittorrentService', () => {
expect(result.success).toBe(true);
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');
});
});
});
@@ -125,6 +125,72 @@ describe('processPlexRecentlyAddedCheck', () => {
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 () => {
const matcher = await import('@/lib/utils/audiobook-matcher');
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 () => {
configMock.getBackendMode.mockResolvedValue('audiobookshelf');
configMock.get.mockResolvedValue(null);
+316
View File
@@ -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');
});
});
+66
View File
@@ -114,6 +114,72 @@ describe('metadata tagger', () => {
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', () => {
it('does NOT escape single quotes (they are literal in double-quoted shell strings)', async () => {
fsMock.access.mockResolvedValue(undefined);