mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-04 21:30:11 +00:00
Compare commits
23 Commits
1711d256c2
..
v1.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 5f62ba7146 | |||
| bc7fff9dd7 | |||
| b775ccf473 | |||
| 1a9aeb4713 | |||
| bb18feac5c | |||
| 4b79b11987 | |||
| 86f7a6a354 | |||
| 071c788ead | |||
| f4fe6f936f | |||
| 741efa685c | |||
| df656b6178 | |||
| d2c90de07f | |||
| 07fbff1133 | |||
| de72180bdd | |||
| e9241d21af | |||
| ad8d44bae0 | |||
| f56efa8b15 | |||
| a7186096df | |||
| 1a25f544b1 | |||
| edecda9e64 | |||
| 6b76932a0a | |||
| 02b636e5b8 | |||
| 37f063229c |
@@ -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
|
||||
# =========================================================================
|
||||
|
||||
@@ -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
|
||||
@@ -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`
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "readmeabook",
|
||||
"version": "1.0.15",
|
||||
"version": "1.2.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "readmeabook",
|
||||
"version": "1.0.15",
|
||||
"version": "1.2.0",
|
||||
"dependencies": {
|
||||
"@heroicons/react": "^2.2.0",
|
||||
"@prisma/client": "^6.19.0",
|
||||
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "readmeabook",
|
||||
"version": "1.1.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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -315,6 +315,9 @@ export class ProwlarrService {
|
||||
limit: 100,
|
||||
extended: 1,
|
||||
},
|
||||
headers: {
|
||||
'User-Agent': 'ReadMeABook',
|
||||
},
|
||||
timeout: DOWNLOAD_CLIENT_TIMEOUT,
|
||||
responseType: 'text', // Get XML as text
|
||||
});
|
||||
|
||||
@@ -108,6 +108,7 @@ export class QBittorrentService implements IDownloadClient {
|
||||
private username: string;
|
||||
private password: string;
|
||||
private cookie?: string;
|
||||
private authOptional: boolean;
|
||||
private defaultSavePath: string;
|
||||
private defaultCategory: string;
|
||||
private disableSSLVerify: boolean;
|
||||
@@ -126,11 +127,16 @@ export class QBittorrentService implements IDownloadClient {
|
||||
this.baseUrl = baseUrl.replace(/\/$/, '');
|
||||
this.username = username;
|
||||
this.password = password;
|
||||
this.authOptional = !username && !password;
|
||||
this.defaultSavePath = defaultSavePath;
|
||||
this.defaultCategory = defaultCategory;
|
||||
this.disableSSLVerify = disableSSLVerify;
|
||||
this.pathMappingConfig = pathMappingConfig || { enabled: false, remotePath: '', localPath: '' };
|
||||
|
||||
if (this.authOptional) {
|
||||
logger.info('[QBittorrent] No credentials configured — running in auth-optional mode (suitable for IP-whitelisted qBittorrent or auth-less proxies like Decypharr)');
|
||||
}
|
||||
|
||||
// Create HTTPS agent if SSL verification is disabled
|
||||
if (disableSSLVerify && this.baseUrl.startsWith('https')) {
|
||||
this.httpsAgent = new https.Agent({
|
||||
@@ -152,9 +158,23 @@ export class QBittorrentService implements IDownloadClient {
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate and establish session
|
||||
* Build request headers including the session cookie when one exists.
|
||||
* In auth-optional mode no cookie is set and the Cookie header is omitted.
|
||||
*/
|
||||
private authHeaders(): Record<string, string> {
|
||||
return this.cookie ? { Cookie: this.cookie } : {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate and establish session.
|
||||
* In auth-optional mode (no username/password configured) this is a no-op.
|
||||
*/
|
||||
async login(): Promise<void> {
|
||||
if (this.authOptional) {
|
||||
logger.debug('[QBittorrent] Skipping login — auth-optional mode');
|
||||
return;
|
||||
}
|
||||
|
||||
const loginUrl = `${this.baseUrl}/api/v2/auth/login`;
|
||||
|
||||
logger.debug('[QBittorrent] Attempting login', {
|
||||
@@ -241,7 +261,7 @@ export class QBittorrentService implements IDownloadClient {
|
||||
}
|
||||
|
||||
// Ensure we're authenticated
|
||||
if (!this.cookie) {
|
||||
if (!this.cookie && !this.authOptional) {
|
||||
await this.login();
|
||||
}
|
||||
|
||||
@@ -260,8 +280,10 @@ export class QBittorrentService implements IDownloadClient {
|
||||
return await this.addTorrentFile(url, category, options);
|
||||
}
|
||||
} catch (error) {
|
||||
// Try re-authenticating once if we get a 403
|
||||
if (!retried && axios.isAxiosError(error) && error.response?.status === 403) {
|
||||
// Try re-authenticating once if we get a 403 — only meaningful when credentials are configured.
|
||||
// In auth-optional mode a 403 means the server actually wants auth (e.g. IP no longer whitelisted),
|
||||
// so retrying login is pointless and would mask the real error.
|
||||
if (!retried && !this.authOptional && axios.isAxiosError(error) && error.response?.status === 403) {
|
||||
logger.info('[QBittorrent] Session expired, re-authenticating...');
|
||||
await this.login();
|
||||
return this.addTorrent(url, options, true);
|
||||
@@ -322,7 +344,7 @@ export class QBittorrentService implements IDownloadClient {
|
||||
|
||||
const response = await this.client.post('/torrents/add', form, {
|
||||
headers: {
|
||||
Cookie: this.cookie,
|
||||
...this.authHeaders(),
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
});
|
||||
@@ -470,7 +492,7 @@ export class QBittorrentService implements IDownloadClient {
|
||||
|
||||
const response = await this.client.post('/torrents/add', formData, {
|
||||
headers: {
|
||||
Cookie: this.cookie,
|
||||
...this.authHeaders(),
|
||||
...formData.getHeaders(),
|
||||
},
|
||||
maxBodyLength: Infinity,
|
||||
@@ -491,7 +513,7 @@ export class QBittorrentService implements IDownloadClient {
|
||||
* Applies reverse path mapping (local → remote) for remote seedbox scenarios
|
||||
*/
|
||||
protected async ensureCategory(category: string): Promise<void> {
|
||||
if (!this.cookie) {
|
||||
if (!this.cookie && !this.authOptional) {
|
||||
await this.login();
|
||||
}
|
||||
|
||||
@@ -501,7 +523,7 @@ export class QBittorrentService implements IDownloadClient {
|
||||
try {
|
||||
// First, get all categories to check if it exists and what save path it has
|
||||
const categoriesResponse = await this.client.get('/torrents/categories', {
|
||||
headers: { Cookie: this.cookie },
|
||||
headers: this.authHeaders(),
|
||||
});
|
||||
|
||||
const categories = categoriesResponse.data;
|
||||
@@ -519,7 +541,7 @@ export class QBittorrentService implements IDownloadClient {
|
||||
}),
|
||||
{
|
||||
headers: {
|
||||
Cookie: this.cookie,
|
||||
...this.authHeaders(),
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
}
|
||||
@@ -541,7 +563,7 @@ export class QBittorrentService implements IDownloadClient {
|
||||
}),
|
||||
{
|
||||
headers: {
|
||||
Cookie: this.cookie,
|
||||
...this.authHeaders(),
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
}
|
||||
@@ -572,13 +594,13 @@ export class QBittorrentService implements IDownloadClient {
|
||||
* Get torrent status and progress
|
||||
*/
|
||||
async getTorrent(hash: string): Promise<TorrentInfo> {
|
||||
if (!this.cookie) {
|
||||
if (!this.cookie && !this.authOptional) {
|
||||
await this.login();
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await this.client.get('/torrents/info', {
|
||||
headers: { Cookie: this.cookie },
|
||||
headers: this.authHeaders(),
|
||||
params: { hashes: hash },
|
||||
});
|
||||
|
||||
@@ -610,7 +632,7 @@ export class QBittorrentService implements IDownloadClient {
|
||||
* Get all torrents (optionally filtered by category)
|
||||
*/
|
||||
async getTorrents(category?: string): Promise<TorrentInfo[]> {
|
||||
if (!this.cookie) {
|
||||
if (!this.cookie && !this.authOptional) {
|
||||
await this.login();
|
||||
}
|
||||
|
||||
@@ -621,7 +643,7 @@ export class QBittorrentService implements IDownloadClient {
|
||||
}
|
||||
|
||||
const response = await this.client.get('/torrents/info', {
|
||||
headers: { Cookie: this.cookie },
|
||||
headers: this.authHeaders(),
|
||||
params,
|
||||
});
|
||||
|
||||
@@ -636,7 +658,7 @@ export class QBittorrentService implements IDownloadClient {
|
||||
* Pause torrent
|
||||
*/
|
||||
async pauseTorrent(hash: string): Promise<void> {
|
||||
if (!this.cookie) {
|
||||
if (!this.cookie && !this.authOptional) {
|
||||
await this.login();
|
||||
}
|
||||
|
||||
@@ -646,7 +668,7 @@ export class QBittorrentService implements IDownloadClient {
|
||||
new URLSearchParams({ hashes: hash }),
|
||||
{
|
||||
headers: {
|
||||
Cookie: this.cookie,
|
||||
...this.authHeaders(),
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
}
|
||||
@@ -663,7 +685,7 @@ export class QBittorrentService implements IDownloadClient {
|
||||
* Resume torrent
|
||||
*/
|
||||
async resumeTorrent(hash: string): Promise<void> {
|
||||
if (!this.cookie) {
|
||||
if (!this.cookie && !this.authOptional) {
|
||||
await this.login();
|
||||
}
|
||||
|
||||
@@ -673,7 +695,7 @@ export class QBittorrentService implements IDownloadClient {
|
||||
new URLSearchParams({ hashes: hash }),
|
||||
{
|
||||
headers: {
|
||||
Cookie: this.cookie,
|
||||
...this.authHeaders(),
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
}
|
||||
@@ -690,7 +712,7 @@ export class QBittorrentService implements IDownloadClient {
|
||||
* Delete torrent
|
||||
*/
|
||||
async deleteTorrent(hash: string, deleteFiles: boolean = false): Promise<void> {
|
||||
if (!this.cookie) {
|
||||
if (!this.cookie && !this.authOptional) {
|
||||
await this.login();
|
||||
}
|
||||
|
||||
@@ -703,7 +725,7 @@ export class QBittorrentService implements IDownloadClient {
|
||||
}),
|
||||
{
|
||||
headers: {
|
||||
Cookie: this.cookie,
|
||||
...this.authHeaders(),
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
}
|
||||
@@ -720,13 +742,13 @@ export class QBittorrentService implements IDownloadClient {
|
||||
* Get files in torrent
|
||||
*/
|
||||
async getFiles(hash: string): Promise<TorrentFile[]> {
|
||||
if (!this.cookie) {
|
||||
if (!this.cookie && !this.authOptional) {
|
||||
await this.login();
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await this.client.get('/torrents/files', {
|
||||
headers: { Cookie: this.cookie },
|
||||
headers: this.authHeaders(),
|
||||
params: { hash },
|
||||
});
|
||||
|
||||
@@ -741,13 +763,13 @@ export class QBittorrentService implements IDownloadClient {
|
||||
* Get all configured categories from qBittorrent
|
||||
*/
|
||||
async getCategories(): Promise<string[]> {
|
||||
if (!this.cookie) {
|
||||
if (!this.cookie && !this.authOptional) {
|
||||
await this.login();
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await this.client.get('/torrents/categories', {
|
||||
headers: { Cookie: this.cookie },
|
||||
headers: this.authHeaders(),
|
||||
});
|
||||
|
||||
return Object.keys(response.data || {});
|
||||
@@ -761,7 +783,7 @@ export class QBittorrentService implements IDownloadClient {
|
||||
* Set category for torrent
|
||||
*/
|
||||
async setCategory(hash: string, category: string): Promise<void> {
|
||||
if (!this.cookie) {
|
||||
if (!this.cookie && !this.authOptional) {
|
||||
await this.login();
|
||||
}
|
||||
|
||||
@@ -774,7 +796,7 @@ export class QBittorrentService implements IDownloadClient {
|
||||
}),
|
||||
{
|
||||
headers: {
|
||||
Cookie: this.cookie,
|
||||
...this.authHeaders(),
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
}
|
||||
@@ -788,26 +810,36 @@ export class QBittorrentService implements IDownloadClient {
|
||||
}
|
||||
|
||||
/**
|
||||
* Test connection to qBittorrent
|
||||
* Test connection to qBittorrent.
|
||||
* In auth-optional mode the /app/version probe IS the connectivity check, so it must succeed.
|
||||
* In credentialed mode login() is the connectivity check and version is best-effort.
|
||||
*/
|
||||
async testConnection(): Promise<ConnectionTestResult> {
|
||||
try {
|
||||
await this.login();
|
||||
await this.login(); // no-op when authOptional; throws on real auth failure
|
||||
|
||||
// Fetch version after successful login
|
||||
let version: string | undefined;
|
||||
try {
|
||||
const versionResponse = await this.client.get('/app/version', {
|
||||
headers: { Cookie: this.cookie },
|
||||
headers: this.authHeaders(),
|
||||
});
|
||||
const raw = versionResponse.data || '';
|
||||
version = typeof raw === 'string' ? raw.replace(/^v/i, '') : undefined;
|
||||
} catch {
|
||||
// Version fetch is non-critical - connection is still valid
|
||||
const version = typeof raw === 'string' ? raw.replace(/^v/i, '') : undefined;
|
||||
return { success: true, version, message: `Connected to qBittorrent${version ? ` ${version}` : ''}` };
|
||||
} catch (versionError) {
|
||||
if (this.authOptional) {
|
||||
// No login happened — version probe was our only connectivity signal.
|
||||
const status = axios.isAxiosError(versionError) ? versionError.response?.status : undefined;
|
||||
const baseMessage = versionError instanceof Error ? versionError.message : 'Connection failed';
|
||||
const message = status === 401 || status === 403
|
||||
? `qBittorrent requires authentication (HTTP ${status}). Provide username/password or whitelist this app's IP in qBittorrent.`
|
||||
: `Failed to reach qBittorrent: ${baseMessage}`;
|
||||
logger.error('[QBittorrent] Auth-optional connection probe failed', { status, message: baseMessage });
|
||||
return { success: false, message };
|
||||
}
|
||||
// Credentialed path: login already succeeded, version is nice-to-have.
|
||||
logger.debug('Could not fetch qBittorrent version');
|
||||
return { success: true, message: 'Connected to qBittorrent' };
|
||||
}
|
||||
|
||||
return { success: true, version, message: `Connected to qBittorrent${version ? ` ${version}` : ''}` };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Connection failed';
|
||||
logger.error('Connection test failed', { error: message });
|
||||
@@ -826,6 +858,7 @@ export class QBittorrentService implements IDownloadClient {
|
||||
): Promise<string> {
|
||||
const baseUrl = url.replace(/\/$/, '');
|
||||
const loginUrl = `${baseUrl}/api/v2/auth/login`;
|
||||
const authOptional = !username && !password;
|
||||
|
||||
// Create HTTPS agent if SSL verification is disabled
|
||||
let httpsAgent: https.Agent | undefined;
|
||||
@@ -844,9 +877,25 @@ export class QBittorrentService implements IDownloadClient {
|
||||
passwordLength: password?.length,
|
||||
sslVerifyDisabled: disableSSLVerify,
|
||||
hasHttpsAgent: !!httpsAgent,
|
||||
authOptional,
|
||||
});
|
||||
|
||||
try {
|
||||
if (authOptional) {
|
||||
// No credentials provided — skip /auth/login and probe /app/version directly.
|
||||
// Works for IP-whitelisted qBittorrent and auth-less qBit-compatible proxies (e.g. Decypharr).
|
||||
logger.info('[QBittorrent] No credentials provided, probing /app/version directly');
|
||||
const versionResponse = await axios.get(`${baseUrl}/api/v2/app/version`, {
|
||||
httpsAgent,
|
||||
timeout: DOWNLOAD_CLIENT_TIMEOUT,
|
||||
});
|
||||
logger.info('[QBittorrent] Auth-optional version check successful', {
|
||||
version: versionResponse.data,
|
||||
});
|
||||
const rawVersion = versionResponse.data || '';
|
||||
return typeof rawVersion === 'string' ? rawVersion.replace(/^v/i, '') || 'Connected' : 'Connected';
|
||||
}
|
||||
|
||||
const requestBody = new URLSearchParams({ username, password });
|
||||
const requestHeaders = {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
@@ -980,6 +1029,11 @@ export class QBittorrentService implements IDownloadClient {
|
||||
|
||||
// HTTP status errors
|
||||
if (status === 401 || status === 403) {
|
||||
if (authOptional) {
|
||||
throw new Error(
|
||||
`qBittorrent requires authentication (HTTP ${status}). Provide username/password, or whitelist this app's IP in qBittorrent's Web UI settings.`
|
||||
);
|
||||
}
|
||||
throw new Error(
|
||||
`Authentication failed (HTTP ${status}). Check your username and password.`
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user